{selected.title}
+{selected.description}
+diff --git a/examples/07-collaboration/13-versioning-yjs14/tsconfig.json b/examples/07-collaboration/13-versioning-yjs14/tsconfig.json index b34a7c8cb2..93fa81bee8 100644 --- a/examples/07-collaboration/13-versioning-yjs14/tsconfig.json +++ b/examples/07-collaboration/13-versioning-yjs14/tsconfig.json @@ -17,7 +17,7 @@ "jsx": "react-jsx", "composite": true }, - "include": [".", "src/test.ts"], + "include": ["."], "__ADD_FOR_LOCAL_DEV_references": [ { "path": "../../../packages/core/" diff --git a/examples/07-collaboration/14-suggestion-gallery/.bnexample.json b/examples/07-collaboration/14-suggestion-gallery/.bnexample.json new file mode 100644 index 0000000000..56d19955cc --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/.bnexample.json @@ -0,0 +1,10 @@ +{ + "playground": true, + "docs": false, + "author": "yousefed", + "tags": ["Advanced", "Development", "Collaboration"], + "dependencies": { + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.16" + } +} diff --git a/examples/07-collaboration/14-suggestion-gallery/README.md b/examples/07-collaboration/14-suggestion-gallery/README.md new file mode 100644 index 0000000000..aab7cb380a --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/README.md @@ -0,0 +1,18 @@ +# Suggestion Scenarios Gallery + +Browse the suggestion (track-changes) rendering scenarios interactively. Each +entry sets up a base document and applies a change in suggestion mode, so you can +see how insertions, deletions and type changes are visualized as a diff. + +The **Base** pane (left) is read-only and shows the document before the change. +The **Suggestion** pane (right) is editable — keep typing to create more +suggestions on top. + +These are the same scenarios covered by the y-prosemirror visual tests; the +per-scenario definitions live in `src/scenarios.ts` so the tests and this gallery +stay in sync. + +**Relevant Docs:** + +- [Editor Setup](/docs/editor-basics/setup) +- [Collaboration](/docs/collaboration/real-time-collaboration) diff --git a/examples/07-collaboration/14-suggestion-gallery/index.html b/examples/07-collaboration/14-suggestion-gallery/index.html new file mode 100644 index 0000000000..a8713956a6 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/index.html @@ -0,0 +1,14 @@ + +
+ + +{selected.description}
+
+ {this.state.error.message}
+
+ /
; images/files/dividers have none), so it's what tells the two cases +apart. It can be nested below `.bn-block-content` (e.g. code wraps it in
),
+hence the descendant match.
+*/
+.bn-suggestion-node--delete .bn-block-content .bn-inline-content {
+ color: var(--user-color-dark);
+ text-decoration: line-through;
+}
+
+.dark.bn-root .bn-suggestion-node--delete .bn-block-content .bn-inline-content {
+ color: var(--user-color-light);
+}
+
+/*
+Deleted table cells are a special case: a / has no `.bn-block-content` and
+its text sits in a bare (not `.bn-inline-content`), so neither the
+strikethrough above nor the block card reaches it — and a table row/cell can't
+host the "Deleted" card anyway. Treat them like inline deletions instead: strike
+the cell text through in the author's color, and suppress the fallback badge.
+*/
+.bn-suggestion-node--delete :is(td, th) p {
+ color: var(--user-color-dark);
+ text-decoration: line-through;
+}
+
+.dark.bn-root .bn-suggestion-node--delete :is(td, th) p {
+ color: var(--user-color-light);
+}
+
+.bn-suggestion-node--delete > :is(table, tr, td, th):first-child::before {
+ content: none;
+}
+
+/*
+A block with NO inline content (image, file, divider, whole table, …) has no text
+to mark up, so the suggestion is shown as a filled card in the author's color, per
+block — on insertions and deletions alike. The card lives on `.bn-block-content`
+because it's the only element present for every block type (the suggestion wrapper
+spans the whole subtree; the media wrapper exists only for files), but it sets
+only non-collapsing properties — background / radius / padding never depend on the
+content's intrinsic size, so no block can break.
+*/
+.bn-suggestion-node .bn-block-content:not(:has(.bn-inline-content)) {
+ background-color: color-mix(in srgb, var(--user-color-light) 50%, white);
+ border-radius: 16px;
+ padding: 12px;
+}
+
+.dark.bn-root
+ .bn-suggestion-node
+ .bn-block-content:not(:has(.bn-inline-content)) {
background-color: color-mix(in srgb, var(--user-color-dark) 50%, black);
}
+/*
+Media (image/video/audio/file) has an intrinsic width via its
+`.bn-file-block-content-wrapper`, so the card hugs it instead of spanning the
+column. Width-less blocks (a divider's `flex: 1`
, a table) keep full width on
+purpose — `fit-content` would collapse them to nothing, and full width is the
+right look for them anyway. This is the only place that touches sizing, and it's
+gated on a wrapper that only width-bearing blocks have.
+*/
+.bn-suggestion-node
+ .bn-block-content:not(:has(.bn-inline-content)):has(
+ > .bn-file-block-content-wrapper
+ ) {
+ width: fit-content;
+}
+
+/*
+A deletion additionally flags the block with the localized "Deleted" label, placed
+above the content (out of flow) with extra top padding reserving its row.
+*/
+.bn-suggestion-node--delete .bn-block-content:not(:has(.bn-inline-content)) {
+ position: relative;
+ padding: 48px 24px 24px;
+}
+
+.bn-suggestion-node--delete
+ .bn-block-content:not(:has(.bn-inline-content))::before {
+ content: var(--deleted-label, "Deleted");
+ /* Sits in the reserved top padding, above the content. Out of flow so it never
+ becomes a flex item beside the block. */
+ position: absolute;
+ top: 16px;
+ left: 24px;
+ font-size: 18px;
+ font-weight: 500;
+ line-height: 1.2;
+ /* Use the editor's text color (themed for light/dark) rather than inheriting,
+ which would pick up the suggestion's user color. */
+ color: var(--bn-colors-editor-text);
+}
+
/*
Modification marks (data-type="modification") are shown as a dotted underline in
the author's color rather than a filled highlight. The text and background are
diff --git a/packages/core/src/y/extensions/Versioning.ts b/packages/core/src/y/extensions/Versioning.ts
index b1d4dbbfb5..40f5d629e0 100644
--- a/packages/core/src/y/extensions/Versioning.ts
+++ b/packages/core/src/y/extensions/Versioning.ts
@@ -1,9 +1,19 @@
-import { configureYProsemirror, pauseSync } from "@y/prosemirror";
+import {
+ configureYProsemirror,
+ deltaAttributionToFormat,
+ pauseSync,
+ nodeToDelta,
+ deltaToPSteps,
+} from "@y/prosemirror";
+import * as d from "lib0/delta";
import * as Y from "@y/y";
+import { Transaction } from "prosemirror-state";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { PreviewController } from "../../extensions/Versioning/index.js";
import { findTypeInOtherYdoc } from "../utils.js";
+import { mapAttributionToMark } from "./YSync.js";
+import { blockMatchNodes } from "./blockMatchNodes.js";
/**
* Empties the document before a {@link configureYProsemirror} refill so
@@ -19,6 +29,29 @@ function clearDocumentForConfigure(editor: BlockNoteEditor) {
editor.removeBlocks(editor.document);
}
+function getProseMirrorTrFromYFragment({
+ tr,
+ fragment,
+ attributionManager,
+}: {
+ tr: Transaction;
+ fragment: Y.Type;
+ attributionManager?: Y.AbstractAttributionManager;
+}): Transaction {
+ const ycontent = deltaAttributionToFormat(
+ fragment.toDeltaDeep(attributionManager || Y.noAttributionsManager),
+ mapAttributionToMark,
+ );
+ // @todo it is preferred to apply the minimal diff - at least for debugging purposes. the
+ // document replacal is more reliable though
+
+ const pcontent = nodeToDelta(tr.doc, undefined, true);
+ const diff = d.diff(pcontent.done(), ycontent.done(), {
+ compare: blockMatchNodes,
+ });
+ return deltaToPSteps(tr, diff, undefined, undefined);
+}
+
/**
* Creates a Yjs-specific adapter that provides the {@link PreviewController}
* and `getCurrentState` callback required by the base
@@ -64,9 +97,11 @@ export function createYjsVersioningAdapter(
// views from scratch instead of reusing stale-positioned ones. See
// clearDocumentForConfigure.
clearDocumentForConfigure(editor);
- editor.exec(
- configureYProsemirror({
- ytype: findTypeInOtherYdoc(fragment, doc),
+
+ editor.exec((state, dispatch) => {
+ const tr = getProseMirrorTrFromYFragment({
+ tr: state.tr,
+ fragment: findTypeInOtherYdoc(fragment, doc),
// Pass the optional content map as `attrs` so the diff attribution
// manager knows who/when authored each change. Without it, the AM
// only produces "what changed" (empty userIds, null timestamps) and
@@ -78,8 +113,12 @@ export function createYjsVersioningAdapter(
attributions ? { attrs: attributions } : undefined,
)
: undefined,
- }),
- );
+ });
+ if (dispatch) {
+ dispatch(tr);
+ }
+ return true;
+ });
},
exitPreview: () => {
// Empty the document before reconfiguring so ProseMirror rebuilds node
diff --git a/packages/core/src/y/extensions/YSync.ts b/packages/core/src/y/extensions/YSync.ts
index 6fe7057c22..a844fd2dd7 100644
--- a/packages/core/src/y/extensions/YSync.ts
+++ b/packages/core/src/y/extensions/YSync.ts
@@ -53,7 +53,7 @@ const colorsForUserIds = (
* - y-attributed-delete: { id, "user-color-light", "user-color-dark" }
* - y-attributed-format: { id, "user-color-light", "user-color-dark" }
*/
-const mapAttributionToMark = (
+export const mapAttributionToMark = (
format: Record | null,
attribution: {
insert?: readonly string[];
diff --git a/packages/core/src/y/extensions/blockMatchNodes.ts b/packages/core/src/y/extensions/blockMatchNodes.ts
index 79c0a230f5..0e79597a3d 100644
--- a/packages/core/src/y/extensions/blockMatchNodes.ts
+++ b/packages/core/src/y/extensions/blockMatchNodes.ts
@@ -1,6 +1,6 @@
+import { $prosemirrorDelta } from "@y/prosemirror";
import * as delta from "lib0/delta";
import * as schema from "lib0/schema";
-import { $prosemirrorDelta } from "@y/prosemirror";
/**
* Canonical name of a content delta's first block child (the child carried by an
@@ -23,6 +23,24 @@ 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): 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,
): { rows: number; cols: number } | null {
@@ -137,6 +155,16 @@ 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);
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 506a65bd93..2362931af4 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1811,6 +1811,28 @@ export const examples = {
readme:
'This example shows how to use the `VersioningExtension` with collaborative editing using `@y/y` (v14). Snapshots are stored in localStorage using Yjs v2 state updates.\n\n**Try it out:** Edit the document, then click the "Version History" button to open the sidebar. From there you can save snapshots, preview older versions, rename them, and restore them.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Real-time collaboration](/docs/features/collaboration)',
},
+ {
+ projectSlug: "suggestion-gallery",
+ fullSlug: "collaboration/suggestion-gallery",
+ pathFromRoot: "examples/07-collaboration/14-suggestion-gallery",
+ config: {
+ playground: true,
+ docs: false,
+ author: "yousefed",
+ tags: ["Advanced", "Development", "Collaboration"],
+ dependencies: {
+ "@y/protocols": "^1.0.6-rc.1",
+ "@y/y": "^14.0.0-rc.16",
+ } as any,
+ },
+ title: "Suggestion Scenarios Gallery",
+ group: {
+ pathFromRoot: "examples/07-collaboration",
+ slug: "collaboration",
+ },
+ readme:
+ "Browse the suggestion (track-changes) rendering scenarios interactively. Each\nentry sets up a base document and applies a change in suggestion mode, so you can\nsee how insertions, deletions and type changes are visualized as a diff.\n\nThe **Base** pane (left) is read-only and shows the document before the change.\nThe **Suggestion** pane (right) is editable — keep typing to create more\nsuggestions on top.\n\nThese are the same scenarios covered by the y-prosemirror visual tests; the\nper-scenario definitions live in `src/scenarios.ts` so the tests and this gallery\nstay in sync.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/editor-basics/setup)\n- [Collaboration](/docs/collaboration/real-time-collaboration)",
+ },
],
},
extensions: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 98067cd3ce..1f91cc8e63 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4214,6 +4214,55 @@ importers:
specifier: 'catalog:'
version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
+ examples/07-collaboration/14-suggestion-gallery:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ '@y/protocols':
+ specifier: ^1.0.6-rc.1
+ version: 1.0.6-rc.1(@y/y@14.0.0-rc.18)
+ '@y/y':
+ specifier: 14.0.0-rc.18
+ version: 14.0.0-rc.18
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
+ vite-plus:
+ specifier: 'catalog:'
+ version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0))
+
examples/08-extensions/01-tiptap-arrow-conversion:
dependencies:
'@blocknote/ariakit':
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-chromium-linux.png
new file mode 100644
index 0000000000..30fd839ee4
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-firefox-linux.png
new file mode 100644
index 0000000000..6a82839356
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-webkit-linux.png
new file mode 100644
index 0000000000..8cccb0e945
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-bullet-to-empty-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-chromium-linux.png
new file mode 100644
index 0000000000..cdc139b59e
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-firefox-linux.png
new file mode 100644
index 0000000000..5618efbeab
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-webkit-linux.png
new file mode 100644
index 0000000000..e288acaf57
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-colored-block-to-empty-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png
index 2744facb25..ffb3f4ef07 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png
index a0d4c6604b..0413a5d729 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png
index 6b2a1c7437..c8c46bab63 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-heading-to-empty-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-chromium-linux.png
new file mode 100644
index 0000000000..49e4a14c82
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-firefox-linux.png
new file mode 100644
index 0000000000..93b53c06c3
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-webkit-linux.png
new file mode 100644
index 0000000000..5890672450
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-nested-bullets-to-empty-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-chromium-linux.png
new file mode 100644
index 0000000000..2de4d1d161
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-firefox-linux.png
new file mode 100644
index 0000000000..8b2f5d77a8
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-webkit-linux.png
new file mode 100644
index 0000000000..c6da03ca77
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-numbered-to-empty-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-paragraph-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-paragraph-chromium-linux.png
index 682128a3b5..97e2bc6b51 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-paragraph-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-add-paragraph-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-chromium-linux.png
new file mode 100644
index 0000000000..123bfc07f8
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-firefox-linux.png
new file mode 100644
index 0000000000..5031ec25f7
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-webkit-linux.png
new file mode 100644
index 0000000000..50a5330647
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-code-block-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-chromium-linux.png
new file mode 100644
index 0000000000..b6236bfa7c
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-firefox-linux.png
new file mode 100644
index 0000000000..3ec96ecb1b
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-webkit-linux.png
new file mode 100644
index 0000000000..18da779999
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-divider-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png
index f2e57c1198..a6d32cf4c9 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png
index 60b9f870b0..d10fc7603c 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png
index c798e1b1b2..087a27afeb 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-image-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-chromium-linux.png
new file mode 100644
index 0000000000..e25c25b06a
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-firefox-linux.png
new file mode 100644
index 0000000000..7b4021c14a
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-webkit-linux.png
new file mode 100644
index 0000000000..1cf3c25a9a
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-mixed-parent-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-linux.png
index 4890f1914e..8dc026817e 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-firefox-linux.png
index 65c0cd7b68..a6b54175a9 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-webkit-linux.png
index 1c0dbd8d96..348295e7f4 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-nested-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-linux.png
index 3d4a0ff641..a4ba667a00 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-firefox-linux.png
index eb82392497..a9b994cee8 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-webkit-linux.png
index 381a3f609f..7faf44304c 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-delete-parent-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-chromium-linux.png
new file mode 100644
index 0000000000..dc9553fdb5
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-firefox-linux.png
new file mode 100644
index 0000000000..e4700ac3dc
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-webkit-linux.png
new file mode 100644
index 0000000000..1b9e4c42b3
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-insert-image-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-chromium-linux.png
new file mode 100644
index 0000000000..c889839a09
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-firefox-linux.png
new file mode 100644
index 0000000000..e3c0196a95
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-webkit-linux.png
new file mode 100644
index 0000000000..a4407868ae
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-nest-bullet-under-existing-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-linux.png
index 26eadc4553..7ab0228ab5 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-all-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-linux.png
index 1d43e9d254..1a0768bff2 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-firefox-linux.png
index b75a8af95f..d5410003de 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-webkit-linux.png
index 3bd207b240..00e82f5d74 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/addRemoveBlocks.test.tsx/add-remove-remove-paragraph-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-bold-vs-italic-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-bold-vs-italic-chromium-linux.png
index bc23d3ff39..c42e72bd88 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-bold-vs-italic-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-bold-vs-italic-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-typo-fix-vs-delete-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-typo-fix-vs-delete-chromium-linux.png
index adf3b0c1f7..418929e60c 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-typo-fix-vs-delete-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/basicText.concurrent.test.tsx/concurrent-typo-fix-vs-delete-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-linux.png
index 6e511c9757..74a89b6d5b 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-firefox-linux.png
index 971c32088b..3b957e6623 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-webkit-linux.png
index cc34a7889e..9eecb25685 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-up-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-linux.png
index f9bdd7847f..17b7f148a5 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-firefox-linux.png
index a99d1ff67f..c4172ea220 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-webkit-linux.png
index 0bfc50947d..296eefe65c 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/moveBlocks.test.tsx/move-paragraph-with-children-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-linux.png
index 08def41be1..055d7b3c3f 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-firefox-linux.png
index 6409f048dd..234d600fc3 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-webkit-linux.png
index 8dbbe23a3c..2f569e9631 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.concurrent.test.tsx/concurrent-indent-cascade-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-chromium-linux.png
new file mode 100644
index 0000000000..205b800207
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-firefox-linux.png
new file mode 100644
index 0000000000..48070f7099
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-webkit-linux.png
new file mode 100644
index 0000000000..0c2b08b530
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-change-parent-type-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-linux.png
index 3f8319852f..57d7ec24f4 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-firefox-linux.png
index 275fed43d8..27ad94db9e 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-webkit-linux.png
index b1d37dc208..d77ed2020a 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-indent-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-linux.png
index 97217cc488..40b74367c1 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-firefox-linux.png
index 5d6c1b2eb0..65bb1c509e 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-webkit-linux.png
index a1a4c64dc7..a2b88c0b3b 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/nesting.test.tsx/nesting-unindent-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-add-column-and-add-row-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-add-column-and-add-row-chromium-linux.png
index dbd823b1ac..216ebd57a8 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-add-column-and-add-row-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-add-column-and-add-row-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-chromium-linux.png
index cb9bcdf0df..4583d5a83b 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-firefox-linux.png
index c6cbe4f813..9ef6403a13 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-webkit-linux.png
index b1abbda9c0..3640e4897b 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-delete-column-vs-add-row-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-row-and-column-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-row-and-column-chromium-linux.png
index a651bc107c..09946ce0da 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-row-and-column-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-concurrent-row-and-column-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-column-then-row-b-adds-column-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-column-then-row-b-adds-column-chromium-linux.png
index c4afd0b3c4..16cdb7ff92 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-column-then-row-b-adds-column-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-column-then-row-b-adds-column-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-row-then-column-b-adds-row-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-row-then-column-b-adds-row-chromium-linux.png
index 92097e6414..5907b34f3a 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-row-then-column-b-adds-row-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.concurrent.test.tsx/table-sequential-add-row-then-column-b-adds-row-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-column-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-column-chromium-linux.png
index c57bb41106..ff03472ab5 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-column-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-column-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-row-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-row-chromium-linux.png
index 6a5cec7d94..5ddd0e7ea2 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-row-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-add-row-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-edit-cell-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-edit-cell-chromium-linux.png
index 29de311bbf..8695471cc9 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-edit-cell-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-edit-cell-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-chromium-linux.png
index 50e7755c54..33722b6dbd 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-firefox-linux.png
index 351609ac17..d5c185cb2c 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-webkit-linux.png
index 177a4dda55..9c654f1b4e 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-merge-cells-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-chromium-linux.png
index 22697dad2d..c34d461bce 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-firefox-linux.png
index b513b35582..8a8aa78867 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-firefox-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-webkit-linux.png
index 75455ec77b..073056d02c 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-webkit-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-column-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-row-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-row-chromium-linux.png
index 72584b1d1f..af072855a2 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-row-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-remove-row-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-split-cell-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-split-cell-chromium-linux.png
index d64ae19981..5cacfea40b 100644
Binary files a/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-split-cell-chromium-linux.png and b/tests/src/end-to-end/y-prosemirror/__screenshots__/tables.test.tsx/table-split-cell-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-chromium-linux.png
new file mode 100644
index 0000000000..bd98975c55
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-firefox-linux.png
new file mode 100644
index 0000000000..673da7d1c3
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-webkit-linux.png
new file mode 100644
index 0000000000..8ae2ba8b26
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-heading-vs-list-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-chromium-linux.png
new file mode 100644
index 0000000000..0159d970dd
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-firefox-linux.png
new file mode 100644
index 0000000000..aa20c4f815
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-webkit-linux.png
new file mode 100644
index 0000000000..edad8a4a64
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.concurrent.test.tsx/concurrent-text-edit-vs-heading-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-chromium-linux.png
new file mode 100644
index 0000000000..5b2531d288
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-firefox-linux.png
new file mode 100644
index 0000000000..51cd4d1458
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-webkit-linux.png
new file mode 100644
index 0000000000..a9648bb879
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-list-to-paragraph-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-chromium-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-chromium-linux.png
new file mode 100644
index 0000000000..d95d3421e2
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-chromium-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-firefox-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-firefox-linux.png
new file mode 100644
index 0000000000..08291dda01
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-firefox-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-webkit-linux.png b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-webkit-linux.png
new file mode 100644
index 0000000000..c2d9bff92c
Binary files /dev/null and b/tests/src/end-to-end/y-prosemirror/__screenshots__/typeChanges.test.tsx/type-change-paragraph-to-heading-webkit-linux.png differ
diff --git a/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx b/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx
index f6b232987c..b5c2e4aa8a 100644
--- a/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/addRemoveBlocks.test.tsx
@@ -15,16 +15,71 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
-// Inline SVG data URL – avoids a network fetch for the image src.
-const IMG_SRC =
- "data:image/svg+xml;utf8,";
+// Scenario data (the `initial` seed + the `apply` change) is shared with the
+// suggestion-gallery example so the gallery and these tests never drift. The
+// image URL is imported from there too, so the poll below checks the exact value
+// the scenario sets.
+import {
+ IMG_SRC_BASE,
+ scenarios,
+} from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { SingleScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const addHeading = scenarios.find(
+ (s) => s.id === "add-heading",
+) as SingleScenario;
+const addBullet = scenarios.find(
+ (s) => s.id === "add-bullet",
+) as SingleScenario;
+const addNumbered = scenarios.find(
+ (s) => s.id === "add-numbered",
+) as SingleScenario;
+const addNestedBullets = scenarios.find(
+ (s) => s.id === "add-nested-bullets",
+) as SingleScenario;
+const addColoredBlock = scenarios.find(
+ (s) => s.id === "add-colored-block",
+) as SingleScenario;
+const nestBulletExisting = scenarios.find(
+ (s) => s.id === "nest-bullet-existing",
+) as SingleScenario;
+const addParagraphAfter = scenarios.find(
+ (s) => s.id === "add-paragraph-after",
+) as SingleScenario;
+const removeParagraph = scenarios.find(
+ (s) => s.id === "remove-paragraph",
+) as SingleScenario;
+const removeAll = scenarios.find(
+ (s) => s.id === "remove-all",
+) as SingleScenario;
+const deleteNested = scenarios.find(
+ (s) => s.id === "delete-nested",
+) as SingleScenario;
+const deleteParent = scenarios.find(
+ (s) => s.id === "delete-parent",
+) as SingleScenario;
+const deleteImage = scenarios.find(
+ (s) => s.id === "delete-image",
+) as SingleScenario;
+const deleteMixedParent = scenarios.find(
+ (s) => s.id === "delete-mixed-parent",
+) as SingleScenario;
+const deleteCodeBlock = scenarios.find(
+ (s) => s.id === "delete-code-block",
+) as SingleScenario;
+const deleteDivider = scenarios.find(
+ (s) => s.id === "delete-divider",
+) as SingleScenario;
+const insertImage = scenarios.find(
+ (s) => s.id === "insert-image",
+) as SingleScenario;
// Empty doc gets a heading inserted at the top.
test("suggestion mode: add heading to empty doc", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "add heading at top" });
- editor.replaceBlocks(editor.document, []);
+ editor.replaceBlocks(editor.document, addHeading.initial);
await sync();
// See note in "add paragraph after existing block" – snapshot the
@@ -33,9 +88,7 @@ test("suggestion mode: add heading to empty doc", async () => {
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.replaceBlocks(editor.document, [
- { id: "h0", type: "heading", props: { level: 1 }, content: "New heading" },
- ]);
+ addHeading.apply(editor);
await waitForSuggestion(editor);
@@ -108,97 +161,82 @@ test("suggestion mode: add heading to empty doc", async () => {
`);
});
-// Add a paragraph after an existing heading.
-test("suggestion mode: add paragraph after existing block", async () => {
+// Empty doc gets a bullet list item inserted at the top. Exercises the
+// bullet marker (`•`) on suggestion-wrapped block content – the inserted
+// item's `.bn-block-content` is wrapped in ``, which breaks the
+// `.bn-block > .bn-block-content` chain the marker rule relies on.
+test("suggestion mode: add bullet list item to empty doc", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
- await setupSuggestionTest({ userAction: "append paragraph" });
+ await setupSuggestionTest({ userAction: "add bullet at top" });
- editor.replaceBlocks(editor.document, [
- { id: "h0", type: "heading", props: { level: 1 }, content: "Title" },
- ]);
+ editor.replaceBlocks(editor.document, addBullet.initial);
await sync();
- await expectVisible(screen.getByTestId("editor-A").getByText("Title"));
- // Capture the base document *before* enabling suggestions: `baseDoc`
- // is the live fragment editor A is bound to, so suggestion-mode edits
- // flush attribution marks back into it. Reading it after the edit is
- // racy; snapshot the clean pre-suggestion state here instead.
const baseDocXml = ydocXml(baseDoc);
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.insertBlocks(
- [{ id: "p0", type: "paragraph", content: "Body text" }],
- "h0",
- "after",
- );
+ addBullet.apply(editor);
await waitForSuggestion(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
- "add-remove-add-paragraph",
+ "add-remove-add-bullet-to-empty",
);
expect(baseDocXml).toMatchInlineSnapshot(`
"
-
- Title
+
+
"
`);
expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
"
-
-
+ Title
-
-
- Body text
+ >New bullet
"
`);
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
-
- Title
-
+
+
+
+
+
-
+
-
+
Body text
-
+ >New bullet
+
@@ -207,258 +245,1044 @@ test("suggestion mode: add paragraph after existing block", async () => {
`);
});
-// TODO: block-level deletions DO carry a node-level
-// `` mark in the PM doc (visible in the snapshots
-// below), so the data is there. But that mark only has an inline
-// `toDOM` (renders text-content deletions as `` with strikethrough
-// – see SuggestionMarks.ts) and no styling at the block level, so the
-// deleted block still *visually* renders identically to an accepted
-// block. Decide whether block-level `` should
-// also have a visible affordance (a left bar, fade-out, …) so
-// reviewers can tell from the editor that a block is pending removal.
-//
-// Heading + paragraph -> remove the paragraph.
-test("suggestion mode: remove paragraph from heading+paragraph", async () => {
+// Empty doc gets a numbered list item inserted at the top. Exercises the
+// numbered marker (`1.`) on suggestion-wrapped block content (same chain
+// break as the bullet case above).
+test("suggestion mode: add numbered list item to empty doc", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
- await setupSuggestionTest({ userAction: "remove body" });
+ await setupSuggestionTest({ userAction: "add numbered at top" });
- editor.replaceBlocks(editor.document, [
- { id: "h0", type: "heading", props: { level: 1 }, content: "Title" },
- { id: "p0", type: "paragraph", content: "Body text" },
- ]);
+ editor.replaceBlocks(editor.document, addNumbered.initial);
await sync();
- await expectVisible(screen.getByTestId("editor-A").getByText("Body text"));
- // See note in "add paragraph after existing block" – snapshot the
- // clean base before suggestions mutate the bound `baseDoc`.
const baseDocXml = ydocXml(baseDoc);
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.removeBlocks(["p0"]);
+ addNumbered.apply(editor);
await waitForSuggestion(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
- "add-remove-remove-paragraph",
+ "add-remove-add-numbered-to-empty",
);
expect(baseDocXml).toMatchInlineSnapshot(`
"
-
- Title
-
-
- Body text
+
+
"
`);
expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
- "
-
- Title
-
- "
- `);
+ "
+
+ New numbered
+
+ "
+ `);
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
-
- Title
-
-
- Body text
+
+
+
+
+
+
+ New numbered
+
+
+
+
"
`);
});
-// Remove every block from a doc that has one paragraph.
-test("suggestion mode: remove all blocks", async () => {
+// Empty doc gets a 3-level nested bullet list inserted as a suggestion.
+//
+// Known issue — tracked in the suggestion gallery ("add-nested-bullets").
+// This baseline intentionally captures all three rows as `•`.
+test("suggestion mode: add nested bullet list to empty doc", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
- await setupSuggestionTest({ userAction: "delete all" });
+ await setupSuggestionTest({ userAction: "add nested bullets" });
- editor.replaceBlocks(editor.document, [
- { id: "p0", type: "paragraph", content: "Only block" },
- ]);
+ editor.replaceBlocks(editor.document, addNestedBullets.initial);
await sync();
- await expectVisible(screen.getByTestId("editor-A").getByText("Only block"));
- // See note in "add paragraph after existing block" – snapshot the
- // clean base before suggestions mutate the bound `baseDoc`.
const baseDocXml = ydocXml(baseDoc);
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.removeBlocks(["p0"]);
+ addNestedBullets.apply(editor);
await waitForSuggestion(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
- "add-remove-remove-all",
+ "add-remove-add-nested-bullets-to-empty",
);
expect(baseDocXml).toMatchInlineSnapshot(`
"
-
- Only block
+
+
"
`);
expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
"
-
-
+
+ Level 0
+
+
+ Level 1
+
+
+ Level 2
+
+
+
+
"
`);
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
-
-
-
+
+
+
+
+
+
+ Only block
-
-
+ >
+
+ Level 0
+
+
+
+
+
+
+
+
+ Level 1
+
+
+
+
+
+
+
+
+ Level 2
+
+
+
+
+
+
+
+
+
+
+
+
"
`);
});
-// Delete a nested child block, parent stays.
-test("suggestion mode: delete nested block", async () => {
+// Empty doc gets a background-colored block (with a nested child) inserted as a
+// suggestion.
+// Known issue — tracked in the suggestion gallery ("add-colored-block").
+// Validate: the parent row is tinted but the child's row is not.
+test("suggestion mode: add colored block with child to empty doc", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
- await setupSuggestionTest({ userAction: "delete inner block" });
+ await setupSuggestionTest({ userAction: "add colored block" });
- editor.replaceBlocks(editor.document, [
- {
- id: "parent",
- type: "paragraph",
- content: "Parent",
- children: [{ id: "child", type: "paragraph", content: "Child" }],
- },
- ]);
+ editor.replaceBlocks(editor.document, addColoredBlock.initial);
await sync();
- await expectVisible(screen.getByTestId("editor-A").getByText("Child"));
- // See note in "add paragraph after existing block" – snapshot the
- // clean base before suggestions mutate the bound `baseDoc`.
const baseDocXml = ydocXml(baseDoc);
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.removeBlocks(["child"]);
+ addColoredBlock.apply(editor);
await waitForSuggestion(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
- "add-remove-delete-nested",
+ "add-remove-add-colored-block-to-empty",
);
expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
"
-
- Parent
+
+ Colored parent
-
- Child
+
+ Child block
"
`);
- expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
- "
-
- Parent
-
- "
- `);
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
-
- Parent
-
-
-
- Child
-
-
-
-
+
+
+
+
+
+
+
+
+
+ Colored parent
+
+
+
+
+
+
+
+
+ Child block
+
+
+
+
+
+
+
+
"
`);
});
-// Delete a parent block that has children. Documents what happens to
-// the children – BlockNote may keep them as top-level siblings or
-// delete them too.
-test("suggestion mode: delete parent block (with children)", async () => {
+// Two sibling bullets exist in the base; in suggestion mode the second is
+// nested under the first (`nestBlock`). Unlike the all-new subtree above, the
+// parent bullet already exists – only the newly-nested child is the suggestion.
+//
+// Known issue — tracked in the suggestion gallery ("nest-bullet-existing"):
+// the nested child shows `•` instead of `◦`. Baseline captures `•`.
+test("suggestion mode: nest a bullet under an existing bullet", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "nest bullet under existing" });
+
+ editor.replaceBlocks(editor.document, nestBulletExisting.initial);
+ await sync();
+
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ nestBulletExisting.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-nest-bullet-under-existing",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+ Parent
+
+
+ Child
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+ Parent
+
+
+ Child
+
+
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ Parent
+
+
+
+
+ Child
+
+
+
+
+
+
+ Parent
+
+
+
+
+
+
+
+
+ Child
+
+
+
+
+
+
+
+
+
+ "
+ `);
+});
+
+// Add a paragraph after an existing heading.
+test("suggestion mode: add paragraph after existing block", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "append paragraph" });
+
+ editor.replaceBlocks(editor.document, addParagraphAfter.initial);
+ await sync();
+ await expectVisible(screen.getByTestId("editor-A").getByText("Title"));
+
+ // Capture the base document *before* enabling suggestions: `baseDoc`
+ // is the live fragment editor A is bound to, so suggestion-mode edits
+ // flush attribution marks back into it. Reading it after the edit is
+ // racy; snapshot the clean pre-suggestion state here instead.
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ addParagraphAfter.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-add-paragraph",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+ Title
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+ Title
+
+
+ Body text
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+ Title
+
+
+
+
+
+ Body text
+
+
+
+
+
+ "
+ `);
+});
+
+// Block-level deletions render with a visible affordance so reviewers can spot a
+// pending removal: a deleted block with inline content strikes its text through
+// in the author's color, while one with no inline content (image, divider, …)
+// shows a filled "Deleted" card (both styled per `.bn-block-content` in
+// Block.css). The node-level `` mark in the PM doc (visible
+// in the snapshots) carries the data.
+//
+// Heading + paragraph -> remove the paragraph; the deleted body strikes through.
+test("suggestion mode: remove paragraph from heading+paragraph", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "remove body" });
+
+ editor.replaceBlocks(editor.document, removeParagraph.initial);
+ await sync();
+ await expectVisible(screen.getByTestId("editor-A").getByText("Body text"));
+
+ // See note in "add paragraph after existing block" – snapshot the
+ // clean base before suggestions mutate the bound `baseDoc`.
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ removeParagraph.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-remove-paragraph",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+ Title
+
+
+ Body text
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+ Title
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+ Title
+
+
+
+ Body text
+
+
+
+ "
+ `);
+});
+
+// Remove every block from a doc that has one paragraph.
+test("suggestion mode: remove all blocks", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "delete all" });
+
+ editor.replaceBlocks(editor.document, removeAll.initial);
+ await sync();
+ await expectVisible(screen.getByTestId("editor-A").getByText("Only block"));
+
+ // See note in "add paragraph after existing block" – snapshot the
+ // clean base before suggestions mutate the bound `baseDoc`.
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ removeAll.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-remove-all",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+ Only block
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ Only block
+
+
+
+ "
+ `);
+});
+
+// Delete a nested child block, parent stays.
+test("suggestion mode: delete nested block", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "delete inner block" });
+
+ editor.replaceBlocks(editor.document, deleteNested.initial);
+ await sync();
+ await expectVisible(screen.getByTestId("editor-A").getByText("Child"));
+
+ // See note in "add paragraph after existing block" – snapshot the
+ // clean base before suggestions mutate the bound `baseDoc`.
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ deleteNested.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-delete-nested",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+ Parent
+
+
+ Child
+
+
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+ Parent
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ Parent
+
+
+ Child
+
+
+
+
+
+
+
+
+ Parent
+
+
+
+
+
+ "
+ `);
+});
+
+// Delete a parent block that has children. Documents what happens to
+// the children – BlockNote may keep them as top-level siblings or
+// delete them too.
+test("suggestion mode: delete parent block (with children)", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "delete outer block" });
+
+ editor.replaceBlocks(editor.document, deleteParent.initial);
+ await sync();
+ await expectVisible(screen.getByTestId("editor-A").getByText("Parent"));
+
+ // See note in "add paragraph after existing block" – snapshot the
+ // clean base before suggestions mutate the bound `baseDoc`.
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ deleteParent.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-delete-parent",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+ Parent
+
+
+ Child
+
+
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ Parent
+
+
+ Child
+
+
+
+
+
+
+
+
+
+
+
+
+ "
+ `);
+});
+
+// Delete the sole image block in suggestion mode. An image is an atom
+// blockContent with no inline text and no blockGroup child, so the only
+// schema-valid way to attribute its deletion is to wrap the whole
+// Deleting a sole atom image block: the suggestion diff marks the image
+// block as deleted.
+test("suggestion mode: delete image block", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({
+ userAction: "delete image",
+ });
+
+ editor.replaceBlocks(editor.document, deleteImage.initial);
+ await sync();
+ await expect
+ .poll(() => (editor.document[0]?.props as { url?: string })?.url)
+ .toBe(IMG_SRC_BASE);
+
+ // See note in "add paragraph after existing block" – snapshot the
+ // clean base before suggestions mutate the bound `baseDoc`.
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ deleteImage.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-A"),
+ "add-remove-delete-image",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "
+ `);
+});
+
+// A deleted parent paragraph whose children are a nested paragraph AND a nested
+// image. Validates the *per-block* delete decision (the whole point of the
+// inline-vs-block logic): the parent + nested paragraph (inline content) strike
+// through, while the nested image (no inline content) gets the "DELETED" badge —
+// all within the same deletion.
+test("suggestion mode: delete parent with nested paragraph and image", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
- await setupSuggestionTest({ userAction: "delete outer block" });
+ await setupSuggestionTest({ userAction: "delete mixed block" });
- editor.replaceBlocks(editor.document, [
- {
- id: "parent",
- type: "paragraph",
- content: "Parent",
- children: [{ id: "child", type: "paragraph", content: "Child" }],
- },
- ]);
+ editor.replaceBlocks(editor.document, deleteMixedParent.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("Parent"));
- // See note in "add paragraph after existing block" – snapshot the
- // clean base before suggestions mutate the bound `baseDoc`.
const baseDocXml = ydocXml(baseDoc);
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.removeBlocks(["parent"]);
+ deleteMixedParent.apply(editor);
await waitForSuggestion(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
- "add-remove-delete-parent",
+ "add-remove-delete-mixed-parent",
);
expect(baseDocXml).toMatchInlineSnapshot(`
@@ -466,8 +1290,19 @@ test("suggestion mode: delete parent block (with children)", async () => {
Parent
-
- Child
+
+ Nested paragraph
+
+
+
@@ -483,70 +1318,226 @@ test("suggestion mode: delete parent block (with children)", async () => {
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
-
-
- Parent
-
-
+
+
+ Parent
-
- Child
+
+ Nested paragraph
+
+
+
-
-
+
+
+
+
+
+
+
+
+
"
`);
});
-// Delete the sole image block in suggestion mode. An image is an atom
-// blockContent with no inline text and no blockGroup child, so the only
-// schema-valid way to attribute its deletion is to wrap the whole
-// Deleting a sole atom image block: the suggestion diff marks the image
-// block as deleted.
-test("suggestion mode: delete image block", async () => {
+// A deleted code block. Its inline content lives in ``, nested below `.bn-block-content` — so the descendant
+// `:has(.bn-inline-content)` must still classify it as an inline-content block
+// and strike it through, rather than show the block "DELETED" badge.
+test("suggestion mode: delete code block", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
- await setupSuggestionTest({
- userAction: "delete image",
- });
+ await setupSuggestionTest({ userAction: "delete code block" });
- editor.replaceBlocks(editor.document, [
- {
- id: "img",
- type: "image",
- props: { url: IMG_SRC, previewWidth: 150 },
- },
- ]);
+ editor.replaceBlocks(editor.document, deleteCodeBlock.initial);
await sync();
- await expect
- .poll(() => (editor.document[0]?.props as { url?: string })?.url)
- .toBe(IMG_SRC);
+ await expect.poll(() => editor.document[0]?.type).toBe("codeBlock");
- // See note in "add paragraph after existing block" – snapshot the
- // clean base before suggestions mutate the bound `baseDoc`.
const baseDocXml = ydocXml(baseDoc);
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.removeBlocks(["img"]);
+ deleteCodeBlock.apply(editor);
await waitForSuggestion(editor);
await expectScreenshot(
- screen.getByTestId("editor-A"),
- "add-remove-delete-image",
+ screen.getByTestId("editor-root"),
+ "add-remove-delete-code-block",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+ const x = 1;
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ const x = 1;
+
+
+
+
+
+
+
+
+
+
+ "
+ `);
+});
+
+// A deleted divider (`
`, content: "none") takes the block "DELETED" card.
+// Edge case for the card's `width: fit-content`: an
has no intrinsic width,
+// so the card could collapse to just the label — this baseline captures the
+// actual rendering so any regression there is visible.
+test("suggestion mode: delete divider", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "delete divider" });
+
+ editor.replaceBlocks(editor.document, deleteDivider.initial);
+ await sync();
+ await expect.poll(() => editor.document[0]?.type).toBe("divider");
+
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ deleteDivider.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-delete-divider",
+ );
+
+ expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "
+ `);
+});
+
+// An inserted image (no inline content) takes the same per-block card as a deleted
+// one — author-colored, rounded, hugging the image — but with no "Deleted" label.
+// Confirms the card background is shared between insertions and deletions for
+// non-inline blocks, while inserted inline content (covered by the other insert
+// tests) keeps only its inline highlight and gets no block background.
+test("suggestion mode: insert image block", async () => {
+ const { editor, screen, baseDoc, suggestionDoc, sync } =
+ await setupSuggestionTest({ userAction: "insert image" });
+
+ editor.replaceBlocks(editor.document, insertImage.initial);
+ await sync();
+
+ const baseDocXml = ydocXml(baseDoc);
+
+ editor.getExtension(SuggestionsExtension)!.enableSuggestions();
+
+ insertImage.apply(editor);
+
+ await waitForSuggestion(editor);
+
+ await expectScreenshot(
+ screen.getByTestId("editor-root"),
+ "add-remove-insert-image",
);
expect(baseDocXml).toMatchInlineSnapshot(`
+ "
+
+
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
"
{
"
`);
- expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
- "
-
-
-
- "
- `);
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
@@ -576,16 +1560,8 @@ test("suggestion mode: delete image block", async () => {
user-color-light="#fff0c2"
user-color-dark="#8a6d1a"
>
-
-
+
+
{
user-color-light="#fff0c2"
user-color-dark="#8a6d1a"
>
-
+
-
+
diff --git a/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx b/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx
index 2235912c9c..da3b751d09 100644
--- a/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/basicText.concurrent.test.tsx
@@ -28,6 +28,18 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + A's/B's `applyA`/`applyB` changes) is
+// shared with the suggestion-gallery example so the two never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { ConcurrentScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const typoVsDelete = scenarios.find(
+ (s) => s.id === "concurrent-typo-vs-delete",
+) as ConcurrentScenario;
+const boldVsItalic = scenarios.find(
+ (s) => s.id === "concurrent-bold-vs-italic",
+) as ConcurrentScenario;
+
// Concurrent text edits on overlapping range: A fixes a typo while B
// deletes the whole word. After CRDT merge, snapshot what the merged
// editor ends up displaying.
@@ -52,9 +64,7 @@ test("concurrent: A fixes typo, B deletes the word", async () => {
// Seed: A writes "hello wrold" (typo) directly to baseDoc since
// suggestion mode isn't on yet. Then `seed()` fans baseDoc into
// all three suggestion docs so everyone starts from the same state.
- userA.editor.replaceBlocks(userA.editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello wrold" },
- ]);
+ userA.editor.replaceBlocks(userA.editor.document, typoVsDelete.initial);
seed();
await expectVisible(
@@ -67,15 +77,10 @@ test("concurrent: A fixes typo, B deletes the word", async () => {
enableSuggestions();
// A: fix typo "wrold" -> "world".
- const [blockA] = userA.editor.document;
- userA.editor.updateBlock(blockA, {
- type: "paragraph",
- content: "hello world",
- });
+ typoVsDelete.applyA(userA.editor);
// B: delete the misspelled word entirely.
- const [blockB] = userB.editor.document;
- userB.editor.updateBlock(blockB, { type: "paragraph", content: "hello " });
+ typoVsDelete.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -89,15 +94,8 @@ test("concurrent: A fixes typo, B deletes the word", async () => {
"concurrent-typo-fix-vs-delete",
);
- // TODO: the merged YDoc ends up at "hello o" – an `o` survives even
- // though both A (who replaced "wrold" with "world") and B (who
- // deleted "wrold" outright) effectively wanted "wrold" gone. The
- // CRDT keeps A's inserted `o` because B's delete-range covered the
- // original "wrold" letters but not A's freshly-inserted characters,
- // so the union of "delete everything B saw" + "keep what A added"
- // leaves a stray `o`. Worth deciding whether this is the desired
- // merge semantic for the product or whether the suggestion layer
- // should resolve overlapping edits differently.
+ // Known issue — tracked in the suggestion gallery ("concurrent-typo-vs-delete"):
+ // the merged doc keeps a stray "o" (the snapshot below is "hello o").
expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
"
@@ -133,20 +131,30 @@ test("concurrent: A fixes typo, B deletes the word", async () => {
hello
w
o
r
+ rold
+ >o
+ ld
@@ -176,9 +184,7 @@ test("concurrent: A bolds the word, B italicises the word", async () => {
// Seed: A writes plain "hello world" directly to baseDoc, then
// `seed()` fans it into all three suggestion docs.
- userA.editor.replaceBlocks(userA.editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ userA.editor.replaceBlocks(userA.editor.document, boldVsItalic.initial);
seed();
await expectVisible(
@@ -188,24 +194,10 @@ test("concurrent: A bolds the word, B italicises the word", async () => {
enableSuggestions();
// A: bold "world".
- const [blockA] = userA.editor.document;
- userA.editor.updateBlock(blockA, {
- type: "paragraph",
- content: [
- { type: "text", text: "hello ", styles: {} },
- { type: "text", text: "world", styles: { bold: true } },
- ],
- });
+ boldVsItalic.applyA(userA.editor);
// B: italic "world".
- const [blockB] = userB.editor.document;
- userB.editor.updateBlock(blockB, {
- type: "paragraph",
- content: [
- { type: "text", text: "hello ", styles: {} },
- { type: "text", text: "world", styles: { italic: true } },
- ],
- });
+ boldVsItalic.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -264,14 +256,21 @@ test("concurrent: A bolds the word, B italicises the word", async () => {
hello
-
- world
-
+
+
+ world
+
+
diff --git a/tests/src/end-to-end/y-prosemirror/basicText.test.tsx b/tests/src/end-to-end/y-prosemirror/basicText.test.tsx
index a16e72487b..e03a713512 100644
--- a/tests/src/end-to-end/y-prosemirror/basicText.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/basicText.test.tsx
@@ -18,6 +18,24 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + the `apply` change) is shared with the
+// suggestion-gallery example so the gallery and these tests never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { SingleScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const renameWord = scenarios.find(
+ (s) => s.id === "text-rename-word",
+) as SingleScenario;
+const addBold = scenarios.find(
+ (s) => s.id === "text-add-bold",
+) as SingleScenario;
+const removeBold = scenarios.find(
+ (s) => s.id === "text-remove-bold",
+) as SingleScenario;
+const addItalicToBold = scenarios.find(
+ (s) => s.id === "text-add-italic-to-bold",
+) as SingleScenario;
+
// Pure text edit: replace one word with another and confirm the diff
// is rendered as inline / spans around the changed letters.
test("suggestion mode: 'hello world' -> 'hello universe'", async () => {
@@ -26,9 +44,7 @@ test("suggestion mode: 'hello world' -> 'hello universe'", async () => {
// 1. Set the base doc to "hello world". The block id is pinned so the
// snapshots stay deterministic.
- editor.replaceBlocks(editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ editor.replaceBlocks(editor.document, renameWord.initial);
// 2. Replay base updates into the suggestion doc so both docs start
// from the same state.
@@ -41,8 +57,7 @@ test("suggestion mode: 'hello world' -> 'hello universe'", async () => {
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
// 4. Replace "world" with "universe" via updateBlock.
- const [block] = editor.document;
- editor.updateBlock(block, { type: "paragraph", content: "hello universe" });
+ renameWord.apply(editor);
// Wait for the suggestion edit to land in the DOM (React commits the
// re-render on the next frame; without this the screenshot can race
@@ -121,9 +136,7 @@ test("suggestion mode: add bold to 'world'", async () => {
await setupSuggestionTest({ userAction: "bold 'world'" });
// Base: plain "hello world".
- editor.replaceBlocks(editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ editor.replaceBlocks(editor.document, addBold.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("hello world"));
@@ -131,14 +144,7 @@ test("suggestion mode: add bold to 'world'", async () => {
// Suggestion edit: bold the word "world" (content text is unchanged,
// only the style differs).
- const [block] = editor.document;
- editor.updateBlock(block, {
- type: "paragraph",
- content: [
- { type: "text", text: "hello ", styles: {} },
- { type: "text", text: "world", styles: { bold: true } },
- ],
- });
+ addBold.apply(editor);
await waitForSuggestion(editor);
@@ -197,16 +203,7 @@ test("suggestion mode: remove bold from 'world'", async () => {
await setupSuggestionTest({ userAction: "unbold 'world'" });
// Base: "hello " + bold "world".
- editor.replaceBlocks(editor.document, [
- {
- id: "block-hello",
- type: "paragraph",
- content: [
- { type: "text", text: "hello ", styles: {} },
- { type: "text", text: "world", styles: { bold: true } },
- ],
- },
- ]);
+ editor.replaceBlocks(editor.document, removeBold.initial);
await sync();
// Use the full paragraph text – the User A column heading also
// contains the word "world", which would clash with getByText.
@@ -215,11 +212,7 @@ test("suggestion mode: remove bold from 'world'", async () => {
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
// Suggestion edit: strip bold from "world".
- const [block] = editor.document;
- editor.updateBlock(block, {
- type: "paragraph",
- content: "hello world",
- });
+ removeBold.apply(editor);
await waitForSuggestion(editor);
@@ -264,13 +257,9 @@ test("suggestion mode: remove bold from 'world'", async () => {
`);
});
-// TODO: the snapshot below reveals that `y-attributed-format` wraps
-// *all* marks on the affected range, not just the newly added one.
-// The PM XML shows
-// world
-// so from the attribution data alone we can't tell which mark is new
-// (italic) and which is pre-existing (bold). If accept/reject logic
-// needs to revert only the new mark, this granularity is insufficient.
+// Known issue — tracked in the suggestion gallery ("text-add-italic-to-bold"):
+// the `y-attributed-format` mark wraps all marks on the range, not just the new
+// one, so the snapshot below can't distinguish the added mark from existing ones.
//
// Format added on top of an existing format: bold "world" gets italic
// layered on (bold is preserved). Checks that suggestion attribution
@@ -280,30 +269,14 @@ test("suggestion mode: add italic to already-bold 'world'", async () => {
await setupSuggestionTest({ userAction: "italic on top of bold" });
// Base: "hello " + bold "world".
- editor.replaceBlocks(editor.document, [
- {
- id: "block-hello",
- type: "paragraph",
- content: [
- { type: "text", text: "hello ", styles: {} },
- { type: "text", text: "world", styles: { bold: true } },
- ],
- },
- ]);
+ editor.replaceBlocks(editor.document, addItalicToBold.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("hello world"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
// Suggestion edit: add italic to "world" while keeping it bold.
- const [block] = editor.document;
- editor.updateBlock(block, {
- type: "paragraph",
- content: [
- { type: "text", text: "hello ", styles: {} },
- { type: "text", text: "world", styles: { bold: true, italic: true } },
- ],
- });
+ addItalicToBold.apply(editor);
await waitForSuggestion(editor);
diff --git a/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx b/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx
index b2d3da3e53..632b743472 100644
--- a/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx
+++ b/tests/src/end-to-end/y-prosemirror/fixtures/concurrentSuggestionFixture.tsx
@@ -110,22 +110,40 @@ export async function setupConcurrentSuggestionTest({
// and the merged result is stable across runs, making these tests
// reliable to snapshot.
+ // Each editor's attribution manager reads its `attrs` (a mutable
+ // `Y.Attributions`) on every transaction. We back each `attrs` with an
+ // in-memory store that records the author of each change (see
+ // `createInMemoryAttributionStore` below) so suggestions render in their
+ // author's color instead of all sharing the default. A and B are single-user
+ // docs, so every *local* edit is theirs. The merged doc replays A's and B's
+ // updates (see `sync()`); we tag those updates with the author id as the Yjs
+ // transaction origin so the merged view can color each change per user.
+ const attrsA = createInMemoryAttributionStore(suggestionDocA, (tr) =>
+ tr.local ? "A" : null,
+ );
const managerA = Y.createAttributionManagerFromDiff(baseDoc, suggestionDocA, {
- attrs: new Y.Attributions(),
+ attrs: attrsA,
});
managerA.suggestionMode = true;
+ const attrsB = createInMemoryAttributionStore(suggestionDocB, (tr) =>
+ tr.local ? "B" : null,
+ );
const managerB = Y.createAttributionManagerFromDiff(baseDoc, suggestionDocB, {
- attrs: new Y.Attributions(),
+ attrs: attrsB,
});
managerB.suggestionMode = true;
// Merged is a viewer – it shows both users' suggestions but doesn't
// record new ones, so `suggestionMode = false`.
+ const attrsMerged = createInMemoryAttributionStore(
+ suggestionDocMerged,
+ (tr) => (tr.origin === "A" || tr.origin === "B" ? tr.origin : null),
+ );
const managerMerged = Y.createAttributionManagerFromDiff(
baseDoc,
suggestionDocMerged,
- { attrs: new Y.Attributions() },
+ { attrs: attrsMerged },
);
managerMerged.suggestionMode = false;
@@ -239,8 +257,18 @@ export async function setupConcurrentSuggestionTest({
editorMerged.getExtension(SuggestionsExtension)!.enableSuggestions();
},
sync: () => {
- Y.applyUpdate(suggestionDocMerged, Y.encodeStateAsUpdate(suggestionDocA));
- Y.applyUpdate(suggestionDocMerged, Y.encodeStateAsUpdate(suggestionDocB));
+ // Tag each user's updates with their id as the transaction origin so the
+ // merged doc's attribution store can color A's vs B's changes separately.
+ Y.applyUpdate(
+ suggestionDocMerged,
+ Y.encodeStateAsUpdate(suggestionDocA),
+ "A",
+ );
+ Y.applyUpdate(
+ suggestionDocMerged,
+ Y.encodeStateAsUpdate(suggestionDocB),
+ "B",
+ );
},
};
}
@@ -253,3 +281,52 @@ function makeAwareness(
a.setLocalStateField("user", user);
return a;
}
+
+/**
+ * In-memory attribution store — the local-only stand-in for the server-side
+ * attribution store (YHub) that real deployments use.
+ *
+ * It observes the doc and, for every transaction, records the author of that
+ * transaction's inserts/deletes into a mutable `Y.Attributions`. A
+ * `DiffAttributionManager` re-reads that same `attrs` object on each transaction
+ * (via its own `beforeObserverCalls` handler), so the suggestion marks pick up
+ * the author and render in their color (`colorsForUserIds` in YSync.ts).
+ *
+ * Crucially this store's handler must run BEFORE the manager's, so it is
+ * registered here and the caller creates the manager immediately afterwards
+ * (handlers fire in registration order) — the author is recorded before the
+ * manager reads it for the just-applied change.
+ *
+ * `resolveUserId(tr)` returns the transaction author's id, or null to skip
+ * (the base-content seed and the manager's base→suggestion flow carry no
+ * author and must stay unattributed).
+ */
+function createInMemoryAttributionStore(
+ doc: Y.Doc,
+ resolveUserId: (tr: any) => string | null,
+): Y.Attributions {
+ const attrs = new Y.Attributions();
+ doc.on("beforeObserverCalls", (tr: any) => {
+ const userId = resolveUserId(tr);
+ if (userId == null) {
+ return;
+ }
+ if (!tr.insertSet.isEmpty()) {
+ Y.insertIntoIdMap(
+ attrs.inserts,
+ Y.createIdMapFromIdSet(tr.insertSet, [
+ Y.createContentAttribute("insert", userId),
+ ]),
+ );
+ }
+ if (!tr.deleteSet.isEmpty()) {
+ Y.insertIntoIdMap(
+ attrs.deletes,
+ Y.createIdMapFromIdSet(tr.deleteSet, [
+ Y.createContentAttribute("delete", userId),
+ ]),
+ );
+ }
+ });
+ return attrs;
+}
diff --git a/tests/src/end-to-end/y-prosemirror/moveBlocks.test.tsx b/tests/src/end-to-end/y-prosemirror/moveBlocks.test.tsx
index d50af409ad..a25d7d3ec7 100644
--- a/tests/src/end-to-end/y-prosemirror/moveBlocks.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/moveBlocks.test.tsx
@@ -15,23 +15,31 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + the `apply` change) is shared with the
+// suggestion-gallery example so the gallery and these tests never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { SingleScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const moveParagraphUp = scenarios.find(
+ (s) => s.id === "move-paragraph-up",
+) as SingleScenario;
+const moveParagraphWithChildren = scenarios.find(
+ (s) => s.id === "move-paragraph-with-children",
+) as SingleScenario;
+
// Move a plain paragraph one slot up. Base has three siblings; we
// move the middle one to the top.
test("suggestion mode: move paragraph up", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "move middle up" });
- editor.replaceBlocks(editor.document, [
- { id: "first", type: "paragraph", content: "First" },
- { id: "middle", type: "paragraph", content: "Middle" },
- { id: "last", type: "paragraph", content: "Last" },
- ]);
+ editor.replaceBlocks(editor.document, moveParagraphUp.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("First"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.moveBlocksUp("middle");
+ moveParagraphUp.apply(editor);
await waitForSuggestion(editor);
@@ -116,21 +124,13 @@ test("suggestion mode: move paragraph with children", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "move parent + child up" });
- editor.replaceBlocks(editor.document, [
- { id: "first", type: "paragraph", content: "First" },
- {
- id: "parent",
- type: "paragraph",
- content: "Parent",
- children: [{ id: "child", type: "paragraph", content: "Child" }],
- },
- ]);
+ editor.replaceBlocks(editor.document, moveParagraphWithChildren.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("First"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.moveBlocksUp("parent");
+ moveParagraphWithChildren.apply(editor);
await waitForSuggestion(editor);
diff --git a/tests/src/end-to-end/y-prosemirror/nesting.concurrent.test.tsx b/tests/src/end-to-end/y-prosemirror/nesting.concurrent.test.tsx
index d1cd939865..e4bcbff92f 100644
--- a/tests/src/end-to-end/y-prosemirror/nesting.concurrent.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/nesting.concurrent.test.tsx
@@ -13,6 +13,18 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + A's/B's `applyA`/`applyB` changes) is
+// shared with the suggestion-gallery example so the two never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { ConcurrentScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const indentCascade = scenarios.find(
+ (s) => s.id === "concurrent-indent-cascade",
+) as ConcurrentScenario;
+const nestBothUnderN0 = scenarios.find(
+ (s) => s.id === "concurrent-nest-both-under-n0",
+) as ConcurrentScenario;
+
// Two cascading indents from a flat list of three siblings:
// A nests N1 under N0;
// B nests N2 under N1.
@@ -40,23 +52,17 @@ test("concurrent: A indents N1, B indents N2 below N1", async () => {
});
// Base: three siblings.
- userA.editor.replaceBlocks(userA.editor.document, [
- { id: "n0", type: "paragraph", content: "N0" },
- { id: "n1", type: "paragraph", content: "N1" },
- { id: "n2", type: "paragraph", content: "N2" },
- ]);
+ userA.editor.replaceBlocks(userA.editor.document, indentCascade.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("N0"));
enableSuggestions();
// A: nest N1 under N0.
- userA.editor.setTextCursorPosition("n1", "start");
- userA.editor.nestBlock();
+ indentCascade.applyA(userA.editor);
// B: nest N2 under N1 (in B's local view N1 is still a sibling).
- userB.editor.setTextCursorPosition("n2", "start");
- userB.editor.nestBlock();
+ indentCascade.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -122,45 +128,30 @@ test("concurrent: A indents N1, B indents N2 below N1", async () => {
+
+ N1
+
+
+ N2
+
+
+
"
`);
expect(editorHtml(merged.editor)).toMatchInlineSnapshot(`
"
-
- N0
-
-
-
-
-
-
- N1
-
-
-
-
-
-
-
+
+ N0
+
+
+
@@ -168,27 +159,127 @@ test("concurrent: A indents N1, B indents N2 below N1", async () => {
N1
-
-
-
+
- N0
+
+
+
+
+
+
+
+
+ N1
+
+
+
+
+
+
+
+
+
+
+
+
+ N2
+
+
+
+
+ N1
+
+
+
+
+
+
+
+
+ N2
+
+
+
+
+
+
+
+
"
`);
@@ -198,18 +289,11 @@ test("concurrent: A indents N1, B indents N2 below N1", async () => {
// A adds N1 as a child of N0;
// B adds N2 as a child of N0.
//
-// KNOWN ISSUE: the CRDT merge result here is non-deterministic across
-// runs because it depends on `Y.Doc.clientID` tiebreaking, which is
-// randomly generated. Empirically we see two distinct outcomes:
-// - A wins: N1 nested under N0, N2 ends up as a *sibling* of N0
-// with `` (B's nesting is silently lost);
-// - B wins: N2 nested under N0, plus an auto-injected empty
-// paragraph appears with N1 nested under *that* empty paragraph.
-// Both are arguably bugs. We deliberately don't pin clientIDs at the
-// fixture level (that would mask this), so the test is skipped until
-// upstream merge behaviour is decided/fixed. The inline snapshots
-// below preserve the "A wins" variant captured against a pinned-ID
-// run, as documentation of one of the two observed outcomes.
+// Known issue (test skipped) — tracked in the suggestion gallery
+// ("concurrent-nest-both-under-n0"). We deliberately don't pin clientIDs at the
+// fixture level (that would mask the non-determinism); the inline snapshots
+// below preserve the "A wins" variant, captured against a pinned-ID run, as
+// documentation of one of the two observed outcomes.
test.skip("concurrent: A nests N1 under N0, B nests N2 under N0", async () => {
const {
userA,
@@ -229,31 +313,17 @@ test.skip("concurrent: A nests N1 under N0, B nests N2 under N0", async () => {
});
// Base: single block N0.
- userA.editor.replaceBlocks(userA.editor.document, [
- { id: "n0", type: "paragraph", content: "N0" },
- ]);
+ userA.editor.replaceBlocks(userA.editor.document, nestBothUnderN0.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("N0"));
enableSuggestions();
// A: insert N1 as sibling of N0, then nest under N0.
- userA.editor.insertBlocks(
- [{ id: "n1", type: "paragraph", content: "N1" }],
- "n0",
- "after",
- );
- userA.editor.setTextCursorPosition("n1", "start");
- userA.editor.nestBlock();
+ nestBothUnderN0.applyA(userA.editor);
// B: same shape with N2.
- userB.editor.insertBlocks(
- [{ id: "n2", type: "paragraph", content: "N2" }],
- "n0",
- "after",
- );
- userB.editor.setTextCursorPosition("n2", "start");
- userB.editor.nestBlock();
+ nestBothUnderN0.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -312,12 +382,8 @@ test.skip("concurrent: A nests N1 under N0, B nests N2 under N0", async () => {
"
`);
- // TODO: the merge is asymmetric – A's N1 lands nested under N0 (as
- // intended), but B's N2 ends up as a *sibling* even though B's local
- // suggestion doc had N2 nested under N0 too. The first-to-nest wins,
- // the second user's nesting is silently lost. If both users see the
- // exact same operation in their local view, we'd expect the merge to
- // preserve both nestings (or at least surface the conflict).
+ // The asymmetric merge below (A's N1 nests, B's N2 lands as a sibling) is the
+ // known issue tracked in the gallery ("concurrent-nest-both-under-n0").
expect(editorHtml(merged.editor)).toMatchInlineSnapshot(`
"
diff --git a/tests/src/end-to-end/y-prosemirror/nesting.test.tsx b/tests/src/end-to-end/y-prosemirror/nesting.test.tsx
index 54f9e6d833..0e6fecc517 100644
--- a/tests/src/end-to-end/y-prosemirror/nesting.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/nesting.test.tsx
@@ -3,11 +3,6 @@
* Vitest browser-mode tests for nesting-related suggestions: indent,
* unindent, and type-change on a block that already has children.
* Same shape as `propChanges.test.tsx`.
- *
- * The third test (`change parent type with children`) is marked
- * `test.fails` because it hits the same known y-prosemirror
- * `deltaToPSteps` bug that affects all type-changes-in-suggestion-mode
- * (see `typeChanges.test.tsx`).
*/
import { SuggestionsExtension } from "@blocknote/core/y";
import { expect, test } from "vite-plus/test";
@@ -16,29 +11,40 @@ import { expectScreenshot, expectVisible } from "./fixtures/browserExpect.js";
import {
editorHtml,
setupSuggestionTest,
+ waitForSuggestion,
ydocXml,
} from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + the `apply` change) is shared with the
+// suggestion-gallery example so the gallery and these tests never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { SingleScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const indentBlock = scenarios.find(
+ (s) => s.id === "nesting-indent",
+) as SingleScenario;
+const unindentBlock = scenarios.find(
+ (s) => s.id === "nesting-unindent",
+) as SingleScenario;
+const changeParentType = scenarios.find(
+ (s) => s.id === "nesting-change-parent-type",
+) as SingleScenario;
+
// Indent: take two sibling paragraphs and nest the second under the
// first.
test("suggestion mode: indent a block", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "indent N1" });
- editor.replaceBlocks(editor.document, [
- { id: "n0", type: "paragraph", content: "N0" },
- { id: "n1", type: "paragraph", content: "N1" },
- ]);
+ editor.replaceBlocks(editor.document, indentBlock.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("N0"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- // Place cursor in N1 and ask BlockNote to nest it under N0.
- editor.setTextCursorPosition("n1", "start");
- editor.nestBlock();
+ indentBlock.apply(editor);
- await expect.poll(() => editor.document[0]?.children.length).toBe(1);
+ await waitForSuggestion(editor);
await expectScreenshot(screen.getByTestId("editor-root"), "nesting-indent");
@@ -73,38 +79,15 @@ test("suggestion mode: indent a block", async () => {
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
-
- N0
-
-
-
-
-
-
- N1
-
-
-
-
-
-
-
+
+
+ N0
+
+
{
N1
+
+
+
+
+ N0
+
+
+
+
+
+
+
+
+ N1
+
+
+
+
+
+
+
+
"
`);
@@ -124,23 +157,15 @@ test("suggestion mode: unindent a block", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "unindent N1" });
- editor.replaceBlocks(editor.document, [
- {
- id: "n0",
- type: "paragraph",
- content: "N0",
- children: [{ id: "n1", type: "paragraph", content: "N1" }],
- },
- ]);
+ editor.replaceBlocks(editor.document, unindentBlock.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("N0"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.setTextCursorPosition("n1", "start");
- editor.unnestBlock();
+ unindentBlock.apply(editor);
- await expect.poll(() => editor.document.length).toBe(2);
+ await waitForSuggestion(editor);
await expectScreenshot(screen.getByTestId("editor-root"), "nesting-unindent");
@@ -169,20 +194,41 @@ test("suggestion mode: unindent a block", async () => {
expect(editorHtml(editor)).toMatchInlineSnapshot(`
"
-
- N0
-
+
+
+ N0
N1
-
-
+
+
+
+
+
+
+ N0
+
+
+
+
{
`);
});
-// Change parent block's type while keeping its children. Hits the
-// known y-prosemirror type-change bug.
-test.fails("suggestion mode: change block type of a block with children", async () => {
+// Change parent block's type while keeping its children.
+test("suggestion mode: change block type of a block with children", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "parent → heading" });
- editor.replaceBlocks(editor.document, [
- {
- id: "n0",
- type: "paragraph",
- content: "N0",
- children: [{ id: "n1", type: "paragraph", content: "N1" }],
- },
- ]);
+ editor.replaceBlocks(editor.document, changeParentType.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("N0"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- const [parent] = editor.document;
- editor.updateBlock(parent, { type: "heading", props: { level: 1 } });
+ changeParentType.apply(editor);
- await expect.poll(() => editor.document[0]?.type).toBe("heading");
+ // TODO: should this be editor.document[0], or expose .documentWithoutDeletions?
+ await expect.poll(() => editor.document[1]?.type).toBe("heading");
await expectScreenshot(
screen.getByTestId("editor-root"),
"nesting-change-parent-type",
);
- expect(ydocXml(baseDoc)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot();
- expect(editorHtml(editor)).toMatchInlineSnapshot();
+ expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
+ "
+
+ N0
+
+
+ N1
+
+
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+ N0
+
+
+ N1
+
+
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ N0
+
+
+ N1
+
+
+
+
+
+
+
+
+ N0
+
+
+
+
+
+
+
+
+ N1
+
+
+
+
+
+
+
+
+
+ "
+ `);
});
diff --git a/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx b/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx
index c088118bd5..d9a9edb55e 100644
--- a/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/propChanges.concurrent.test.tsx
@@ -4,8 +4,8 @@
* suggestions. Same shape as `basicText.concurrent.test.tsx` but the
* edits are block-level prop changes rather than content edits.
*
- * See `propChanges.test.tsx` for the TODO on prop changes producing no
- * `y-attributed-*` mark – the same applies here.
+ * The "no `y-attributed-*` mark for block-prop changes" known issue (tracked in
+ * the suggestion gallery's "Prop changes" scenarios) applies here too.
*/
import { expect, test } from "vite-plus/test";
import { expectScreenshot, expectVisible } from "./fixtures/browserExpect.js";
@@ -13,6 +13,15 @@ import { expectScreenshot, expectVisible } from "./fixtures/browserExpect.js";
import { setupConcurrentSuggestionTest } from "./fixtures/concurrentSuggestionFixture.js";
import { editorHtml, ydocXml } from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + A's/B's `applyA`/`applyB` changes) is
+// shared with the suggestion-gallery example so the two never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { ConcurrentScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const textColorVsBgColor = scenarios.find(
+ (s) => s.id === "concurrent-textcolor-vs-bgcolor",
+) as ConcurrentScenario;
+
// Two users edit independent props on the same block: A changes
// `textColor`, B changes `backgroundColor`. Neither edit touches the
// other's prop, so the CRDT merge should preserve both.
@@ -35,9 +44,7 @@ test("concurrent: A changes textColor, B changes backgroundColor", async () => {
});
// Seed: plain "hello world" with default colors.
- userA.editor.replaceBlocks(userA.editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ userA.editor.replaceBlocks(userA.editor.document, textColorVsBgColor.initial);
seed();
await expectVisible(
screen.getByTestId(userA.testId).getByText("hello world"),
@@ -46,18 +53,10 @@ test("concurrent: A changes textColor, B changes backgroundColor", async () => {
enableSuggestions();
// A: change textColor to red.
- const [blockA] = userA.editor.document;
- userA.editor.updateBlock(blockA, {
- type: "paragraph",
- props: { textColor: "red" },
- });
+ textColorVsBgColor.applyA(userA.editor);
// B: change backgroundColor to yellow.
- const [blockB] = userB.editor.document;
- userB.editor.updateBlock(blockB, {
- type: "paragraph",
- props: { backgroundColor: "yellow" },
- });
+ textColorVsBgColor.applyB(userB.editor);
// Prop changes don't generate y-attributed marks, so we poll on the
// individual editor doc states instead.
diff --git a/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx b/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx
index 65a7ca492c..4ddba8d3ea 100644
--- a/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/propChanges.test.tsx
@@ -16,21 +16,33 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
-// Tiny inline SVG data URLs – avoids a network fetch (placehold.co
-// occasionally returns after the screenshot is taken).
-const IMG_SRC_BASE =
- "data:image/svg+xml;utf8,";
-const IMG_SRC_NEW =
- "data:image/svg+xml;utf8,";
+// Scenario data (the `initial` seed + the `apply` change) is shared with the
+// suggestion-gallery example so the gallery and these tests never drift. The
+// image URLs are imported from there too, so the polls below check the exact
+// value the scenario sets.
+import {
+ IMG_SRC_BASE,
+ IMG_SRC_NEW,
+ scenarios,
+} from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { SingleScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const textAlignment = scenarios.find(
+ (s) => s.id === "prop-text-alignment",
+) as SingleScenario;
+const headingLevel = scenarios.find(
+ (s) => s.id === "prop-heading-level",
+) as SingleScenario;
+const imageWidth = scenarios.find(
+ (s) => s.id === "prop-image-width",
+) as SingleScenario;
+const imageSource = scenarios.find(
+ (s) => s.id === "prop-image-source",
+) as SingleScenario;
-// TODO: block-level prop changes generate NO `y-attributed-*` mark in
-// the editor's PM doc – the suggestion doc carries the new value but
-// the editor shows it as if it were already accepted. Compare with the
-// inline-format case in `basicText.test.tsx` which at least produces a
-// `y-attributed-format` mark (still no visual style, but at least
-// detectable from the data). Decide whether block-prop suggestions
-// should also be wrapped in a `y-attributed-format` (or similar) so
-// reviewers / accept-reject UI can target them.
+// Known issue — tracked in the suggestion gallery (the "Prop changes" scenarios,
+// e.g. "prop-text-alignment"): block-level prop changes generate no
+// `y-attributed-*` mark, so the pending change is invisible in the diff.
//
// Block-level prop change: paragraph's `textAlignment` flips from
// "left" to "center". Text content is unchanged.
@@ -38,19 +50,13 @@ test("suggestion mode: change text alignment to center", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "center align" });
- editor.replaceBlocks(editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ editor.replaceBlocks(editor.document, textAlignment.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("hello world"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- const [block] = editor.document;
- editor.updateBlock(block, {
- type: "paragraph",
- props: { textAlignment: "center" },
- });
+ textAlignment.apply(editor);
// Prop changes don't generate `y-attributed-*` marks, so the
// `waitForSuggestion` helper used elsewhere is too narrow here.
@@ -99,24 +105,13 @@ test("suggestion mode: change heading level from 1 to 2", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "demote heading" });
- editor.replaceBlocks(editor.document, [
- {
- id: "block-hello",
- type: "heading",
- props: { level: 1 },
- content: "hello world",
- },
- ]);
+ editor.replaceBlocks(editor.document, headingLevel.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("hello world"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- const [block] = editor.document;
- editor.updateBlock(block, {
- type: "heading",
- props: { level: 2 },
- });
+ headingLevel.apply(editor);
await expect
.poll(() => (editor.document[0]?.props as { level?: number })?.level)
@@ -176,16 +171,7 @@ test("suggestion mode: resize image (previewWidth)", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "resize image" });
- editor.replaceBlocks(editor.document, [
- {
- id: "block-image",
- type: "image",
- props: {
- url: IMG_SRC_BASE,
- previewWidth: 200,
- },
- },
- ]);
+ editor.replaceBlocks(editor.document, imageWidth.initial);
await sync();
// Default `alt=""` on the image makes it decorative, so
// `getByRole("img")` doesn't see it. Poll on the prop having
@@ -196,11 +182,7 @@ test("suggestion mode: resize image (previewWidth)", async () => {
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- const [block] = editor.document;
- editor.updateBlock(block, {
- type: "image",
- props: { previewWidth: 400 },
- });
+ imageWidth.apply(editor);
await expect
.poll(
@@ -268,16 +250,7 @@ test("suggestion mode: change image source", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "swap image src" });
- editor.replaceBlocks(editor.document, [
- {
- id: "block-image",
- type: "image",
- props: {
- url: IMG_SRC_BASE,
- previewWidth: 200,
- },
- },
- ]);
+ editor.replaceBlocks(editor.document, imageSource.initial);
await sync();
// Default `alt=""` on the image makes it decorative, so
// `getByRole("img")` doesn't see it. Poll on the prop having
@@ -288,11 +261,7 @@ test("suggestion mode: change image source", async () => {
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- const [block] = editor.document;
- editor.updateBlock(block, {
- type: "image",
- props: { url: IMG_SRC_NEW },
- });
+ imageSource.apply(editor);
await expect
.poll(() => (editor.document[0]?.props as { url?: string })?.url)
diff --git a/tests/src/end-to-end/y-prosemirror/tables.concurrent.test.tsx b/tests/src/end-to-end/y-prosemirror/tables.concurrent.test.tsx
index 3a59cb5c43..2e06317bf1 100644
--- a/tests/src/end-to-end/y-prosemirror/tables.concurrent.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/tables.concurrent.test.tsx
@@ -13,22 +13,39 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
-// Shared 2x2 starting table.
-const TABLE_2X2 = {
- id: "table",
- type: "table" as const,
- content: {
- type: "tableContent" as const,
- rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }],
- },
-};
+// Scenario data (the `initial` seed + A's/B's `applyA`/`applyB` changes) is
+// shared with the suggestion-gallery example so the two never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { ConcurrentScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const deleteRowVsAddCol = scenarios.find(
+ (s) => s.id === "concurrent-table-row-vs-column",
+) as ConcurrentScenario;
+const addRowVsAddCol = scenarios.find(
+ (s) => s.id === "concurrent-table-row-and-column",
+) as ConcurrentScenario;
+const delColVsAddRow = scenarios.find(
+ (s) => s.id === "concurrent-table-delcol-vs-addrow",
+) as ConcurrentScenario;
+const seqColThenRow = scenarios.find(
+ (s) => s.id === "concurrent-table-seq-col-then-row",
+) as ConcurrentScenario;
+const seqRowThenCol = scenarios.find(
+ (s) => s.id === "concurrent-table-seq-row-then-col",
+) as ConcurrentScenario;
+const addColVsAddRow = scenarios.find(
+ (s) => s.id === "concurrent-table-addcol-vs-addrow",
+) as ConcurrentScenario;
// A deletes the last row, B adds a third column. Two disjoint
// structural edits to the same table.
-// The merged editor's afterTransaction throws
-// `applyChangesetToDelta: Unexpected case` in y-prosemirror when
-// these two suggestions sync, so this is marked `test.fails` until
-// upstream supports this interleaving.
+//
+// Known issue — tracked in the suggestion gallery ("concurrent-table-row-vs-column").
+// Kept `test.fails` until the fix lands.
+//
+// NB: this throws synchronously in `sync()`, but it's invisible in a normal
+// `.fails` run — vitest suppresses a passing (expected-fail) test's error output,
+// so "Unexpected case" only shows in the log if you temporarily drop `.fails`.
test.fails("concurrent: A deletes a row, B adds a column", async () => {
const {
userA,
@@ -47,29 +64,15 @@ test.fails("concurrent: A deletes a row, B adds a column", async () => {
userBAction: "add column",
});
- userA.editor.replaceBlocks(userA.editor.document, [TABLE_2X2]);
+ userA.editor.replaceBlocks(userA.editor.document, deleteRowVsAddCol.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("A1"));
enableSuggestions();
- // A: drop row 2.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1"] }],
- },
- });
+ deleteRowVsAddCol.applyA(userA.editor);
- // B: add a third column.
- userB.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }],
- },
- });
+ deleteRowVsAddCol.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -109,33 +112,15 @@ test("concurrent: A adds a row, B adds a column", async () => {
userBAction: "add column",
});
- userA.editor.replaceBlocks(userA.editor.document, [TABLE_2X2]);
+ userA.editor.replaceBlocks(userA.editor.document, addRowVsAddCol.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("A1"));
enableSuggestions();
- // A: add a third row.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1"] },
- { cells: ["A2", "B2"] },
- { cells: ["A3", "B3"] },
- ],
- },
- });
+ addRowVsAddCol.applyA(userA.editor);
- // B: add a third column.
- userB.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }],
- },
- });
+ addRowVsAddCol.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -475,9 +460,9 @@ test("concurrent: A adds a row, B adds a column", async () => {
B1
{
rowspan="1"
>
C1
@@ -522,9 +507,9 @@ test("concurrent: A adds a row, B adds a column", async () => {
B2
{
rowspan="1"
>
C2
@@ -550,13 +535,13 @@ test("concurrent: A adds a row, B adds a column", async () => {
@@ -568,13 +553,13 @@ test("concurrent: A adds a row, B adds a column", async () => {
rowspan="1"
>
A3
@@ -583,7 +568,7 @@ test("concurrent: A adds a row, B adds a column", async () => {
@@ -595,13 +580,13 @@ test("concurrent: A adds a row, B adds a column", async () => {
rowspan="1"
>
B3
@@ -649,33 +634,15 @@ test("concurrent: A deletes a column, B adds a row", async () => {
userBAction: "add row",
});
- userA.editor.replaceBlocks(userA.editor.document, [TABLE_2X2]);
+ userA.editor.replaceBlocks(userA.editor.document, delColVsAddRow.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("A1"));
enableSuggestions();
- // A: drop column B.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1"] }, { cells: ["A2"] }],
- },
- });
+ delColVsAddRow.applyA(userA.editor);
- // B: add a third row.
- userB.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1"] },
- { cells: ["A2", "B2"] },
- { cells: ["A3", "B3"] },
- ],
- },
- });
+ delColVsAddRow.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -916,7 +883,7 @@ test("concurrent: A deletes a column, B adds a row", async () => {
A1
@@ -942,7 +909,7 @@ test("concurrent: A deletes a column, B adds a row", async () => {
A2
@@ -958,15 +925,15 @@ test("concurrent: A deletes a column, B adds a row", async () => {
{
rowspan="1"
>
A3
{
rowspan="1"
>
B3
@@ -1048,46 +1015,17 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
userBAction: "add column",
});
- userA.editor.replaceBlocks(userA.editor.document, [TABLE_2X2]);
+ userA.editor.replaceBlocks(userA.editor.document, seqColThenRow.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("A1"));
enableSuggestions();
- // A: add a third column.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }],
- },
- });
-
- await waitForSuggestion(userA.editor);
-
- // A: then add a third row.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1", "C1"] },
- { cells: ["A2", "B2", "C2"] },
- { cells: ["A3", "B3", "C3"] },
- ],
- },
- });
+ seqColThenRow.applyA(userA.editor);
await waitForSuggestion(userA.editor);
- // B: add their own column.
- userB.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1", "D1"] }, { cells: ["A2", "B2", "D2"] }],
- },
- });
+ seqColThenRow.applyB(userB.editor);
await waitForSuggestion(userB.editor);
@@ -1486,7 +1424,7 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
B1
@@ -1498,13 +1436,13 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
rowspan="1"
>
C1
@@ -1513,9 +1451,9 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
{
rowspan="1"
>
D1
@@ -1560,7 +1498,7 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
B2
@@ -1572,13 +1510,13 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
rowspan="1"
>
C2
@@ -1587,9 +1525,9 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
{
rowspan="1"
>
D2
@@ -1615,13 +1553,13 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
@@ -1633,13 +1571,13 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
rowspan="1"
>
A3
@@ -1648,7 +1586,7 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
@@ -1660,13 +1598,13 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
rowspan="1"
>
B3
@@ -1675,7 +1613,7 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
@@ -1687,13 +1625,13 @@ test("sequential: A adds a column then a row, B adds a column", async () => {
rowspan="1"
>
C3
@@ -1741,54 +1679,17 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
userBAction: "add row",
});
- userA.editor.replaceBlocks(userA.editor.document, [TABLE_2X2]);
+ userA.editor.replaceBlocks(userA.editor.document, seqRowThenCol.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("A1"));
enableSuggestions();
- // A: add a third row.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1"] },
- { cells: ["A2", "B2"] },
- { cells: ["A3", "B3"] },
- ],
- },
- });
-
- await waitForSuggestion(userA.editor);
-
- // A: then add a third column.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1", "C1"] },
- { cells: ["A2", "B2", "C2"] },
- { cells: ["A3", "B3", "C3"] },
- ],
- },
- });
+ seqRowThenCol.applyA(userA.editor);
await waitForSuggestion(userA.editor);
- // B: add their own row.
- userB.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1"] },
- { cells: ["A2", "B2"] },
- { cells: ["D1", "D2"] },
- ],
- },
- });
+ seqRowThenCol.applyB(userB.editor);
await waitForSuggestion(userB.editor);
@@ -2191,7 +2092,7 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
B1
@@ -2203,13 +2104,13 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
rowspan="1"
>
C1
@@ -2238,7 +2139,7 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
B2
@@ -2250,13 +2151,13 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
rowspan="1"
>
C2
@@ -2266,13 +2167,13 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
@@ -2284,13 +2185,13 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
rowspan="1"
>
A3
@@ -2299,7 +2200,7 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
@@ -2311,13 +2212,13 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
rowspan="1"
>
B3
@@ -2326,7 +2227,7 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
@@ -2338,13 +2239,13 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
rowspan="1"
>
C3
@@ -2355,15 +2256,15 @@ test("sequential: A adds a row then a column, B adds a row", async () => {
{
rowspan="1"
>
D1
{
rowspan="1"
>
D2
@@ -2452,33 +2353,15 @@ test("concurrent: A adds a column, B adds a row", async () => {
userBAction: "add row",
});
- userA.editor.replaceBlocks(userA.editor.document, [TABLE_2X2]);
+ userA.editor.replaceBlocks(userA.editor.document, addColVsAddRow.initial);
seed();
await expectVisible(screen.getByTestId(userA.testId).getByText("A1"));
enableSuggestions();
- // A: add a third column.
- userA.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }],
- },
- });
+ addColVsAddRow.applyA(userA.editor);
- // B: add a third row.
- userB.editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1"] },
- { cells: ["A2", "B2"] },
- { cells: ["A3", "B3"] },
- ],
- },
- });
+ addColVsAddRow.applyB(userB.editor);
await waitForSuggestion(userA.editor);
await waitForSuggestion(userB.editor);
@@ -2818,7 +2701,7 @@ test("concurrent: A adds a column, B adds a row", async () => {
B1
@@ -2830,13 +2713,13 @@ test("concurrent: A adds a column, B adds a row", async () => {
rowspan="1"
>
C1
@@ -2865,7 +2748,7 @@ test("concurrent: A adds a column, B adds a row", async () => {
B2
@@ -2877,13 +2760,13 @@ test("concurrent: A adds a column, B adds a row", async () => {
rowspan="1"
>
C2
@@ -2893,15 +2776,15 @@ test("concurrent: A adds a column, B adds a row", async () => {
{
rowspan="1"
>
A3
{
rowspan="1"
>
B3
diff --git a/tests/src/end-to-end/y-prosemirror/tables.test.tsx b/tests/src/end-to-end/y-prosemirror/tables.test.tsx
index 877206c75c..68a2de07ee 100644
--- a/tests/src/end-to-end/y-prosemirror/tables.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/tables.test.tsx
@@ -18,38 +18,48 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
-// Shared 2x2 table baseline used by most of the tests below.
-const TABLE_2X2 = {
- id: "table",
- type: "table" as const,
- content: {
- type: "tableContent" as const,
- rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }],
- },
-};
+// Scenario data (the `initial` seed + the `apply` change) is shared with the
+// suggestion-gallery example so the gallery and these tests never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { SingleScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const addRow = scenarios.find(
+ (s) => s.id === "table-add-row",
+) as SingleScenario;
+const addColumn = scenarios.find(
+ (s) => s.id === "table-add-column",
+) as SingleScenario;
+const removeRow = scenarios.find(
+ (s) => s.id === "table-remove-row",
+) as SingleScenario;
+const removeColumn = scenarios.find(
+ (s) => s.id === "table-remove-column",
+) as SingleScenario;
+const editCell = scenarios.find(
+ (s) => s.id === "table-edit-cell",
+) as SingleScenario;
+const columnColor = scenarios.find(
+ (s) => s.id === "table-column-color",
+) as SingleScenario;
+const mergeCells = scenarios.find(
+ (s) => s.id === "table-merge-cells",
+) as SingleScenario;
+const splitCell = scenarios.find(
+ (s) => s.id === "table-split-cell",
+) as SingleScenario;
// Add a third row to a 2x2 table.
test("suggestion mode: add row", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "add row" });
- editor.replaceBlocks(editor.document, [TABLE_2X2]);
+ editor.replaceBlocks(editor.document, addRow.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("A1"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- { cells: ["A1", "B1"] },
- { cells: ["A2", "B2"] },
- { cells: ["A3", "B3"] },
- ],
- },
- });
+ addRow.apply(editor);
await expect.poll(() => editor.document[0]?.children.length).toBe(0);
await expectVisible(screen.getByTestId("editor-A").getByText("A3"));
@@ -301,19 +311,13 @@ test("suggestion mode: add column", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "add column" });
- editor.replaceBlocks(editor.document, [TABLE_2X2]);
+ editor.replaceBlocks(editor.document, addColumn.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("A1"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }],
- },
- });
+ addColumn.apply(editor);
await expectVisible(screen.getByTestId("editor-A").getByText("C1"));
@@ -554,19 +558,13 @@ test("suggestion mode: remove row", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "remove last row" });
- editor.replaceBlocks(editor.document, [TABLE_2X2]);
+ editor.replaceBlocks(editor.document, removeRow.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("A2"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1"] }],
- },
- });
+ removeRow.apply(editor);
await expectScreenshot(screen.getByTestId("editor-root"), "table-remove-row");
@@ -715,19 +713,13 @@ test("suggestion mode: remove column", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "remove last column" });
- editor.replaceBlocks(editor.document, [TABLE_2X2]);
+ editor.replaceBlocks(editor.document, removeColumn.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("B1"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1"] }, { cells: ["A2"] }],
- },
- });
+ removeColumn.apply(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
@@ -887,19 +879,13 @@ test("suggestion mode: update text in cell", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "edit top-left cell" });
- editor.replaceBlocks(editor.document, [TABLE_2X2]);
+ editor.replaceBlocks(editor.document, editCell.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("A1"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1 edited", "B1"] }, { cells: ["A2", "B2"] }],
- },
- });
+ editCell.apply(editor);
await expectVisible(screen.getByTestId("editor-A").getByText("edited"));
@@ -1073,40 +1059,13 @@ test("suggestion mode: change column background color", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "highlight first column" });
- editor.replaceBlocks(editor.document, [TABLE_2X2]);
+ editor.replaceBlocks(editor.document, columnColor.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("A1"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- {
- cells: [
- {
- type: "tableCell",
- props: { backgroundColor: "yellow" },
- content: ["A1"],
- },
- { type: "tableCell", content: ["B1"] },
- ],
- },
- {
- cells: [
- {
- type: "tableCell",
- props: { backgroundColor: "yellow" },
- content: ["A2"],
- },
- { type: "tableCell", content: ["B2"] },
- ],
- },
- ],
- },
- });
+ columnColor.apply(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
@@ -1171,7 +1130,7 @@ test("suggestion mode: change column background color", async () => {
{
{
{
{
`);
});
-// TODO: this is broken as it's an extra "deleted column" is shown
+// Known issue — tracked in the suggestion gallery ("table-merge-cells"): the
+// diff shows a phantom extra "deleted column".
// Merge two horizontally adjacent cells in the top row by setting
// colspan=2 on the first cell and dropping the second.
@@ -1277,30 +1237,13 @@ test("suggestion mode: merge two cells", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "merge top-row cells" });
- editor.replaceBlocks(editor.document, [TABLE_2X2]);
+ editor.replaceBlocks(editor.document, mergeCells.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("A1"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- {
- cells: [
- {
- type: "tableCell",
- props: { colspan: 2 },
- content: ["A1+B1"],
- },
- ],
- },
- { cells: ["A2", "B2"] },
- ],
- },
- });
+ mergeCells.apply(editor);
await expectScreenshot(
screen.getByTestId("editor-root"),
@@ -1504,39 +1447,13 @@ test("suggestion mode: split a merged cell", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "split top-row cell" });
- editor.replaceBlocks(editor.document, [
- {
- id: "table",
- type: "table",
- content: {
- type: "tableContent",
- rows: [
- {
- cells: [
- {
- type: "tableCell",
- props: { colspan: 2 },
- content: ["A1+B1"],
- },
- ],
- },
- { cells: ["A2", "B2"] },
- ],
- },
- },
- ]);
+ editor.replaceBlocks(editor.document, splitCell.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("A1+B1"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- editor.updateBlock("table", {
- type: "table",
- content: {
- type: "tableContent",
- rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }],
- },
- });
+ splitCell.apply(editor);
await expectScreenshot(screen.getByTestId("editor-root"), "table-split-cell");
diff --git a/tests/src/end-to-end/y-prosemirror/typeChanges.concurrent.test.tsx b/tests/src/end-to-end/y-prosemirror/typeChanges.concurrent.test.tsx
index f5d2810334..a141c2907a 100644
--- a/tests/src/end-to-end/y-prosemirror/typeChanges.concurrent.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/typeChanges.concurrent.test.tsx
@@ -2,11 +2,6 @@
/**
* Vitest browser-mode tests for two-user concurrent type-change
* suggestions. Same shape as `propChanges.concurrent.test.tsx`.
- *
- * KNOWN BUG: see `typeChanges.test.tsx` – block-type changes in
- * suggestion mode currently throw in y-prosemirror's `deltaToPSteps`.
- * Both tests below are marked `test.fails`; when the upstream bug is
- * fixed they will flip red and we can capture proper snapshots.
*/
import { expect, test } from "vite-plus/test";
import { expectScreenshot, expectVisible } from "./fixtures/browserExpect.js";
@@ -14,9 +9,21 @@ import { expectScreenshot, expectVisible } from "./fixtures/browserExpect.js";
import { setupConcurrentSuggestionTest } from "./fixtures/concurrentSuggestionFixture.js";
import { editorHtml, ydocXml } from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + A's/B's `applyA`/`applyB` changes) is
+// shared with the suggestion-gallery example so the two never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { ConcurrentScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const headingVsList = scenarios.find(
+ (s) => s.id === "concurrent-heading-vs-list",
+) as ConcurrentScenario;
+const textVsHeading = scenarios.find(
+ (s) => s.id === "concurrent-text-vs-heading",
+) as ConcurrentScenario;
+
// Two competing type changes on the same block: A wants a heading, B
// wants a list item.
-test.fails("concurrent: A → heading, B → list item", async () => {
+test("concurrent: A → heading, B → list item", async () => {
const {
userA,
userB,
@@ -34,9 +41,7 @@ test.fails("concurrent: A → heading, B → list item", async () => {
userBAction: "→ list item",
});
- userA.editor.replaceBlocks(userA.editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ userA.editor.replaceBlocks(userA.editor.document, headingVsList.initial);
seed();
await expectVisible(
screen.getByTestId(userA.testId).getByText("hello world"),
@@ -44,18 +49,14 @@ test.fails("concurrent: A → heading, B → list item", async () => {
enableSuggestions();
- const [blockA] = userA.editor.document;
- userA.editor.updateBlock(blockA, {
- type: "heading",
- props: { level: 1 },
- });
+ headingVsList.applyA(userA.editor);
- const [blockB] = userB.editor.document;
- userB.editor.updateBlock(blockB, { type: "bulletListItem" });
+ headingVsList.applyB(userB.editor);
- await expect.poll(() => userA.editor.document[0]?.type).toBe("heading");
+ // TODO: should this be editor.document[0], or expose .documentWithoutDeletions?
+ await expect.poll(() => userA.editor.document[1]?.type).toBe("heading");
await expect
- .poll(() => userB.editor.document[0]?.type)
+ .poll(() => userB.editor.document[1]?.type)
.toBe("bulletListItem");
sync();
@@ -65,17 +66,130 @@ test.fails("concurrent: A → heading, B → list item", async () => {
"concurrent-heading-vs-list",
);
- expect(ydocXml(baseDoc)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot();
- expect(editorHtml(merged.editor)).toMatchInlineSnapshot();
+ expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+
+ hello world
+
+ "
+ `);
+ expect(editorHtml(merged.editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ hello world
+
+
+
+
+
+
+ hello world
+
+
+
+
+
+
+
+
+ hello world
+
+
+
+
+
+ "
+ `);
});
// Mixed: A does a text edit (no type change), B changes the type.
// Exercises the path where one user's suggestion is a regular text
// diff and the other's is a block-type swap.
-test.fails("concurrent: A edits text, B → heading", async () => {
+test("concurrent: A edits text, B → heading", async () => {
const {
userA,
userB,
@@ -93,9 +207,7 @@ test.fails("concurrent: A edits text, B → heading", async () => {
userBAction: "→ heading",
});
- userA.editor.replaceBlocks(userA.editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ userA.editor.replaceBlocks(userA.editor.document, textVsHeading.initial);
seed();
await expectVisible(
screen.getByTestId(userA.testId).getByText("hello world"),
@@ -103,24 +215,17 @@ test.fails("concurrent: A edits text, B → heading", async () => {
enableSuggestions();
- const [blockA] = userA.editor.document;
- userA.editor.updateBlock(blockA, {
- type: "paragraph",
- content: "hello universe",
- });
+ textVsHeading.applyA(userA.editor);
- const [blockB] = userB.editor.document;
- userB.editor.updateBlock(blockB, {
- type: "heading",
- props: { level: 1 },
- });
+ textVsHeading.applyB(userB.editor);
await expect
.poll(() =>
userA.editor.prosemirrorState.doc.toString().includes("y-attributed"),
)
.toBe(true);
- await expect.poll(() => userB.editor.document[0]?.type).toBe("heading");
+ // TODO: should this be editor.document[0], or expose .documentWithoutDeletions?
+ await expect.poll(() => userB.editor.document[1]?.type).toBe("heading");
sync();
@@ -129,9 +234,113 @@ test.fails("concurrent: A edits text, B → heading", async () => {
"concurrent-text-edit-vs-heading",
);
- expect(ydocXml(baseDoc)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot();
- expect(editorHtml(merged.editor)).toMatchInlineSnapshot();
+ expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(ydocXml(suggestionDocA)).toMatchInlineSnapshot(`
+ "
+
+ hello universe
+
+ "
+ `);
+ expect(ydocXml(suggestionDocB)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(ydocXml(suggestionDocMerged)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(editorHtml(merged.editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+
+
+ hello
+ wo
+ r
+ ld
+
+
+
+
+
+
+
+
+ hello world
+
+
+
+
+
+ "
+ `);
});
diff --git a/tests/src/end-to-end/y-prosemirror/typeChanges.test.tsx b/tests/src/end-to-end/y-prosemirror/typeChanges.test.tsx
index e1d3a6f19e..846b490fcb 100644
--- a/tests/src/end-to-end/y-prosemirror/typeChanges.test.tsx
+++ b/tests/src/end-to-end/y-prosemirror/typeChanges.test.tsx
@@ -3,14 +3,6 @@
* Vitest browser-mode tests for type-change suggestions: swapping the
* block type (paragraph ↔ heading ↔ list item) while preserving its
* inline content. Same shape as `propChanges.test.tsx`.
- *
- * KNOWN BUG: `editor.updateBlock(block, { type: ... })` in suggestion
- * mode currently throws `TransformError: No node at mark step's
- * position` from y-prosemirror's `deltaToPSteps`. Tests are marked
- * `test.fails` so they pass while the bug exists – when the
- * underlying issue is fixed, the tests will start passing for real
- * and `test.fails` will flip them red, signalling that snapshots need
- * to be captured.
*/
import { SuggestionsExtension } from "@blocknote/core/y";
import { expect, test } from "vite-plus/test";
@@ -22,63 +14,181 @@ import {
ydocXml,
} from "./fixtures/suggestionFixture.js";
+// Scenario data (the `initial` seed + the `apply` change) is shared with the
+// suggestion-gallery example so the gallery and these tests never drift.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+import type { SingleScenario } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+const listToParagraph = scenarios.find(
+ (s) => s.id === "type-list-to-paragraph",
+) as SingleScenario;
+const paragraphToHeading = scenarios.find(
+ (s) => s.id === "type-paragraph-to-heading",
+) as SingleScenario;
+
// Demote a bullet-list item to a plain paragraph. Inline content
// "hello world" stays the same; only the wrapping node type changes.
-test.fails("suggestion mode: change list item to paragraph", async () => {
+test("suggestion mode: change list item to paragraph", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "list → paragraph" });
- editor.replaceBlocks(editor.document, [
- {
- id: "block-hello",
- type: "bulletListItem",
- content: "hello world",
- },
- ]);
+ editor.replaceBlocks(editor.document, listToParagraph.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("hello world"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- const [block] = editor.document;
- editor.updateBlock(block, { type: "paragraph" });
+ listToParagraph.apply(editor);
- await expect.poll(() => editor.document[0]?.type).toBe("paragraph");
+ // TODO: should this be editor.document[0], or expose .documentWithoutDeletions?
+ await expect.poll(() => editor.document[1]?.type).toBe("paragraph");
await expectScreenshot(
screen.getByTestId("editor-root"),
"type-change-list-to-paragraph",
);
- expect(ydocXml(baseDoc)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot();
- expect(editorHtml(editor)).toMatchInlineSnapshot();
+ expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ hello world
+
+
+
+
+
+
+ hello world
+
+
+
+
+
+ "
+ `);
});
// Promote a paragraph to a level-1 heading. Same inline content.
-test.fails("suggestion mode: change paragraph to heading", async () => {
+test("suggestion mode: change paragraph to heading", async () => {
const { editor, screen, baseDoc, suggestionDoc, sync } =
await setupSuggestionTest({ userAction: "paragraph → heading" });
- editor.replaceBlocks(editor.document, [
- { id: "block-hello", type: "paragraph", content: "hello world" },
- ]);
+ editor.replaceBlocks(editor.document, paragraphToHeading.initial);
await sync();
await expectVisible(screen.getByTestId("editor-A").getByText("hello world"));
editor.getExtension(SuggestionsExtension)!.enableSuggestions();
- const [block] = editor.document;
- editor.updateBlock(block, { type: "heading", props: { level: 1 } });
+ paragraphToHeading.apply(editor);
- await expect.poll(() => editor.document[0]?.type).toBe("heading");
+ // TODO: should this be editor.document[0], or expose .documentWithoutDeletions?
+ await expect.poll(() => editor.document[1]?.type).toBe("heading");
await expectScreenshot(
screen.getByTestId("editor-root"),
"type-change-paragraph-to-heading",
);
- expect(ydocXml(baseDoc)).toMatchInlineSnapshot();
- expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot();
- expect(editorHtml(editor)).toMatchInlineSnapshot();
+ expect(ydocXml(baseDoc)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(ydocXml(suggestionDoc)).toMatchInlineSnapshot(`
+ "
+
+ hello world
+
+ "
+ `);
+ expect(editorHtml(editor)).toMatchInlineSnapshot(`
+ "
+
+
+
+ hello world
+
+
+
+
+
+
+ hello world
+
+
+
+
+
+ "
+ `);
});
diff --git a/tests/src/end-to-end/y-prosemirror/versioning.test.tsx b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx
new file mode 100644
index 0000000000..35378e66e4
--- /dev/null
+++ b/tests/src/end-to-end/y-prosemirror/versioning.test.tsx
@@ -0,0 +1,126 @@
+/**
+ * Versioning-mode coverage for every scenario — single- AND multi-user.
+ *
+ * The other files in this folder exercise the SuggestionsExtension diff overlay.
+ * This one exercises the OTHER diff path — `createYjsVersioningAdapter`'s
+ * `enterPreview`, which reconfigures the editor through y-prosemirror
+ * (`configureYProsemirror`). That path crashes for a few scenarios: moving a
+ * block that carries (or dissolves) a nested blockGroup makes y-prosemirror's
+ * `applyDelta` throw lib0 "Unexpected case". Each scenario is run through the
+ * same shape the gallery's Versioning mode uses — every user applies their
+ * change on their own clone of the base, the clones are merged via the Yjs CRDT,
+ * and the merge is diffed against the base — so any scenario (single or
+ * concurrent) that breaks the versioning diff is caught in CI.
+ */
+import { BlockNoteEditor } from "@blocknote/core";
+import {
+ blocksToYDoc,
+ createYjsVersioningAdapter,
+ withCollaboration,
+} from "@blocknote/core/y";
+import * as Y from "@y/y";
+import { expect, test } from "vite-plus/test";
+
+// Scenario data is shared with the suggestion-gallery example, so this covers the
+// exact same cases the gallery's Versioning mode renders.
+import { scenarios } from "@examples/07-collaboration/14-suggestion-gallery/src/scenarios";
+
+// A headless editor, used only for its (default) schema when seeding Y.Docs.
+let schemaEditor: BlockNoteEditor | undefined;
+const getSchema = () => (schemaEditor ??= BlockNoteEditor.create());
+
+function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
+ return a.length === b.length && a.every((byte, i) => byte === b[i]);
+}
+
+/** Clone a Y.Doc's content into a fresh doc with a pinned clientID (so the
+ * concurrent merge tiebreak — and thus the test — is deterministic). */
+function cloneWithId(source: Y.Doc, clientID: number): Y.Doc {
+ const doc = new Y.Doc();
+ Y.applyUpdate(doc, Y.encodeStateAsUpdate(source));
+ doc.clientID = clientID;
+ return doc;
+}
+
+/** Mount a collaborative editor on `doc`, returning it + a teardown. */
+function mountEditor(doc: Y.Doc): {
+ editor: BlockNoteEditor;
+ teardown: () => void;
+} {
+ const div = document.createElement("div");
+ document.body.appendChild(div);
+ const editor = BlockNoteEditor.create(
+ withCollaboration({
+ collaboration: {
+ fragment: doc.get("doc"),
+ provider: undefined,
+ user: { name: "User", color: "#8a6d1a" },
+ },
+ }),
+ );
+ editor.mount(div);
+ return {
+ editor,
+ teardown: () => {
+ editor.unmount();
+ div.remove();
+ },
+ };
+}
+
+for (const scenario of scenarios) {
+ const applies =
+ scenario.kind === "single"
+ ? [scenario.apply]
+ : [scenario.applyA, scenario.applyB];
+ // const runner = VERSIONING_CRASHES.has(scenario.id) ? test.fails : test;
+
+ test(`versioning diff: ${scenario.title}`, async () => {
+ const teardown: Array<() => void> = [];
+ try {
+ // "Before": the scenario's initial blocks, seeded synchronously.
+ const beforeDoc = blocksToYDoc(getSchema(), scenario.initial, "doc");
+ beforeDoc.clientID = 1;
+ teardown.push(() => beforeDoc.destroy());
+ const before = Y.encodeStateAsUpdateV2(beforeDoc);
+
+ // "After": each user applies their change on its own clone; the clones are
+ // merged into `afterDoc` via the CRDT — exactly like the gallery's merge.
+ const afterDoc = cloneWithId(beforeDoc, 2);
+ teardown.push(() => afterDoc.destroy());
+
+ for (let i = 0; i < applies.length; i++) {
+ const userDoc = cloneWithId(beforeDoc, 3 + i);
+ const { editor, teardown: unmount } = mountEditor(userDoc);
+ teardown.push(() => {
+ unmount();
+ userDoc.destroy();
+ });
+
+ applies[i](editor);
+ // Wait for the y-prosemirror binding to flush the change into `userDoc`.
+ await expect
+ .poll(() => !bytesEqual(Y.encodeStateAsUpdateV2(userDoc), before))
+ .toBe(true);
+ Y.applyUpdate(afterDoc, Y.encodeStateAsUpdate(userDoc));
+ }
+
+ const after = Y.encodeStateAsUpdateV2(afterDoc);
+
+ // The versioning diff render — this is the path that throws for the
+ // nested-move / table-merge crashers.
+ const { editor: diffEditor, teardown: unmount } = mountEditor(afterDoc);
+ teardown.push(unmount);
+ const adapter = createYjsVersioningAdapter(
+ diffEditor,
+ afterDoc.get("doc"),
+ );
+ adapter.preview.enterPreview(after, before);
+
+ // Reached only when enterPreview didn't throw: the diff is now showing.
+ expect(diffEditor.prosemirrorState.doc.childCount).toBeGreaterThan(0);
+ } finally {
+ teardown.reverse().forEach((fn) => fn());
+ }
+ });
+}
diff --git a/tests/src/examples.d.ts b/tests/src/examples.d.ts
index 2e54a52de3..0e9578fd9f 100644
--- a/tests/src/examples.d.ts
+++ b/tests/src/examples.d.ts
@@ -10,3 +10,40 @@ declare module "@examples/*" {
const App: ComponentType;
export default App;
}
+
+// The y-prosemirror suggestion tests share their scenario definitions (the
+// `initial` seed + the `apply` change) with the suggestion-gallery example, so
+// the two never drift. A specific declaration (more specific than the
+// `@examples/*` wildcard above) gives the named exports real types without a
+// `paths` entry descending into the example sources (which would break the
+// composite build, TS6059 — see the note above).
+declare module "@examples/07-collaboration/14-suggestion-gallery/src/scenarios" {
+ import type { BlockNoteEditor, PartialBlock } from "@blocknote/core";
+ export type SingleScenario = {
+ kind: "single";
+ id: string;
+ title: string;
+ category: string;
+ description: string;
+ initial: PartialBlock[];
+ apply: (editor: BlockNoteEditor) => void;
+ knownCrash?: boolean;
+ };
+ export type ConcurrentScenario = {
+ kind: "concurrent";
+ id: string;
+ title: string;
+ category: string;
+ description: string;
+ initial: PartialBlock[];
+ applyA: (editor: BlockNoteEditor) => void;
+ applyB: (editor: BlockNoteEditor) => void;
+ knownCrash?: boolean;
+ };
+ export type SuggestionScenario = SingleScenario | ConcurrentScenario;
+ export const scenarios: SuggestionScenario[];
+ // Exported image data URLs so prop-change tests poll the same value a
+ // scenario sets (see scenarios.ts).
+ export const IMG_SRC_BASE: string;
+ export const IMG_SRC_NEW: string;
+}