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              12x                               12x 617x 600x 600x 600x 83x 83x   517x 49x 49x 49x 49x 49x       49x 49x 49x 49x   49x 49x   468x 468x 468x       468x 468x 482x 482x 482x 25x       468x           468x 468x 463x 463x 463x 9x     468x 468x 468x 468x         468x 4x 4x       464x 464x   464x 464x 101x 101x 101x 170x 170x   101x 170x 170x 170x 169x 108x     363x 3065x 363x 2897x 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);
      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);
};