Skip to content

Commit 6438ddc

Browse files
authored
feat(superdoc/ui): ui.document surface for export, mode, ready (SD-2816) (#3015)
A custom toolbar's Export button, document-mode toggle, and ready-state guard previously had to reach for the host SuperDoc instance. The React provider exposed a SuperDocHost interface as an escape hatch for that single use case (export); ui.document removes the need for it. Adds ui.document with: - getSnapshot() / subscribe() over { ready, mode } memoized so typing-only transactions don't re-fire the listener. - setMode(mode) that forwards to superdoc.setDocumentMode. - export(options) that forwards to superdoc.export and propagates the result / rejection. Throws a clear error when the host stub omits export() instead of silently returning undefined. Dirty / unsaved-changes tracking is intentionally not on this slice: SuperDoc has no host-side dirty primitive today, and inventing one (transaction listener + reset semantics) is a separate ticket. Re-exports DocumentSlice / DocumentHandle / DocumentExportInput through superdoc/ui and packages/superdoc/src/ui.d.ts so consumers can type their own toolbar bindings without reaching into the internal ui types module. 12 new tests cover snapshot / subscribe memoization / setMode (with host omission and host throwing) / export forwarding / export missing / export rejection. 140 ui tests pass; bundle audit clean.
1 parent a3d9a34 commit 6438ddc

5 files changed

Lines changed: 460 additions & 5 deletions

File tree

packages/super-editor/src/ui/create-super-doc-ui.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import type {
2121
CommandHandle,
2222
CommandsHandle,
2323
CommentsHandle,
24+
DocumentExportInput,
25+
DocumentHandle,
26+
DocumentSlice,
2427
DynamicCommandHandle,
2528
EqualityFn,
2629
ReviewHandle,
@@ -453,6 +456,14 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
453456
*/
454457
let selectionMemo: { key: string; slice: SelectionSlice } | null = null;
455458

459+
/**
460+
* Memoized document slice. Object identity stable while `ready`
461+
* and `mode` are unchanged so `shallowEqual` on `state.document`
462+
* short-circuits subscribers (typing-only transactions don't move
463+
* either field, but they do trigger computeState rebuilds).
464+
*/
465+
let documentMemo: { slice: DocumentSlice } | null = null;
466+
456467
/**
457468
* Stable string key over a SelectionInfo for slice memoization. Two
458469
* infos producing the same key represent the same observable
@@ -637,9 +648,21 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
637648
}
638649
}
639650

651+
// Memoize the document slice. Reference stays stable while
652+
// (ready, mode) are unchanged so `shallowEqual` on `state.document`
653+
// short-circuits ui.document.subscribe per transaction.
654+
let documentSlice: DocumentSlice;
655+
if (documentMemo && documentMemo.slice.ready === ready && documentMemo.slice.mode === documentMode) {
656+
documentSlice = documentMemo.slice;
657+
} else {
658+
documentSlice = { ready, mode: documentMode };
659+
documentMemo = { slice: documentSlice };
660+
}
661+
640662
const partial: SuperDocUIState = {
641663
ready,
642664
documentMode,
665+
document: documentSlice,
643666
selection: selectionSlice,
644667
toolbar: { context: toolbarSnapshot.context, commands: builtInCommands } as ToolbarSnapshotSlice,
645668
comments: {
@@ -1488,6 +1511,52 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
14881511
},
14891512
};
14901513

1514+
// ---- ui.document -------------------------------------------------------
1515+
//
1516+
// Session-level surface (Export DOCX, document-mode toggle, ready
1517+
// state). Sugar over `state.document` plus passthroughs to the host
1518+
// SuperDoc instance's setDocumentMode / export. Lifts the operations
1519+
// that previously forced consumers to wire a separate "host" hook
1520+
// through their React context (the SuperDocHost interface that
1521+
// SD-2813's React provider exposes today; that becomes a thin
1522+
// backwards-compat shim once consumers migrate to ui.document).
1523+
const document: DocumentHandle = {
1524+
getSnapshot: () => computeState().document,
1525+
subscribe(listener) {
1526+
return select((state) => state.document, shallowEqual).subscribe((snapshot) => {
1527+
try {
1528+
listener({ snapshot });
1529+
} catch {
1530+
// see scheduleNotify
1531+
}
1532+
});
1533+
},
1534+
setMode(mode) {
1535+
// Routes through the host setter; ignored when the stub omits
1536+
// it (test stubs / SSR). The host emits 'document-mode-change'
1537+
// which is already in SUPERDOC_EVENTS, so the next snapshot
1538+
// reflects the new mode without explicit notify here.
1539+
const setter = superdoc.setDocumentMode;
1540+
if (typeof setter !== 'function') return;
1541+
try {
1542+
setter.call(superdoc, mode);
1543+
} catch (err) {
1544+
console.error('[superdoc/ui] ui.document.setMode failed:', err);
1545+
}
1546+
},
1547+
async export(options?: DocumentExportInput): Promise<unknown> {
1548+
const exportFn = superdoc.export;
1549+
if (typeof exportFn !== 'function') {
1550+
// Surface a clear error rather than a silent no-op: a
1551+
// consumer that wired up an Export button has every right
1552+
// to know the host doesn't implement export. Same posture
1553+
// as the requireDocComments helper used by ui.comments.
1554+
throw new Error('ui.document.export: host SuperDoc instance does not implement export().');
1555+
}
1556+
return exportFn.call(superdoc, options);
1557+
},
1558+
};
1559+
14911560
const destroy = () => {
14921561
if (destroyed) return;
14931562
destroyed = true;
@@ -1505,5 +1574,5 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
15051574
teardown.length = 0;
15061575
};
15071576

1508-
return { select, toolbar, commands, comments, review, selection, viewport, destroy };
1577+
return { select, toolbar, commands, comments, review, selection, viewport, document, destroy };
15091578
}
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { createSuperDocUI } from './create-super-doc-ui.js';
4+
import type { SuperDocLike } from './types.js';
5+
6+
/**
7+
* Stub mirroring the controller test pattern. Adds `setDocumentMode`
8+
* and `export` so ui.document can route through them; both are vi.fn
9+
* so call counts and arguments are observable.
10+
*/
11+
function makeStubs(initialMode: 'editing' | 'suggesting' | 'viewing' = 'editing') {
12+
const editorListeners = new Map<string, Set<(...args: unknown[]) => void>>();
13+
const superdocListeners = new Map<string, Set<(...args: unknown[]) => void>>();
14+
15+
const editor = {
16+
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
17+
if (!editorListeners.has(event)) editorListeners.set(event, new Set());
18+
editorListeners.get(event)!.add(handler);
19+
}),
20+
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
21+
editorListeners.get(event)?.delete(handler);
22+
}),
23+
state: { selection: { empty: true, from: 0, to: 0 } },
24+
options: { documentId: 'doc-1', isHeaderOrFooter: false },
25+
commands: {},
26+
isEditable: true,
27+
doc: {
28+
selection: {
29+
current: vi.fn(() => ({ empty: true, text: '', target: null })),
30+
},
31+
},
32+
};
33+
34+
const superdoc: SuperDocLike & {
35+
fireEditor(event: string, ...args: unknown[]): void;
36+
fireSuperdoc(event: string, ...args: unknown[]): void;
37+
setDocumentMode: ReturnType<typeof vi.fn>;
38+
export: ReturnType<typeof vi.fn>;
39+
} = {
40+
activeEditor: editor as never,
41+
config: { documentMode: initialMode },
42+
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
43+
if (!superdocListeners.has(event)) superdocListeners.set(event, new Set());
44+
superdocListeners.get(event)!.add(handler);
45+
}),
46+
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
47+
superdocListeners.get(event)?.delete(handler);
48+
}),
49+
setDocumentMode: vi.fn((_mode: 'editing' | 'suggesting' | 'viewing') => {
50+
// Plain spy: don't mirror host behavior (mutate + emit) here.
51+
// Tests that need the controller to observe a mode change call
52+
// `fireSuperdoc('document-mode-change', { documentMode })`
53+
// explicitly, which keeps the spy's call list tight and avoids
54+
// accidentally re-entering the controller graph.
55+
}),
56+
export: vi.fn(async (_options?: unknown) => ({ ok: true })),
57+
fireEditor(event, ...args) {
58+
const handlers = editorListeners.get(event);
59+
if (!handlers) return;
60+
[...handlers].forEach((handler) => handler(...args));
61+
},
62+
fireSuperdoc(event, ...args) {
63+
const handlers = superdocListeners.get(event);
64+
if (!handlers) return;
65+
[...handlers].forEach((handler) => handler(...args));
66+
},
67+
};
68+
69+
return { superdoc, editor };
70+
}
71+
72+
let warnSpy: ReturnType<typeof vi.spyOn>;
73+
let errorSpy: ReturnType<typeof vi.spyOn>;
74+
75+
beforeEach(() => {
76+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
77+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
78+
});
79+
80+
afterEach(() => {
81+
warnSpy.mockRestore();
82+
errorSpy.mockRestore();
83+
});
84+
85+
describe('ui.document', () => {
86+
it('exposes the slice via getSnapshot with ready + mode mirrored from state', () => {
87+
const { superdoc } = makeStubs('suggesting');
88+
const ui = createSuperDocUI({ superdoc });
89+
90+
expect(ui.document.getSnapshot()).toEqual({ ready: true, mode: 'suggesting' });
91+
92+
ui.destroy();
93+
});
94+
95+
it('subscribe fires once synchronously with the initial snapshot', () => {
96+
const { superdoc } = makeStubs('editing');
97+
const ui = createSuperDocUI({ superdoc });
98+
99+
const listener = vi.fn();
100+
ui.document.subscribe(listener);
101+
102+
expect(listener).toHaveBeenCalledTimes(1);
103+
expect(listener.mock.calls[0][0]).toEqual({ snapshot: { ready: true, mode: 'editing' } });
104+
105+
ui.destroy();
106+
});
107+
108+
it('subscribe re-fires when document-mode-change fires from the host', async () => {
109+
const { superdoc } = makeStubs('editing');
110+
const ui = createSuperDocUI({ superdoc });
111+
112+
const listener = vi.fn();
113+
ui.document.subscribe(listener);
114+
expect(listener).toHaveBeenCalledTimes(1);
115+
116+
// Mirror SuperDoc's emit pattern: mutate config, then fire the
117+
// event the controller is bound to so the next snapshot reflects
118+
// the new mode.
119+
superdoc.config!.documentMode = 'viewing';
120+
superdoc.fireSuperdoc('document-mode-change', { documentMode: 'viewing' });
121+
await Promise.resolve();
122+
await Promise.resolve();
123+
124+
expect(listener).toHaveBeenCalledTimes(2);
125+
expect(listener.mock.calls[1][0].snapshot).toEqual({ ready: true, mode: 'viewing' });
126+
127+
ui.destroy();
128+
});
129+
130+
it('subscribe does not re-fire on transactions that leave the slice unchanged', async () => {
131+
const { superdoc, editor } = makeStubs('editing');
132+
const ui = createSuperDocUI({ superdoc });
133+
134+
const listener = vi.fn();
135+
ui.document.subscribe(listener);
136+
expect(listener).toHaveBeenCalledTimes(1);
137+
138+
// Fire a typing-only transaction. ready/mode are both unchanged
139+
// so shallowEqual should short-circuit the subscriber. Without
140+
// the documentMemo, every transaction allocates a fresh slice
141+
// and re-fires listeners.
142+
const handlers = (editor.on as ReturnType<typeof vi.fn>).mock.calls
143+
.filter((c) => c[0] === 'transaction')
144+
.map((c) => c[1]) as Array<() => void>;
145+
handlers.forEach((h) => h());
146+
await Promise.resolve();
147+
await Promise.resolve();
148+
149+
expect(listener).toHaveBeenCalledTimes(1);
150+
151+
ui.destroy();
152+
});
153+
154+
it('setMode forwards to superdoc.setDocumentMode with the passed mode', () => {
155+
const { superdoc } = makeStubs('editing');
156+
const ui = createSuperDocUI({ superdoc });
157+
158+
ui.document.setMode('viewing');
159+
expect(superdoc.setDocumentMode).toHaveBeenCalledTimes(1);
160+
expect(superdoc.setDocumentMode).toHaveBeenCalledWith('viewing');
161+
162+
ui.destroy();
163+
});
164+
165+
it('setMode is a no-op when the host omits the setter', () => {
166+
const { superdoc } = makeStubs('editing');
167+
delete (superdoc as { setDocumentMode?: unknown }).setDocumentMode;
168+
const ui = createSuperDocUI({ superdoc });
169+
170+
// Should not throw.
171+
expect(() => ui.document.setMode('viewing')).not.toThrow();
172+
173+
ui.destroy();
174+
});
175+
176+
it('setMode swallows host errors and reports to console.error', () => {
177+
const { superdoc } = makeStubs('editing');
178+
superdoc.setDocumentMode = vi.fn(() => {
179+
throw new Error('host explosion');
180+
}) as ReturnType<typeof vi.fn>;
181+
const ui = createSuperDocUI({ superdoc });
182+
183+
expect(() => ui.document.setMode('viewing')).not.toThrow();
184+
expect(errorSpy).toHaveBeenCalled();
185+
expect(String(errorSpy.mock.calls[0][0])).toContain('ui.document.setMode failed');
186+
187+
ui.destroy();
188+
});
189+
190+
it('export forwards options to superdoc.export and returns its result', async () => {
191+
const { superdoc } = makeStubs('editing');
192+
const ui = createSuperDocUI({ superdoc });
193+
194+
const result = await ui.document.export({ exportType: ['docx'], triggerDownload: false });
195+
expect(result).toEqual({ ok: true });
196+
expect(superdoc.export).toHaveBeenCalledTimes(1);
197+
expect(superdoc.export).toHaveBeenCalledWith({ exportType: ['docx'], triggerDownload: false });
198+
199+
ui.destroy();
200+
});
201+
202+
it('export throws a clear error when the host omits export()', async () => {
203+
const { superdoc } = makeStubs('editing');
204+
delete (superdoc as { export?: unknown }).export;
205+
const ui = createSuperDocUI({ superdoc });
206+
207+
await expect(ui.document.export()).rejects.toThrow(/host SuperDoc instance does not implement export/);
208+
209+
ui.destroy();
210+
});
211+
212+
it('export propagates host rejections to the caller', async () => {
213+
const { superdoc } = makeStubs('editing');
214+
superdoc.export = vi.fn(async () => {
215+
throw new Error('export blew up');
216+
}) as ReturnType<typeof vi.fn>;
217+
const ui = createSuperDocUI({ superdoc });
218+
219+
await expect(ui.document.export()).rejects.toThrow('export blew up');
220+
221+
ui.destroy();
222+
});
223+
224+
it('document slice on getSnapshot returns the same reference across reads when unchanged', () => {
225+
const { superdoc } = makeStubs('editing');
226+
const ui = createSuperDocUI({ superdoc });
227+
228+
const a = ui.document.getSnapshot();
229+
const b = ui.document.getSnapshot();
230+
expect(a).toBe(b);
231+
232+
ui.destroy();
233+
});
234+
235+
it('document slice identity changes when ready or mode flips', async () => {
236+
const { superdoc } = makeStubs('editing');
237+
const ui = createSuperDocUI({ superdoc });
238+
239+
const before = ui.document.getSnapshot();
240+
241+
superdoc.config!.documentMode = 'viewing';
242+
superdoc.fireSuperdoc('document-mode-change', { documentMode: 'viewing' });
243+
await Promise.resolve();
244+
await Promise.resolve();
245+
const after = ui.document.getSnapshot();
246+
247+
expect(before).not.toBe(after);
248+
expect(after.mode).toBe('viewing');
249+
250+
ui.destroy();
251+
});
252+
});

packages/super-editor/src/ui/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,9 @@ export type {
119119
ViewportHandle,
120120
ViewportRect,
121121
ViewportRectResult,
122+
123+
// Document
124+
DocumentExportInput,
125+
DocumentHandle,
126+
DocumentSlice,
122127
} from './types.js';

0 commit comments

Comments
 (0)