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:
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<typeof Y.createAttributionManagerFromDiff>;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 `<SuggestionPanes>` (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 ( <div className={ "bn-gallery-editors" + (paneCount >= 4 ? " bn-gallery-editors--four" : "") } > <div className="bn-gallery-pane"> <div className="bn-gallery-pane-label">Base (editable)</div> <BlockNoteView editor={baseEditor} /> </div> <SuggestionPanes key={nonce} base={setup.base} authors={authors} applyInitial={nonce === 0} /> </div> );}/** * 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) => ( <UserSuggestion key={a.id} baseDoc={setup.baseDoc} suggestionDoc={a.suggestionDoc} manager={a.manager} user={a.user} apply={applyInitial ? a.apply : undefined} label={a.label} /> ))} {setup.merged && ( <MergedSuggestion baseDoc={setup.baseDoc} merged={setup.merged} authorDocs={setup.combined.map((a) => ({ 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 ( <div className="bn-gallery-pane" style={{ borderTopColor: user.color, borderTopWidth: 3 }} > <div className="bn-gallery-pane-label" style={{ color: user.color }}> {label} </div> <BlockNoteView editor={editor} /> </div> );}/** * 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 ( <div className="bn-gallery-pane"> <div className="bn-gallery-pane-label">Merged (read-only)</div> <BlockNoteView editor={editor} editable={false} /> </div> );}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 `<VersionMerge>` (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 ( <div className={ "bn-gallery-editors " + (setup.users.length > 1 ? "bn-gallery-editors--four" : "bn-gallery-editors--three") } > <div className="bn-gallery-pane"> <div className="bn-gallery-pane-label">Version 1 (editable)</div> <BlockNoteView editor={beforeEditor} /> </div> <VersionMerge key={nonce} beforeDoc={setup.beforeDoc} users={setup.users} applyInitial={nonce === 0} /> </div> );}/** * 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) => ( <UserVersion key={u.id} doc={setup.userDocs[i]} user={u.user} apply={applyInitial ? u.apply : undefined} label={u.label} /> ))} <div className="bn-gallery-pane"> <div className="bn-gallery-pane-label">Diff (read-only)</div> <BlockNoteView editor={diffEditor} editable={false} /> </div> </> );}/** * 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 ( <div className="bn-gallery-pane" style={{ borderTopColor: user.color, borderTopWidth: 3 }} > <div className="bn-gallery-pane-label" style={{ color: user.color }}> {label} </div> <BlockNoteView editor={editor} /> </div> );}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<Mode>("versioning"); const selected = scenarios.find((s) => s.id === selectedId)!; const categories = [...new Set(scenarios.map((s) => s.category))]; return ( <div className="bn-gallery"> <aside className="bn-gallery-sidebar"> <h2>Suggestion scenarios</h2> {categories.map((category) => ( <div key={category} className="bn-gallery-category"> <div className="bn-gallery-category-label">{category}</div> {scenarios .filter((s) => s.category === category) .map((s) => ( <button key={s.id} type="button" className={ "bn-gallery-item" + (s.id === selectedId ? " bn-gallery-item--active" : "") } onClick={() => setSelectedId(s.id)} > {s.title} {s.kind === "concurrent" ? " 👥" : ""} {severityBadge(s)} </button> ))} </div> ))} </aside> <main className="bn-gallery-main"> <div className="bn-gallery-header"> <div> <h1 className="bn-gallery-title">{selected.title}</h1> <p className="bn-gallery-description">{selected.description}</p> </div> <div className="bn-gallery-modes"> {(["suggestions", "versioning"] as Mode[]).map((m) => ( <button key={m} type="button" className={ "bn-gallery-mode" + (mode === m ? " bn-gallery-mode--active" : "") } onClick={() => setMode(m)} > {m === "suggestions" ? "Suggestions" : "Versioning"} </button> ))} </div> </div> {selected.feedback && selected.feedback.length > 0 && ( <div className="bn-gallery-feedback"> <div className="bn-gallery-feedback-title"> {selected.feedback.some((f) => f.severity !== "info") ? "Known issues" : "Notes"} </div> {[...selected.feedback] .sort( (a, b) => SEVERITY[a.severity].rank - SEVERITY[b.severity].rank, ) .map((f, i) => ( <div key={i} className={`bn-gallery-feedback-item bn-gallery-feedback-item--${f.severity}`} > <span className="bn-gallery-feedback-badge"> {f.severity} </span> <span>{f.note}</span> </div> ))} </div> )} <ScenarioErrorBoundary key={`${mode}:${selected.id}`}> {mode === "versioning" ? ( <VersioningView scenario={selected} /> ) : ( <SuggestionsView scenario={selected} /> )} </ScenarioErrorBoundary> </main> </div> );}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 ( <div style={{ padding: 16, border: "1px solid #e5484d", borderRadius: 8, background: "#fff5f5", color: "#c01c28", }} > <strong>This scenario crashed.</strong> <pre style={{ whiteSpace: "pre-wrap", marginTop: 8, fontSize: 12 }}> {this.state.error.message} </pre> </div> ); } return this.props.children; }}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<typeof BlockNoteEditor.create> | 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.)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,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23ff6b6b'/></svg>";export const IMG_SRC_NEW = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%234ecdc4'/></svg>";// 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 <ins>.", }, ], 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).", }, ], 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", 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: [ { severity: "high", note: "Crashes in versioning mode — moving a block that carries a nested child subtree makes the diff renderer (enterPreview → y-prosemirror) throw lib0 'Unexpected case' (recursive applyDelta). Suggestion mode is fine (it uses a different diff path). Same core applyDelta bug as the concurrent-table crash.", }, ], }, // --- Nesting --- { kind: "single", id: "nesting-indent", 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: "high", note: "Crashes in versioning mode — un-nesting restructures the parent's blockGroup, making the diff renderer (enterPreview → y-prosemirror) throw lib0 'Unexpected case' (recursive applyDelta). Suggestion mode is fine (it uses a different diff path). Same core applyDelta bug as the concurrent-table crash.", }, ], 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: "info", note: "Block moves and (un)nesting are 'copy and replace' operations, so concurrent ones like these can get lost. This is a pre-existing issue, not specific to diffing / suggestions.", }, ], 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: "high", note: "throws error", }, ], 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"] }, ], }, }), },];.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;}