Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"@uppy/xhr-upload": "^3.4.0",
"@vercel/analytics": "^1.6.1",
"@y-sweet/react": "^0.6.3",
"@y/prosemirror": "^2.0.0-14",
"@y/prosemirror": "^2.0.0-15",
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.26",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"react-dom": "^19.2.3",
"@y/protocols": "^1.0.6-rc.1",
"@y/y": "^14.0.0-rc.26",
"@y/prosemirror": "^2.0.0-14",
"@y/prosemirror": "^2.0.0-15",
"@y/websocket": "^4.0.0-rc.2"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion examples/07-collaboration/11-versioning-yjs13/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@y/prosemirror": "^2.0.0-14",
"@y/prosemirror": "^2.0.0-15",
"@y/protocols": "^1.0.6-rc.1",
"@y/y": "^14.0.0-rc.26",
"y-websocket": "^2.1.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.26",
"lib0": "1.0.0-rc.36",
"@y/prosemirror": "^2.0.0-14"
"@y/prosemirror": "^2.0.0-15"
},
"devDependencies": {
"@types/react": "^19.2.3",
Expand Down
2 changes: 1 addition & 1 deletion examples/07-collaboration/13-versioning-yjs14/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@mantine/hooks": "^9.0.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@y/prosemirror": "^2.0.0-14",
"@y/prosemirror": "^2.0.0-15",
"@y/protocols": "^1.0.6-rc.1",
"@y/websocket": "^4.0.0-3",
"@y/y": "^14.0.0-rc.26",
Expand Down
39 changes: 15 additions & 24 deletions examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ export const scenarios: SuggestionScenario[] = [
},
{
severity: "high",
note: "Indenting re-creates Parent and Child as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows both as deleted and inserted again, all credited to whoever indented.",
note: "Indenting moves Child: the diff shows it as deleted at its old place and inserted under Parent, credited to whoever indented. Parent itself is unchanged.",
},
],
title: "Nest a bullet under another",
Expand Down Expand Up @@ -300,12 +300,7 @@ export const scenarios: SuggestionScenario[] = [
{
kind: "single",
id: "delete-nested",
feedback: [
{
severity: "high",
note: "Deleting the only child re-creates Parent as a new block (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows Parent as deleted and inserted again, credited to whoever deleted the child.",
},
],
feedback: [],
title: "Delete a nested block",
category: "Add / remove blocks",
description: "Delete the nested child of a parent block.",
Expand Down Expand Up @@ -645,7 +640,7 @@ export const scenarios: SuggestionScenario[] = [
feedback: [
{
severity: "high",
note: "Indenting re-creates N0 and N1 as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows both as deleted and inserted again, all credited to whoever indented.",
note: "Indenting moves N1: the diff shows it as deleted at its old place and inserted under N0, credited to whoever indented. N0 itself is unchanged.",
},
],
title: "Indent a block",
Expand All @@ -668,7 +663,7 @@ export const scenarios: SuggestionScenario[] = [
feedback: [
{
severity: "high",
note: "Outdenting re-creates N0 and N1 as new blocks (the schema fix stores a block that gains or loses its children as a new block). The diff therefore shows N0 and N1 as deleted and inserted again, all credited to whoever outdented.",
note: "Outdenting moves N1: the diff shows it as deleted under N0 and inserted after it, credited to whoever outdented. N0 itself is unchanged.",
},
],
title: "Unindent a block",
Expand Down Expand Up @@ -1043,12 +1038,12 @@ export const scenarios: SuggestionScenario[] = [
id: "concurrent-indent-cascade",
feedback: [
{
severity: "high",
note: "The diff also shows N0 and N2 as deleted and inserted again: indenting re-creates blocks (see Indent a block).",
severity: "low",
note: "N2 is lost. Indenting is a move, which deletes the original block and inserts a copy: B nests N2 into the original N1, which A's indent deletes, so N2 goes with it (shown as deleted by B, whose indent deleted N2's own original). Before nesting changes diffed in place, B's indent replaced N1 instead, so N1 appeared twice. Needs move support.",
},
{
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).",
severity: "high",
note: "Versioning shows N2 as deleted by B, though B only indented it.",
},
],
title: "Cascading indents",
Expand Down Expand Up @@ -1078,7 +1073,7 @@ export const scenarios: SuggestionScenario[] = [
},
{
severity: "high",
note: "The diff also shows N0 and N1 as deleted and inserted again, all credited to A: indenting re-creates blocks (see Indent a block).",
note: "The diff shows N1 as deleted at its old place and inserted under N0, all credited to A: indenting moves the block (see Indent a block). N0 itself is unchanged.",
},
],
title: "Indent a block vs edit its text",
Expand All @@ -1102,12 +1097,12 @@ export const scenarios: SuggestionScenario[] = [
id: "concurrent-nest-into-moved-block",
feedback: [
{
severity: "high",
note: "The diff also shows R and B1–B3 as deleted and inserted again: indenting and moving re-create blocks.",
severity: "low",
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); they show as deleted by A, whose indent deleted their originals. Before nesting changes diffed in place, Q appeared twice instead. Needs move support.",
},
{
severity: "low",
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.",
severity: "high",
note: "Versioning shows B1–B3 as deleted by A, though A only nested them.",
},
],
title: "Nest blocks into a block that is moved",
Expand Down Expand Up @@ -1258,12 +1253,8 @@ export const scenarios: SuggestionScenario[] = [
id: "concurrent-nest-both-under-n0",
feedback: [
{
severity: "high",
note: "The diff also shows N0 as deleted and inserted twice, credited to A and to B: nesting re-creates it.",
},
{
severity: "low",
note: "In this concurrent editing scenario the N0 block is duplicated. Previously this scenario would likely drop one of the changes, so it's not a regression per se. A better fix for the schema compatibility could resolve this.",
severity: "info",
note: "Both new blocks end up under the one N0. Every block's child group lives in the shared document from the start (hidden while empty), so A and B nest into the same group instead of each creating their own, and the merge is an ordinary concurrent insert.",
},
],
title: "Both nest a new block under N0",
Expand Down
2 changes: 1 addition & 1 deletion examples/08-extensions/02-versioning/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"react": "^19.2.3",
"react-dom": "^19.2.3",
"@y/y": "^14.0.0-rc.26",
"@y/prosemirror": "^2.0.0-14",
"@y/prosemirror": "^2.0.0-15",
"react-icons": "^5.5.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
"yjs": "^13.6.33"
},
"peerDependencies": {
"@y/prosemirror": "^2.0.0-14",
"@y/prosemirror": "^2.0.0-15",
"@y/protocols": "^1.0.6-rc.1",
"@y/y": "^14.0.0-rc.26",
"y-prosemirror": "^1.3.7",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/pm-nodes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ content: "blockContent blockGroup?",

A `blockContainer` is a container node that always contains a `blockContent` node, and optionally a `blockGroup` node (for nested children). It is used as the wrapper for most blocks. This structure makes it possible to nest blocks within blocks.

In a collaborative (Yjs) document the `blockGroup` of a block is persistent: it is created together with the block, stays in the Y document when its last child is removed, and is hidden from the ProseMirror document while it is empty. The `blockContainer` in Y therefore holds `blockContent blockGroup` (or, in documents written before this, `blockContent` until the first nesting), and a container may even hold several `blockGroup`s after two users nested a first child concurrently; the editor shows them as one. See `y/extensions/persistentBlockGroups.ts`.

### BlockContent (group)

Example:
Expand Down
7 changes: 7 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 { persistentBlockGroups } from "./persistentBlockGroups.js";
import { docToBlocks } from "../../api/nodeConversions/nodeToBlock.js";
import type {
BlockSchema,
Expand Down Expand Up @@ -178,6 +179,12 @@ export const YSyncExtension = createExtension(
// needed; `blockContainer` already whitelists the `y-attributed-*`
// marks. See blockMatchNodes.ts.
customCompare: blockMatchNodes,
// Nested `blockGroup`s stay in Y once they exist (hidden while
// empty) and every new block gets one, so two users nesting a first
// child under the same block concurrently write into the same
// group instead of each inserting their own. See
// persistentBlockGroups.ts.
transformers: [persistentBlockGroups],
// 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
36 changes: 8 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 @@ -132,6 +114,14 @@ function getTableDimensions(
* keeps the same first-child type => same identity => the diff descends and
* merges as usual.
*
* A change in nesting is *not* an identity change: a container that gains or
* loses its child `blockGroup` is diffed in place. The persistent block group
* transformer (see persistentBlockGroups.ts) keeps every `blockGroup` in Y and
* routes the view's group insert into the container's existing group, so the
* in-place diff can never leave two groups in one container. Replacing the
* container instead would recreate the race the transformer removes (two users
* nesting a first child concurrently would each insert their own container).
*
* @param a removed (old) node
* @param b inserted (new) node
* @returns whether `a` and `b` are the same node (diff in place) vs different (replace)
Expand All @@ -157,16 +147,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;
}

// Disabled: replacing a table that one edit reshapes both ways silently
// dropped concurrent edits to it, and version diffs compare all the changes
// between two versions, so concurrent one-way reshapes (a row and a column)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/y/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export function withCollaboration<
export * from "./AttributionExtension.js";
export * from "./DiffVersioningExtension.js";
export * from "./ForkYDoc.js";
export * from "./persistentBlockGroups.js";
export * from "./RelativePositionMapping.js";
export * from "./Suggestions.js";
export * from "./YAttributionMarks.js";
Expand Down
Loading
Loading