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

92.22% Statements 83/90
81.03% Branches 47/58
100% Functions 1/1
94.2% Lines 65/69

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              12x                               12x 620x 603x 603x 603x 83x 83x   520x 51x 51x 51x 51x 51x       51x 51x 51x 51x   51x 51x   469x 469x 469x       469x 469x 485x 485x 485x 26x       469x           469x 469x 465x 465x 465x 9x     469x 469x 469x 469x         469x 5x 5x       464x 464x   464x 464x 101x 101x 101x 167x 167x   101x 167x 167x 167x 167x 103x     363x 2892x 363x 3113x 363x 6x       464x    
/**
 * Granular ProseMirror document patching.
 *
 * Given an old and a new ProseMirror document, this module builds a minimal
 * {@link Transaction} that transforms one into the other.
 */
 
import {Slice} from 'prosemirror-model';
import type {Node as PmNode} from 'prosemirror-model';
import type {Transaction} from 'prosemirror-state';
 
/**
 * Build minimal transaction steps that transform `oldNode` into `newNode`.
 *
 * The `offset` parameter is the absolute document position of the node's
 * opening tag. For the root `doc` node pass `-1` (default) because Prose-
 * Mirror's doc node has no wrapper positions — its content starts at 0.
 *
 * @param tr        The transaction to append steps to.
 * @param oldNode   The current (old) document / sub-tree root.
 * @param newNode   The desired (new) document / sub-tree root.
 * @param offset    Absolute position of this node's opening tag (-1 for doc).
 */
export const applyPatch = (tr: Transaction, oldNode: PmNode, newNode: PmNode, offset: number = -1): void => {
  if (oldNode === newNode) return;
  const oldContent = oldNode.content;
  const newContent = newNode.content;
  if (oldContent.eq(newContent)) {
    if (offset >= 0 && !oldNode.sameMarkup(newNode)) tr.setNodeMarkup(offset, null, newNode.attrs, newNode.marks);
    return;
  }
  if (oldNode.isTextblock) {
    const start = oldContent.findDiffStart(newContent);
    Eif (start !== null) {
      let {a: endA, b: endB} = oldContent.findDiffEnd(newContent)!;
      const overlap = start - Math.min(endA, endB);
      Iif (overlap > 0) {
        endA += overlap;
        endB += overlap;
      }
      const from = offset + 1 + start;
      const to = offset + 1 + endA;
      const slice = newContent.cut(start, endB);
      Eif (from !== to || slice.size > 0) tr.replace(from, to, new Slice(slice, 0, 0));
    }
    if (!oldNode.sameMarkup(newNode)) tr.setNodeMarkup(offset, null, newNode.attrs, newNode.marks);
    return;
  }
  const oldCount = oldNode.childCount;
  const newCount = newNode.childCount;
  const minCount = Math.min(oldCount, newCount);
 
  // Find longest common prefix. The `===` check covers cache hits; `.eq()`
  // covers structurally-equal nodes that weren't cached.
  let pfx = 0;
  while (pfx < minCount) {
    const oc = oldNode.child(pfx);
    const nc = newNode.child(pfx);
    if (oc !== nc && !oc.eq(nc)) break;
    pfx++;
  }
 
  // All children match and counts are equal — only markup may have changed.
  Iif (pfx === minCount && oldCount === newCount) {
    if (offset >= 0 && !oldNode.sameMarkup(newNode)) tr.setNodeMarkup(offset, null, newNode.attrs, newNode.marks);
    return;
  }
 
  // Find longest common suffix (avoid overlapping with prefix).
  let sfx = 0;
  while (sfx < minCount - pfx) {
    const oc = oldNode.child(oldCount - 1 - sfx);
    const nc = newNode.child(newCount - 1 - sfx);
    if (oc !== nc && !oc.eq(nc)) break;
    sfx++;
  }
 
  const oldEnd = oldCount - sfx;
  const newEnd = newCount - sfx;
  const oldChanged = oldEnd - pfx;
  const newChanged = newEnd - pfx;
 
  // When every child changed and we're not at the doc root, replace the whole
  // node atomically. This avoids ProseMirror auto-filling required children
  // (e.g. a list node that loses all its items via `tr.delete`).
  if (offset >= 0 && pfx === 0 && sfx === 0) {
    tr.replaceWith(offset, offset + oldNode.nodeSize, newNode);
    return;
  }
 
  // Compute the base offset of the first changed child.
  let childOffset = offset + 1;
  for (let i = 0; i < pfx; i++) childOffset += oldNode.child(i).nodeSize;
 
  const sameNumberOfChangedChildren = oldChanged === newChanged;
  if (sameNumberOfChangedChildren) {
    const offsets: number[] = new Array(oldChanged);
    let off = childOffset;
    for (let i = 0; i < oldChanged; i++) {
      offsets[i] = off;
      off += oldNode.child(pfx + i).nodeSize;
    }
    for (let i = oldChanged - 1; i >= 0; i--) {
      const oc = oldNode.child(pfx + i);
      const nc = newNode.child(pfx + i);
      Iif (oc === nc || oc.eq(nc)) continue;
      if (oc.type === nc.type) applyPatch(tr, oc, nc, offsets[i]);
      else tr.replaceWith(offsets[i], offsets[i] + oc.nodeSize, nc);
    }
  } else {
    let to = childOffset;
    for (let i = 0; i < oldChanged; i++) to += oldNode.child(pfx + i).nodeSize;
    const children: PmNode[] = [];
    for (let i = pfx; i < newEnd; i++) children.push(newNode.child(i));
    if (children.length) tr.replaceWith(childOffset, to, children);
    else Eif (childOffset !== to) tr.delete(childOffset, to);
  }
 
  // After children have been reconciled, update wrapper markup if needed.
  Iif (offset >= 0 && !oldNode.sameMarkup(newNode)) tr.setNodeMarkup(offset, null, newNode.attrs, newNode.marks);
};