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 @@ + + + + + Suggestion Scenarios Gallery + + + +
+ + + diff --git a/examples/07-collaboration/14-suggestion-gallery/main.tsx b/examples/07-collaboration/14-suggestion-gallery/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/07-collaboration/14-suggestion-gallery/package.json b/examples/07-collaboration/14-suggestion-gallery/package.json new file mode 100644 index 0000000000..f24d947771 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocknote/example-collaboration-suggestion-gallery", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.16" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/07-collaboration/14-suggestion-gallery/src/App.tsx b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx new file mode 100644 index 0000000000..3a385200c3 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/App.tsx @@ -0,0 +1,656 @@ +import "@blocknote/core/fonts/inter.css"; +import "@blocknote/mantine/style.css"; +import "./style.css"; + +import type { BlockNoteEditor } from "@blocknote/core"; +import { + createYjsVersioningAdapter, + SuggestionsExtension, + withCollaboration, +} from "@blocknote/core/y"; +import { BlockNoteView } from "@blocknote/mantine"; +import { useCreateBlockNote } from "@blocknote/react"; +import { Awareness } from "@y/protocols/awareness"; +import * as Y from "@y/y"; +import { useEffect, useState } from "react"; + +import { ScenarioErrorBoundary } from "./ErrorBoundary"; +import { + buildSuggestionScenarioDocs, + cloneDoc, + createAttributionStore, + docFromBlocks, +} from "./scenarioDocs"; +import { scenarios, SuggestionScenario } from "./scenarios"; + +type Mode = "suggestions" | "versioning"; + +function makeAwareness(doc: Y.Doc, name: string, color: string): Awareness { + const awareness = new Awareness(doc); + awareness.setLocalStateField("user", { name, color }); + return awareness; +} + +// Hardcoded to match the attribution-mark palette (the colors BlockNote derives +// per author id "A" / "B"), so a user's pane chrome matches their color in the +// Diff / Merged panes. +const USER_A = { name: "User A", color: "#8a6d1a" }; +const USER_B = { name: "User B", color: "#8a2e24" }; + +type AttributionManager = ReturnType; + +type SuggestionAuthor = { + id: string; + label: string; + user: { name: string; color: string }; + apply: (editor: BlockNoteEditor) => void; +}; + +/** + * The authors making suggestions from the base — one for a single scenario, two + * (A and B) for a concurrent one. + */ +function suggestionAuthors(scenario: SuggestionScenario): SuggestionAuthor[] { + if (scenario.kind === "single") { + return [ + { + id: "A", + label: "User A (editable)", + user: USER_A, + apply: scenario.apply, + }, + ]; + } + return [ + { + id: "A", + label: "User A (editable)", + user: USER_A, + apply: scenario.applyA, + }, + { + id: "B", + label: "User B (editable)", + user: USER_B, + apply: scenario.applyB, + }, + ]; +} + +/** + * Suggestions mode for any scenario — Base (read-only) + one editable pane per + * author + (for a concurrent scenario) a read-only Merged pane that replays every + * author's suggestions live. Docs are built up front, so each editor just enables + * suggestion mode and applies its change on mount. + */ +function SuggestionsView({ scenario }: { scenario: SuggestionScenario }) { + const [setup] = useState(() => { + const base = docFromBlocks(scenario.initial); + return { base, baseAwareness: new Awareness(base) }; + }); + + const baseEditor = useCreateBlockNote( + withCollaboration({ + collaboration: { + fragment: setup.base.get("doc"), + provider: { awareness: setup.baseAwareness }, + user: { name: "Base", color: "#888888" }, + }, + }), + ); + + // Editing the base resets the suggestions — remount `` (fresh + // clones of the new base, no suggestion re-applied) via the `nonce` key, the + // same way editing Version 1 resets the versioning view. + const [nonce, setNonce] = useState(0); + useEffect(() => { + const onBaseEdit = () => setNonce((n) => n + 1); + setup.base.on("update", onBaseEdit); + return () => setup.base.off("update", onBaseEdit); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const authors = suggestionAuthors(scenario); + const paneCount = 1 + authors.length + (authors.length > 1 ? 1 : 0); + return ( +
= 4 ? " bn-gallery-editors--four" : "") + } + > +
+
Base (editable)
+ +
+ +
+ ); +} + +/** + * The author panes + (for a concurrent scenario) the Merged pane, built from a + * snapshot of the base. `applyInitial` applies each author's suggestion on the + * first build; a reset (base edited) leaves them clean, mirroring the versioning + * view's user panes. + */ +function SuggestionPanes({ + base, + authors, + applyInitial, +}: { + base: Y.Doc; + authors: SuggestionAuthor[]; + applyInitial: boolean; +}) { + const [setup] = useState(() => { + const docs = buildSuggestionScenarioDocs( + base, + authors.map((a) => a.id), + ); + return { + baseDoc: docs.baseDoc, + combined: authors.map((a, i) => ({ ...a, ...docs.authors[i] })), + merged: docs.merged, + }; + }); + + return ( + <> + {setup.combined.map((a) => ( + + ))} + {setup.merged && ( + ({ + id: a.id, + doc: a.suggestionDoc, + }))} + /> + )} + + ); +} + +/** + * One editable author pane in suggestion mode: enables suggestions + applies the + * author's change on mount; edits land in `suggestionDoc` as tracked changes. + */ +function UserSuggestion({ + baseDoc, + suggestionDoc, + manager, + user, + apply, + label, +}: { + baseDoc: Y.Doc; + suggestionDoc: Y.Doc; + manager: AttributionManager; + user: { name: string; color: string }; + apply?: (editor: BlockNoteEditor) => void; + label: string; +}) { + const [setup] = useState(() => ({ + awareness: makeAwareness(baseDoc, user.name, user.color), + })); + + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + fragment: baseDoc.get("doc"), + provider: { awareness: setup.awareness }, + suggestionDoc, + attributionManager: manager, + user, + }, + }), + ); + + useEffect(() => { + editor.getExtension(SuggestionsExtension)!.enableSuggestions(); + apply?.(editor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {label} +
+ +
+ ); +} + +/** + * The read-only Merged pane (concurrent only): a viewer editor that replays each + * author's suggestions, forwarded from their docs and tagged by author id, so any + * new suggestion shows up live. + */ +function MergedSuggestion({ + baseDoc, + merged, + authorDocs, +}: { + baseDoc: Y.Doc; + merged: { doc: Y.Doc; manager: AttributionManager }; + authorDocs: { id: string; doc: Y.Doc }[]; +}) { + const [setup] = useState(() => ({ awareness: new Awareness(baseDoc) })); + + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + fragment: baseDoc.get("doc"), + provider: { awareness: setup.awareness }, + suggestionDoc: merged.doc, + attributionManager: merged.manager, + user: { name: "Merged", color: "#666666" }, + }, + }), + ); + + useEffect(() => { + editor.getExtension(SuggestionsExtension)!.enableSuggestions(); + const offs = authorDocs.map(({ id, doc }) => { + const onUpdate = (update: Uint8Array) => + Y.applyUpdate(merged.doc, update, id); + doc.on("update", onUpdate); + return () => doc.off("update", onUpdate); + }); + // Pull in suggestions already applied on mount. + authorDocs.forEach(({ id, doc }) => + Y.applyUpdate(merged.doc, Y.encodeStateAsUpdate(doc), id), + ); + return () => offs.forEach((off) => off()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
Merged (read-only)
+ +
+ ); +} + +type VersioningUser = { + id: string; + label: string; + user: { name: string; color: string }; + apply: (editor: BlockNoteEditor) => void; +}; + +/** + * The editable "user" versions a scenario merges into Version 2 — one for a + * single-user scenario, two (A and B) for a concurrent one. + */ +function versioningUsers(scenario: SuggestionScenario): VersioningUser[] { + if (scenario.kind === "single") { + return [ + { + id: "A", + label: "Version 2 (editable)", + user: USER_A, + apply: scenario.apply, + }, + ]; + } + return [ + { + id: "A", + label: "User A (editable)", + user: USER_A, + apply: scenario.applyA, + }, + { + id: "B", + label: "User B (editable)", + user: USER_B, + apply: scenario.applyB, + }, + ]; +} + +/** + * Versioning mode for any scenario — Version 1 (base) + one editable pane per + * "user" + a read-only Diff. Version 2 is the live CRDT merge of the user docs: + * editing any user re-merges (and re-diffs); editing Version 1 resets every user + * back to a fresh clone (via the `nonce` remount). + */ +function VersioningView({ scenario }: { scenario: SuggestionScenario }) { + const [setup] = useState(() => { + const beforeDoc = docFromBlocks(scenario.initial); + return { + beforeDoc, + beforeAwareness: makeAwareness(beforeDoc, "Version 1", "#888888"), + users: versioningUsers(scenario), + }; + }); + + const beforeEditor = useCreateBlockNote( + withCollaboration({ + collaboration: { + fragment: setup.beforeDoc.get("doc"), + provider: { awareness: setup.beforeAwareness }, + user: { name: "Version 1", color: "#888888" }, + }, + }), + ); + + // Editing Version 1 resets the merge — remount `` (fresh clones + // of the new base, no change re-applied) via the `nonce` key. + const [nonce, setNonce] = useState(0); + useEffect(() => { + const onVersion1Edit = () => setNonce((n) => n + 1); + setup.beforeDoc.on("update", onVersion1Edit); + return () => setup.beforeDoc.off("update", onVersion1Edit); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
1 + ? "bn-gallery-editors--four" + : "bn-gallery-editors--three") + } + > +
+
Version 1 (editable)
+ +
+ +
+ ); +} + +/** + * The user panes + the Diff. Each user gets an editable editor on its own clone + * of the base; their edits are forwarded into `afterDoc` (a CRDT merge), which + * the read-only Diff shows against the base. `applyInitial` applies each user's + * change on the first build; a reset (Version 1 edited) leaves them clean. + */ +function VersionMerge({ + beforeDoc, + users, + applyInitial, +}: { + beforeDoc: Y.Doc; + users: VersioningUser[]; + applyInitial: boolean; +}) { + const [setup] = useState(() => { + const afterDoc = cloneDoc(beforeDoc); + const ids = new Set(users.map((u) => u.id)); + // Record which user authored each merged change (by the Yjs origin the + // edits are forwarded with), so the Diff can color A's and B's + // contributions in their own colors instead of one flat diff color. + const attrs = createAttributionStore(afterDoc, (tr) => + ids.has(String(tr.origin)) ? String(tr.origin) : null, + ); + return { + userDocs: users.map(() => cloneDoc(beforeDoc)), + afterDoc, + attrs, + diffAwareness: new Awareness(afterDoc), + }; + }); + + const diffEditor = useCreateBlockNote( + withCollaboration({ + collaboration: { + fragment: setup.afterDoc.get("doc"), + provider: { awareness: setup.diffAwareness }, + user: USER_A, + }, + }), + ); + + useEffect(() => { + // Forward every user edit into the merge doc (idempotent CRDT apply), so any + // change to any user re-diffs. + // Forward with the author's id as the Yjs origin so the attribution store + // tags each merged change with its author. + const offs = setup.userDocs.map((doc, i) => { + const origin = users[i].id; + const onUpdate = (update: Uint8Array) => + Y.applyUpdate(setup.afterDoc, update, origin); + doc.on("update", onUpdate); + return () => doc.off("update", onUpdate); + }); + // Also pull in any edits that already flushed (the initial applies). + setup.userDocs.forEach((doc, i) => + Y.applyUpdate(setup.afterDoc, Y.encodeStateAsUpdate(doc), users[i].id), + ); + + const adapter = createYjsVersioningAdapter( + diffEditor, + setup.afterDoc.get("doc"), + ); + const renderDiff = () => + adapter.preview.enterPreview( + Y.encodeStateAsUpdateV2(setup.afterDoc), + Y.encodeStateAsUpdateV2(beforeDoc), + setup.attrs, + ); + renderDiff(); + setup.afterDoc.on("update", renderDiff); + + return () => { + offs.forEach((off) => off()); + setup.afterDoc.off("update", renderDiff); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + {users.map((u, i) => ( + + ))} +
+
Diff (read-only)
+ +
+ + ); +} + +/** + * One editable "user" version: an editor on `doc` (a base clone), with the + * scenario change applied on mount (unless this is a reset, when `apply` is + * omitted). Edits flow into the merge through `doc`. + */ +function UserVersion({ + doc, + user, + apply, + label, +}: { + doc: Y.Doc; + user: { name: string; color: string }; + apply?: (editor: BlockNoteEditor) => void; + label: string; +}) { + const [setup] = useState(() => ({ + awareness: makeAwareness(doc, user.name, user.color), + })); + + const editor = useCreateBlockNote( + withCollaboration({ + collaboration: { + fragment: doc.get("doc"), + provider: { awareness: setup.awareness }, + user, + }, + }), + ); + + useEffect(() => { + apply?.(editor); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {label} +
+ +
+ ); +} + +const SEVERITY = { + high: { icon: "🔴", rank: 0 }, + low: { icon: "🟡", rank: 1 }, + info: { icon: "🔵", rank: 2 }, +} as const; + +// The most-severe note across a scenario's feedback — a known crash counts as +// high — or null if it has none. Drives the sidebar indicator. +function topSeverity(s: SuggestionScenario): "high" | "low" | "info" | null { + const fb = s.feedback ?? []; + if (s.knownCrash || fb.some((f) => f.severity === "high")) { + return "high"; + } + if (fb.some((f) => f.severity === "low")) { + return "low"; + } + return fb.some((f) => f.severity === "info") ? "info" : null; +} + +function severityBadge(s: SuggestionScenario): string { + const sev = topSeverity(s); + return sev ? SEVERITY[sev].icon + " " : ""; +} + +export default function App() { + const [selectedId, setSelectedId] = useState(scenarios[0].id); + const [mode, setMode] = useState("versioning"); + const selected = scenarios.find((s) => s.id === selectedId)!; + + const categories = [...new Set(scenarios.map((s) => s.category))]; + + return ( +
+ + +
+
+
+

{selected.title}

+

{selected.description}

+
+
+ {(["suggestions", "versioning"] as Mode[]).map((m) => ( + + ))} +
+
+ + {selected.feedback && selected.feedback.length > 0 && ( +
+
+ {selected.feedback.some((f) => f.severity !== "info") + ? "Known issues" + : "Notes"} +
+ {[...selected.feedback] + .sort( + (a, b) => SEVERITY[a.severity].rank - SEVERITY[b.severity].rank, + ) + .map((f, i) => ( +
+ + {f.severity} + + {f.note} +
+ ))} +
+ )} + + + {mode === "versioning" ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/examples/07-collaboration/14-suggestion-gallery/src/ErrorBoundary.tsx b/examples/07-collaboration/14-suggestion-gallery/src/ErrorBoundary.tsx new file mode 100644 index 0000000000..bf2cf02923 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +import { Component, ErrorInfo, ReactNode } from "react"; + +/** + * Catches render/setup errors from a scenario so one crashing case (e.g. a + * concurrent table merge that hits prosemirror-tables' `fixTables` bug) shows an + * inline message instead of white-screening the whole gallery. Reset via `key`. + */ +export class ScenarioErrorBoundary extends Component< + { children: ReactNode }, + { error: Error | null } +> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + // eslint-disable-next-line no-console + console.error("Scenario crashed:", error, info); + } + + render() { + if (this.state.error) { + return ( +
+ This scenario crashed. +
+            {this.state.error.message}
+          
+
+ ); + } + return this.props.children; + } +} diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts new file mode 100644 index 0000000000..5111ab3f98 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarioDocs.ts @@ -0,0 +1,124 @@ +import { BlockNoteEditor, PartialBlock } from "@blocknote/core"; +import { blocksToYDoc } from "@blocknote/core/y"; +import * as Y from "@y/y"; + +const FRAGMENT = "doc"; + +// A headless editor, used only for its (default) schema when building Y.Docs +// from blocks — `blocksToYDoc` needs an editor to resolve the schema. Created +// lazily on first use so importing this module has no side effects. +let schemaEditor: ReturnType | undefined; +const getSchemaEditor = () => (schemaEditor ??= BlockNoteEditor.create()); + +/** + * Build a fully-seeded Y.Doc from blocks, **synchronously**. No editor is bound, + * so an editor later created on this doc ADOPTS the content instead of writing a + * competing initial blockGroup. This is the gate — but as the default path, which + * lets the views skip the seed-then-poll-then-sync dance entirely. + */ +export function docFromBlocks(blocks: PartialBlock[]): Y.Doc { + return blocksToYDoc(getSchemaEditor(), blocks, FRAGMENT); +} + +/** + * Clone a Y.Doc into a fresh one. Preserves the source's Y ids (so a later diff + * shows only the real changes, not a full replace) and keeps a single root (the + * fresh doc has no init blockGroup to collide with). + */ +export function cloneDoc( + source: Y.Doc, + opts?: { isSuggestionDoc?: boolean }, +): Y.Doc { + const doc = opts?.isSuggestionDoc + ? new Y.Doc({ isSuggestionDoc: true }) + : new Y.Doc(); + Y.applyUpdate(doc, Y.encodeStateAsUpdate(source)); + return doc; +} + +/** + * The docs + managers for a suggestion scenario, built against a snapshot clone + * of `sourceBase` — so the caller can keep an editable "live" base and rebuild + * these on every base edit. One suggestion doc + manager per author + * (`authorIds`), whose tracked edits are attributed to their id — so each mark + * carries a non-empty `data-user-ids` and the hover tooltip shows. With more than + * one author a `merged` viewer doc is added that replays every author's + * suggestions, tagged by the Yjs transaction origin. Client ids are pinned so the + * merge tiebreak is stable. + */ +export function buildSuggestionScenarioDocs( + sourceBase: Y.Doc, + authorIds: string[], +) { + const baseDoc = cloneDoc(sourceBase); + baseDoc.clientID = 1; + + const authors = authorIds.map((id, i) => { + const suggestionDoc = cloneDoc(baseDoc, { isSuggestionDoc: true }); + suggestionDoc.clientID = i + 2; + const manager = Y.createAttributionManagerFromDiff(baseDoc, suggestionDoc, { + attrs: createAttributionStore(suggestionDoc, (tr) => + tr.local ? id : null, + ), + }); + manager.suggestionMode = true; + return { id, suggestionDoc, manager }; + }); + + const merged = + authorIds.length > 1 + ? (() => { + const doc = cloneDoc(baseDoc, { isSuggestionDoc: true }); + doc.clientID = authorIds.length + 2; + const manager = Y.createAttributionManagerFromDiff(baseDoc, doc, { + attrs: createAttributionStore(doc, (tr) => + authorIds.includes(String(tr.origin)) ? String(tr.origin) : null, + ), + }); + manager.suggestionMode = false; + return { doc, manager }; + })() + : undefined; + + return { baseDoc, authors, merged }; +} + +/** + * In-memory attribution store — records the author of each transaction into a + * mutable `Y.Attributions` so suggestion marks render in their author's color. + * `resolveUserId` returns the author id, or null to leave a change unattributed + * (the base seed and the manager's own base→suggestion flow carry no author). + * Mirrors the store in `concurrentSuggestionFixture.tsx`. + */ +export function createAttributionStore( + 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; +} + +// (single- and multi-author suggestion docs are built by +// `buildSuggestionScenarioDocs` above.) diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts new file mode 100644 index 0000000000..f3fbed808a --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts @@ -0,0 +1,1295 @@ +import type { BlockNoteEditor, PartialBlock } from "@blocknote/core"; + +/** + * A browsable suggestion scenario. + * + * `single` scenarios set up one suggesting editor (diffed against the base): + * 1. the editor is seeded with `initial`, + * 2. the base is synced into the suggestion doc (so it becomes the "before"), + * 3. suggestion mode is enabled, + * 4. `apply(editor)` performs the change — which now renders as a diff. + * + * These are the same scenarios exercised by the y-prosemirror visual tests; the + * goal is to share these definitions so the tests and this gallery never drift. + * (Test wiring stays in the fixtures; only the per-scenario data lives here.) + */ +/** + * A tracked note for a scenario, surfaced in the gallery so this example doubles + * as a living list of behavior worth knowing. `high` = wrong merge result, crash, + * or data loss; `low` = cosmetic / attribution quirk or a nice-to-have; `info` = + * a neutral note about expected behavior (not a problem). Keep the issue-level + * notes in sync with the `TODO` / `KNOWN LIMITATION` / `test.skip` / `test.fails` + * notes in the y-prosemirror e2e tests — that's where each is proven. + */ +export type Feedback = { + severity: "info" | "low" | "high"; + note: string; +}; + +export type SingleScenario = { + kind: "single"; + id: string; + title: string; + category: string; + description: string; + /** Blocks the document starts with (the "before"). */ + initial: PartialBlock[]; + /** The change to make in suggestion mode (the "after"). */ + apply: (editor: BlockNoteEditor) => void; + /** Set when the scenario is known to throw, so the gallery can warn. */ + knownCrash?: boolean; + /** Known issues / improvement points, shown in the gallery. */ + feedback?: Feedback[]; +}; + +/** + * A two-user concurrent scenario. Both users start from `initial`, then User A + * runs `applyA` and User B runs `applyB` independently in suggestion mode; the + * tests/gallery merge the two edits through the Yjs CRDT. + */ +export type ConcurrentScenario = { + kind: "concurrent"; + id: string; + title: string; + category: string; + description: string; + /** Blocks both users start from (the shared "before"). */ + initial: PartialBlock[]; + /** User A's change (suggestion mode). */ + applyA: (editor: BlockNoteEditor) => void; + /** User B's change (suggestion mode). */ + applyB: (editor: BlockNoteEditor) => void; + /** Set when the scenario is known to throw, so the gallery can warn. */ + knownCrash?: boolean; + /** Known issues / improvement points, shown in the gallery. */ + feedback?: Feedback[]; +}; + +export type SuggestionScenario = SingleScenario | ConcurrentScenario; + +// Inline SVG data URLs — avoid a network fetch for image sources. `IMG_SRC_BASE` +// (red) and `IMG_SRC_NEW` (teal) are exported so tests can poll against the exact +// URL a scenario sets, with no chance of the two drifting. +export const IMG_SRC_BASE = + "data:image/svg+xml;utf8,"; +export const IMG_SRC_NEW = + "data:image/svg+xml;utf8,"; + +// Shared 2×2 table baseline used by most of the table scenarios. +const TABLE_2X2 = { + id: "table", + type: "table" as const, + content: { + type: "tableContent" as const, + rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }], + }, +}; + +export const scenarios: SuggestionScenario[] = [ + { + kind: "single", + id: "add-heading", + title: "Add heading", + category: "Add / remove blocks", + description: + "Insert a level-1 heading into an empty document. The inserted block is " + + "highlighted in the author's color.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "h0", + type: "heading", + props: { level: 1 }, + content: "New heading", + }, + ]), + }, + { + kind: "single", + id: "delete-image", + title: "Delete image", + category: "Add / remove blocks", + description: + "Delete an image block. Blocks with no inline content (image, divider, …) " + + "are flagged with a 'Deleted' card rather than struck through.", + initial: [ + { + id: "img", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 150 }, + }, + ], + apply: (editor) => editor.removeBlocks(["img"]), + }, + { + kind: "single", + id: "add-bullet", + title: "Add bullet item", + category: "Add / remove blocks", + description: "Insert a bullet list item into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { id: "b0", type: "bulletListItem", content: "New bullet" }, + ]), + }, + { + kind: "single", + id: "add-numbered", + title: "Add numbered item", + category: "Add / remove blocks", + description: "Insert a numbered list item into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { id: "n0", type: "numberedListItem", content: "New numbered" }, + ]), + }, + { + kind: "single", + id: "add-nested-bullets", + feedback: [ + { + severity: "low", + note: "Nested bullets all render as • instead of •/◦/▪ — the suggestion-mark wrappers (display: contents) break the depth-detecting CSS chains. Fix: compute each bullet's nesting level in JS and expose it as data-bullet-level, then pick the glyph with a wrapper-independent attribute selector (as numbered lists do with data-index).", + }, + ], + title: "Add nested bullets", + category: "Add / remove blocks", + description: + "Insert a three-level nested bullet list into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "l0", + type: "bulletListItem", + content: "Level 0", + children: [ + { + id: "l1", + type: "bulletListItem", + content: "Level 1", + children: [ + { id: "l2", type: "bulletListItem", content: "Level 2" }, + ], + }, + ], + }, + ]), + }, + { + kind: "single", + id: "add-colored-block", + feedback: [ + { + severity: "low", + note: "The nested child loses the parent's background tint — the :has() background selector breaks when the inserted content is wrapped in .", + }, + ], + title: "Add colored block with child", + category: "Add / remove blocks", + description: + "Insert a blue-background paragraph with a nested child into an empty " + + "document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "c0", + type: "paragraph", + props: { backgroundColor: "blue" }, + content: "Colored parent", + children: [{ id: "c1", type: "paragraph", content: "Child block" }], + }, + ]), + }, + { + kind: "single", + id: "nest-bullet-existing", + feedback: [ + { + severity: "low", + note: "Nested bullets all render as • instead of •/◦/▪ — the suggestion-mark wrappers (display: contents) break the depth-detecting CSS chains. Fix: compute each bullet's nesting level in JS and expose it as data-bullet-level, then pick the glyph with a wrapper-independent attribute selector (as numbered lists do with data-index).", + }, + { + severity: "low", + note: "Going from 0 to 1+ children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + }, + ], + title: "Nest a bullet under another", + category: "Add / remove blocks", + description: "Nest the second bullet under the first.", + initial: [ + { id: "p", type: "bulletListItem", content: "Parent" }, + { id: "c", type: "bulletListItem", content: "Child" }, + ], + apply: (editor) => { + editor.setTextCursorPosition("c", "start"); + editor.nestBlock(); + }, + }, + { + kind: "single", + id: "add-paragraph-after", + title: "Add paragraph after a block", + category: "Add / remove blocks", + description: "Insert a paragraph after an existing heading.", + initial: [ + { id: "h0", type: "heading", props: { level: 1 }, content: "Title" }, + ], + apply: (editor) => + editor.insertBlocks( + [{ id: "p0", type: "paragraph", content: "Body text" }], + "h0", + "after", + ), + }, + { + kind: "single", + id: "remove-paragraph", + title: "Remove a paragraph", + category: "Add / remove blocks", + description: + "Delete the body paragraph from a heading + paragraph document.", + initial: [ + { id: "h0", type: "heading", props: { level: 1 }, content: "Title" }, + { id: "p0", type: "paragraph", content: "Body text" }, + ], + apply: (editor) => editor.removeBlocks(["p0"]), + }, + { + kind: "single", + id: "remove-all", + title: "Remove the only block", + category: "Add / remove blocks", + description: "Delete the single block in the document.", + initial: [{ id: "p0", type: "paragraph", content: "Only block" }], + apply: (editor) => editor.removeBlocks(["p0"]), + }, + { + kind: "single", + id: "delete-nested", + feedback: [ + { + severity: "low", + note: "Going from 1+ to 0 children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + }, + ], + title: "Delete a nested block", + category: "Add / remove blocks", + description: "Delete the nested child of a parent block.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + apply: (editor) => editor.removeBlocks(["child"]), + }, + { + kind: "single", + id: "delete-parent", + title: "Delete a parent block", + category: "Add / remove blocks", + description: "Delete a parent block together with its nested child.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + apply: (editor) => editor.removeBlocks(["parent"]), + }, + { + kind: "single", + id: "delete-mixed-parent", + title: "Delete parent with mixed children", + category: "Add / remove blocks", + description: + "Delete a parent block whose children are a paragraph and an image.", + initial: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [ + { id: "p1", type: "paragraph", content: "Nested paragraph" }, + { + id: "img", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 150 }, + }, + ], + }, + ], + apply: (editor) => editor.removeBlocks(["parent"]), + }, + { + kind: "single", + id: "delete-code-block", + title: "Delete a code block", + category: "Add / remove blocks", + description: "Delete a code block.", + initial: [{ id: "code", type: "codeBlock", content: "const x = 1;" }], + apply: (editor) => editor.removeBlocks(["code"]), + }, + { + kind: "single", + id: "delete-divider", + title: "Delete a divider", + category: "Add / remove blocks", + description: "Delete a divider (a block with no inline content).", + initial: [{ id: "hr", type: "divider" }], + apply: (editor) => editor.removeBlocks(["hr"]), + }, + { + kind: "single", + id: "insert-image", + title: "Insert an image", + category: "Add / remove blocks", + description: "Insert an image block into an empty document.", + initial: [], + apply: (editor) => + editor.replaceBlocks(editor.document, [ + { + id: "img", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 150 }, + }, + ]), + }, + { + kind: "single", + id: "type-list-to-paragraph", + title: "List item → paragraph", + category: "Type changes", + description: + "Demote a bullet list item to a paragraph. The inline content is preserved; " + + "only the wrapping block type changes (old struck through, new inserted).", + initial: [ + { id: "block-hello", type: "bulletListItem", content: "hello world" }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "paragraph" }); + }, + }, + { + kind: "single", + id: "type-paragraph-to-heading", + title: "Paragraph → heading", + category: "Type changes", + description: + "Promote a paragraph to a level-1 heading. The inline content is preserved; " + + "the original is struck through and the new heading inserted beside it.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 1 } }); + }, + }, + + // --- Basic text --- + { + kind: "single", + id: "text-rename-word", + feedback: [ + { + severity: "low", + note: "The diff looks a bit garbled — individual characters are suggested mid-word. Real-world typing (character-by-character) wouldn't show this, but a programmatic updateBlock (as in this demo) does. A coarser, word-based diff would fix it.", + }, + ], + title: "Rename a word", + category: "Basic text", + description: + "Replace 'world' with 'universe'. The diff splits the changed run into " + + "struck-through deletions and inserted characters.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: "hello universe", + }); + }, + }, + { + kind: "single", + id: "text-add-bold", + title: "Add bold", + category: "Basic text", + description: + "Bold the word 'world' — a format-only change, tracked via the " + + "modification marker rather than an insert/delete.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }); + }, + }, + { + kind: "single", + id: "text-remove-bold", + title: "Remove bold", + category: "Basic text", + description: + "Strip bold from an already-bold 'world' — the symmetric format-only " + + "removal.", + initial: [ + { + id: "block-hello", + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: "hello world", + }); + }, + }, + { + kind: "single", + id: "text-add-italic-to-bold", + feedback: [], + title: "Add italic over bold", + category: "Basic text", + description: + "Add italic to a word that is already bold, keeping both marks.", + initial: [ + { + id: "block-hello", + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true, italic: true } }, + ], + }); + }, + }, + + // --- Move blocks --- + { + kind: "single", + id: "move-paragraph-up", + title: "Move paragraph up", + category: "Move blocks", + description: + "Move the middle paragraph above the first — a delete at the old position " + + "and an insert at the new one.", + initial: [ + { id: "first", type: "paragraph", content: "First" }, + { id: "middle", type: "paragraph", content: "Middle" }, + { id: "last", type: "paragraph", content: "Last" }, + ], + apply: (editor) => editor.moveBlocksUp("middle"), + }, + { + kind: "single", + id: "move-paragraph-with-children", + title: "Move paragraph with children", + category: "Move blocks", + description: + "Move a parent paragraph (and its nested child) up one position.", + initial: [ + { id: "first", type: "paragraph", content: "First" }, + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + apply: (editor) => editor.moveBlocksUp("parent"), + feedback: [], + }, + + // --- Nesting --- + { + kind: "single", + id: "nesting-indent", + feedback: [ + { + severity: "low", + note: "Going from 0 to 1+ children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + }, + ], + title: "Indent a block", + category: "Nesting", + description: + "Nest N1 under N0 (indent). The moved block is re-inserted nested under " + + "its new parent.", + initial: [ + { id: "n0", type: "paragraph", content: "N0" }, + { id: "n1", type: "paragraph", content: "N1" }, + ], + apply: (editor) => { + editor.setTextCursorPosition("n1", "start"); + editor.nestBlock(); + }, + }, + { + kind: "single", + id: "nesting-unindent", + title: "Unindent a block", + feedback: [ + { + severity: "low", + note: "Going from 1+ to 0 children re-creates the block as a new one — so concurrent edits to the original block can be lost, the whole new block is attributed to whoever made the change, and the diff takes more space than needed. A consequence of the schema fix.", + }, + ], + category: "Nesting", + description: "Un-nest N1 out of N0 (outdent) back to a top-level sibling.", + initial: [ + { + id: "n0", + type: "paragraph", + content: "N0", + children: [{ id: "n1", type: "paragraph", content: "N1" }], + }, + ], + apply: (editor) => { + editor.setTextCursorPosition("n1", "start"); + editor.unnestBlock(); + }, + }, + { + kind: "single", + id: "nesting-change-parent-type", + feedback: [ + { + severity: "low", + note: "Changing a parent's type deletes the old block and creates a new one — so concurrent edits to the original block can be lost, and the entire new block is attributed to whoever changed the type. A consequence of the schema fix.", + }, + ], + title: "Change type of a parent block", + category: "Nesting", + description: + "Change a parent paragraph (with a nested child) to a heading; the child " + + "nesting is preserved.", + initial: [ + { + id: "n0", + type: "paragraph", + content: "N0", + children: [{ id: "n1", type: "paragraph", content: "N1" }], + }, + ], + apply: (editor) => { + const [parent] = editor.document; + editor.updateBlock(parent, { type: "heading", props: { level: 1 } }); + }, + }, + + // --- Prop changes --- + { + kind: "single", + id: "prop-text-alignment", + feedback: [ + { + severity: "low", + note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", + }, + ], + title: "Center-align", + category: "Prop changes", + description: + "Change a paragraph's text alignment from left to center — a block-level " + + "prop change (no insert/delete marks are generated).", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + props: { textAlignment: "center" }, + }); + }, + }, + { + kind: "single", + id: "prop-heading-level", + feedback: [ + { + severity: "low", + note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", + }, + ], + title: "Demote heading", + category: "Prop changes", + description: "Change a heading from level 1 to level 2.", + initial: [ + { + id: "block-hello", + type: "heading", + props: { level: 1 }, + content: "hello world", + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 2 } }); + }, + }, + { + kind: "single", + id: "prop-image-width", + feedback: [ + { + severity: "low", + note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", + }, + ], + title: "Resize image", + category: "Prop changes", + description: "Change an image's previewWidth (200 → 400).", + initial: [ + { + id: "block-image", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 200 }, + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "image", + props: { previewWidth: 400 }, + }); + }, + }, + { + kind: "single", + id: "prop-image-source", + feedback: [ + { + severity: "low", + note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", + }, + ], + title: "Change image source", + category: "Prop changes", + description: "Swap an image's url for a different source.", + initial: [ + { + id: "block-image", + type: "image", + props: { url: IMG_SRC_BASE, previewWidth: 200 }, + }, + ], + apply: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "image", props: { url: IMG_SRC_NEW } }); + }, + }, + + // --- Tables --- + { + kind: "single", + id: "table-add-row", + title: "Add row", + category: "Tables", + description: "Add a third row to a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + }, + { + kind: "single", + id: "table-add-column", + title: "Add column", + category: "Tables", + description: "Add a third column to a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + }, + { + kind: "single", + id: "table-remove-row", + title: "Remove row", + category: "Tables", + description: "Remove the last row from a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1"] }], + }, + }), + }, + { + kind: "single", + id: "table-remove-column", + title: "Remove column", + category: "Tables", + description: "Remove the last column from a 2×2 table.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1"] }, { cells: ["A2"] }], + }, + }), + }, + { + kind: "single", + id: "table-edit-cell", + title: "Edit a cell", + category: "Tables", + description: "Edit the text of the top-left cell.", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1 edited", "B1"] }, { cells: ["A2", "B2"] }], + }, + }), + }, + { + kind: "single", + id: "table-column-color", + title: "Highlight a column", + category: "Tables", + description: "Set a green background on the first column's cells.", + feedback: [ + { + severity: "low", + note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", + }, + ], + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [ + { + type: "tableCell", + props: { backgroundColor: "green" }, + content: ["A1"], + }, + { type: "tableCell", content: ["B1"] }, + ], + }, + { + cells: [ + { + type: "tableCell", + props: { backgroundColor: "green" }, + content: ["A2"], + }, + { type: "tableCell", content: ["B2"] }, + ], + }, + ], + }, + }), + }, + { + kind: "single", + id: "table-merge-cells", + feedback: [ + { + severity: "low", + note: "The diff shows a phantom extra 'deleted column' that isn't actually part of the merge.", + }, + ], + title: "Merge cells", + category: "Tables", + description: "Merge the two top-row cells into one (colspan 2).", + initial: [TABLE_2X2], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [ + { + type: "tableCell", + props: { colspan: 2 }, + content: ["A1+B1"], + }, + ], + }, + { cells: ["A2", "B2"] }, + ], + }, + }), + }, + { + kind: "single", + id: "table-split-cell", + title: "Split a merged cell", + category: "Tables", + description: "Split a merged top-row cell back into two.", + initial: [ + { + id: "table", + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [ + { + type: "tableCell", + props: { colspan: 2 }, + content: ["A1+B1"], + }, + ], + }, + { cells: ["A2", "B2"] }, + ], + }, + }, + ], + apply: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1"] }, { cells: ["A2", "B2"] }], + }, + }), + }, + + // --- Concurrent (two users, merged via the Yjs CRDT) --- + { + kind: "concurrent", + id: "concurrent-typo-vs-delete", + feedback: [], + title: "Fix typo vs delete word", + category: "Basic text", + description: + "A fixes a typo while B deletes the word; the CRDT merges both.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello wrold" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "paragraph", content: "hello world" }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "paragraph", content: "hello " }); + }, + }, + { + kind: "concurrent", + id: "concurrent-bold-vs-italic", + title: "Bold vs italic", + category: "Basic text", + description: + "A bolds a word while B italicises it; both marks land after the merge.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { bold: true } }, + ], + }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: [ + { type: "text", text: "hello ", styles: {} }, + { type: "text", text: "world", styles: { italic: true } }, + ], + }); + }, + }, + { + kind: "concurrent", + id: "concurrent-indent-cascade", + feedback: [ + { + severity: "low", + note: "Block N1 appears in two places. Previously this concurrency scenario would also not be correctly handled (one of the edits would be dropped).", + }, + ], + title: "Cascading indents", + category: "Nesting", + description: "A indents N1 while B indents N2.", + initial: [ + { id: "n0", type: "paragraph", content: "N0" }, + { id: "n1", type: "paragraph", content: "N1" }, + { id: "n2", type: "paragraph", content: "N2" }, + ], + applyA: (editor) => { + editor.setTextCursorPosition("n1", "start"); + editor.nestBlock(); + }, + applyB: (editor) => { + editor.setTextCursorPosition("n2", "start"); + editor.nestBlock(); + }, + }, + { + kind: "concurrent", + id: "concurrent-nest-both-under-n0", + feedback: [ + { + severity: "info", + note: "In this concurrent editing scenario the N0 block is duplicated. Previously this scenario would likely drop one of the changes, so it's not a regression per se. A better fix for the schema compatibility could resolve this.", + }, + ], + title: "Both nest a new block under N0", + category: "Nesting", + description: + "A and B each insert a sibling after N0 and nest it (known-tricky merge).", + initial: [{ id: "n0", type: "paragraph", content: "N0" }], + applyA: (editor) => { + editor.insertBlocks( + [{ id: "n1", type: "paragraph", content: "N1" }], + "n0", + "after", + ); + editor.setTextCursorPosition("n1", "start"); + editor.nestBlock(); + }, + applyB: (editor) => { + editor.insertBlocks( + [{ id: "n2", type: "paragraph", content: "N2" }], + "n0", + "after", + ); + editor.setTextCursorPosition("n2", "start"); + editor.nestBlock(); + }, + }, + { + kind: "concurrent", + id: "concurrent-textcolor-vs-bgcolor", + title: "Text color vs background color", + category: "Prop changes", + description: + "A sets text color red while B sets background yellow; both apply.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + feedback: [ + { + severity: "low", + note: "Block-level prop changes produce no y-attributed-* mark, so the pending change renders as if already accepted — it's invisible in the diff.", + }, + ], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + props: { textColor: "red" }, + }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + props: { backgroundColor: "yellow" }, + }); + }, + }, + { + kind: "concurrent", + id: "concurrent-heading-vs-list", + feedback: [ + { + severity: "info", + note: "Both changes are preserved in the merge — A's heading change and B's list-item change both survive.", + }, + ], + title: "Heading vs list item", + category: "Type changes", + description: + "A turns the block into a heading while B turns it into a list item.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 1 } }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "bulletListItem" }); + }, + }, + { + kind: "concurrent", + id: "concurrent-text-vs-heading", + feedback: [ + { + severity: "low", + note: "User A's content edit is lost — it's overwritten by B's simultaneous block-type change. This is a consequence of the schema fix.", + }, + ], + title: "Edit text vs change to heading", + category: "Type changes", + description: "A edits the text while B promotes the block to a heading.", + initial: [{ id: "block-hello", type: "paragraph", content: "hello world" }], + applyA: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { + type: "paragraph", + content: "hello universe", + }); + }, + applyB: (editor) => { + const [block] = editor.document; + editor.updateBlock(block, { type: "heading", props: { level: 1 } }); + }, + }, + { + kind: "concurrent", + id: "concurrent-table-row-and-column", + title: "Add row vs add column", + category: "Tables", + description: "A adds a row while B adds a column.", + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + }, + { + kind: "concurrent", + id: "concurrent-table-addcol-vs-addrow", + title: "Add column vs add row", + category: "Tables", + description: "A adds a column while B adds a row.", + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + }, + { + kind: "concurrent", + id: "concurrent-table-row-vs-column", + feedback: [ + { + severity: "high", + note: "Crashes — prosemirror-tables' fixTables treats the suggestion-marked table as malformed and feeds y-prosemirror a delta Yjs can't apply (lib0 'Unexpected case'). Confirmed via a fixTables on/off loop (25/25 crashes on, 0/25 off); fix is to block fixTablesKey transactions while suggestions are active, mirroring AIExtension during ai-writing.", + }, + ], + title: "Delete row vs add column", + category: "Tables", + description: + "A deletes a row while B adds a column — known to crash the merge " + + "(prosemirror-tables fixTables).", + knownCrash: true, + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { type: "tableContent", rows: [{ cells: ["A1", "B1"] }] }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }), + }, + + { + kind: "concurrent", + id: "concurrent-table-delcol-vs-addrow", + title: "Delete column vs add row", + feedback: [ + { + severity: "high", + note: "Diff seems weird and A2 in wrong place", + }, + ], + category: "Tables", + description: "A deletes a column while B adds a row.", + initial: [TABLE_2X2], + applyA: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1"] }, { cells: ["A2"] }], + }, + }), + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }), + }, + { + kind: "concurrent", + id: "concurrent-table-seq-col-then-row", + title: "A adds column then row, B adds column", + category: "Tables", + description: "A adds a column and then a row (two edits); B adds a column.", + initial: [TABLE_2X2], + applyA: (editor) => { + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "C1"] }, { cells: ["A2", "B2", "C2"] }], + }, + }); + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1", "C1"] }, + { cells: ["A2", "B2", "C2"] }, + { cells: ["A3", "B3", "C3"] }, + ], + }, + }); + }, + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["A1", "B1", "D1"] }, { cells: ["A2", "B2", "D2"] }], + }, + }), + }, + { + kind: "concurrent", + id: "concurrent-table-seq-row-then-col", + title: "A adds row then column, B adds row", + category: "Tables", + description: "A adds a row and then a column (two edits); B adds a row.", + initial: [TABLE_2X2], + applyA: (editor) => { + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["A3", "B3"] }, + ], + }, + }); + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1", "C1"] }, + { cells: ["A2", "B2", "C2"] }, + { cells: ["A3", "B3", "C3"] }, + ], + }, + }); + }, + applyB: (editor) => + editor.updateBlock("table", { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["A1", "B1"] }, + { cells: ["A2", "B2"] }, + { cells: ["D1", "D2"] }, + ], + }, + }), + }, +]; diff --git a/examples/07-collaboration/14-suggestion-gallery/src/style.css b/examples/07-collaboration/14-suggestion-gallery/src/style.css new file mode 100644 index 0000000000..68dae69154 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/src/style.css @@ -0,0 +1,203 @@ +.bn-gallery { + display: grid; + grid-template-columns: 240px 1fr; + gap: 16px; + height: 100vh; + box-sizing: border-box; + padding: 16px; +} + +.bn-gallery-sidebar { + overflow-y: auto; + border-right: 1px solid #e6e6e6; + padding-right: 12px; +} + +.bn-gallery-sidebar h2 { + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #888; + margin: 0 0 12px; +} + +.bn-gallery-category { + margin-bottom: 16px; +} + +.bn-gallery-category-label { + font-size: 12px; + font-weight: 600; + color: #aaa; + margin-bottom: 4px; +} + +.bn-gallery-item { + display: block; + width: 100%; + text-align: left; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 14px; + color: #333; +} + +.bn-gallery-item:hover { + background: #f2f2f2; +} + +.bn-gallery-item--active { + background: #e7f1ff; + color: #1971c2; + font-weight: 600; +} + +.bn-gallery-main { + overflow-y: auto; +} + +.bn-gallery-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} + +.bn-gallery-modes { + display: inline-flex; + border: 1px solid #d8d8d8; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; +} + +.bn-gallery-mode { + padding: 6px 14px; + border: none; + background: #fff; + cursor: pointer; + font-size: 14px; + color: #555; +} + +.bn-gallery-mode + .bn-gallery-mode { + border-left: 1px solid #d8d8d8; +} + +.bn-gallery-mode--active { + background: #1971c2; + color: #fff; + font-weight: 600; +} + +.bn-gallery-editors--three { + grid-template-columns: 1fr 1fr 1fr; +} + +.bn-gallery-editors--four { + grid-template-columns: 1fr 1fr 1fr 1fr; +} + +.bn-gallery-title { + font-size: 20px; + margin: 0 0 4px; +} + +.bn-gallery-description { + color: #666; + margin: 0 0 16px; + max-width: 60ch; +} + +.bn-gallery-editors { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.bn-gallery-pane { + border: 1px solid #e6e6e6; + border-radius: 8px; + padding: 8px; + min-width: 0; +} + +.bn-gallery-pane-label { + font-size: 12px; + font-weight: 600; + color: #888; + padding: 4px 8px; +} + +.bn-gallery-feedback { + border: 1px solid #ececec; + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 16px; + background: #fafafa; +} + +.bn-gallery-feedback-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #888; + margin-bottom: 6px; +} + +.bn-gallery-feedback-item { + display: flex; + gap: 8px; + align-items: baseline; + font-size: 13px; + line-height: 1.45; + color: #444; + padding: 5px 0 5px 8px; + border-left: 3px solid transparent; +} + +.bn-gallery-feedback-item + .bn-gallery-feedback-item { + border-top: 1px solid #efefef; +} + +.bn-gallery-feedback-item--high { + border-left-color: #e03131; +} + +.bn-gallery-feedback-item--low { + border-left-color: #f2b705; +} + +.bn-gallery-feedback-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 1px 6px; + border-radius: 4px; +} + +.bn-gallery-feedback-item--high .bn-gallery-feedback-badge { + background: #ffe3e3; + color: #c92a2a; +} + +.bn-gallery-feedback-item--low .bn-gallery-feedback-badge { + background: #fff3bf; + color: #a67c00; +} + +.bn-gallery-feedback-item--info { + border-left-color: #1971c2; +} + +.bn-gallery-feedback-item--info .bn-gallery-feedback-badge { + background: #e7f1ff; + color: #1971c2; +} diff --git a/examples/07-collaboration/14-suggestion-gallery/tsconfig.json b/examples/07-collaboration/14-suggestion-gallery/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/07-collaboration/14-suggestion-gallery/vite-env.d.ts b/examples/07-collaboration/14-suggestion-gallery/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index bb5dedfdec..671a4aea49 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -183,6 +183,11 @@ NESTED BLOCKS > .bn-block > div[data-type="modification"] > div[data-type="modification"] + > .bn-block-content[data-content-type="heading"], +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del) + > .bn-suggestion-node > .bn-block-content[data-content-type="heading"] { font-size: var(--level); font-weight: bold; @@ -239,6 +244,11 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] + > .bn-block-content[data-content-type="numberedListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del) + > .bn-suggestion-node > .bn-block-content[data-content-type="numberedListItem"]::before { content: var(--index) "."; } @@ -349,7 +359,12 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] - > .bn-block-content[data-content-type="bulletListItem"]::before { + > .bn-block-content[data-content-type="bulletListItem"]::before, +.bn-block-outer:not([data-prev-type]) + > .bn-block + > :is(ins, del) + > .bn-suggestion-node + .bn-block-content[data-content-type="bulletListItem"]::before { content: "•"; } @@ -785,18 +800,28 @@ Block-level (over a node) suggestion marks. The `.bn-suggestion-node` span is nodes (its children — e.g.

//) carry the highlight. Like inline marks they also show an attribution tooltip on hover (handled in JS, see SuggestionMarks.ts). + +This rule is the highlight for insertions, and the fallback for block deletions +that wrap content with no `.bn-block-content` of their own (e.g. a deleted table +row/cell). Block deletions that *do* wrap blocks are restyled per-block below. */ .bn-suggestion-node > * { background-color: color-mix(in srgb, var(--user-color-light) 50%, white); border-radius: 4px; } +.dark.bn-root .bn-suggestion-node > * { + background-color: color-mix(in srgb, var(--user-color-dark) 50%, black); +} + /* A deleted block is tagged with a localized "Deleted" badge before its content. The wrapper span (.bn-suggestion-node--delete) is `display: contents` and can't render a pseudo-element of its own, so the badge is rendered on the first wrapped node, which inherits the label text from the `--deleted-label` custom property -set in SuggestionMarks.ts (falling back to "Deleted" if absent). +set in SuggestionMarks.ts (falling back to "Deleted" if absent). This is the +fallback badge for deletions without a `.bn-block-content` (see above); deletions +that wrap blocks get a per-block badge below instead. */ .bn-suggestion-node--delete > *:first-child::before { content: var(--deleted-label, "Deleted"); @@ -814,10 +839,125 @@ set in SuggestionMarks.ts (falling back to "Deleted" if absent). color: var(--bn-colors-editor-text); } -.dark.bn-root .bn-suggestion-node > * { +/* +Block suggestions are decided *per block*, not per mark: a deleted parent holding +a paragraph and an image should strike the paragraph text yet still flag the image +as deleted. So when a mark wraps real blocks we drop the subtree-wide highlight +(scoped with `:has(.bn-block-content)` so table row/cell suggestions keep the +fallback above) and restyle each `.bn-block-content` on its own terms below — for +insertions and deletions alike. +*/ +.bn-suggestion-node:has(.bn-block-content) > *, +.dark.bn-root .bn-suggestion-node:has(.bn-block-content) > * { + background-color: transparent; +} + +/* The fallback "Deleted" badge (above) is only for deletions with no + `.bn-block-content`; per-block deletions are flagged individually below. */ +.bn-suggestion-node--delete:has(.bn-block-content) > *:first-child::before { + content: none; +} + +/* +A block WITH inline content (paragraph, heading, list item, quote, code, …) needs +no block-level background: a deletion strikes its text through in the author's +color, while an insertion already highlights the text via its inline mark +(.bn-suggestion-mark), so the transparent rule above is all it needs. +`.bn-inline-content` is present only on inline-content blocks (table cells use a +bare

; 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; +}