Skip to content

Commit 03ab3f3

Browse files
authored
refactor: interaction routing (#3607)
* chore(superdoc): prove v1 runtime interaction routing
1 parent 4c74ec6 commit 03ab3f3

22 files changed

Lines changed: 5287 additions & 20 deletions

packages/superdoc/src/SuperDoc.test.js

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ const createCommentsStoreWithFloatingGetter = () => {
293293

294294
const mountComponent = async (
295295
superdocStub,
296-
{ surfaceManager = null, superdocStore = null, commentsStore = null } = {},
296+
{ surfaceManager = null, superdocStore = null, commentsStore = null, attachTo = null } = {},
297297
) => {
298298
superdocStoreStub = superdocStore ?? buildSuperdocStore();
299299
commentsStoreStub = commentsStore ?? buildCommentsStore();
@@ -303,6 +303,7 @@ const mountComponent = async (
303303
const component = (await import('./SuperDoc.vue')).default;
304304

305305
return mount(component, {
306+
...(attachTo ? { attachTo } : {}),
306307
global: {
307308
components: {
308309
SuperEditor: SuperEditorStub,
@@ -335,6 +336,7 @@ const mountComponent = async (
335336

336337
const createSuperdocStub = () => {
337338
const toolbar = { config: { aiApiKey: 'abc' }, setActiveEditor: vi.fn(), updateToolbarState: vi.fn() };
339+
const runtimeMap = new Map();
338340
return {
339341
config: {
340342
modules: { comments: {}, ai: {}, toolbar: {}, pdf: {} },
@@ -354,6 +356,13 @@ const createSuperdocStub = () => {
354356
broadcastPdfDocumentReady: vi.fn(),
355357
broadcastSidebarToggle: vi.fn(),
356358
setActiveEditor: vi.fn(),
359+
registerEditorRuntime: vi.fn((runtime) => {
360+
if (runtime?.id) runtimeMap.set(runtime.id, runtime);
361+
}),
362+
unregisterEditorRuntime: vi.fn((runtimeId) => runtimeMap.delete(runtimeId)),
363+
setActiveRuntime: vi.fn(),
364+
getActiveRuntime: vi.fn(() => null),
365+
activateRuntimeFromEventTarget: vi.fn(() => false),
357366
lockSuperdoc: vi.fn(),
358367
emit: vi.fn(),
359368
listeners: vi.fn(),
@@ -362,6 +371,32 @@ const createSuperdocStub = () => {
362371
};
363372
};
364373

374+
const createRuntimeEditorMock = (documentId = 'doc-1') => ({
375+
options: { documentId },
376+
editorVersion: 1,
377+
state: {
378+
doc: { textBetween: vi.fn(() => '') },
379+
selection: { from: 0, to: 0, empty: true },
380+
},
381+
commands: {
382+
insertContent: vi.fn(() => true),
383+
},
384+
view: { focus: vi.fn() },
385+
focus: vi.fn(),
386+
on: vi.fn(),
387+
off: vi.fn(),
388+
exportDocx: vi.fn(async () => new ArrayBuffer(0)),
389+
});
390+
391+
const createPresentationEditorMock = () => ({
392+
focus: vi.fn(),
393+
setZoom: vi.fn(),
394+
setContextMenuDisabled: vi.fn(),
395+
on: vi.fn(),
396+
off: vi.fn(),
397+
getCommentBounds: vi.fn(() => ({})),
398+
});
399+
365400
const createFloatingCommentsSchema = () =>
366401
new Schema({
367402
nodes: {
@@ -861,6 +896,141 @@ describe('SuperDoc.vue', () => {
861896
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
862897
});
863898

899+
it('routes product focus/pointer hits through activateRuntimeFromEventTarget and cleans up on unmount', async () => {
900+
const superdocStub = createSuperdocStub();
901+
const wrapper = await mountComponent(superdocStub, { attachTo: document.body });
902+
await nextTick();
903+
904+
const subDocument = wrapper.element.querySelector('.superdoc__sub-document');
905+
expect(subDocument).not.toBeNull();
906+
const target = document.createElement('span');
907+
subDocument.appendChild(target);
908+
909+
// Real product DOM events inside the marked runtime root activate the owning
910+
// runtime through the shell helper — no painter inspection or dispatch here.
911+
target.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
912+
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(target, 'focusin');
913+
914+
target.dispatchEvent(new Event('pointerdown', { bubbles: true }));
915+
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(target, 'pointerdown');
916+
917+
target.dispatchEvent(new Event('mousedown', { bubbles: true }));
918+
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(target, 'mousedown');
919+
920+
const callsBeforeUnmount = superdocStub.activateRuntimeFromEventTarget.mock.calls.length;
921+
922+
wrapper.unmount();
923+
924+
// After unmount the capture listeners are gone: further hits do not route.
925+
document.body.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
926+
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }));
927+
expect(superdocStub.activateRuntimeFromEventTarget.mock.calls.length).toBe(callsBeforeUnmount);
928+
});
929+
930+
it('runtime hit routing outside any marked root delegates to the registry no-op (does not throw)', async () => {
931+
const superdocStub = createSuperdocStub();
932+
const wrapper = await mountComponent(superdocStub, { attachTo: document.body });
933+
await nextTick();
934+
935+
// An event outside the document area still routes to the helper, which
936+
// resolves no owning runtime and is a safe no-op (returns false).
937+
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }));
938+
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(document.body, 'pointerdown');
939+
940+
wrapper.unmount();
941+
});
942+
943+
it('skips v1 runtime registration when the document host root is unavailable', async () => {
944+
const superdocStub = createSuperdocStub();
945+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
946+
const wrapper = await mountComponent(superdocStub);
947+
await nextTick();
948+
949+
const doc = superdocStoreStub.documents.value[0];
950+
wrapper.vm.$.setupState.setSubDocumentRoot(doc, null);
951+
952+
const options = wrapper.findComponent(SuperEditorStub).props('options');
953+
options.onCreate({ editor: createRuntimeEditorMock('doc-1') });
954+
955+
expect(warnSpy).toHaveBeenCalledWith(
956+
'[SuperDoc] v1 runtime host root unavailable; skipping runtime registration for',
957+
'doc-1',
958+
);
959+
expect(superdocStub.registerEditorRuntime).not.toHaveBeenCalled();
960+
961+
wrapper.unmount();
962+
});
963+
964+
it('registers a v1 runtime, attaches the presentation editor, and activates it on focus', async () => {
965+
const superdocStub = createSuperdocStub();
966+
const wrapper = await mountComponent(superdocStub);
967+
await nextTick();
968+
969+
const editor = createRuntimeEditorMock('doc-1');
970+
const presentationEditor = createPresentationEditorMock();
971+
const editorComponent = wrapper.findComponent(SuperEditorStub);
972+
const options = editorComponent.props('options');
973+
superdocStoreStub.documents.value[0].setPresentationEditor = vi.fn();
974+
975+
options.onCreate({ editor });
976+
const runtime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];
977+
978+
expect(runtime.documentId).toBe('doc-1');
979+
expect(superdocStub.setActiveRuntime).toHaveBeenLastCalledWith(runtime.id, 'v1-editor-create');
980+
981+
editorComponent.vm.$emit('editor-ready', { editor, presentationEditor });
982+
await nextTick();
983+
expect(runtime.getSnapshot().state).toBe('editing-ready');
984+
expect(presentationEditor.on).toHaveBeenCalledWith('paginationUpdate', expect.any(Function));
985+
expect(presentationEditor.setContextMenuDisabled).toHaveBeenCalledWith(false);
986+
987+
superdocStub.setActiveRuntime.mockClear();
988+
options.onFocus({ editor });
989+
expect(superdocStub.setActiveRuntime).toHaveBeenCalledWith(runtime.id, 'v1-editor-focus');
990+
991+
runtime.dispose();
992+
expect(superdocStub.unregisterEditorRuntime).toHaveBeenCalledWith(runtime.id);
993+
994+
wrapper.unmount();
995+
});
996+
997+
it('disposes an existing v1 runtime before registering a replacement for the same document', async () => {
998+
const superdocStub = createSuperdocStub();
999+
const wrapper = await mountComponent(superdocStub);
1000+
await nextTick();
1001+
1002+
const options = wrapper.findComponent(SuperEditorStub).props('options');
1003+
options.onCreate({ editor: createRuntimeEditorMock('doc-1') });
1004+
const firstRuntime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];
1005+
const disposeSpy = vi.spyOn(firstRuntime, 'dispose');
1006+
1007+
options.onCreate({ editor: createRuntimeEditorMock('doc-1') });
1008+
const secondRuntime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];
1009+
1010+
expect(disposeSpy).toHaveBeenCalledTimes(1);
1011+
expect(superdocStub.unregisterEditorRuntime).toHaveBeenCalledWith(firstRuntime.id);
1012+
expect(superdocStub.registerEditorRuntime).toHaveBeenCalledTimes(2);
1013+
expect(secondRuntime.id).not.toBe(firstRuntime.id);
1014+
1015+
wrapper.unmount();
1016+
});
1017+
1018+
it('disposes registered v1 runtimes on component unmount', async () => {
1019+
const superdocStub = createSuperdocStub();
1020+
const wrapper = await mountComponent(superdocStub);
1021+
await nextTick();
1022+
1023+
const options = wrapper.findComponent(SuperEditorStub).props('options');
1024+
options.onCreate({ editor: createRuntimeEditorMock('doc-1') });
1025+
const runtime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];
1026+
const disposeSpy = vi.spyOn(runtime, 'dispose');
1027+
1028+
wrapper.unmount();
1029+
1030+
expect(disposeSpy).toHaveBeenCalledTimes(1);
1031+
expect(superdocStub.unregisterEditorRuntime).toHaveBeenCalledWith(runtime.id);
1032+
});
1033+
8641034
it('forwards configured passwords to SuperEditor options', async () => {
8651035
const superdocStub = createSuperdocStub();
8661036
superdocStub.config.password = 'top-secret';

packages/superdoc/src/SuperDoc.vue

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import { getVisibleThreadAnchorClientY } from './helpers/comment-focus.js';
5454
import { useUiFontFamily } from './composables/useUiFontFamily.js';
5555
import { usePasswordPrompt } from './composables/use-password-prompt.js';
5656
import { useFindReplace } from './composables/use-find-replace.js';
57+
import { createV1EditorRuntimeAdapter } from './core/editor-runtime/v1/v1-editor-runtime-adapter.js';
58+
import { markRuntimeRoot, unmarkRuntimeRoot } from './core/editor-runtime/root-marker.js';
5759
import { collectTouchedTrackedChangeIds } from './helpers/collect-touched-tracked-change-ids.js';
5860
import SurfaceHost from './components/surfaces/SurfaceHost.vue';
5961
import {
@@ -246,8 +248,11 @@ const findReplace = useFindReplace({
246248
getFindReplaceConfig: () => proxy.$superdoc?.config?.modules?.surfaces?.findReplace,
247249
});
248250

249-
// Use the composable to get the selected text
250-
const { selectedText } = useSelectedText(activeEditorRef);
251+
// Use the active runtime for selected text when available; fall back to the
252+
// legacy active editor during startup and in tests.
253+
const { selectedText } = useSelectedText(activeEditorRef, {
254+
getActiveRuntime: () => proxy.$superdoc?.getActiveRuntime?.(),
255+
});
251256

252257
// Use the AI composable
253258
const {
@@ -413,10 +418,66 @@ const onEditorContentControlClick = (payload) => {
413418
proxy.$superdoc.emit('content-control:click', payload);
414419
};
415420

421+
// Shell-owned per-document state for the v1 runtime adapter.
422+
const subDocumentRoots = new Map();
423+
const v1Runtimes = new Map();
424+
let v1RuntimeSeq = 0;
425+
426+
/**
427+
* Store the shell-owned wrapper for a document editor. This wrapper is outside
428+
* painter DOM and is the only element stamped with the runtime marker.
429+
* @param {Object} doc - the document model
430+
* @param {HTMLElement|null} el - the wrapper element, or null on unmount
431+
*/
432+
const setSubDocumentRoot = (doc, el) => {
433+
if (!doc?.id) return;
434+
if (el) subDocumentRoots.set(doc.id, el);
435+
else subDocumentRoots.delete(doc.id);
436+
};
437+
438+
/**
439+
* Register a pending v1 runtime at editor creation. The visible
440+
* PresentationEditor is attached later from onEditorReady.
441+
* @param {string} documentId
442+
* @param {Object} editor - the live v1 Editor instance
443+
*/
444+
const registerV1Runtime = (documentId, editor) => {
445+
const root = subDocumentRoots.get(documentId);
446+
if (!root) {
447+
console.warn('[SuperDoc] v1 runtime host root unavailable; skipping runtime registration for', documentId);
448+
return;
449+
}
450+
451+
const existing = v1Runtimes.get(documentId);
452+
if (existing) existing.adapter.runtime.dispose();
453+
454+
const runtimeId = `v1:${documentId}:${++v1RuntimeSeq}`;
455+
const adapter = createV1EditorRuntimeAdapter({
456+
id: runtimeId,
457+
documentId,
458+
root,
459+
editor,
460+
setGlobalZoom: (factor) => PresentationEditor.setGlobalZoom(factor),
461+
onUnregister: (id) => {
462+
proxy.$superdoc.unregisterEditorRuntime(id);
463+
const current = v1Runtimes.get(documentId);
464+
if (current && current.runtimeId === id) v1Runtimes.delete(documentId);
465+
const hostRoot = subDocumentRoots.get(documentId);
466+
if (hostRoot) unmarkRuntimeRoot(hostRoot);
467+
},
468+
});
469+
470+
markRuntimeRoot(root, runtimeId);
471+
proxy.$superdoc.registerEditorRuntime(adapter.runtime);
472+
v1Runtimes.set(documentId, { runtimeId, adapter });
473+
proxy.$superdoc.setActiveRuntime(runtimeId, 'v1-editor-create');
474+
};
475+
416476
const onEditorCreate = ({ editor }) => {
417477
const { documentId } = editor.options;
418478
const doc = getDocument(documentId);
419479
doc.setEditor(editor);
480+
registerV1Runtime(documentId, editor);
420481
proxy.$superdoc.setActiveEditor(editor);
421482
editor.on?.('contentControlFocus', onEditorContentControlFocus);
422483
editor.on?.('contentControlBlur', onEditorContentControlBlur);
@@ -448,6 +509,9 @@ const onEditorReady = ({ editor, presentationEditor }) => {
448509
// not linger on the reactive document model.
449510
if (doc.password) doc.password = undefined;
450511
}
512+
513+
const v1Runtime = v1Runtimes.get(documentId);
514+
if (v1Runtime) v1Runtime.adapter.attachPresentationEditor(presentationEditor);
451515
presentationEditor.setContextMenuDisabled?.(proxy.$superdoc.config.disableContextMenu);
452516
getTrackedChangeIndex(editor);
453517

@@ -503,9 +567,27 @@ const onEditorDestroy = () => {
503567
};
504568

505569
const onEditorFocus = ({ editor }) => {
570+
const documentId = editor?.options?.documentId;
571+
const entry = documentId ? v1Runtimes.get(documentId) : null;
572+
if (entry) proxy.$superdoc.setActiveRuntime(entry.runtimeId, 'v1-editor-focus');
506573
proxy.$superdoc.setActiveEditor(editor);
507574
};
508575

576+
// Shell-owned product DOM hit capture. Real focus/pointer hits inside a marked
577+
// runtime root activate the owning runtime through the registry. This handler
578+
// stays deliberately minimal: it resolves a runtime from the event target and
579+
// does nothing editor-semantic — no painter DOM inspection, no coordinate
580+
// mapping, no command dispatch, no selection semantics. Activation outside any
581+
// marked root is a no-op (the registry returns no owner).
582+
const activateRuntimeFromEvent = (event, reason) => {
583+
proxy.$superdoc?.activateRuntimeFromEventTarget?.(event.target, reason);
584+
};
585+
const handleRuntimeFocusIn = (event) => activateRuntimeFromEvent(event, 'focusin');
586+
const handleRuntimePointerDown = (event) => activateRuntimeFromEvent(event, 'pointerdown');
587+
// `mousedown` is a fallback for environments that do not dispatch pointer
588+
// events consistently; it routes through the same idempotent activation path.
589+
const handleRuntimeMouseDown = (event) => activateRuntimeFromEvent(event, 'mousedown');
590+
509591
const onEditorDocumentLocked = ({ editor, isLocked, lockedBy }) => {
510592
proxy.$superdoc.lockSuperdoc(isLocked, lockedBy);
511593
};
@@ -1358,6 +1440,14 @@ onMounted(() => {
13581440
document.addEventListener('contextmenu', handleDocumentContextMenu, true);
13591441
document.addEventListener('keydown', handleDocumentShortcut, true);
13601442

1443+
// Capture-phase product hit routing: activate the owning runtime from real
1444+
// focus/pointer hits. Capture so a marked root nested under shells that stop
1445+
// propagation still resolves; the handler is idempotent and a no-op outside
1446+
// any marked runtime root.
1447+
document.addEventListener('focusin', handleRuntimeFocusIn, true);
1448+
document.addEventListener('pointerdown', handleRuntimePointerDown, true);
1449+
document.addEventListener('mousedown', handleRuntimeMouseDown, true);
1450+
13611451
recalculateCompactCommentsMode();
13621452
ensureCompactMeasurementObserver();
13631453
});
@@ -1434,9 +1524,17 @@ function handleContainerKeydown(e) {
14341524
onBeforeUnmount(() => {
14351525
passwordPrompt.destroy();
14361526
findReplace.destroy();
1527+
for (const entry of Array.from(v1Runtimes.values())) {
1528+
entry.adapter.runtime.dispose();
1529+
}
1530+
v1Runtimes.clear();
1531+
subDocumentRoots.clear();
14371532
document.removeEventListener('mousedown', handleDocumentMouseDown);
14381533
document.removeEventListener('contextmenu', handleDocumentContextMenu, true);
14391534
document.removeEventListener('keydown', handleDocumentShortcut, true);
1535+
document.removeEventListener('focusin', handleRuntimeFocusIn, true);
1536+
document.removeEventListener('pointerdown', handleRuntimePointerDown, true);
1537+
document.removeEventListener('mousedown', handleRuntimeMouseDown, true);
14401538
if (selectionUpdateRafId != null) {
14411539
cancelAnimationFrame(selectionUpdateRafId);
14421540
selectionUpdateRafId = null;
@@ -1812,6 +1910,7 @@ const getPDFViewer = () => {
18121910
class="superdoc__sub-document sub-document"
18131911
v-for="doc in documents"
18141912
:key="`${doc.id}:${doc.editorMountNonce}`"
1913+
:ref="(el) => setSubDocumentRoot(doc, el)"
18151914
>
18161915
<!-- PDF renderer -->
18171916
<PdfViewer

0 commit comments

Comments
 (0)