Skip to content

Commit f7505eb

Browse files
committed
fix(versioning): diff structural changes made by the old Yjs binding
The old binding changed a block's type in place, keeping the old and the new content in one block, which a diff renders as schema-invalid content that is then dropped. Before rendering a diff, replace each block that changed structurally since the earlier version with a copy, as the current binding stores such a change, so it shows as a deleted and an inserted block. The copy and the deletion keep the change's authors. The versioning snapshot shows that this also splits tables two users resize concurrently (to revisit).
1 parent 4125870 commit f7505eb

3 files changed

Lines changed: 281 additions & 75 deletions

File tree

‎packages/core/src/y/extensions/legacyYjsDocBinding.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,7 @@ describe("legacy Yjs document binding", () => {
383383
]);
384384
});
385385

386-
// To be fixed by #3173.
387-
it.fails.each(structuralChanges)(
386+
it.each(structuralChanges)(
388387
"diffs $name made with the old binding like one made with the new binding",
389388
({ blocks, change }) => {
390389
const legacy = createLegacyEditor();

‎packages/core/src/y/extensions/snapshotPreview.ts‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,123 @@ import { configureYProsemirror } from "@y/prosemirror";
22
import * as Y from "@y/y";
33

44
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
5+
import { findTypeInOtherYdoc } from "../utils.js";
6+
import { blockMatchNodes } from "./blockMatchNodes.js";
57
import {
68
decodeFragmentUpdate,
79
destroyDecodedFragment,
810
} from "./snapshotCodec.js";
911

12+
/** Whether the item with this id was already in `baseline`. */
13+
function inBaseline(baseline: Y.Doc, id: Y.ID): boolean {
14+
const last = baseline.store.clients.get(id.client)?.at(-1);
15+
return last !== undefined && id.clock < last.id.clock + last.length;
16+
}
17+
18+
/** The distinct attributions recorded for `node` and its descendants. */
19+
function subtreeAttributions(
20+
node: Y.Node,
21+
map: Y.IdMap<any>,
22+
): Y.ContentAttribute<any>[] {
23+
const found = new Map<string, Y.ContentAttribute<any>>();
24+
for (const structs of node.doc!.store.clients.values()) {
25+
for (const item of structs) {
26+
if (
27+
item instanceof Y.Item &&
28+
(item === node._item || Y.isParentOf(node, item))
29+
) {
30+
for (const range of map.slice(
31+
item.id.client,
32+
item.id.clock,
33+
item.length,
34+
)) {
35+
for (const attr of range.attrs ?? []) {
36+
found.set(`${attr.name}:${String(attr.val)}`, attr);
37+
}
38+
}
39+
}
40+
}
41+
}
42+
return [...found.values()];
43+
}
44+
45+
/**
46+
* Replace each block that changed structurally since `baseline` with a fresh
47+
* copy, where "structurally" is what `blockMatchNodes` treats as a different
48+
* block (e.g. a type change). The old collaboration binding stored such changes
49+
* inside the same container, which a diff renders as schema-invalid content
50+
* that is then dropped. A fresh container diffs as a deleted block next to an
51+
* inserted one, as the current binding stores it. The copy and the deletion
52+
* take over the original's attributions, so the change keeps its author.
53+
*/
54+
function splitChangedBlocks(
55+
node: Y.Node,
56+
baseline: Y.Doc,
57+
attributions: Y.ContentMap | undefined,
58+
added: Y.ContentMap,
59+
): void {
60+
for (let index = 0; index < node.length; index++) {
61+
const child = node.get(index);
62+
if (!(child instanceof Y.Node)) {
63+
continue;
64+
}
65+
if (child.name === "blockContainer" && child._item) {
66+
// A block created after the baseline has no previous version.
67+
const before = inBaseline(baseline, child._item.id)
68+
? findTypeInOtherYdoc(child, baseline)
69+
: undefined;
70+
if (
71+
before &&
72+
!blockMatchNodes(before.toDeltaDeep(), child.toDeltaDeep())
73+
) {
74+
const inserted = attributions
75+
? subtreeAttributions(child, attributions.inserts)
76+
: [];
77+
const deleted = attributions
78+
? subtreeAttributions(child, attributions.deletes)
79+
: [];
80+
const doc = child.doc!;
81+
// A change that only inserted (or only deleted) still has one author
82+
// for both sides of the split, under that side's attribution kind.
83+
function as(
84+
kind: "insert" | "delete",
85+
attrs: Y.ContentAttribute<any>[],
86+
) {
87+
return attrs.map((attr) => Y.createContentAttribute(kind, attr.val));
88+
}
89+
const authors = inserted.length ? inserted : deleted;
90+
function record(tr: Y.Transaction) {
91+
if (authors.length) {
92+
Y.insertIntoIdMap(
93+
added.inserts,
94+
Y.createIdMapFromIdSet(tr.insertSet, as("insert", authors)),
95+
);
96+
Y.insertIntoIdMap(
97+
added.deletes,
98+
Y.createIdMapFromIdSet(
99+
tr.deleteSet,
100+
as("delete", deleted.length ? deleted : authors),
101+
),
102+
);
103+
}
104+
}
105+
doc.on("beforeObserverCalls", record);
106+
try {
107+
doc.transact(() => {
108+
const copy = child.clone();
109+
node.delete(index);
110+
node.insert(index, [copy]);
111+
});
112+
} finally {
113+
doc.off("beforeObserverCalls", record);
114+
}
115+
continue;
116+
}
117+
}
118+
splitChangedBlocks(child, baseline, attributions, added);
119+
}
120+
}
121+
10122
/**
11123
* Decode a snapshot, diff it against a baseline if given, and render it.
12124
*
@@ -33,14 +145,32 @@ export function showSnapshotPreview(
33145
try {
34146
const snapshot = decodeFragmentUpdate(fragment, snapshotContent);
35147
try {
148+
let renderAttributions = attributions;
149+
if (baseline) {
150+
const added = Y.createContentMap();
151+
splitChangedBlocks(
152+
snapshot.fragment,
153+
baseline.doc,
154+
attributions,
155+
added,
156+
);
157+
if (attributions) {
158+
renderAttributions = Y.createContentMap(
159+
Y.mergeIdMaps([attributions.inserts, added.inserts]),
160+
Y.mergeIdMaps([attributions.deletes, added.deletes]),
161+
);
162+
}
163+
}
36164
editor.exec(
37165
configureYProsemirror({
38166
ytype: snapshot.fragment,
39167
renderer: baseline
40168
? Y.createDiffRenderer(
41169
baseline.doc,
42170
snapshot.doc,
43-
attributions ? { attributions } : undefined,
171+
renderAttributions
172+
? { attributions: renderAttributions }
173+
: undefined,
44174
)
45175
: undefined,
46176
}),

0 commit comments

Comments
 (0)