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

94.25% Statements 82/87
88.88% Branches 40/45
100% Functions 9/9
100% Lines 71/71

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            5x 5x 5x           5x 1096x 1096x 565x 565x       5x           827x 827x 827x 827x 827x 827x 793x 793x 793x 793x 793x 793x 793x 793x 592x 604x 604x 604x 604x 604x         201x 201x 176x 176x         5x 262x 262x 262x 262x 262x 262x 524x 265x   259x     5x 604x 604x 604x 195x 195x       409x 147x 147x 147x       262x 3x 3x   262x     5x   195x 195x   315x 195x 44x 44x 44x       151x 151x 151x 151x     93x 105x      
/**
 * Given the current `editor.children` (old) and a freshly-converted Slate
 * document (dst), this module applies the minimal set of Slate `Transforms`
 * needed to bring the editor in sync with the dst document.
 */
 
import {Transforms, Editor} from 'slate';
import * as str from 'json-joy/lib/util/diff/str';
import {deepEqual} from '@jsonjoy.com/json-equal';
import type {SlateDescendantNode, SlateDocument, SlateElementNode, SlateTextNode} from '../types';
 
/**
 * Apply minimal Slate transforms to make `editor.children` match `dst`.
 */
export const applyPatch = (editor: Editor, dst: SlateDocument): void => {
  const oldDoc = editor.children as SlateDocument;
  if (oldDoc === dst) return;
  Editor.withoutNormalizing(editor, () => {
    patchChildren(editor, [], oldDoc as SlateDescendantNode[], dst as SlateDescendantNode[]);
  });
};
 
const patchChildren = (
  editor: Editor,
  basePath: number[],
  src: SlateDescendantNode[],
  dst: SlateDescendantNode[],
): void => {
  const srcLen = src.length;
  const dstLen = dst.length;
  const minLen = Math.min(srcLen, dstLen);
  let pfx = 0;
  while (pfx < minLen && deepEqual(src[pfx], dst[pfx])) pfx++;
  if (pfx === srcLen && pfx === dstLen) return;
  let sfx = 0;
  while (sfx < minLen - pfx && deepEqual(src[srcLen - 1 - sfx], dst[dstLen - 1 - sfx])) sfx++;
  const srcEnd = srcLen - sfx;
  const dstEnd = dstLen - sfx;
  const srcChanged = srcEnd - pfx;
  const dstChanged = dstEnd - pfx;
  const sameCount = srcChanged === dstChanged;
  if (sameCount) {
    for (let i = srcChanged - 1; i >= 0; i--) {
      const idx = pfx + i;
      const oldNode = src[idx];
      const newNode = dst[idx];
      Iif (deepEqual(oldNode, newNode)) continue;
      patchNode(editor, [...basePath, idx], oldNode, newNode);
    }
  } else {
    // Different counts in the changed window.
    // Delete old[pfx..oldEnd) in reverse order, then insert new[pfx..newEnd).
    for (let i = srcEnd - 1; i >= pfx; i--) Transforms.removeNodes(editor, {at: [...basePath, i]});
    if (dstChanged > 0) {
      const toInsert = dst.slice(pfx, dstEnd) as any[];
      Transforms.insertNodes(editor, toInsert, {at: [...basePath, pfx]});
    }
  }
};
 
const attrsEqual = (a: SlateElementNode, b: SlateElementNode): boolean => {
  Iif (a === b) return true;
  Iif (a.type !== b.type) return false;
  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);
  Iif (aKeys.length !== bKeys.length) return false;
  for (const key of aKeys) {
    if (key === 'children') continue;
    if (!deepEqual(a[key], b[key])) return false;
  }
  return true;
};
 
const patchNode = (editor: Editor, path: number[], src: SlateDescendantNode, dst: SlateDescendantNode): void => {
  const srcIsText = 'text' in src;
  const dstIsText = 'text' in dst;
  if (srcIsText && dstIsText) {
    patchTextNode(editor, path, src as any, dst as any);
    return;
  }
 
  // Replace whole node.
  if (srcIsText || dstIsText || src.type !== dst.type) {
    Transforms.removeNodes(editor, {at: path});
    Transforms.insertNodes(editor, dst as any, {at: path});
    return;
  }
 
  // Patch attributes and recurse on children.
  if (!attrsEqual(src, dst)) {
    const {children: _, ...newAttrs} = dst;
    Transforms.setNodes(editor, newAttrs as any, {at: path});
  }
  patchChildren(editor, path, src.children, dst.children);
};
 
const patchTextNode = (editor: Editor, path: number[], src: SlateTextNode, dst: SlateTextNode): void => {
  // Check whether non-text mark properties changed.
  const oldKeys = Object.keys(src);
  const newKeys = Object.keys(dst);
  const markChanged =
    oldKeys.length !== newKeys.length || oldKeys.some((k) => k !== 'text' && !deepEqual(src[k], dst[k]));
  if (markChanged) {
    Transforms.removeNodes(editor, {at: path});
    Transforms.insertNodes(editor, dst as any, {at: path});
    return;
  }
 
  // Diff and apply text content changes.
  const srcTxt = src.text;
  const dstTxt = dst.text;
  Iif (srcTxt === dstTxt) return;
  str.apply(
    str.diff(srcTxt, dstTxt),
    srcTxt.length,
    (pos, str) => Transforms.insertText(editor, str, {at: {path, offset: pos}}),
    (pos, len) => Transforms.delete(editor, {at: {path, offset: pos}, distance: len, unit: 'character'}),
  );
};