diff --git a/packages/core/src/api/combineTransactionSteps.ts b/packages/core/src/api/combineTransactionSteps.ts new file mode 100644 index 0000000000..e27f7ced36 --- /dev/null +++ b/packages/core/src/api/combineTransactionSteps.ts @@ -0,0 +1,22 @@ +import { combineTransactionSteps as replayTransactionSteps } from "@tiptap/core"; +import type { Node } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; +import type { Transform } from "prosemirror-transform"; + +/** + * Tiptap's `combineTransactionSteps`, but a single transaction that starts at + * `oldDoc` is returned as it is. Replaying its steps into a new transform + * took seconds for a version diff, which has thousands of steps. + * + * The result can be the transaction itself, so it only gives what callers + * read: adding steps to it would change that transaction. + */ +export function combineTransactionSteps( + oldDoc: Node, + transactions: readonly Transaction[], +): Pick { + if (transactions.length === 1 && transactions[0].before === oldDoc) { + return transactions[0]; + } + return replayTransactionSteps(oldDoc, [...transactions]); +} diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts index 3e45c14330..89f142cba1 100644 --- a/packages/core/src/api/getBlockInfoFromPos.test.ts +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -155,6 +155,37 @@ describe("getNodeId", () => { expect(getNodeId(blockGroup.child(2), doc)).toBe("0-1"); }); + it("counts same-id nodes in document order, including nested ones", () => { + const schema = getSchema(); + + // A deleted block before its live copy, and a deleted copy nested in + // the live block's children. + const nestedDeleted = makeBlockContainer(schema, "0", "Nested", true); + const liveWithChild = schema.nodes["blockContainer"].createChecked( + { id: "0" }, + [ + schema.nodes["paragraph"].createChecked({}, schema.text("Live")), + schema.nodes["blockGroup"].createChecked({}, nestedDeleted), + ], + ); + const firstDeleted = makeBlockContainer(schema, "0", "First", true); + + const doc = schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [ + firstDeleted, + liveWithChild, + ]), + ); + + const blockGroup = doc.firstChild!; + expect(getNodeId(blockGroup.child(0), doc)).toBe("0-0"); + expect(getNodeId(blockGroup.child(1), doc)).toBe("0"); + expect(getNodeId(blockGroup.child(1).lastChild!.firstChild!, doc)).toBe( + "0-2", + ); + }); + it("throws when a suggested-deletion node is not found in the provided doc", () => { const schema = getSchema(); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index a9512f0f6a..50b7b311aa 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -182,6 +182,36 @@ export function isSuggestedDeletionNode(node: Node): boolean { return node.marks.some((m) => ["y-attributed-delete"].includes(m.type.name)); } +// Computed in one walk per doc: callers look up every block, and a walk per +// suggested-deletion node made that quadratic in large diffs. +const deletedNodeIdsByDoc = new WeakMap>(); + +/** + * The ids of the suggested-deletion nodes in `doc`: `-`, where + * `index` counts the nodes with the same id before it, in document order. + */ +function getDeletedNodeIds(doc: Node): Map { + let deletedNodeIds = deletedNodeIdsByDoc.get(doc); + if (deletedNodeIds === undefined) { + const ids = new Map(); + const counts = new Map(); + doc.descendants((descNode) => { + const id = descNode.attrs.id; + if (id) { + const index = counts.get(id) ?? 0; + if (isSuggestedDeletionNode(descNode) && !ids.has(descNode)) { + ids.set(descNode, `${id}-${index}`); + } + counts.set(id, index + 1); + } + return true; + }); + deletedNodeIds = ids; + deletedNodeIdsByDoc.set(doc, deletedNodeIds); + } + return deletedNodeIds; +} + /** * The block ID to address `node` by. Normally its `id` attribute, but a node * kept around by suggestion mode (see {@link isSuggestedDeletionNode}) shares @@ -203,28 +233,13 @@ export function getNodeId(node: Node, doc: Node): string { * so we need to differentiate them by counting how many nodes with the same ID come before them in the document, and adding that count to the ID. */ if (isSuggestedDeletionNode(node)) { - // walk the doc to find the node and count it's index if others have the same ID, to differentiate them - let index = 0; - let found = false; - doc.descendants((descNode: Node) => { - if (found) { - return false; // stop the walk - } - if (descNode.attrs.id === id) { - if (descNode === node) { - found = true; - return false; // stop the walk - } - index++; - } - return true; // continue the walk - }); - if (!found) { + const deletedId = getDeletedNodeIds(doc).get(node); + if (deletedId === undefined) { throw new Error( `Node ${node.type.name} with ID ${id} not found in document`, ); } - return `${id}-${index}`; + return deletedId; } // TODO handle deleted nodes return id; diff --git a/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts index b2853b9181..d03a61a01c 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -656,7 +656,7 @@ describe("getBlocksChangedByTransaction - ranged optimization", () => { throw new Error("expected a wrapped block"); } // Adding a mark produces an AddMarkStep, whose StepMap is empty — the case - // getChangedRange has to recover from the step's own from/to. + // getChangedRangeWithAttrs has to recover from the step's own from/to. tr.addMark( info.content.beforePos + 1, info.content.afterPos - 1, diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts index 9157a86812..c374ccc9fe 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -1,4 +1,4 @@ -import { combineTransactionSteps } from "@tiptap/core"; +import { combineTransactionSteps } from "./combineTransactionSteps.js"; import deepEqual from "fast-deep-equal"; import type { Node } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; @@ -11,7 +11,7 @@ import { import type { BlockSchema } from "../schema/index.js"; import type { InlineContentSchema } from "../schema/inlineContent/types.js"; import type { StyleSchema } from "../schema/styles/types.js"; -import { getChangedRange } from "./getChangedRange.js"; +import { getChangedRangeWithAttrs } from "./getChangedRangeWithAttrs.js"; import { getNodeId } from "./getBlockInfoFromPos.js"; import { nodeToBlock } from "./nodeConversions/nodeToBlock.js"; import { isNodeBlock } from "./nodeUtil.js"; @@ -303,7 +303,7 @@ export function getBlocksChangedByTransaction< ]); // Changed range in the new doc; null means nothing changed. - const newRange = getChangedRange(combinedTransaction); + const newRange = getChangedRangeWithAttrs(combinedTransaction); if (!newRange) { return []; } diff --git a/packages/core/src/api/getChangedRange.ts b/packages/core/src/api/getChangedRangeWithAttrs.ts similarity index 95% rename from packages/core/src/api/getChangedRange.ts rename to packages/core/src/api/getChangedRangeWithAttrs.ts index c6f78de8bc..ebd21a52a3 100644 --- a/packages/core/src/api/getChangedRange.ts +++ b/packages/core/src/api/getChangedRangeWithAttrs.ts @@ -9,8 +9,8 @@ import type { Transform } from "prosemirror-transform"; * * O(steps), like `changedRange()`. Returns null when nothing changed. */ -export function getChangedRange( - transform: Transform, +export function getChangedRangeWithAttrs( + transform: Pick, ): { from: number; to: number } | null { const { mapping, steps } = transform; let from = Number.POSITIVE_INFINITY; diff --git a/packages/core/src/api/getChangedRanges.test.ts b/packages/core/src/api/getChangedRanges.test.ts new file mode 100644 index 0000000000..6c8ec6357e --- /dev/null +++ b/packages/core/src/api/getChangedRanges.test.ts @@ -0,0 +1,391 @@ +import { getChangedRanges as tiptapGetChangedRanges } from "@tiptap/core"; +import { Node, Schema, Slice } from "prosemirror-model"; +import { + AddNodeMarkStep, + Mapping, + RemoveNodeMarkStep, + ReplaceStep, + Transform, + canJoin, + canSplit, + findWrapping, + liftTarget, +} from "prosemirror-transform"; +import { describe, expect, it } from "vite-plus/test"; + +import { getChangedRanges } from "./getChangedRanges.js"; + +const schema = new Schema({ + nodes: { + doc: { + content: "block+", + attrs: { title: { default: null } }, + marks: "_", + }, + paragraph: { + group: "block", + content: "text*", + attrs: { level: { default: 0 } }, + marks: "_", + }, + heading: { group: "block", content: "text*", marks: "_" }, + blockquote: { group: "block", content: "block+", marks: "_" }, + text: {}, + }, + marks: { strong: {}, comment: {} }, +}); + +function paragraph(text: string) { + return schema.node("paragraph", null, text ? schema.text(text) : []); +} + +/** Paragraphs at 0-5, 5-10 and 10-15, with text at 1-4, 6-9 and 11-14. */ +function threeParagraphs() { + return schema.node("doc", null, [ + paragraph("abc"), + paragraph("def"), + paragraph("ghi"), + ]); +} + +/** `[old from, old to, new from, new to]` of each changed range. */ +function ranges( + implementation: (transform: Transform) => ReturnType, + transform: Transform, +) { + return implementation(transform).map(({ oldRange, newRange }) => [ + oldRange.from, + oldRange.to, + newRange.from, + newRange.to, + ]); +} + +const cases: [string, (tr: Transform) => void, number[][]][] = [ + ["no steps", () => {}, []], + ["insert text", (tr) => tr.insert(2, schema.text("X")), [[2, 2, 2, 3]]], + ["delete text", (tr) => tr.delete(6, 8), [[6, 8, 6, 6]]], + [ + "replace text", + (tr) => tr.replaceWith(6, 8, schema.text("XY")), + [[6, 8, 6, 8]], + ], + [ + "two inserts in document order", + (tr) => tr.insert(2, schema.text("X")).insert(8, schema.text("Y")), + [ + [2, 2, 2, 3], + [7, 7, 8, 9], + ], + ], + [ + "two inserts in reverse order", + (tr) => tr.insert(7, schema.text("Y")).insert(2, schema.text("X")), + [ + [7, 7, 8, 9], + [2, 2, 2, 3], + ], + ], + [ + "two inserts at the same position", + (tr) => tr.insert(2, schema.text("X")).insert(2, schema.text("Y")), + [[2, 2, 2, 4]], + ], + [ + "insert, then delete inside it", + (tr) => tr.insert(2, schema.text("XYZ")).delete(3, 4), + [[2, 2, 2, 4]], + ], + [ + "insert, then delete around it", + (tr) => tr.insert(7, schema.text("X")).delete(6, 9), + [[6, 8, 6, 6]], + ], + ["delete a paragraph", (tr) => tr.delete(5, 10), [[5, 10, 5, 5]]], + [ + "insert a paragraph", + (tr) => tr.insert(5, paragraph("new")), + [[5, 5, 5, 10]], + ], + [ + "add a mark", + (tr) => tr.addMark(1, 3, schema.marks.strong.create()), + [[1, 3, 1, 3]], + ], + [ + "add two marks to the same range", + (tr) => + tr + .addMark(1, 3, schema.marks.strong.create()) + .addMark(1, 3, schema.marks.comment.create()), + [[1, 3, 1, 3]], + ], + [ + "remove a mark inside an added one", + (tr) => + tr + .addMark(1, 4, schema.marks.strong.create()) + .removeMark(2, 3, schema.marks.strong), + [[1, 4, 1, 4]], + ], + // Steps without a position range are skipped (see `getChangedRangeWithAttrs`). + ["set a node attribute", (tr) => tr.setNodeAttribute(5, "level", 1), []], + ["set a doc attribute", (tr) => tr.setDocAttribute("title", "x"), []], + [ + "add a node mark", + (tr) => tr.addNodeMark(5, schema.marks.comment.create()), + [], + ], + [ + "wrap a paragraph", + (tr) => { + const range = tr.doc.resolve(6).blockRange()!; + tr.wrap(range, findWrapping(range, schema.nodes.blockquote)!); + }, + [ + [5, 5, 5, 6], + [10, 10, 11, 12], + ], + ], + [ + "wrap, then lift", + (tr) => { + const wrapRange = tr.doc.resolve(6).blockRange()!; + tr.wrap(wrapRange, findWrapping(wrapRange, schema.nodes.blockquote)!); + const liftRange = tr.doc.resolve(7).blockRange()!; + tr.lift(liftRange, liftTarget(liftRange)!); + }, + [ + [5, 5, 5, 5], + [10, 10, 10, 10], + ], + ], + [ + "large replace, then edit inside it", + (tr) => + tr + .replaceWith(1, 14, schema.text("XXXXXXXXXX")) + .insert(4, schema.text("Y")), + [[1, 14, 1, 12]], + ], + [ + "edit, then large replace around it", + (tr) => tr.insert(7, schema.text("Y")).replaceWith(5, 16, paragraph("Z")), + [[5, 15, 5, 8]], + ], + [ + "mark inside an insert", + (tr) => + tr + .insert(2, schema.text("XYZ")) + .addMark(3, 4, schema.marks.strong.create()), + [[2, 2, 2, 5]], + ], +]; + +describe.each([ + ["Tiptap", tiptapGetChangedRanges], + ["BlockNote", getChangedRanges], +])("getChangedRanges (%s)", (_, implementation) => { + it.each(cases)("%s", (_name, change, expected) => { + const tr = new Transform(threeParagraphs()); + change(tr); + expect(ranges(implementation, tr)).toEqual(expected); + }); +}); + +/** A seeded random number in `[0, n)`. */ +function random(seed: number) { + let state = seed; + return (n: number) => { + state = (state * 16807) % 2147483647; + return state % n; + }; +} + +/** Applies one random valid change to `tr`. */ +function randomChange(tr: Transform, rnd: (n: number) => number) { + const textblocks: { pos: number; node: Node }[] = []; + tr.doc.descendants((node, pos) => { + if (node.isTextblock) { + textblocks.push({ pos, node }); + } + return true; + }); + const { pos, node } = textblocks[rnd(textblocks.length)]; + const textPos = (offset: number) => pos + 1 + offset; + const size = node.content.size; + const text = "xyz".slice(rnd(3)); + const docSize = tr.doc.content.size; + + switch (rnd(15)) { + case 0: + tr.insert(textPos(rnd(size + 1)), schema.text("abc".slice(rnd(3)))); + break; + case 1: { + const from = rnd(size + 1); + tr.delete(textPos(from), textPos(from + rnd(size - from + 1))); + break; + } + case 2: { + const from = rnd(size + 1); + const to = textPos(from + rnd(size - from + 1)); + if (text) { + tr.replaceWith(textPos(from), to, schema.text(text)); + } + break; + } + case 3: + if (tr.doc.childCount > 1) { + const index = rnd(tr.doc.childCount); + let start = 0; + for (let i = 0; i < index; i++) { + start += tr.doc.child(i).nodeSize; + } + tr.delete(start, start + tr.doc.child(index).nodeSize); + } + break; + case 4: { + const index = rnd(tr.doc.childCount + 1); + let start = 0; + for (let i = 0; i < index; i++) { + start += tr.doc.child(i).nodeSize; + } + tr.insert(start, paragraph(text)); + break; + } + case 5: { + const from = rnd(docSize + 1); + const to = from + rnd(docSize - from + 1); + const mark = [schema.marks.strong, schema.marks.comment][rnd(2)]; + if (rnd(2)) { + tr.addMark(from, to, mark.create()); + } else { + tr.removeMark(from, to, mark); + } + break; + } + case 6: + tr.setNodeAttribute(pos, "level", rnd(3)); + break; + case 7: { + const range = tr.doc.resolve(textPos(0)).blockRange(); + const target = range && liftTarget(range); + if (range && target !== null && target !== undefined) { + tr.lift(range, target); + } + break; + } + case 8: { + const range = tr.doc.resolve(textPos(0)).blockRange(); + const wrapping = range && findWrapping(range, schema.nodes.blockquote); + if (range && wrapping) { + tr.wrap(range, wrapping); + } + break; + } + case 9: + if (rnd(2)) { + tr.maybeStep(new AddNodeMarkStep(pos, schema.marks.comment.create())); + } else { + tr.setDocAttribute("title", String(rnd(3))); + } + break; + case 10: { + const at = textPos(rnd(size + 1)); + if (canSplit(tr.doc, at)) { + tr.split(at); + } + break; + } + case 11: + // What `tr.join` makes, without its throw when the join doesn't fit. + if (canJoin(tr.doc, pos)) { + tr.maybeStep(new ReplaceStep(pos - 1, pos + 1, Slice.empty, true)); + } + break; + case 12: + tr.setBlockType( + pos, + pos + node.nodeSize, + [schema.nodes.paragraph, schema.nodes.heading][rnd(2)], + ); + break; + case 13: + tr.maybeStep(new RemoveNodeMarkStep(pos, schema.marks.comment.create())); + break; + case 14: { + const from = rnd(docSize + 1); + tr.delete(from, from + rnd(docSize - from + 1)); + break; + } + } +} + +describe("getChangedRanges", () => { + it("returns the same ranges as Tiptap for random changes", () => { + const rnd = random(42); + const stepTypes = new Set(); + for (let run = 0; run < 3000; run++) { + const tr = new Transform( + schema.node( + "doc", + null, + Array.from({ length: 1 + rnd(6) }, () => + paragraph("abcdef".slice(rnd(7))), + ), + ), + ); + const stepCount = 1 + rnd(run % 10 === 0 ? 200 : 30); + for (let i = 0; i < stepCount; i++) { + randomChange(tr, rnd); + } + tr.steps.forEach((step) => stepTypes.add(step.constructor.name)); + expect(ranges(getChangedRanges, tr)).toEqual( + ranges(tiptapGetChangedRanges, tr), + ); + } + expect([...stepTypes].sort()).toEqual([ + "AddMarkStep", + "AddNodeMarkStep", + "AttrStep", + "DocAttrStep", + "RemoveMarkStep", + "RemoveNodeMarkStep", + "ReplaceAroundStep", + "ReplaceStep", + ]); + }); + + it("returns the same ranges as Tiptap for many steps in document order and in reverse", () => { + const blocks = Array.from({ length: 300 }, (_, i) => paragraph(`p${i}`)); + for (const reverse of [false, true]) { + const tr = new Transform(schema.node("doc", null, blocks)); + const starts: number[] = []; + tr.doc.forEach((_node, offset) => starts.push(offset)); + for (const start of reverse ? starts.reverse() : starts) { + tr.insert(tr.mapping.map(start + 1), schema.text("!")); + tr.setNodeAttribute(tr.mapping.map(start), "level", 1); + } + expect(ranges(getChangedRanges, tr)).toEqual( + ranges(tiptapGetChangedRanges, tr), + ); + } + }); + + it("returns the same ranges as Tiptap for a mirrored mapping", () => { + const tr = new Transform(threeParagraphs()); + tr.insert(2, schema.text("X")); + tr.step(tr.steps[0].invert(tr.docs[0])); + tr.insert(7, schema.text("Y")); + const mirrored = new Mapping(); + mirrored.appendMap(tr.mapping.maps[0]); + mirrored.appendMap(tr.mapping.maps[1], 0); + mirrored.appendMap(tr.mapping.maps[2]); + Object.assign(tr, { mapping: mirrored }); + + expect(ranges(getChangedRanges, tr)).toEqual( + ranges(tiptapGetChangedRanges, tr), + ); + }); +}); diff --git a/packages/core/src/api/getChangedRanges.ts b/packages/core/src/api/getChangedRanges.ts new file mode 100644 index 0000000000..90aa72cc7e --- /dev/null +++ b/packages/core/src/api/getChangedRanges.ts @@ -0,0 +1,368 @@ +import { + getChangedRanges as getChangedRangesQuadratic, + type ChangedRange, +} from "@tiptap/core"; +import type { Step, StepMap, Transform } from "prosemirror-transform"; + +/** + * Tiptap's `getChangedRanges` (https://github.com/ueberdosis/tiptap/blob/main/packages/core/src/helpers/getChangedRanges.ts), + * with the same result but without its quadratic cost on transforms with many + * steps. Tiptap maps each changed range through every later step and back + * through every step, and compares every range with every other range. A + * version diff of a few thousand blocks has ~10,000 steps, which took seconds. + * + * Here, mapping skips runs of steps that provably leave the position + * unchanged or only shift it (see {@link StepMapIndex}), and the simplify pass + * only compares ranges that can contain each other. For steps in document + * order, both are close to linear. + * + * See also `getChangedRangeWithAttrs` (one range covering all changes, including + * attribute-only steps, which this function skips like Tiptap's). + * + * Consider deprecating this function: a list of ranges is costly to compute + * exactly, which is what makes this code complex. Its remaining callers + * (UniqueID, autolink) might work from one range or from input events, but + * what that changes in each of them needs a careful look first. + */ +export function getChangedRanges( + transform: Pick, +): ChangedRange[] { + const { mapping, steps } = transform; + const maps = mapping.maps; + // A mirrored mapping (from rebasing steps) recovers positions across step + // pairs, which the skips below don't model. + for (let i = 0; i < maps.length; i++) { + if (mapping.getMirror(i) !== undefined) { + // Tiptap's function only reads `mapping` and `steps`. + return getChangedRangesQuadratic({ mapping, steps } as Transform); + } + } + + const index = new StepMapIndex(maps); + const changes: ChangedRange[] = []; + maps.forEach((stepMap, i) => { + const ranges: { from: number; to: number }[] = []; + stepMap.forEach((from, to) => ranges.push({ from, to })); + // Steps that change no positions (e.g. marks) still change their range. + if (!ranges.length) { + const { from, to } = steps[i] as Step & { from?: number; to?: number }; + if (from === undefined || to === undefined) { + return; + } + ranges.push({ from, to }); + } + + for (const { from, to } of ranges) { + const newStart = index.mapForward(from, -1, i); + const newEnd = index.mapForward(to, 1, i); + changes.push({ + oldRange: { + from: index.mapBackward(newStart, -1), + to: index.mapBackward(newEnd, 1), + }, + newRange: { from: newStart, to: newEnd }, + }); + } + }); + + return simplifyChangedRanges(changes); +} + +/** + * Removes duplicated ranges and ranges that other ranges fully contain, like + * Tiptap's `simplifyChangedRanges`. + */ +function simplifyChangedRanges(changes: ChangedRange[]): ChangedRange[] { + const seen = new Set(); + const unique = changes.filter(({ oldRange, newRange }) => { + const key = `${oldRange.from},${oldRange.to},${newRange.from},${newRange.to}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + if (unique.length === 1) { + return unique; + } + + // Each old range is its new range mapped back with the same mapping, which + // keeps order. So when a new range contains another, its old range does + // too, and comparing the new ranges is enough. + const byNewFrom = unique + .map((_, i) => i) + .sort((a, b) => unique[a].newRange.from - unique[b].newRange.from); + const newTo = new SegmentTree( + byNewFrom.map((i) => unique[i].newRange.to), + "max", + ); + + return unique.filter((change, i) => { + // The last candidate whose new range starts at or before this one. + let lo = 0; + let hi = byNewFrom.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (unique[byNewFrom[mid]].newRange.from <= change.newRange.from) { + lo = mid; + } else { + hi = mid - 1; + } + } + const contained = newTo.someBefore( + lo, + (to) => to >= change.newRange.to, + (k) => byNewFrom[k] !== i, + ); + return !contained; + }); +} + +/** + * Maps positions through a list of step maps like `Mapping#map`, but skips + * runs of maps that can't affect the position. For a step map, a position + * before its first range stays unchanged, and a position after all its + * ranges only shifts by the map's size change. Only positions inside its + * ranges need the map itself. + * + * Each map is checked directly. Only a long run of such maps is skipped with + * a segment tree, so transforms with few steps don't pay for the trees. + */ +class StepMapIndex { + private readonly inverted: (StepMap | undefined)[]; + /** Per map: the lowest start of its ranges, before or after the map. */ + private readonly firstStarts: number[] = []; + /** Per map: its last range end in the old doc. */ + private readonly oldEnds: number[] = []; + /** Per map: its last range end in the new doc. */ + private readonly newEnds: number[] = []; + /** Sum of the size changes of the maps before each index. */ + private readonly shift: number[] = [0]; + private trees: + | { + firstStart: SegmentTree; + /** `oldEnds` minus `shift` at each map. */ + forwardShiftFrom: SegmentTree; + /** `newEnds` minus `shift` after each map. */ + backwardShiftFrom: SegmentTree; + } + | undefined; + + constructor(private readonly maps: readonly StepMap[]) { + this.inverted = new Array(maps.length); + for (const map of maps) { + let first = Infinity; + let oldEnd = -Infinity; + let newEnd = -Infinity; + let sizeChange = 0; + map.forEach((oldStart, oldStop, newStart, newStop) => { + first = Math.min(first, oldStart, newStart); + oldEnd = Math.max(oldEnd, oldStop); + newEnd = Math.max(newEnd, newStop); + sizeChange += newStop - newStart - (oldStop - oldStart); + }); + this.firstStarts.push(first); + this.oldEnds.push(oldEnd); + this.newEnds.push(newEnd); + this.shift.push(this.shift[this.shift.length - 1] + sizeChange); + } + } + + /** Like `mapping.slice(from).map(pos, assoc)`. */ + mapForward(pos: number, assoc: number, from: number): number { + // How many maps in a row `pos` lay before (> 0) or after (< 0). + let skipped = 0; + let i = from; + while (i < this.maps.length) { + if (pos < this.firstStarts[i]) { + // `pos` lies before this map. + skipped = Math.max(skipped, 0) + 1; + if (skipped > LONG_RUN) { + i = this.getTrees().firstStart.firstFrom(i, (s) => s <= pos); + skipped = 0; + } else { + i++; + } + } else if (pos > this.oldEnds[i]) { + // `pos` lies after this map, which only shifts it. + skipped = Math.min(skipped, 0) - 1; + if (skipped < -LONG_RUN) { + const base = pos - this.shift[i]; + const next = this.getTrees().forwardShiftFrom.firstFrom( + i, + (end) => end >= base, + ); + pos += this.shift[next] - this.shift[i]; + i = next; + skipped = 0; + } else { + pos += this.shift[i + 1] - this.shift[i]; + i++; + } + } else { + pos = this.maps[i].map(pos, assoc); + skipped = 0; + i++; + } + } + return pos; + } + + /** Like `mapping.invert().map(pos, assoc)`. */ + mapBackward(pos: number, assoc: number): number { + let skipped = 0; + let i = this.maps.length - 1; + while (i >= 0) { + if (pos < this.firstStarts[i]) { + skipped = Math.max(skipped, 0) + 1; + if (skipped > LONG_RUN) { + i = this.getTrees().firstStart.lastFrom(i, (s) => s <= pos); + skipped = 0; + } else { + i--; + } + } else if (pos > this.newEnds[i]) { + skipped = Math.min(skipped, 0) - 1; + if (skipped < -LONG_RUN) { + const base = pos - this.shift[i + 1]; + const next = this.getTrees().backwardShiftFrom.lastFrom( + i, + (end) => end >= base, + ); + pos -= this.shift[i + 1] - this.shift[next + 1]; + i = next; + skipped = 0; + } else { + pos -= this.shift[i + 1] - this.shift[i]; + i--; + } + } else { + this.inverted[i] ??= this.maps[i].invert(); + pos = this.inverted[i]!.map(pos, assoc); + skipped = 0; + i--; + } + } + return pos; + } + + private getTrees() { + this.trees ??= { + firstStart: new SegmentTree(this.firstStarts, "min"), + forwardShiftFrom: new SegmentTree( + this.oldEnds.map((end, i) => end - this.shift[i]), + "max", + ), + backwardShiftFrom: new SegmentTree( + this.newEnds.map((end, i) => end - this.shift[i + 1]), + "max", + ), + }; + return this.trees; + } +} + +/** Above this many maps in a row that can be skipped, a tree skips the rest. */ +const LONG_RUN = 8; + +/** Finds the nearest value that matches a monotone min/max condition. */ +class SegmentTree { + private readonly size: number; + private readonly tree: number[]; + + constructor( + private readonly values: number[], + private readonly kind: "min" | "max", + ) { + this.size = values.length; + this.tree = new Array(4 * Math.max(1, values.length)); + if (values.length) { + this.build(1, 0, values.length - 1); + } + } + + /** + * The first index at or after `from` whose value matches, or `size`. + * `matches` must hold for a subtree's min (or max) whenever it holds for + * any value in it. + */ + firstFrom(from: number, matches: (value: number) => boolean): number { + const found = this.first(1, 0, this.size - 1, from, matches); + return found === -1 ? this.size : found; + } + + /** The last index at or before `to` whose value matches, or -1. */ + lastFrom(to: number, matches: (value: number) => boolean): number { + return this.last(1, 0, this.size - 1, to, matches); + } + + /** + * Whether `found` holds for an index at or before `to` whose value + * matches, checking those indexes from the last one down. + */ + someBefore( + to: number, + matches: (value: number) => boolean, + found: (index: number) => boolean, + ): boolean { + for (let i = this.lastFrom(to, matches); i !== -1;) { + if (found(i)) { + return true; + } + i = i === 0 ? -1 : this.lastFrom(i - 1, matches); + } + return false; + } + + private build(node: number, lo: number, hi: number) { + if (lo === hi) { + this.tree[node] = this.values[lo]; + return; + } + const mid = (lo + hi) >> 1; + this.build(2 * node, lo, mid); + this.build(2 * node + 1, mid + 1, hi); + this.tree[node] = + this.kind === "min" + ? Math.min(this.tree[2 * node], this.tree[2 * node + 1]) + : Math.max(this.tree[2 * node], this.tree[2 * node + 1]); + } + + private first( + node: number, + lo: number, + hi: number, + from: number, + matches: (value: number) => boolean, + ): number { + if (hi < from || !matches(this.tree[node])) { + return -1; + } + if (lo === hi) { + return lo; + } + const mid = (lo + hi) >> 1; + const left = this.first(2 * node, lo, mid, from, matches); + return left !== -1 + ? left + : this.first(2 * node + 1, mid + 1, hi, from, matches); + } + + private last( + node: number, + lo: number, + hi: number, + to: number, + matches: (value: number) => boolean, + ): number { + if (lo > to || !matches(this.tree[node])) { + return -1; + } + if (lo === hi) { + return lo; + } + const mid = (lo + hi) >> 1; + const right = this.last(2 * node + 1, mid + 1, hi, to, matches); + return right !== -1 ? right : this.last(2 * node, lo, mid, to, matches); + } +} diff --git a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts index 026dc91ea8..ddbab38b35 100644 --- a/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts +++ b/packages/core/src/extensions/PreviousBlockType/PreviousBlockType.ts @@ -2,7 +2,7 @@ import { findChildrenInRange } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import { getNodeId } from "../../api/getBlockInfoFromPos.js"; -import { getChangedRange } from "../../api/getChangedRange.js"; +import { getChangedRangeWithAttrs } from "../../api/getChangedRangeWithAttrs.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; const PLUGIN_KEY = new PluginKey(`previous-blocks`); @@ -74,9 +74,9 @@ export const PreviousBlockTypeExtension = createExtension(() => { } // Only check nodes in the changed range, not the whole document. - // getChangedRange() also covers attribute-only steps (AttrStep), so a + // getChangedRangeWithAttrs() also covers attribute-only steps (AttrStep), so a // block whose `level`/`index` changes with no content edit is caught. - const newRange = getChangedRange(transaction); + const newRange = getChangedRangeWithAttrs(transaction); if (!newRange) { return prev; } diff --git a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts index eb407f7706..4a215034a2 100644 --- a/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts +++ b/packages/core/src/extensions/tiptap-extensions/Link/helpers/autolink.ts @@ -1,12 +1,9 @@ import type { NodeWithPos } from "@tiptap/core"; -import { - combineTransactionSteps, - findChildrenInRange, - getChangedRanges, - getMarksBetween, -} from "@tiptap/core"; +import { findChildrenInRange, getMarksBetween } from "@tiptap/core"; import type { MarkType } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { combineTransactionSteps } from "../../../../api/combineTransactionSteps.js"; +import { getChangedRanges } from "../../../../api/getChangedRanges.js"; import type { LinkMatch } from "./linkDetector.js"; import { tokenizeLink } from "./linkDetector.js"; @@ -58,9 +55,7 @@ export function autolink(options: AutolinkOptions): Plugin { } const { tr } = newState; - const transform = combineTransactionSteps(oldState.doc, [ - ...transactions, - ]); + const transform = combineTransactionSteps(oldState.doc, transactions); const changes = getChangedRanges(transform); changes.forEach(({ newRange }) => { diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts index dbb95c9a8c..d4fb1ba0a2 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.test.ts @@ -113,43 +113,29 @@ describe("UniqueID: duplicate id handling", () => { expect(new Set(ids).size).toBe(4); }); - it("preserves the duplicate id of a suggested-deletion block while still rewriting the plain duplicate", () => { + it("keeps the shared id of a suggested-deletion block and its surviving copy", () => { editor = createEditor(); const view = editor._tiptapEditor.view; // Insert two new blocks sharing the id "dup" in a single transaction: a // plain (live) one and a suggested-deletion one (y-attributed-delete mark). - // The plain block's id is rewritten, but the suggested-deletion block MUST - // keep its "dup" id, because in suggestion mode it intentionally shares the - // id with the surviving node. + // This is how a version diff shows a re-created block (a type change or a + // move): the deleted original next to its copy, with the same id. Neither + // id is rewritten, otherwise the shown copy gets a different id. const liveDup = makeBlockContainer(editor, "dup", "Live dup", false); const deletedDup = makeBlockContainer(editor, "dup", "Deleted dup", true); const firstBlock = view.state.doc.firstChild!.firstChild!; const insertPos = firstBlock.nodeSize + 1; - // Insert the live block first, then the suggested-deletion block after it. view.dispatch(view.state.tr.insert(insertPos, [liveDup, deletedDup])); - const ids = getBlockIds(view.state.doc); - - expect(ids).toHaveLength(4); - // The suggested-deletion block keeps "dup". - const dupCount = ids.filter((id) => id === "dup").length; - expect(dupCount).toBe(1); - - // Confirm it is specifically the suggested-deletion node that kept "dup". - let suggestedDeletionId: string | null = null; - view.state.doc.descendants((node) => { - if ( - node.type.name === "blockContainer" && - node.marks.some((m) => m.type.name === "y-attributed-delete") - ) { - suggestedDeletionId = node.attrs.id; - } - return true; - }); - expect(suggestedDeletionId).toBe("dup"); + expect(getBlockIds(view.state.doc)).toEqual([ + "block-a", + "dup", + "dup", + "block-b", + ]); }); it("exposes distinct ids in editor.document even though two ProseMirror nodes share the same id", () => { diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index c6c57a72c9..a654823f9c 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -1,13 +1,9 @@ -import { - combineTransactionSteps, - Extension, - findChildrenInRange, - getChangedRanges, -} from "@tiptap/core"; +import { Extension, findChildrenInRange } from "@tiptap/core"; import { uuidv4 } from "lib0/random"; import { Fragment, Node, Slice } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; -import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js"; +import { combineTransactionSteps } from "../../../api/combineTransactionSteps.js"; +import { getChangedRanges } from "../../../api/getChangedRanges.js"; /** * Code from Tiptap UniqueID extension (https://tiptap.dev/api/extensions/unique-id) @@ -158,12 +154,12 @@ const UniqueID = Extension.create({ } const { tr } = newState; const { types, generateID } = this.options; - const transform = combineTransactionSteps( - oldState.doc, - transactions as any, - ); + const transform = combineTransactionSteps(oldState.doc, transactions); const { mapping } = transform; // get changed ranges based on the old state + // TODO: one range for all changes (`getChangedRangeWithAttrs`) would + // be simpler and faster, and might be a better solution, but needs a + // careful look at which ids it would rewrite. const changes = getChangedRanges(transform); changes.forEach(({ newRange }) => { @@ -174,7 +170,10 @@ const UniqueID = Extension.create({ return types.includes(node.type.name); }, ); + // A block marked as deleted shares its id with its surviving + // copy (see `isMarkedDeleted`), so it doesn't count as a duplicate. const newIds = newNodes + .filter(({ node }) => !isMarkedDeleted(node)) .map(({ node }) => node.attrs.id) .filter((id) => id !== null); const duplicatedNewIds = findDuplicates(newIds); @@ -224,11 +223,12 @@ const UniqueID = Extension.create({ }); return; } - // check if the node doesn’t exist in the old state - const { deleted } = mapping.invert().mapResult(pos); - const newNode = deleted && duplicatedNewIds.includes(id); - // purposefully skip rewriting ids for suggested deletion nodes, to avoid modifying them - if (newNode && !isSuggestedDeletionNode(node)) { + // check if the node doesn’t exist in the old state. Mapping + // through every step is slow, so only for duplicated ids. + const newNode = + duplicatedNewIds.includes(id) && + mapping.invert().mapResult(pos).deleted; + if (newNode) { tr.setNodeMarkup(pos, undefined, { ...node.attrs, id: generateID(), diff --git a/packages/core/src/y/extensions/AttributionExtension.ts b/packages/core/src/y/extensions/AttributionExtension.ts index 4f47e96a8b..6d6143577e 100644 --- a/packages/core/src/y/extensions/AttributionExtension.ts +++ b/packages/core/src/y/extensions/AttributionExtension.ts @@ -1,6 +1,5 @@ -import { AddNodeMarkStep } from "prosemirror-transform"; -import { getChangedRanges } from "@tiptap/core"; import { Plugin, PluginKey, type Transaction } from "prosemirror-state"; +import { getChangedRangeWithAttrs } from "../../api/getChangedRangeWithAttrs.js"; import { createExtension, createStore, @@ -132,34 +131,15 @@ export const AttributionExtension = createExtension( const store = createStore(undefined); - // Load the authors of the attribution marks in `tr`'s changed ranges, so + // Load the authors of the attribution marks in `tr`'s changed range, so // their colors/usernames resolve (colors then flow to marks via `syncRootVars`). - // `getChangedRanges` covers mark-only steps too — which suggestion mode adds - // over existing text and `tr.changedRange()` would miss. + // The range covers mark-only and node-mark steps too — which suggestion + // mode adds over existing text and `tr.changedRange()` would miss. const loadChangedUsers = (tr: Transaction) => { - const ranges = getChangedRanges(tr); - // Most changes are local (often several steps in one small span), so scan a - // single range spanning all of them rather than each range individually. - let from = Infinity; - let to = -Infinity; - for (const { newRange } of ranges) { - from = Math.min(from, newRange.from); - to = Math.max(to, newRange.to); - } - + const range = getChangedRangeWithAttrs(tr); const ids = new Set(); - // AddNodeMarkStep has an empty position map, so getChangedRanges cannot - // locate its node. Load its authors directly from the added mark. - for (const step of tr.steps) { - if ( - step instanceof AddNodeMarkStep && - step.mark.type.name in ATTRIBUTION_MARK_TYPES - ) { - getAttributionUserIds(step.mark).forEach((id) => ids.add(id)); - } - } - if (ranges.length > 0) { - tr.doc.nodesBetween(from, to, (node) => { + if (range) { + tr.doc.nodesBetween(range.from, range.to, (node) => { for (const mark of node.marks) { if ( ATTRIBUTION_MARK_TYPES[ diff --git a/packages/core/src/y/extensions/versionDiffPerformance.test.ts b/packages/core/src/y/extensions/versionDiffPerformance.test.ts new file mode 100644 index 0000000000..2079f5566e --- /dev/null +++ b/packages/core/src/y/extensions/versionDiffPerformance.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ +import * as Y from "@y/y"; +import { Plugin } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { blocksToYType } from "../utils.js"; +import { withCollaboration } from "./index.js"; +import { createYVersionView } from "./Versioning.js"; + +type Editor = BlockNoteEditor; + +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.unmount(); + } +}); + +function collaborativeEditor(doc: Y.Doc): Editor { + const editor = BlockNoteEditor.create( + withCollaboration({ + collaboration: { + fragment: doc.get("doc"), + user: { name: "Test", color: "#ff0000" }, + }, + }), + ); + const element = document.createElement("div"); + document.body.appendChild(element); + editor.mount(element); + editors.push(editor); + return editor; +} + +/** Records the step count of every document change `editor` dispatches. */ +function recordSteps(editor: Editor): number[] { + const steps: number[] = []; + editor.prosemirrorView!.updateState( + editor.prosemirrorState.reconfigure({ + plugins: [ + ...editor.prosemirrorState.plugins, + new Plugin({ + filterTransaction(tr) { + if (tr.docChanged) { + steps.push(tr.steps.length); + } + return true; + }, + }), + ], + }), + ); + return steps; +} + +describe("Version diff performance", () => { + // The binding renders a diff as one transaction, with a step per change. + // Plugins then handle it once, without replaying its steps + // (`combineTransactionSteps`), so a large diff stays fast. + it("shows a version in one transaction", () => { + const blockCount = 300; + const doc = new Y.Doc({ gc: false }); + const seed = BlockNoteEditor.create(); + seed.replaceBlocks( + seed.document, + Array.from({ length: blockCount }, (_, i) => ({ + id: `b${i}`, + type: "paragraph" as const, + content: `Block ${i}`, + })), + ); + blocksToYType(seed, seed.document, doc.get("doc")); + const before = Y.encodeStateAsUpdateV2(doc); + + // Indenting re-creates a block, so every other block is shown twice: + // deleted at its old place and inserted at its new place. + const user = collaborativeEditor(doc); + for (let i = 1; i < blockCount; i += 2) { + user.setTextCursorPosition(`b${i}`); + user.nestBlock(); + } + const after = Y.encodeStateAsUpdateV2(doc); + + const viewDoc = new Y.Doc(); + Y.applyUpdateV2(viewDoc, after); + const editor = collaborativeEditor(viewDoc); + const steps = recordSteps(editor); + createYVersionView(editor, viewDoc.get("doc")) + .open() + .show({ + content: after, + comparison: { content: before }, + target: { type: "snapshot", id: "after" }, + }); + + expect(steps).toHaveLength(1); + }); +});