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 1666x   9x                                             9x 9x 715x   715x 715x       1661x       3511x 3511x 3511x 1840x 1840x 1840x 1840x 299x 299x 467x 467x 467x   467x     467x 467x 467x       1671x 1671x     1671x 5x 5x   5x     5x 5x 5x 5x     1666x 1666x 1666x 1666x 1666x 1666x 1465x   1465x     1465x 1465x   1666x         2381x 2381x 2381x 2381x 3511x 3511x 3511x 3511x 3511x         715x 715x 715x 715x      
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;
  }
}