type GapFlags = { canEdit: boolean; connected: boolean; synced: boolean }; type GapSnapshot = { keys: string[]; types: string[]; texts: string[]; expected: string[]; initial: string[]; selection?: { collapsed: boolean; key: string }; yjs: { update: number[]; xml: string; roundtrip: string; blocks: Array<{ type: string; text: string; state?: unknown }>; restoredBlocks: Array<{ type: string; text: string; state?: unknown }>; }; }; declare global { interface Window { cardGapFixture: { mount(flags: GapFlags, selection?: "caret" | "range"): void; flags(flags: GapFlags): void; plugin(mounted: boolean): void; seed(selection?: "caret" | "range"): void; ready(): boolean; snapshot(): GapSnapshot; errors: string[]; }; } } export let cardGapBinding = ` import { useLayoutEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { Realm, RealmContext } from "@mdxeditor/gurx"; import { createYjsBinding, syncLexicalUpdateToYjs } from "@lexical/yjs"; import { $createParagraphNode, $createTextNode, $createRangeSelection, $setSelection } from "lexical"; import * as Y from "yjs"; import { $createQuestionnaireNode, $createDecisionNode, registry } from "@chopin/dialect"; import { register } from "./index"; import type { Binding, Provider } from "@lexical/yjs"; register(); let provider: Provider = { awareness: { getLocalState: () => null, getStates: () => new Map(), off() {}, on() {}, setLocalState() {}, setLocalStateField() {} }, connect() {}, disconnect() {}, off() {}, on() {} } as unknown as Provider; let gapRoot; let gapEditor; let gapRealm; let gapDoc; let gapBinding: Binding; let gapReady = false; let gapFlags; let gapSelection; let setPlugin; let gapInitial: string[] = []; let gapExpected: string[] = []; function gapSharedBlocks(doc) { return doc.get("root", Y.XmlText).toDelta().map(operation => { let node = operation.insert; if (!(node instanceof Y.XmlText) && !(node instanceof Y.XmlElement)) throw new Error("Unexpected canonical Yjs root item"); let attributes = node.getAttributes(); let state = attributes.__state; if (state !== undefined && !(state instanceof Y.Map)) throw new Error("Unexpected canonical card state"); return { type: attributes.__type, text: node.toString(), state: state?.toJSON() }; }); } function seedGap(selection) { gapEditor.update(() => { let leading = $createParagraphNode(); let first = $createQuestionnaireNode({ id: "gap-q1", questions: [{ id: "q1", header: "First decision", prompt: "Choose first", multiple: false, options: [{ id: "o1", label: "First" }] }] }); let a = Array.from({ length: 3 }, () => $createParagraphNode()); let second = $createDecisionNode({ id: "gap-d1", quote: "First accepted", by: "ana", at: "2026-09-30T00:00:00Z", notes: [] }); let prose = $createParagraphNode().append($createTextNode("Keep this exact prose.")); let third = $createQuestionnaireNode({ id: "gap-q2", questions: [{ id: "q2", header: "Second decision", prompt: "Choose second", multiple: false, options: [{ id: "o2", label: "Second" }] }] }); let b = Array.from({ length: 2 }, () => $createParagraphNode()); let fourth = $createDecisionNode({ id: "gap-d2", quote: "Second accepted", by: "ben", at: "2026-09-30T00:00:00Z", notes: [] }); let tail = [$createParagraphNode(), $createParagraphNode()]; let children = [leading, first, ...a, second, prose, third, ...b, fourth, ...tail]; $getRoot().clear().append(...children); gapInitial = children.map(node => node.getKey()); gapExpected = [leading, first, a[0], second, prose, third, b[0], fourth, ...tail].map(node => node.getKey()); if (selection === "caret") a[1].select(); else if (selection === "range") { let range = $createRangeSelection(); range.anchor.set(a[1].getKey(), 0, "element"); range.focus.set(prose.getFirstChild().getKey(), 4, "text"); $setSelection(range); } else $setSelection(null); }, { discrete: true }); } function GapSeed() { let [editor] = useLexicalComposerContext(); useLayoutEffect(() => { gapEditor = editor; gapDoc = new Y.Doc(); gapBinding = createYjsBinding({ editor, id: "card-gap", doc: gapDoc, docMap: new Map([["card-gap", gapDoc]]) }); let stop = editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, editorState, normalizedNodes, prevEditorState, tags }) => { syncLexicalUpdateToYjs(gapBinding, provider, prevEditorState, editorState, dirtyElements, dirtyLeaves, normalizedNodes, tags); }); seedGap(gapSelection); gapReady = true; return () => { stop(); gapDoc.destroy(); gapReady = false; gapEditor = undefined; }; }, [editor]); return null; } function GapView() { let [mounted, changeMounted] = useState(true); setPlugin = changeMounted; return
{ window.cardGapFixture.errors.push(error.message); throw error; } }}> } placeholder={null} ErrorBoundary={LexicalErrorBoundary} /> {mounted && }
; } window.cardGapFixture = { errors: [], mount(flags, selection) { gapRoot?.unmount(); this.errors = []; gapFlags = flags; gapSelection = selection; gapRealm = new Realm(); gapRealm.pub(widgets$, flags); gapRoot = createRoot(document.querySelector("#fixture")); gapRoot.render(); }, flags(flags) { gapFlags = flags; gapEditor.setEditable(flags.canEdit); gapRealm.pub(widgets$, flags); }, plugin(mounted) { setPlugin(mounted); }, seed(selection) { seedGap(selection); }, ready() { return gapReady; }, snapshot() { let lexical = gapEditor.getEditorState().read(() => { let children = $getRoot().getChildren(); let selection = $getSelection(); return { keys: children.map(node => node.getKey()), types: children.map(node => node.getType()), texts: children.map(node => node.getTextContent()), selection: $isRangeSelection(selection) ? { collapsed: selection.isCollapsed(), key: selection.anchor.getNode().getKey() } : undefined }; }); let update = Y.encodeStateAsUpdate(gapDoc); let restored = new Y.Doc(); Y.applyUpdate(restored, update); let xml = gapDoc.get("root", Y.XmlText).toString(); let roundtrip = restored.get("root", Y.XmlText).toString(); let blocks = gapSharedBlocks(gapDoc); let restoredBlocks = gapSharedBlocks(restored); restored.destroy(); return { ...lexical, initial: gapInitial, expected: gapExpected, yjs: { update: [...update], xml, roundtrip, blocks, restoredBlocks } }; }, }; `;