Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c1a44ff
fix(versioning): don't attribute concurrently added content to a bloc…
YousefED Oct 8, 2026
78a68a3
fix(versioning): diff structural changes made by the old Yjs binding
YousefED Oct 8, 2026
772b69a
feat(versioning): diff nesting changes in place
YousefED Oct 8, 2026
626cebb
fix(versioning): read merged block groups outside the editor
YousefED Oct 8, 2026
b1142ae
refactor(versioning): use function declarations in splitChangedBlocks
YousefED Oct 8, 2026
81a030c
Merge branch 'fix/legacy-version-diff-structure' into feat/nesting-di…
YousefED Oct 8, 2026
df1904f
fix(versioning): don't attribute a move whose copy was lost to the mover
YousefED Oct 8, 2026
86d1a6c
Merge branch 'fix/version-diff-cascade-attribution' into fix/legacy-v…
YousefED Oct 8, 2026
f58750b
Merge branch 'fix/legacy-version-diff-structure' into feat/nesting-di…
YousefED Oct 8, 2026
59e58d7
test(versioning): blame no one for a block lost to cascading indents
YousefED Oct 8, 2026
1033fb6
fix(versioning): credit a moved block's deletion to its deleter, not …
YousefED Oct 8, 2026
86a5835
Merge branch 'fix/version-diff-cascade-attribution' into fix/legacy-v…
YousefED Oct 8, 2026
b577f25
test(versioning): keep moved-block diffs from emptying the document
YousefED Oct 8, 2026
5c0c1bf
Merge branch 'fix/version-diff-cascade-attribution' into fix/legacy-v…
YousefED Oct 8, 2026
0864205
Merge branch 'fix/legacy-version-diff-structure' into feat/nesting-di…
YousefED Oct 8, 2026
dda208f
fix(versioning): read only authors when crediting moves
YousefED Oct 8, 2026
833d943
fix(versioning): don't show changes to a block's id
YousefED Oct 8, 2026
6b4ae25
chore(gallery): attribute version diffs like YHub
YousefED Oct 8, 2026
d3fb4cd
refactor(versioning): show lost moves without an author instead of cr…
YousefED Oct 8, 2026
f833e8a
Merge branch 'fix/version-diff-cascade-attribution' into fix/legacy-v…
YousefED Oct 8, 2026
f86d341
Merge branch 'fix/legacy-version-diff-structure' into feat/nesting-di…
YousefED Oct 8, 2026
015cc52
test(versioning): keep deleted content in every cloned doc
YousefED Oct 8, 2026
475180d
Merge branch 'fix/version-diff-cascade-attribution' into fix/legacy-v…
YousefED Oct 8, 2026
51e4ac0
Merge branch 'fix/legacy-version-diff-structure' into feat/nesting-di…
YousefED Oct 8, 2026
e9ea6c5
Merge branch 'feat/versioning-sidebar-ux-b' into fix/version-diff-cas…
YousefED Oct 8, 2026
c3ed733
Merge branch 'fix/version-diff-cascade-attribution' into fix/legacy-v…
YousefED Oct 8, 2026
140ce15
Merge branch 'fix/legacy-version-diff-structure' into feat/nesting-di…
YousefED Oct 8, 2026
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
12 changes: 6 additions & 6 deletions examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,7 @@ export const scenarios: SuggestionScenario[] = [
feedback: [
{
severity: "low",
note: "Block N1 appears in two places. Previously this concurrency scenario would also not be correctly handled (one of the edits would be dropped).",
note: "N2 is lost: B's indent moves it into the original N1, which A's indent deletes. Versioning shows N2 deleted without an author (B only moved it, and A never saw it there). Before nesting changes diffed in place, N1 appeared twice instead. Needs move support.",
},
],
title: "Cascading indents",
Expand All @@ -1047,7 +1047,7 @@ export const scenarios: SuggestionScenario[] = [
feedback: [
{
severity: "high",
note: "Q appears twice: B's indent moves a copy of Q under R, and A's nesting replaces the original Q with another copy holding B1–B3.",
note: "B1–B3 are lost. A's indent writes them into the original Q, which B's indent concurrently deletes (a move inserts a copy of Q, without them). Versioning shows them deleted without an author. Before nesting changes diffed in place, Q appeared twice instead. Needs move support.",
},
],
title: "Nest blocks into a block that is moved",
Expand Down Expand Up @@ -1103,8 +1103,8 @@ export const scenarios: SuggestionScenario[] = [
id: "concurrent-move-into-deleted-block",
feedback: [
{
severity: "high",
note: "X is lost: B's move inserts a copy into Parent, which A deletes. Versioning shows X as deleted by B, though B only moved it, and doesn't show Child at all. To be fixed by #3166.",
severity: "info",
note: "X is lost: B's move inserts a copy into Parent, which A deletes. Versioning shows X deleted without an author: B only moved it, and A never saw it there.",
},
],
title: "Move a block into a block that is deleted",
Expand Down Expand Up @@ -1133,8 +1133,8 @@ export const scenarios: SuggestionScenario[] = [
id: "concurrent-delete-parent-vs-child-type",
feedback: [
{
severity: "high",
note: "B's type change is lost with Parent. Versioning shows Parent as deleted by A, but doesn't show Child at all. To be fixed by #3166.",
severity: "info",
note: "B's type change is lost with Parent. Versioning shows Parent deleted by A, and Child deleted without an author: B's change replaced Child with a copy that was lost, and the diff can't tell who to credit.",
},
],
title: "Delete a parent vs change its child's type",
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/y/extensions/AttributionExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,7 @@ describe("AttributionExtension user loading", () => {
});
});

// To be fixed by #3166.
it.fails("does not show a change to a block's id", () => {
it("does not show a change to a block's id", () => {
const { editor } = createEditor();
editor.replaceBlocks(editor.document, [
{ type: "paragraph", content: "hello" },
Expand Down
53 changes: 15 additions & 38 deletions packages/core/src/y/extensions/AttributionExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,19 +230,16 @@ export const AttributionExtension = createExtension(
.map((id) => userStore.getUser(id)?.username ?? id)
.filter((username) => username !== "");

// A stable identity string for a wrapper (empty if unattributed), used to
// (a) test whether a mark is attributed and (b) group adjacent marks with
// the *same* attribution under one tooltip. It's an internal grouping key,
// not the displayed text — that's composed in the view from `users` and
// the format label — so it's built from raw `data-*` (ids + format keys)
// and stays free of i18n/username resolution.
// A stable identity string for a mark wrapper, used to group nested marks
// with the *same* change under one tooltip. Every wrapper is a change;
// its author may be unknown (e.g. content removed along with a
// concurrently deleted block). It's an internal grouping key, not the
// displayed text, so it's built from raw `data-*` and stays free of
// i18n/username resolution.
const attributionIdentity = (wrapper: HTMLElement) => {
const ids = parseUserIds(wrapper.dataset["userIds"]);
if (ids.length === 0 && wrapper.dataset["attributes"] === undefined) {
return "";
}
const format = parseFormatKeys(wrapper.dataset["format"]);
return `${wrapper.dataset["attributes"] ?? ""}:${format.join(",")}:${ids.join(",")}`;
return `${wrapper.tagName}:${wrapper.dataset["attributes"] ?? ""}:${format.join(",")}:${ids.join(",")}`;
};

// Build the tooltip state from a wrapper's `data-*` attributes. A
Expand Down Expand Up @@ -302,24 +299,6 @@ export const AttributionExtension = createExtension(
store.setState(undefined);
};

// The innermost attributed mark at or above `el`, skipping unattributed
// wrappers so an attributed ancestor still wins.
const innermostAttributed = (
el: Element | null,
): HTMLElement | undefined => {
while (el) {
const wrapper = el.closest<HTMLElement>(ATTRIBUTION_MARK_SELECTOR);
if (!wrapper) {
return undefined;
}
if (attributionIdentity(wrapper)) {
return wrapper;
}
el = wrapper.parentElement;
}
return undefined;
};

const nodeAttribution = (
target: Element,
): { mark: HTMLElement; preview: Element } | undefined => {
Expand Down Expand Up @@ -356,17 +335,18 @@ export const AttributionExtension = createExtension(
) {
return undefined;
}
const mark = Array.from(
owner.querySelectorAll<HTMLElement>(ATTRIBUTION_MARK_SELECTOR),
).find(attributionIdentity);
const mark = owner.querySelector<HTMLElement>(
ATTRIBUTION_MARK_SELECTOR,
);
return mark ? { mark, preview: owner } : undefined;
};

const onPointerOver = (event: Event) => {
const target = event.target instanceof Element ? event.target : null;
const hoveredMark =
target && dom.contains(target)
? innermostAttributed(target)
? (target.closest<HTMLElement>(ATTRIBUTION_MARK_SELECTOR) ??
undefined)
: undefined;
const fallback =
target && !hoveredMark ? nodeAttribution(target) : undefined;
Expand All @@ -379,21 +359,18 @@ export const AttributionExtension = createExtension(

const identity = attributionIdentity(innermost);
// Anchor on the outermost ancestor with the *same* attribution so one
// tooltip covers the whole region; a differently-attributed ancestor
// breaks the chain, and unattributed ones are climbed past.
// tooltip covers the whole region; a different ancestor breaks the chain.
let anchor = innermost;
let el: Element | null = innermost.parentElement;
while (el) {
const ancestor = el.closest<HTMLElement>(ATTRIBUTION_MARK_SELECTOR);
if (!ancestor) {
break;
}
const ancestorIdentity = attributionIdentity(ancestor);
if (ancestorIdentity === identity) {
anchor = ancestor;
} else if (ancestorIdentity) {
if (attributionIdentity(ancestor) !== identity) {
break;
}
anchor = ancestor;
el = ancestor.parentElement;
}

Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/y/extensions/YAttributionMarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ const createAttributionMarkView =
) =>
({ mark, inline }: { mark: PMMark; inline: boolean }) => {
const editor = options?.editor;
// A block's id isn't content users edit, so a change to it isn't shown
// (e.g. a diff pairing a deleted block with the editor's empty one).
// Inline content may have an `id` prop of its own, which is.
const { id: _id, ...blockChanges } = getAttributeChanges(mark);
const changes = inline ? getAttributeChanges(mark) : blockChanges;
if (type === "attrs" && Object.keys(changes).length === 0) {
const dom = document.createElement(inline ? "span" : "div");
dom.style.display = "contents";
return { dom, contentDOM: dom };
}
// `<ins>`/`<del>` are semantic elements. The modification mark has no
// dedicated element, so it renders as a `<span>` inline or a `<div>` over a
// block, matching its `parseDOM` rules.
Expand All @@ -131,7 +141,7 @@ const createAttributionMarkView =
});
if (type === "attrs") {
dom.dataset["type"] = "attributes";
dom.dataset["attributes"] = JSON.stringify(getAttributeChanges(mark));
dom.dataset["attributes"] = JSON.stringify(changes);
}
if (type === "modification") {
dom.dataset["type"] = "modification";
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/y/extensions/YSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
createExtension,
} from "../../editor/BlockNoteExtension.js";
import { blockMatchNodes } from "./blockMatchNodes.js";
import { mergeBlockGroups } from "./mergeBlockGroups.js";
import { docToBlocks } from "../../api/nodeConversions/nodeToBlock.js";
import type {
BlockSchema,
Expand Down Expand Up @@ -178,6 +179,8 @@ export const YSyncExtension = createExtension(
// needed; `blockContainer` already whitelists the `y-attributed-*`
// marks. See blockMatchNodes.ts.
customCompare: blockMatchNodes,
// Nesting changes diff in place: show a block's groups as one group
transformers: [mergeBlockGroups],
// Initial-empty gate: a single empty paragraph (any id/props) must
// not seed an empty Y fragment — see isInitialBlockNoteDoc above
// and "Initial-content gate" in ProsemirrorRdt's doc.
Expand Down
28 changes: 0 additions & 28 deletions packages/core/src/y/extensions/blockMatchNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,6 @@ const firstChild = (
return null;
};

/**
* Whether a `blockContainer` delta carries a child `blockGroup` — i.e. the block
* has nested children. A container's content is `blockContent blockGroup?`, so
* this is what tells a leaf block apart from a parent.
*/
const hasBlockGroup = (d: schema.Unwrap<typeof $prosemirrorDelta>): boolean => {
for (const op of (d as any).children) {
if (delta.$insertOp.check(op)) {
for (const it of op.insert) {
if (delta.$deltaAny.check(it) && it.name === "blockGroup") {
return true;
}
}
}
}
return false;
};

function getTableDimensions(
d: schema.Unwrap<typeof $prosemirrorDelta>,
): { rows: number; cols: number } | null {
Expand Down Expand Up @@ -155,16 +137,6 @@ export const blockMatchNodes = (
return false;
}

// A change in nesting is structural too: if one container gains or loses a
// child `blockGroup`, diffing it in place would insert/delete the blockGroup as
// a sibling of the block content inside a single container — schema-invalid.
// Treat it as different so the whole container is replaced instead, same as a
// content-type change. Keeps concurrent nesting merges (e.g. two users nesting
// a block under the same parent) from producing a lopsided in-place result.
if (hasBlockGroup(a) !== hasBlockGroup(b)) {
return false;
}

if (childA?.name === "table" && childB?.name === "table") {
const dimA = getTableDimensions(childA);
const dimB = getTableDimensions(childB);
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/y/extensions/legacyYjsDocBinding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,7 @@ describe("legacy Yjs document binding", () => {
]);
});

// To be fixed by #3167.
it.fails.each(structuralChanges)(
it.each(structuralChanges)(
"diffs $name made with the old binding like one made with the new binding",
({ blocks, change }) => {
const legacy = createLegacyEditor();
Expand Down
Loading
Loading