All files / collaborative-slate/src SlateFacade.ts

71.42% Statements 95/133
56.71% Branches 38/67
86.66% Functions 13/15
77.67% Lines 87/112

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 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 2423x 3x 3x 3x 3x     3x                                   3x                                                                                                           3x   39x 39x     68x       68x 68x         39x                 39x 39x 39x     39x   39x 39x 39x 39x 58x 58x 58x                                               1x 1x 1x       58x 58x 58x 58x 58x 58x 58x 58x 58x       58x 58x         58x         26x 25x 25x 25x 15x 15x 15x 15x 15x 15x       11x 10x 10x 10x 10x 10x 10x 10x 10x 10x           10x 10x 10x   10x         41x 39x 39x 39x           3x 20x 20x 20x 40x 40x 40x   20x 20x 20x       3x 15x 15x 15x 15x 27x 23x   11x 11x    
import {HistoryEditor, withHistory} from 'slate-history';
import {FromSlate} from './sync/FromSlate';
import {ToSlateNode} from './sync/toSlateNode';
import {applyPatch} from './sync/applyPatch';
import {slatePointToGap, slatePointToPoint, pointToSlatePoint} from './positions';
import type {Fragment} from 'json-joy/lib/json-crdt-extensions/peritext/block/Fragment';
import type {Range} from 'json-joy/lib/json-crdt-extensions/peritext/rga/Range';
import {Transforms} from 'slate';
import type {Editor, BaseOperation, Point as SlatePoint} from 'slate';
import type {PeritextApi, Peritext} from 'json-joy/lib/json-crdt-extensions';
import type {ViewRange} from 'json-joy/lib/json-crdt-extensions/peritext/editor/types';
import type {
  PeritextRef,
  RichtextEditorFacade,
  PeritextOperation,
  PeritextSelection,
} from '@jsonjoy.com/collaborative-peritext/lib/types';
import type {SlateDocument, SlateEditorOnChange, SlateOperation} from './types';
 
/**
 * Attempt to extract a single {@link PeritextOperation} from Slate's current
 * operation batch. Returns `undefined` when the batch is too complex (anything
 * other than a single `insert_text` or `remove_text`), in which case the caller
 * should fall back to the full document merge path.
 */
const tryExtractPeritextOperation = (
  operations: BaseOperation[],
  editor: Editor,
  txt: Peritext,
): PeritextOperation | undefined => {
  if (operations.length !== 1) return;
  const op = operations[0];
  if (op.type === 'insert_text') {
    const gap = slatePointToGap(txt, editor, op);
    if (gap < 0) return;
    return [gap, 0, op.text];
  }
  if (op.type === 'remove_text') {
    const text = op.text;
    // For single-character deletes only; multi-char could be a complex block join.
    if (text.length > 1) return;
    const gap = slatePointToGap(txt, editor, op);
    if (gap < 0) return;
    return [gap, text.length, ''];
  }
  return;
};
 
export interface SlateFacadeOpts {
  /**
   * Whether to install the `slate-history` undo/redo plugin.
   *
   * - `true` — always install `withHistory` (even if one is already
   *   present on the editor).
   * - `false` — never install `withHistory`.
   * - `undefined` (default) — install `withHistory` only when the editor
   *   does not already have it.
   *
   * When history is enabled, remote changes are applied with
   * `HistoryEditor.withoutSaving()` so they never appear on the undo
   * stack. Undo / redo therefore only reverses local edits; remote
   * changes are treated as if they were always present.
   */
  history?: boolean;
}
 
/**
 * Slate.js implementation of {@link RichtextEditorFacade}. Connects a Slate.js
 * `Editor` to a json-joy JSON CRDT "peritext" node.
 *
 * Usage:
 *
 * ```ts
 * const editor = withReact(createEditor());
 * const peritextRef = () => model.s.toExt();
 * const facade = new SlateFacade(editor, peritextRef);
 * const unbind = PeritextBinding.bind(peritextRef, facade);
 * ```
 */
export class SlateFacade implements RichtextEditorFacade {
  /** Pending remote operations counter. */
  private _remoteCnt = 0;
  private _disposed = false;
 
  private _enterRemote(): void {
    this._remoteCnt++;
  }
 
  private _exitRemote(): void {
    queueMicrotask(() => {
      this._remoteCnt--;
    });
  }
 
  /** Stateful converter that caches Slate nodes by Peritext `Block.hash`. */
  private readonly _toSlate = new ToSlateNode();
 
  private readonly _origOnChange: SlateEditorOnChange | undefined;
  private readonly _slateOnChange: SlateEditorOnChange;
 
  onchange?: (change: PeritextOperation | void) => PeritextRef | void;
  onselection?: () => void;
 
  constructor(
    public readonly editor: Editor,
    protected readonly peritext: PeritextRef,
    protected readonly opts: SlateFacadeOpts = {},
  ) {
    // Optionally install slate-history plugin.
    const {history: historyOpt} = opts;
    const installHistory =
      historyOpt === true ? true : historyOpt === false ? false : !HistoryEditor.isHistoryEditor(editor);
    Eif (installHistory) withHistory(editor);
    this._origOnChange = (editor as any).onChange;
    (editor.onChange as SlateEditorOnChange) = this._slateOnChange = (options?: {operation?: SlateOperation}) => {
      Eif (this._disposed || !!this._remoteCnt) {
        this._origOnChange?.call(editor);
        return;
      }
      const operations = editor.operations;
      const hasDocChange = operations.some((op: BaseOperation) => op.type !== 'set_selection');
      if (hasDocChange) {
        let simpleOperation: PeritextOperation | undefined;
        try {
          const txt = this.peritext().txt;
          simpleOperation = tryExtractPeritextOperation(operations, editor, txt);
        } catch {}
        this.onchange?.(simpleOperation);
      } else {
        this.onselection?.();
      }
 
      // Call _origOnChange (Slate React's internal handler) AFTER syncing
      // the Peritext model. This ensures that any callbacks triggered by
      // Slate React (e.g. sendLocalPresence) see the up-to-date model
      // when converting positions between Slate and CRDT coordinate spaces.
      this._origOnChange?.call(editor);
    };
  }
 
  get(): ViewRange {
    Iif (this._disposed) return ['', 0, []];
    const children = this.editor.children as SlateDocument;
    return FromSlate.convert(children);
  }
 
  set(fragment: Fragment<string>): void {
    Iif (this._disposed) return;
    const newChildren = this._toSlate.convert(fragment);
    Iif (!newChildren.length) return;
    const editor = this.editor;
    this._enterRemote();
    try {
      const doApply = () => {
        applyPatch(editor, newChildren);
        editor.onChange();
      };
      // Exclude remote changes from the undo stack so that undo/redo only
      // reverses local edits.
      if (HistoryEditor.isHistoryEditor(editor)) {
        HistoryEditor.withoutSaving(editor, doApply);
      } else E{
        doApply();
      }
    } finally {
      this._exitRemote();
    }
  }
 
  getSelection(): PeritextSelection | undefined {
    if (this._disposed) return;
    const editor = this.editor;
    const selection = editor.selection;
    if (!selection) return;
    const txt = this.peritext().txt;
    const p1 = slatePointToPoint(txt, editor, selection.anchor);
    const p2 = slatePointToPoint(txt, editor, selection.focus);
    const range = txt.rangeFromPoints(p1, p2);
    const startIsAnchor = isBeforeOrEqual(selection.anchor, selection.focus);
    return [range, startIsAnchor];
  }
 
  setSelection(peritext: PeritextApi, range: Range<string>, startIsAnchor: boolean): void {
    if (this._disposed) return;
    const editor = this.editor;
    const txt = peritext.txt;
    const rootBlock = txt.blocks.root;
    const anchorPoint = startIsAnchor ? range.start : range.end;
    const headPoint = startIsAnchor ? range.end : range.start;
    const anchor = pointToSlatePoint(rootBlock, anchorPoint, editor);
    const focus = pointToSlatePoint(rootBlock, headPoint, editor);
    try {
      Iif (!validatePoint(editor, anchor) || !validatePoint(editor, focus)) {
        return;
      }
    } catch {
      return;
    }
    this._enterRemote();
    try {
      Transforms.select(editor, {anchor, focus});
    } finally {
      this._exitRemote();
    }
  }
 
  dispose(): void {
    if (this._disposed) return;
    this._disposed = true;
    const e = this.editor as any;
    Eif (this._origOnChange && e.onChange === this._slateOnChange) e.onChange = this._origOnChange;
  }
}
 
/** Validate that a Slate point references a valid location within the editor
 * tree. Returns `false` if the path is out of bounds or points to a non-text node. */
const validatePoint = (editor: Editor, point: SlatePoint): boolean => {
  const {path, offset} = point;
  let node: any = editor;
  for (const idx of path) {
    const children = node.children;
    Iif (!children || idx >= children.length) return false;
    node = children[idx];
  }
  Iif (typeof node.text !== 'string') return false;
  Iif (offset > node.text.length) return false;
  return true;
};
 
/** Returns true if point `a` is before or equal to point `b`. */
const isBeforeOrEqual = (a: SlatePoint, b: SlatePoint): boolean => {
  const aPath = a.path;
  const bPath = b.path;
  const len = Math.min(aPath.length, bPath.length);
  for (let i = 0; i < len; i++) {
    if (aPath[i] < bPath[i]) return true;
    Iif (aPath[i] > bPath[i]) return false;
  }
  Iif (aPath.length !== bPath.length) return aPath.length < bPath.length;
  return a.offset <= b.offset;
};