Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,13 @@ export class PresentationEditor extends EventEmitter {
#hiddenHost: HTMLElement;
/** Scroll-isolating wrapper around #hiddenHost. Append/remove this from the DOM. */
#hiddenHostWrapper: HTMLElement;
/**
* Hidden-host elements owned by this instance (story-session hosts in
* addition to #hiddenHost). Hidden hosts mount on document.body without an
* instance marker, so the input bridge consults this set to tell our
* editors apart from those of other SuperDoc instances on the page (SD-3249).
*/
#ownedHiddenHosts = new WeakSet<HTMLElement>();
#layoutOptions: LayoutEngineOptions;
#configuredDocumentBackground: DocumentBackground | undefined;
#layoutState: LayoutState = { blocks: [], measures: [], layout: null, bookmarks: new Map() };
Expand Down Expand Up @@ -999,6 +1006,7 @@ export class PresentationEditor extends EventEmitter {
);
this.#hiddenHostWrapper = hiddenHostWrapper;
this.#hiddenHost = hiddenHost;
this.#ownedHiddenHosts.add(hiddenHost);
if (doc.body) {
doc.body.appendChild(this.#hiddenHostWrapper);
} else {
Expand Down Expand Up @@ -5822,11 +5830,31 @@ export class PresentationEditor extends EventEmitter {
{
useWindowFallback: true,
getTargetEditor: () => this.getActiveEditor(),
ownsEditorDom: (element) => this.#ownsEditorDom(element),
},
);
this.#inputBridge.bind();
}

/**
* Whether a hidden-editor DOM element belongs to this instance.
*
* Hidden hosts (body editor and story-session editors) are appended to
* document.body, so containment in #visibleHost cannot identify them.
* Without this check, the input bridge's window-fallback stale rerouting
* would intercept keystrokes that belong to other SuperDoc instances on the
* same page and re-dispatch them into this instance — with two instances
* the bridges ping-pong each other's synthetics until the stack overflows
* (SD-3249).
*/
#ownsEditorDom(element: HTMLElement): boolean {
if (this.#visibleHost?.contains(element)) {
return true;
}
const hiddenHost = element.closest?.('.presentation-editor__hidden-host');
return hiddenHost instanceof HTMLElement && this.#ownedHiddenHosts.has(hiddenHost);
}

/**
* Set up the header/footer session manager with dependencies and callbacks.
*/
Expand Down Expand Up @@ -6198,6 +6226,9 @@ export class PresentationEditor extends EventEmitter {
#createStorySessionEditor(input: StorySessionEditorFactoryInput): StorySessionEditorFactoryResult {
const { runtime, hostElement, activationOptions } = input;
const editorContext = activationOptions.editorContext ?? {};
// Every story-session editor mounts into this host; record it so the
// input bridge can recognize the editor as ours (SD-3249).
this.#ownedHiddenHosts.add(hostElement);

if (runtime.kind === 'headerFooter' && runtime.locator.storyType === 'headerFooterPart') {
const descriptor = this.#headerFooterSession?.manager?.getDescriptorById(runtime.locator.refId) ?? null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class PresentationInputBridge {
#layoutSurfaces: Set<EventTarget>;
#getTargetDom: () => HTMLElement | null;
#getTargetEditor?: () => BridgeTargetEditor | null;
#ownsEditorDom?: (element: HTMLElement) => boolean;
/** Callback that returns whether the editor is in an editable mode (editing/suggesting vs viewing) */
#isEditable: () => boolean;
#onTargetChanged?: (target: HTMLElement | null) => void;
Expand All @@ -47,6 +48,10 @@ export class PresentationInputBridge {
* - useWindowFallback: Whether to attach window-level event listeners as fallback
* - getTargetEditor: Returns the active editor so focus restoration can
* use editor-aware focus logic instead of raw DOM focus
* - ownsEditorDom: Returns whether a hidden-editor element belongs to this
* bridge's presentation editor instance. Window-fallback stale rerouting
* only intercepts events from owned editors, so multiple SuperDoc
* instances on one page do not hijack each other's input (SD-3249).
*/
constructor(
windowRoot: Window,
Expand All @@ -57,12 +62,14 @@ export class PresentationInputBridge {
options?: {
useWindowFallback?: boolean;
getTargetEditor?: () => BridgeTargetEditor | null;
ownsEditorDom?: (element: HTMLElement) => boolean;
},
) {
this.#windowRoot = windowRoot;
this.#layoutSurfaces = new Set<EventTarget>([layoutSurface]);
this.#getTargetDom = getTargetDom;
this.#getTargetEditor = options?.getTargetEditor;
this.#ownsEditorDom = options?.ownsEditorDom;
this.#isEditable = isEditable;
this.#onTargetChanged = onTargetChanged;
this.#listeners = [];
Expand Down Expand Up @@ -305,6 +312,16 @@ export class PresentationInputBridge {
return null;
}

// Multi-instance guard (SD-3249): only reroute input from editors owned by
// this bridge's presentation editor instance. With several SuperDoc
// instances on one page, every bridge's window-capture listener sees every
// keystroke; without this check each bridge suppresses the other
// instance's trusted events and re-dispatches synthetics that the other
// bridge intercepts in turn, recursing until the stack overflows.
if (this.#ownsEditorDom && !this.#ownsEditorDom(staleEditorTarget)) {
return null;
}

return {
activeTarget,
staleEditorTarget,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PresentationInputBridge } from '../input/PresentationInputBridge.js';
import { CONTEXT_MENU_HANDLED_FLAG } from '../../../components/context-menu/event-flags.js';

Expand Down Expand Up @@ -29,6 +29,14 @@ describe('PresentationInputBridge - Context Menu Handling', () => {
bridge.bind();
});

afterEach(() => {
// Unbind window-fallback listeners and detach this test's DOM so bridges
// from one test cannot intercept events dispatched by later tests.
bridge.destroy();
layoutSurface.remove();
targetDom.remove();
});

describe('#forwardContextMenu', () => {
it('should forward context menu event when flag is NOT set', () => {
const dispatchSpy = vi.spyOn(targetDom, 'dispatchEvent');
Expand Down Expand Up @@ -332,6 +340,63 @@ describe('PresentationInputBridge - Context Menu Handling', () => {
expect(staleEvent.defaultPrevented).toBe(true);
});

it('still reroutes stale body-editor input when active target is a story editor in a different owned hidden host', () => {
// Same-instance scenario the stale reroute exists for (SD-3249 regression
// guard): a story session (footnote/header/footer) is active in its own
// hidden host while native focus survived in the body editor's hidden
// host. Both hosts belong to the SAME instance, so the reroute must run
// even though the two ProseMirrors live in different hidden hosts.
const bodyWrapper = document.createElement('div');
bodyWrapper.className = 'presentation-editor__hidden-host-wrapper';
const bodyHost = document.createElement('div');
bodyHost.className = 'presentation-editor__hidden-host';
const bodyEditor = document.createElement('div');
bodyEditor.className = 'ProseMirror';
bodyEditor.setAttribute('contenteditable', 'true');
bodyHost.appendChild(bodyEditor);
bodyWrapper.appendChild(bodyHost);
document.body.appendChild(bodyWrapper);

const storyWrapper = document.createElement('div');
storyWrapper.className =
'presentation-editor__hidden-host-wrapper presentation-editor__story-hidden-host-wrapper';
const storyHost = document.createElement('div');
storyHost.className = 'presentation-editor__hidden-host presentation-editor__story-hidden-host';
const storyEditor = document.createElement('div');
storyEditor.className = 'ProseMirror';
storyEditor.setAttribute('contenteditable', 'true');
storyHost.appendChild(storyEditor);
storyWrapper.appendChild(storyHost);
document.body.appendChild(storyWrapper);

const storyFocusSpy = vi.spyOn(storyEditor, 'focus').mockImplementation(() => {});
const storyDispatchSpy = vi.spyOn(storyEditor, 'dispatchEvent');

bridge.destroy();
bridge = new PresentationInputBridge(windowRoot, layoutSurface, () => storyEditor, isEditable, undefined, {
useWindowFallback: true,
ownsEditorDom: (element) => bodyWrapper.contains(element) || storyWrapper.contains(element),
});
bridge.bind();

const staleEvent = new InputEvent('beforeinput', {
data: 'a',
inputType: 'insertText',
bubbles: true,
cancelable: true,
});
bodyEditor.dispatchEvent(staleEvent);

expect(storyFocusSpy).toHaveBeenCalled();
expect(storyDispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'beforeinput', data: 'a', inputType: 'insertText' }),
);
expect(staleEvent.defaultPrevented).toBe(true);

bodyWrapper.remove();
storyWrapper.remove();
});

it('does not reroute keyboard input from a registered UI surface editor', () => {
const commentEditor = document.createElement('div');
commentEditor.className = 'ProseMirror';
Expand Down Expand Up @@ -365,3 +430,158 @@ describe('PresentationInputBridge - Context Menu Handling', () => {
});
});
});

describe('PresentationInputBridge - multiple instances on one page (SD-3249)', () => {
type Instance = {
layoutSurface: HTMLElement;
hiddenHostWrapper: HTMLElement;
editorDom: HTMLElement;
bridge: PresentationInputBridge;
};

const instances: Instance[] = [];

/**
* Mirrors the production DOM of one PresentationEditor: a visible layout
* surface plus a hidden-host wrapper appended to document.body containing
* the hidden ProseMirror, with the bridge wired the way
* PresentationEditor#setupInputBridge wires it (window fallback enabled,
* instance-scoped editor ownership).
*/
function createInstance(): Instance {
const layoutSurface = document.createElement('div');
document.body.appendChild(layoutSurface);

const hiddenHostWrapper = document.createElement('div');
hiddenHostWrapper.className = 'presentation-editor__hidden-host-wrapper';
const hiddenHost = document.createElement('div');
hiddenHost.className = 'presentation-editor__hidden-host';
const editorDom = document.createElement('div');
editorDom.className = 'ProseMirror';
editorDom.setAttribute('contenteditable', 'true');
hiddenHost.appendChild(editorDom);
hiddenHostWrapper.appendChild(hiddenHost);
document.body.appendChild(hiddenHostWrapper);

const bridge = new PresentationInputBridge(
window,
layoutSurface,
() => editorDom,
() => true,
undefined,
{
useWindowFallback: true,
ownsEditorDom: (element) => hiddenHostWrapper.contains(element),
},
);
bridge.bind();

const instance = { layoutSurface, hiddenHostWrapper, editorDom, bridge };
instances.push(instance);
return instance;
}

afterEach(() => {
while (instances.length) {
const instance = instances.pop()!;
instance.bridge.destroy();
instance.layoutSurface.remove();
instance.hiddenHostWrapper.remove();
}
});

it('does not suppress or re-dispatch another instance’s beforeinput', () => {
const a = createInstance();
const b = createInstance();

const bDispatchSpy = vi.spyOn(b.editorDom, 'dispatchEvent');
const bFocusSpy = vi.spyOn(b.editorDom, 'focus').mockImplementation(() => {});

// User types in instance A: the trusted event targets A's hidden editor.
const event = new InputEvent('beforeinput', {
data: 'h',
inputType: 'insertText',
bubbles: true,
cancelable: true,
});
a.editorDom.dispatchEvent(event);

// Instance B's window-capture listener must leave the event alone:
// suppressing it blocks typing in A, and re-dispatching synthetics
// ping-pongs between the two bridges until the stack overflows.
expect(event.defaultPrevented).toBe(false);
expect(bDispatchSpy).not.toHaveBeenCalled();
expect(bFocusSpy).not.toHaveBeenCalled();
});

it('does not steal focus on another instance’s plain-character keydown', () => {
const a = createInstance();
const b = createInstance();

const aFocusSpy = vi.spyOn(a.editorDom, 'focus').mockImplementation(() => {});
const bFocusSpy = vi.spyOn(b.editorDom, 'focus').mockImplementation(() => {});

const event = new KeyboardEvent('keydown', {
key: 'h',
bubbles: true,
cancelable: true,
});
a.editorDom.dispatchEvent(event);

expect(bFocusSpy).not.toHaveBeenCalled();
expect(aFocusSpy).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});

it('does not intercept another instance’s non-text keyboard commands', () => {
const a = createInstance();
const b = createInstance();

const bDispatchSpy = vi.spyOn(b.editorDom, 'dispatchEvent');
const bFocusSpy = vi.spyOn(b.editorDom, 'focus').mockImplementation(() => {});

const event = new KeyboardEvent('keydown', {
key: 'Backspace',
bubbles: true,
cancelable: true,
});
a.editorDom.dispatchEvent(event);

expect(event.defaultPrevented).toBe(false);
expect(bDispatchSpy).not.toHaveBeenCalled();
expect(bFocusSpy).not.toHaveBeenCalled();
});

it('typing in either instance is left alone by the other bridge', () => {
const a = createInstance();
const b = createInstance();

const aDispatchSpy = vi.spyOn(a.editorDom, 'dispatchEvent');
const bDispatchSpy = vi.spyOn(b.editorDom, 'dispatchEvent');

const eventForA = new InputEvent('beforeinput', {
data: 'a',
inputType: 'insertText',
bubbles: true,
cancelable: true,
});
a.editorDom.dispatchEvent(eventForA);

const eventForB = new InputEvent('beforeinput', {
data: 'b',
inputType: 'insertText',
bubbles: true,
cancelable: true,
});
b.editorDom.dispatchEvent(eventForB);

expect(eventForA.defaultPrevented).toBe(false);
expect(eventForB.defaultPrevented).toBe(false);
// Each editor only sees the event the user produced in it — no bridge
// injected synthetic re-dispatches. (dispatchEvent may legitimately be
// re-entered per propagation phase by the DOM implementation, so assert
// on event identity rather than call counts.)
expect(aDispatchSpy.mock.calls.every(([dispatched]) => dispatched === eventForA)).toBe(true);
expect(bDispatchSpy.mock.calls.every(([dispatched]) => dispatched === eventForB)).toBe(true);
});
});
Loading