Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,27 @@ export class PresentationEditor extends EventEmitter {
return this.#storySessionManager;
}

/**
* The {@link StoryLocator} for the currently routed editor, or `null`
* when the body editor is active. Notes (footnote/endnote) flow
* through the generic story-session manager; headers/footers flow
* through the legacy header-footer session. Both are unified here so
* external surfaces (selection / positionAt) can thread the locator
* onto a {@link SelectionTarget} without reaching into private state.
*/
getActiveStoryLocator(): StoryLocator | null {
const storySession = this.#storySessionManager?.getActiveSession();
if (storySession) return storySession.locator;

const session = this.#headerFooterSession?.session;
if (!session || session.mode === 'body' || !session.headerFooterRefId) return null;
return {
kind: 'story',
storyType: 'headerFooterPart',
refId: session.headerFooterRefId,
};
}

/**
* Exit any active non-body editing surface and restore the body editor.
*
Expand Down
167 changes: 167 additions & 0 deletions packages/super-editor/src/ui/create-super-doc-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,173 @@ describe('createSuperDocUI', () => {
});
});

// SD-2954: when the live selection resolver returns a TextTarget
// without `story` (the resolver runs against the routed editor and
// has no path back to the host's PresentationEditor), the
// controller stamps the active story locator at the seam where
// both editors are reachable. Without this stamping the live
// selection slice carries body-scoped targets even when the user
// is editing a header, and downstream doc-api ops route to body
// and silently fail to find the block.
it('state.selection.target gets the active story locator stamped when the resolver omits it', async () => {
const headerStory = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' };

const headerEditor = {
on: vi.fn(),
off: vi.fn(),
state: { selection: { empty: false } },
isEditable: true,
doc: {
selection: {
current: vi.fn(() => ({
empty: false,
text: 'header text',
// Resolver returns no story field. Controller must stamp it.
target: { kind: 'text', segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }] },
activeMarks: [],
activeCommentIds: [],
activeChangeIds: [],
})),
},
},
};

const presentationEditor: Record<string, unknown> = {
on: vi.fn(),
off: vi.fn(),
isEditable: true,
state: { selection: { empty: false } },
// Body editor is the host; routed editor is the header.
getActiveEditor: vi.fn(() => headerEditor),
getActiveStoryLocator: vi.fn(() => headerStory),
commands: {},
};

const bodyEditor = {
on: vi.fn(),
off: vi.fn(),
state: { selection: { empty: true } },
isEditable: true,
doc: {
selection: {
current: vi.fn(() => ({
empty: true,
target: null,
activeMarks: [],
activeCommentIds: [],
activeChangeIds: [],
})),
},
},
};
(bodyEditor as unknown as { _presentationEditor: unknown })._presentationEditor = presentationEditor;
(bodyEditor as unknown as { presentationEditor: unknown }).presentationEditor = presentationEditor;

const superdoc = {
activeEditor: bodyEditor as never,
config: { documentMode: 'editing' as const },
on: vi.fn(),
off: vi.fn(),
};

const ui = createSuperDocUI({ superdoc });
teardown.push(() => ui.destroy());

const slice = ui.select((state) => state.selection).get();
expect(slice.target).toEqual({
kind: 'text',
segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }],
story: headerStory,
});
expect(slice.selectionTarget).toEqual({
kind: 'selection',
start: { kind: 'text', blockId: 'h1', offset: 0, story: headerStory },
end: { kind: 'text', blockId: 'h1', offset: 4, story: headerStory },
story: headerStory,
});
});

// SD-2954 regression: `resolveToolbarSources` resolves the
// PresentationEditor through three documented paths, the direct
// `activeEditor.presentationEditor` field, the legacy
// `activeEditor._presentationEditor` field, and the
// `superdocStore.documents[].getPresentationEditor()` lookup.
// `readActiveStoryLocator` reads the locator through the same
// pipeline so all three paths surface the active story. Reading
// `activeEditor.presentationEditor` directly would silently miss
// the latter two and the new selection slice would stay
// body-scoped on those mounts.
it('state.selection.target picks up the active story via the legacy _presentationEditor field', () => {
const headerStory = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId-legacy' };

const headerEditor = {
on: vi.fn(),
off: vi.fn(),
state: { selection: { empty: false } },
isEditable: true,
doc: {
selection: {
current: vi.fn(() => ({
empty: false,
text: 'header text',
target: { kind: 'text', segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }] },
activeMarks: [],
activeCommentIds: [],
activeChangeIds: [],
})),
},
},
};

const presentationEditor: Record<string, unknown> = {
on: vi.fn(),
off: vi.fn(),
isEditable: true,
state: { selection: { empty: false } },
getActiveEditor: vi.fn(() => headerEditor),
getActiveStoryLocator: vi.fn(() => headerStory),
commands: {},
};

// Mount only via the legacy `_presentationEditor` field. The new
// selection state must still pick up the active story.
const bodyEditor = {
on: vi.fn(),
off: vi.fn(),
state: { selection: { empty: true } },
isEditable: true,
doc: {
selection: {
current: vi.fn(() => ({
empty: true,
target: null,
activeMarks: [],
activeCommentIds: [],
activeChangeIds: [],
})),
},
},
};
(bodyEditor as unknown as { _presentationEditor: unknown })._presentationEditor = presentationEditor;

const superdoc = {
activeEditor: bodyEditor as never,
config: { documentMode: 'editing' as const },
on: vi.fn(),
off: vi.fn(),
};

const ui = createSuperDocUI({ superdoc });
teardown.push(() => ui.destroy());

const slice = ui.select((state) => state.selection).get();
expect(slice.target).toEqual({
kind: 'text',
segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }],
story: headerStory,
});
});

it('state.selection.selectionTarget is null when target is null', () => {
const superdoc = makeSuperdocStub();
(superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({
Expand Down
100 changes: 98 additions & 2 deletions packages/super-editor/src/ui/create-super-doc-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
} from '@superdoc/document-api';
import { collectEntityHitsFromChain } from './entity-at.js';
import { shallowEqual } from './equality.js';
import { resolvePositionAt } from './position-at.js';
import { shortcutFromEvent } from './keyboard-shortcuts.js';
import { scrollRangeIntoView } from './scroll-into-view.js';
import { getSelectionAnchorRect, getSelectionRects } from './selection-rects.js';
Expand Down Expand Up @@ -51,6 +52,8 @@ import type {
ViewportEntityAtInput,
ViewportEntityHit,
ViewportGetRectInput,
ViewportPositionAtInput,
ViewportPositionHit,
ViewportHandle,
ViewportRect,
ViewportRectResult,
Expand Down Expand Up @@ -299,6 +302,59 @@ function textTargetToSelectionTarget(
return story ? { kind: 'selection', start, end, story } : { kind: 'selection', start, end };
}

/**
* Reads the currently routed story from the host's PresentationEditor.
* Returns `null` when the body editor is active or when no presentation
* layer is reachable (older mounts, server-side stubs).
*
* Routes through `resolveToolbarSources` so all three documented
* presentation-resolution paths surface the locator: the direct
* `activeEditor.presentationEditor` field, the legacy
* `activeEditor._presentationEditor` field, and the
* `superdocStore.documents[].getPresentationEditor()` lookup that
* non-Vue mounts rely on. Reading `hostEditor.presentationEditor`
* directly would silently miss the latter two and the new selection
* slice would stay body-scoped on those setups.
*
* The selection-info resolver runs against the routed editor and has
* no path back to the host, so the controller stamps the locator onto
* the live TextTarget at the seam where both editors are reachable.
* Same shape SD-2943's `ui.viewport.positionAt` uses for the same
* reason: without it, downstream doc-api ops fall back to body and
* fail to locate the block.
*/
function readActiveStoryLocator(
superdoc: SuperDocUIOptions['superdoc'],
): import('@superdoc/document-api').StoryLocator | null {
let presentation: { getActiveStoryLocator?: () => unknown } | null = null;
try {
const sources = resolveToolbarSources(superdoc as never);
presentation = (sources.presentationEditor as never) ?? null;
} catch {
return null;
}
if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null;
try {
return (presentation.getActiveStoryLocator() ?? null) as import('@superdoc/document-api').StoryLocator | null;
} catch {
return null;
}
}

/**
* Stamp `story` onto a live TextTarget when the routed editor is a
* non-body story and the resolver didn't already attach it. Idempotent
* when `story` is already present (resolver-attached or otherwise).
*/
function attachStoryToTextTarget(
textTarget: import('@superdoc/document-api').TextTarget | null,
story: import('@superdoc/document-api').StoryLocator | null,
): import('@superdoc/document-api').TextTarget | null {
if (!textTarget || !story) return textTarget;
if ((textTarget as { story?: unknown }).story) return textTarget;
return { ...textTarget, story };
}

export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
const { superdoc } = options;

Expand Down Expand Up @@ -602,7 +658,20 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
// inside the resolver) keeps the slice identity stable and lets
// `shallowEqual` short-circuit `ui.select(s => s.selection)`
// subscribers.
const selectionTextTarget = (selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null;
// SD-2954: when the routed editor is a non-body story, stamp the
// active story locator onto the live TextTarget. The selection
// resolver runs against the routed editor and has no path back to
// the host's PresentationEditor, so the controller seam is the
// only place where both are reachable. Direct
// `editor.doc.selection.current()` calls are unaffected by design;
// a deeper adapter change would be a separate ticket.
const hostEditor = resolveHostEditor(superdoc);
const routedIsStory = editor != null && hostEditor != null && editor !== hostEditor;
const activeStory = routedIsStory ? readActiveStoryLocator(superdoc) : null;
const selectionTextTarget = attachStoryToTextTarget(
(selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null,
activeStory,
);
const selectionActiveMarks = (selectionInfo?.activeMarks ?? EMPTY_ACTIVE_IDS) as string[];
const selectionKey = buildSelectionKey(
empty,
Expand Down Expand Up @@ -1658,6 +1727,23 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
if (!startEl || !host.contains(startEl)) return [];
return collectEntityHitsFromChain(startEl);
},

getHost(): HTMLElement | null {
const editor = resolveHostEditor(superdoc);
return editor?.presentationEditor?.visibleHost ?? null;
},

positionAt(input: ViewportPositionAtInput): ViewportPositionHit | null {
if (!input || typeof input.x !== 'number' || typeof input.y !== 'number') return null;
const hostEditor = resolveHostEditor(superdoc);
const routedEditor = resolveRoutedEditor(superdoc);
return resolvePositionAt(
hostEditor as unknown as Parameters<typeof resolvePositionAt>[0],
routedEditor as unknown as Parameters<typeof resolvePositionAt>[1],
input.x,
input.y,
);
},
};

// ---- ui.selection ------------------------------------------------------
Expand Down Expand Up @@ -1741,8 +1827,18 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
// time, the routed editor is body and resolution returns
// `'stale'` rather than placing the selection on the wrong
// surface.
//
// Story locator (SD-2954): pre-resolved here so the helper
// doesn't have to repeat the presentation-editor lookup.
// `readActiveStoryLocator` routes through `resolveToolbarSources`
// and covers the direct, legacy `_presentationEditor`, and
// `superdocStore.documents[].getPresentationEditor()` paths
// uniformly.
const editor = resolveRoutedEditor(superdoc);
return restoreSelection(editor as unknown as Parameters<typeof restoreSelection>[0], capture);
const activeStory = readActiveStoryLocator(superdoc);
return restoreSelection(editor as unknown as Parameters<typeof restoreSelection>[0], capture, {
activeStory,
});
},
};

Expand Down
2 changes: 2 additions & 0 deletions packages/super-editor/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export type {
ViewportEntityHit,
ViewportGetRectInput,
ViewportHandle,
ViewportPositionAtInput,
ViewportPositionHit,
ViewportRect,
ViewportRectResult,

Expand Down
Loading
Loading