All files / collaborative-slate/src/sync FromSlate.ts

98.5% Statements 66/67
85.71% Branches 36/42
100% Functions 7/7
100% Lines 61/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 1239x 9x         9x 1796x   9x                                             9x 9x 789x   789x 789x       1791x       3777x 3777x 3777x 1976x 1976x 1976x 1976x 414x 414x 663x 663x 663x   663x     663x 663x 663x       1801x 1801x     1801x 5x 5x   5x     5x 5x 5x 5x     1796x 1796x 1796x 1796x 1796x 1796x 1595x   1595x     1595x 1595x   1796x         2585x 2585x 2585x 2585x 3777x 3777x 3777x 3777x 3777x         789x 789x 789x 789x      
import {Anchor} from 'json-joy/lib/json-crdt-extensions/peritext/rga/constants';
import {SliceHeaderShift, SliceStacking} from 'json-joy/lib/json-crdt-extensions/peritext/slice/constants';
import type {ViewRange, ViewSlice} from 'json-joy/lib/json-crdt-extensions/peritext/editor/types';
import type {SlateDocument, SlateDescendantNode, SlateTextNode, SlateElementNode} from '../types';
import type {SliceTypeStep, SliceTypeSteps} from 'json-joy/lib/json-crdt-extensions/peritext';
 
const isText = (node: unknown): node is SlateTextNode =>
  typeof node === 'object' && !!node && typeof (node as SlateTextNode).text === 'string';
 
const INLINE_ATOMIC_PLACEHOLDER = '\uFFFC';
 
export interface FromSlateOptions {
  /**
   * Predicate that returns `true` for Slate elements that should be treated
   * as inline (rather than block) when serializing. Inline elements are
   * emitted as an `Atomic` slice covering a single placeholder character,
   * letting them roundtrip without splitting the surrounding paragraph.
   */
  isInline?: (element: SlateElementNode) => boolean;
}
 
/**
 * Converts Slate.js state to a {@link ViewRange} flat string with
 * annotation ranges, which is the natural view format for a Peritext model.
 *
 * Usage:
 *
 * ```typescript
 * FromSlate.convert(node);
 * FromSlate.convert(node, {isInline: (el) => editor.isInline(el)});
 * ```
 */
export class FromSlate {
  static readonly convert = (doc: SlateDocument, options?: FromSlateOptions): ViewRange =>
    new FromSlate(options).convert(doc);
 
  private text = '';
  private slices: ViewSlice[] = [];
  private readonly isInlineEl: (element: SlateElementNode) => boolean;
 
  constructor(options: FromSlateOptions = {}) {
    this.isInlineEl = options.isInline ?? (() => false);
  }
 
  private conv(node: SlateDescendantNode, path: SliceTypeSteps, nodeDiscriminator: number): void {
    Iif (!node || typeof node !== 'object') return;
    const start = this.text.length;
    if ('text' in node) {
      const {text, ...tagMap} = node as SlateTextNode;
      this.text += text;
      const tags = Object.keys(tagMap);
      if (tags.length) {
        const end = start + text.length;
        for (const tag of tags) {
          const data = tagMap[tag];
          const dataEmpty = !data || data === true;
          const stacking: SliceStacking = dataEmpty ? SliceStacking.One : SliceStacking.Many;
          const header =
            (stacking << SliceHeaderShift.Stacking) +
            (Anchor.Before << SliceHeaderShift.X1Anchor) +
            (Anchor.After << SliceHeaderShift.X2Anchor);
          const slice: ViewSlice = [header, start, end, tag];
          if (!dataEmpty) slice.push(data);
          this.slices.push(slice);
        }
      }
    } else {
      const element = node as SlateElementNode;
      const {type, children, ...data} = element;
 
      // Inline elements: emit as an `Atomic` slice over a single placeholder character.
      if (this.isInlineEl(element)) {
        this.text += INLINE_ATOMIC_PLACEHOLDER;
        const end = start + INLINE_ATOMIC_PLACEHOLDER.length;
        const header =
          (SliceStacking.Atomic << SliceHeaderShift.Stacking) +
          (Anchor.Before << SliceHeaderShift.X1Anchor) +
          (Anchor.After << SliceHeaderShift.X2Anchor);
        const hasData = Object.keys(data).length > 0;
        const slice: ViewSlice = hasData ? [header, start, end, type, data] : [header, start, end, type];
        this.slices.push(slice);
        return;
      }
 
      const step: SliceTypeStep = nodeDiscriminator || data ? [type, nodeDiscriminator, data] : type;
      const length = children?.length ?? 0;
      const hasNoChildren = length === 0;
      const isFirstChildInline = isText((children as SlateElementNode['children'])?.[0]);
      const doEmitSplitMarker = hasNoChildren || isFirstChildInline;
      if (doEmitSplitMarker) {
        this.text += '\n';
        const header =
          (SliceStacking.Marker << SliceHeaderShift.Stacking) +
          (Anchor.Before << SliceHeaderShift.X1Anchor) +
          (Anchor.Before << SliceHeaderShift.X2Anchor);
        const slice: ViewSlice = [header, start, start, [...path, step]];
        this.slices.push(slice);
      }
      Eif (length > 0) this.cont([...path, step], children!);
    }
  }
 
  private cont(path: SliceTypeSteps, content: SlateDescendantNode[]): void {
    let prevTag: string = '';
    let discriminator: number = 0;
    const length = content.length;
    for (let i = 0; i < length; i++) {
      const child = content[i];
      const tag = child.type as string;
      discriminator = tag === prevTag ? discriminator + 1 : 0;
      this.conv(child, path, discriminator);
      prevTag = tag;
    }
  }
 
  public convert(node: SlateDocument): ViewRange {
    let length = 0;
    Eif (node && (length = node.length) > 0) this.cont([], node);
    const viewRange = [this.text, 0, this.slices] as ViewRange;
    return viewRange;
  }
}