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

93.33% Statements 84/90
82.75% Branches 48/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              10x                               10x 623x 606x 606x 606x 83x 83x   523x 54x 54x 54x 54x 54x       54x 54x 54x 54x   54x 54x   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 100x 100x 100x 185x 185x   100x 185x 185x 185x 184x 117x     364x 2854x 364x 3079x 364x 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);
      if (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);
};