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 1782x   9x                                             9x 9x 778x   778x 778x       1777x       3789x 3789x 3789x 2002x 2002x 2002x 2002x 467x 467x 673x 673x 673x   673x     673x 673x 673x       1787x 1787x     1787x 5x 5x   5x     5x 5x 5x 5x     1782x 1782x 1782x 1782x 1782x 1782x 1581x   1581x     1581x 1581x   1782x         2560x 2560x 2560x 2560x 3789x 3789x 3789x 3789x 3789x         778x 778x 778x 778x      
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;
  }
}