Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/core/src/api/combineTransactionSteps.ts
Original file line number Diff line number Diff line change
@@ -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<Transform, "before" | "doc" | "mapping" | "steps"> {
if (transactions.length === 1 && transactions[0].before === oldDoc) {
return transactions[0];
}
return replayTransactionSteps(oldDoc, [...transactions]);
}
31 changes: 31 additions & 0 deletions packages/core/src/api/getBlockInfoFromPos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
51 changes: 33 additions & 18 deletions packages/core/src/api/getBlockInfoFromPos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node, Map<Node, string>>();

/**
* The ids of the suggested-deletion nodes in `doc`: `<id>-<index>`, where
* `index` counts the nodes with the same id before it, in document order.
*/
function getDeletedNodeIds(doc: Node): Map<Node, string> {
let deletedNodeIds = deletedNodeIdsByDoc.get(doc);
if (deletedNodeIds === undefined) {
const ids = new Map<Node, string>();
const counts = new Map<string, number>();
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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/api/getBlocksChangedByTransaction.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Transform, "mapping" | "steps">,
): { from: number; to: number } | null {
const { mapping, steps } = transform;
let from = Number.POSITIVE_INFINITY;
Expand Down
Loading
Loading