Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 171 additions & 1 deletion packages/superdoc/src/SuperDoc.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ const createCommentsStoreWithFloatingGetter = () => {

const mountComponent = async (
superdocStub,
{ surfaceManager = null, superdocStore = null, commentsStore = null } = {},
{ surfaceManager = null, superdocStore = null, commentsStore = null, attachTo = null } = {},
) => {
superdocStoreStub = superdocStore ?? buildSuperdocStore();
commentsStoreStub = commentsStore ?? buildCommentsStore();
Expand All @@ -303,6 +303,7 @@ const mountComponent = async (
const component = (await import('./SuperDoc.vue')).default;

return mount(component, {
...(attachTo ? { attachTo } : {}),
global: {
components: {
SuperEditor: SuperEditorStub,
Expand Down Expand Up @@ -335,6 +336,7 @@ const mountComponent = async (

const createSuperdocStub = () => {
const toolbar = { config: { aiApiKey: 'abc' }, setActiveEditor: vi.fn(), updateToolbarState: vi.fn() };
const runtimeMap = new Map();
return {
config: {
modules: { comments: {}, ai: {}, toolbar: {}, pdf: {} },
Expand All @@ -354,6 +356,13 @@ const createSuperdocStub = () => {
broadcastPdfDocumentReady: vi.fn(),
broadcastSidebarToggle: vi.fn(),
setActiveEditor: vi.fn(),
registerEditorRuntime: vi.fn((runtime) => {
if (runtime?.id) runtimeMap.set(runtime.id, runtime);
}),
unregisterEditorRuntime: vi.fn((runtimeId) => runtimeMap.delete(runtimeId)),
setActiveRuntime: vi.fn(),
getActiveRuntime: vi.fn(() => null),
activateRuntimeFromEventTarget: vi.fn(() => false),
lockSuperdoc: vi.fn(),
emit: vi.fn(),
listeners: vi.fn(),
Expand All @@ -362,6 +371,32 @@ const createSuperdocStub = () => {
};
};

const createRuntimeEditorMock = (documentId = 'doc-1') => ({
options: { documentId },
editorVersion: 1,
state: {
doc: { textBetween: vi.fn(() => '') },
selection: { from: 0, to: 0, empty: true },
},
commands: {
insertContent: vi.fn(() => true),
},
view: { focus: vi.fn() },
focus: vi.fn(),
on: vi.fn(),
off: vi.fn(),
exportDocx: vi.fn(async () => new ArrayBuffer(0)),
});

const createPresentationEditorMock = () => ({
focus: vi.fn(),
setZoom: vi.fn(),
setContextMenuDisabled: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getCommentBounds: vi.fn(() => ({})),
});

const createFloatingCommentsSchema = () =>
new Schema({
nodes: {
Expand Down Expand Up @@ -861,6 +896,141 @@ describe('SuperDoc.vue', () => {
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
});

it('routes product focus/pointer hits through activateRuntimeFromEventTarget and cleans up on unmount', async () => {
const superdocStub = createSuperdocStub();
const wrapper = await mountComponent(superdocStub, { attachTo: document.body });
await nextTick();

const subDocument = wrapper.element.querySelector('.superdoc__sub-document');
expect(subDocument).not.toBeNull();
const target = document.createElement('span');
subDocument.appendChild(target);

// Real product DOM events inside the marked runtime root activate the owning
// runtime through the shell helper — no painter inspection or dispatch here.
target.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(target, 'focusin');

target.dispatchEvent(new Event('pointerdown', { bubbles: true }));
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(target, 'pointerdown');

target.dispatchEvent(new Event('mousedown', { bubbles: true }));
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(target, 'mousedown');

const callsBeforeUnmount = superdocStub.activateRuntimeFromEventTarget.mock.calls.length;

wrapper.unmount();

// After unmount the capture listeners are gone: further hits do not route.
document.body.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }));
expect(superdocStub.activateRuntimeFromEventTarget.mock.calls.length).toBe(callsBeforeUnmount);
});

it('runtime hit routing outside any marked root delegates to the registry no-op (does not throw)', async () => {
const superdocStub = createSuperdocStub();
const wrapper = await mountComponent(superdocStub, { attachTo: document.body });
await nextTick();

// An event outside the document area still routes to the helper, which
// resolves no owning runtime and is a safe no-op (returns false).
document.body.dispatchEvent(new Event('pointerdown', { bubbles: true }));
expect(superdocStub.activateRuntimeFromEventTarget).toHaveBeenCalledWith(document.body, 'pointerdown');

wrapper.unmount();
});

it('skips v1 runtime registration when the document host root is unavailable', async () => {
const superdocStub = createSuperdocStub();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const wrapper = await mountComponent(superdocStub);
await nextTick();

const doc = superdocStoreStub.documents.value[0];
wrapper.vm.$.setupState.setSubDocumentRoot(doc, null);

const options = wrapper.findComponent(SuperEditorStub).props('options');
options.onCreate({ editor: createRuntimeEditorMock('doc-1') });

expect(warnSpy).toHaveBeenCalledWith(
'[SuperDoc] v1 runtime host root unavailable; skipping runtime registration for',
'doc-1',
);
expect(superdocStub.registerEditorRuntime).not.toHaveBeenCalled();

wrapper.unmount();
});

it('registers a v1 runtime, attaches the presentation editor, and activates it on focus', async () => {
const superdocStub = createSuperdocStub();
const wrapper = await mountComponent(superdocStub);
await nextTick();

const editor = createRuntimeEditorMock('doc-1');
const presentationEditor = createPresentationEditorMock();
const editorComponent = wrapper.findComponent(SuperEditorStub);
const options = editorComponent.props('options');
superdocStoreStub.documents.value[0].setPresentationEditor = vi.fn();

options.onCreate({ editor });
const runtime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];

expect(runtime.documentId).toBe('doc-1');
expect(superdocStub.setActiveRuntime).toHaveBeenLastCalledWith(runtime.id, 'v1-editor-create');

editorComponent.vm.$emit('editor-ready', { editor, presentationEditor });
await nextTick();
expect(runtime.getSnapshot().state).toBe('editing-ready');
expect(presentationEditor.on).toHaveBeenCalledWith('paginationUpdate', expect.any(Function));
expect(presentationEditor.setContextMenuDisabled).toHaveBeenCalledWith(false);

superdocStub.setActiveRuntime.mockClear();
options.onFocus({ editor });
expect(superdocStub.setActiveRuntime).toHaveBeenCalledWith(runtime.id, 'v1-editor-focus');

runtime.dispose();
expect(superdocStub.unregisterEditorRuntime).toHaveBeenCalledWith(runtime.id);

wrapper.unmount();
});

it('disposes an existing v1 runtime before registering a replacement for the same document', async () => {
const superdocStub = createSuperdocStub();
const wrapper = await mountComponent(superdocStub);
await nextTick();

const options = wrapper.findComponent(SuperEditorStub).props('options');
options.onCreate({ editor: createRuntimeEditorMock('doc-1') });
const firstRuntime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];
const disposeSpy = vi.spyOn(firstRuntime, 'dispose');

options.onCreate({ editor: createRuntimeEditorMock('doc-1') });
const secondRuntime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];

expect(disposeSpy).toHaveBeenCalledTimes(1);
expect(superdocStub.unregisterEditorRuntime).toHaveBeenCalledWith(firstRuntime.id);
expect(superdocStub.registerEditorRuntime).toHaveBeenCalledTimes(2);
expect(secondRuntime.id).not.toBe(firstRuntime.id);

wrapper.unmount();
});

it('disposes registered v1 runtimes on component unmount', async () => {
const superdocStub = createSuperdocStub();
const wrapper = await mountComponent(superdocStub);
await nextTick();

const options = wrapper.findComponent(SuperEditorStub).props('options');
options.onCreate({ editor: createRuntimeEditorMock('doc-1') });
const runtime = superdocStub.registerEditorRuntime.mock.calls.at(-1)[0];
const disposeSpy = vi.spyOn(runtime, 'dispose');

wrapper.unmount();

expect(disposeSpy).toHaveBeenCalledTimes(1);
expect(superdocStub.unregisterEditorRuntime).toHaveBeenCalledWith(runtime.id);
});

it('forwards configured passwords to SuperEditor options', async () => {
const superdocStub = createSuperdocStub();
superdocStub.config.password = 'top-secret';
Expand Down
103 changes: 101 additions & 2 deletions packages/superdoc/src/SuperDoc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import { getVisibleThreadAnchorClientY } from './helpers/comment-focus.js';
import { useUiFontFamily } from './composables/useUiFontFamily.js';
import { usePasswordPrompt } from './composables/use-password-prompt.js';
import { useFindReplace } from './composables/use-find-replace.js';
import { createV1EditorRuntimeAdapter } from './core/editor-runtime/v1/v1-editor-runtime-adapter.js';
import { markRuntimeRoot, unmarkRuntimeRoot } from './core/editor-runtime/root-marker.js';
import { collectTouchedTrackedChangeIds } from './helpers/collect-touched-tracked-change-ids.js';
import SurfaceHost from './components/surfaces/SurfaceHost.vue';
import {
Expand Down Expand Up @@ -246,8 +248,11 @@ const findReplace = useFindReplace({
getFindReplaceConfig: () => proxy.$superdoc?.config?.modules?.surfaces?.findReplace,
});

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

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

// Shell-owned per-document state for the v1 runtime adapter.
const subDocumentRoots = new Map();
const v1Runtimes = new Map();
let v1RuntimeSeq = 0;

/**
* Store the shell-owned wrapper for a document editor. This wrapper is outside
* painter DOM and is the only element stamped with the runtime marker.
* @param {Object} doc - the document model
* @param {HTMLElement|null} el - the wrapper element, or null on unmount
*/
const setSubDocumentRoot = (doc, el) => {
if (!doc?.id) return;
if (el) subDocumentRoots.set(doc.id, el);
else subDocumentRoots.delete(doc.id);
};

/**
* Register a pending v1 runtime at editor creation. The visible
* PresentationEditor is attached later from onEditorReady.
* @param {string} documentId
* @param {Object} editor - the live v1 Editor instance
*/
const registerV1Runtime = (documentId, editor) => {
const root = subDocumentRoots.get(documentId);
if (!root) {
console.warn('[SuperDoc] v1 runtime host root unavailable; skipping runtime registration for', documentId);
return;
}

const existing = v1Runtimes.get(documentId);
if (existing) existing.adapter.runtime.dispose();

const runtimeId = `v1:${documentId}:${++v1RuntimeSeq}`;
const adapter = createV1EditorRuntimeAdapter({
id: runtimeId,
documentId,
root,
editor,
setGlobalZoom: (factor) => PresentationEditor.setGlobalZoom(factor),
onUnregister: (id) => {
proxy.$superdoc.unregisterEditorRuntime(id);
const current = v1Runtimes.get(documentId);
if (current && current.runtimeId === id) v1Runtimes.delete(documentId);
const hostRoot = subDocumentRoots.get(documentId);
if (hostRoot) unmarkRuntimeRoot(hostRoot);
},
});

markRuntimeRoot(root, runtimeId);
proxy.$superdoc.registerEditorRuntime(adapter.runtime);
v1Runtimes.set(documentId, { runtimeId, adapter });
proxy.$superdoc.setActiveRuntime(runtimeId, 'v1-editor-create');
};

const onEditorCreate = ({ editor }) => {
const { documentId } = editor.options;
const doc = getDocument(documentId);
doc.setEditor(editor);
registerV1Runtime(documentId, editor);
proxy.$superdoc.setActiveEditor(editor);
editor.on?.('contentControlFocus', onEditorContentControlFocus);
editor.on?.('contentControlBlur', onEditorContentControlBlur);
Expand Down Expand Up @@ -448,6 +509,9 @@ const onEditorReady = ({ editor, presentationEditor }) => {
// not linger on the reactive document model.
if (doc.password) doc.password = undefined;
}

const v1Runtime = v1Runtimes.get(documentId);
if (v1Runtime) v1Runtime.adapter.attachPresentationEditor(presentationEditor);
presentationEditor.setContextMenuDisabled?.(proxy.$superdoc.config.disableContextMenu);
getTrackedChangeIndex(editor);

Expand Down Expand Up @@ -503,9 +567,27 @@ const onEditorDestroy = () => {
};

const onEditorFocus = ({ editor }) => {
const documentId = editor?.options?.documentId;
const entry = documentId ? v1Runtimes.get(documentId) : null;
if (entry) proxy.$superdoc.setActiveRuntime(entry.runtimeId, 'v1-editor-focus');
proxy.$superdoc.setActiveEditor(editor);
};

// Shell-owned product DOM hit capture. Real focus/pointer hits inside a marked
// runtime root activate the owning runtime through the registry. This handler
// stays deliberately minimal: it resolves a runtime from the event target and
// does nothing editor-semantic — no painter DOM inspection, no coordinate
// mapping, no command dispatch, no selection semantics. Activation outside any
// marked root is a no-op (the registry returns no owner).
const activateRuntimeFromEvent = (event, reason) => {
proxy.$superdoc?.activateRuntimeFromEventTarget?.(event.target, reason);
};
const handleRuntimeFocusIn = (event) => activateRuntimeFromEvent(event, 'focusin');
const handleRuntimePointerDown = (event) => activateRuntimeFromEvent(event, 'pointerdown');
// `mousedown` is a fallback for environments that do not dispatch pointer
// events consistently; it routes through the same idempotent activation path.
const handleRuntimeMouseDown = (event) => activateRuntimeFromEvent(event, 'mousedown');

const onEditorDocumentLocked = ({ editor, isLocked, lockedBy }) => {
proxy.$superdoc.lockSuperdoc(isLocked, lockedBy);
};
Expand Down Expand Up @@ -1358,6 +1440,14 @@ onMounted(() => {
document.addEventListener('contextmenu', handleDocumentContextMenu, true);
document.addEventListener('keydown', handleDocumentShortcut, true);

// Capture-phase product hit routing: activate the owning runtime from real
// focus/pointer hits. Capture so a marked root nested under shells that stop
// propagation still resolves; the handler is idempotent and a no-op outside
// any marked runtime root.
document.addEventListener('focusin', handleRuntimeFocusIn, true);
document.addEventListener('pointerdown', handleRuntimePointerDown, true);
document.addEventListener('mousedown', handleRuntimeMouseDown, true);

recalculateCompactCommentsMode();
ensureCompactMeasurementObserver();
});
Expand Down Expand Up @@ -1434,9 +1524,17 @@ function handleContainerKeydown(e) {
onBeforeUnmount(() => {
passwordPrompt.destroy();
findReplace.destroy();
for (const entry of Array.from(v1Runtimes.values())) {
entry.adapter.runtime.dispose();
}
v1Runtimes.clear();
subDocumentRoots.clear();
document.removeEventListener('mousedown', handleDocumentMouseDown);
document.removeEventListener('contextmenu', handleDocumentContextMenu, true);
document.removeEventListener('keydown', handleDocumentShortcut, true);
document.removeEventListener('focusin', handleRuntimeFocusIn, true);
document.removeEventListener('pointerdown', handleRuntimePointerDown, true);
document.removeEventListener('mousedown', handleRuntimeMouseDown, true);
if (selectionUpdateRafId != null) {
cancelAnimationFrame(selectionUpdateRafId);
selectionUpdateRafId = null;
Expand Down Expand Up @@ -1812,6 +1910,7 @@ const getPDFViewer = () => {
class="superdoc__sub-document sub-document"
v-for="doc in documents"
:key="`${doc.id}:${doc.editorMountNonce}`"
:ref="(el) => setSubDocumentRoot(doc, el)"
>
<!-- PDF renderer -->
<PdfViewer
Expand Down
Loading
Loading