Skip to content

Commit 0132a97

Browse files
authored
feat(ui): story-aware selection state and capture/restore (SD-2954) (#3166)
* feat(ui): add ui.viewport.getHost and positionAt (SD-2943) Two primitives consumers building custom UI keep reaching for and not finding on the public surface: ui.viewport.getHost() returns the editor's painted host element so custom-UI components scope DOM listeners to the editor without a CSS class filter. The information already lives on presentationEditor.visibleHost; this lifts it onto the controller. ui.viewport.positionAt({ x, y }) resolves a viewport coordinate to a caret position on the routed editor's PM document, returning both the SelectionPoint and the SelectionTarget shapes so consumers can pass the result straight to editor.doc.insert / replace / etc. The natural pair to entityAt: while entityAt answers "what entity is under this point?", positionAt answers "what caret position is under this point?" — the missing primitive that lets right-click menus offer "Paste here" / "Insert at this point" honestly, instead of dispatching against the user's previous selection. Both methods scope to the controller's painted host: a multi-instance page can't have one controller's positionAt return positions in another's PM doc, and post-destroy calls return null. Tests cover the happy path, the no-editor-mounted case, and the missing-posAtCoords stub case. * fix(ui): canonical sdBlockId fallback + story scope on positionAt (SD-2943) readBlockId now uses the sdBlockId ?? id ?? blockId fallback the selection resolver already applies, so positionAt resolves paragraph clicks instead of returning null. Adds PresentationEditor.getActiveStoryLocator (unifies story-session and header/footer-session locators) and threads the result onto SelectionPoint.story / SelectionTarget.story so doc-api operations route to the active story instead of falling back to body. * feat(ui): story-aware selection state and capture/restore (SD-2954) The controller stamps the active story locator onto the live TextTarget when the routed editor is a header/footer/footnote/endnote, so state.selection.target / selectionTarget and ui.selection.capture() all carry the same routing information ui.viewport.positionAt got in SD-2943. ui.selection.restore now compares the captured story against the active surface and returns a typed 'stale' on mismatch instead of falling through to a less-specific resolver failure. Captures with no story keep the prior body/default behavior. The fix is scoped to the controller surface. Direct editor.doc.selection.current() calls still return body-scoped targets; threading story through the lower-level resolver is a separate change. * fix(ui): jsdoc placement + restore guard order (SD-2954) Move readActiveStoryLocator and attachStoryToTextTarget below textTargetToSelectionTarget so the existing JSDoc reattaches to its function (it was orphaned between two JSDoc blocks). Move the SD-2954 story-mismatch check after the isEditable / setTextSelection guards so a header capture restored against a viewing-mode editor still surfaces 'read-only', matching what body captures already see in the same condition. Adds a regression test covering the read-only + story-capture path. * fix(ui): resolve story locator via resolveToolbarSources for SD-2954 readActiveStoryLocator was reading hostEditor.presentationEditor directly, missing the legacy _presentationEditor field and the superdocStore.documents[].getPresentationEditor() lookup that resolveToolbarSources covers. Mounts using either fallback would still report body-scoped selection state and return 'stale' for valid story captures. Route the locator lookup through resolveToolbarSources so all three documented presentation-resolution paths surface the active story. Selection-restore drops its duplicate helper and accepts the pre-resolved locator from the controller, removing the separate code path. Adds a regression test covering the _presentationEditor fallback.
1 parent ef85846 commit 0132a97

4 files changed

Lines changed: 398 additions & 6 deletions

File tree

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

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,173 @@ describe('createSuperDocUI', () => {
552552
});
553553
});
554554

555+
// SD-2954: when the live selection resolver returns a TextTarget
556+
// without `story` (the resolver runs against the routed editor and
557+
// has no path back to the host's PresentationEditor), the
558+
// controller stamps the active story locator at the seam where
559+
// both editors are reachable. Without this stamping the live
560+
// selection slice carries body-scoped targets even when the user
561+
// is editing a header, and downstream doc-api ops route to body
562+
// and silently fail to find the block.
563+
it('state.selection.target gets the active story locator stamped when the resolver omits it', async () => {
564+
const headerStory = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' };
565+
566+
const headerEditor = {
567+
on: vi.fn(),
568+
off: vi.fn(),
569+
state: { selection: { empty: false } },
570+
isEditable: true,
571+
doc: {
572+
selection: {
573+
current: vi.fn(() => ({
574+
empty: false,
575+
text: 'header text',
576+
// Resolver returns no story field. Controller must stamp it.
577+
target: { kind: 'text', segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }] },
578+
activeMarks: [],
579+
activeCommentIds: [],
580+
activeChangeIds: [],
581+
})),
582+
},
583+
},
584+
};
585+
586+
const presentationEditor: Record<string, unknown> = {
587+
on: vi.fn(),
588+
off: vi.fn(),
589+
isEditable: true,
590+
state: { selection: { empty: false } },
591+
// Body editor is the host; routed editor is the header.
592+
getActiveEditor: vi.fn(() => headerEditor),
593+
getActiveStoryLocator: vi.fn(() => headerStory),
594+
commands: {},
595+
};
596+
597+
const bodyEditor = {
598+
on: vi.fn(),
599+
off: vi.fn(),
600+
state: { selection: { empty: true } },
601+
isEditable: true,
602+
doc: {
603+
selection: {
604+
current: vi.fn(() => ({
605+
empty: true,
606+
target: null,
607+
activeMarks: [],
608+
activeCommentIds: [],
609+
activeChangeIds: [],
610+
})),
611+
},
612+
},
613+
};
614+
(bodyEditor as unknown as { _presentationEditor: unknown })._presentationEditor = presentationEditor;
615+
(bodyEditor as unknown as { presentationEditor: unknown }).presentationEditor = presentationEditor;
616+
617+
const superdoc = {
618+
activeEditor: bodyEditor as never,
619+
config: { documentMode: 'editing' as const },
620+
on: vi.fn(),
621+
off: vi.fn(),
622+
};
623+
624+
const ui = createSuperDocUI({ superdoc });
625+
teardown.push(() => ui.destroy());
626+
627+
const slice = ui.select((state) => state.selection).get();
628+
expect(slice.target).toEqual({
629+
kind: 'text',
630+
segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }],
631+
story: headerStory,
632+
});
633+
expect(slice.selectionTarget).toEqual({
634+
kind: 'selection',
635+
start: { kind: 'text', blockId: 'h1', offset: 0, story: headerStory },
636+
end: { kind: 'text', blockId: 'h1', offset: 4, story: headerStory },
637+
story: headerStory,
638+
});
639+
});
640+
641+
// SD-2954 regression: `resolveToolbarSources` resolves the
642+
// PresentationEditor through three documented paths, the direct
643+
// `activeEditor.presentationEditor` field, the legacy
644+
// `activeEditor._presentationEditor` field, and the
645+
// `superdocStore.documents[].getPresentationEditor()` lookup.
646+
// `readActiveStoryLocator` reads the locator through the same
647+
// pipeline so all three paths surface the active story. Reading
648+
// `activeEditor.presentationEditor` directly would silently miss
649+
// the latter two and the new selection slice would stay
650+
// body-scoped on those mounts.
651+
it('state.selection.target picks up the active story via the legacy _presentationEditor field', () => {
652+
const headerStory = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId-legacy' };
653+
654+
const headerEditor = {
655+
on: vi.fn(),
656+
off: vi.fn(),
657+
state: { selection: { empty: false } },
658+
isEditable: true,
659+
doc: {
660+
selection: {
661+
current: vi.fn(() => ({
662+
empty: false,
663+
text: 'header text',
664+
target: { kind: 'text', segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }] },
665+
activeMarks: [],
666+
activeCommentIds: [],
667+
activeChangeIds: [],
668+
})),
669+
},
670+
},
671+
};
672+
673+
const presentationEditor: Record<string, unknown> = {
674+
on: vi.fn(),
675+
off: vi.fn(),
676+
isEditable: true,
677+
state: { selection: { empty: false } },
678+
getActiveEditor: vi.fn(() => headerEditor),
679+
getActiveStoryLocator: vi.fn(() => headerStory),
680+
commands: {},
681+
};
682+
683+
// Mount only via the legacy `_presentationEditor` field. The new
684+
// selection state must still pick up the active story.
685+
const bodyEditor = {
686+
on: vi.fn(),
687+
off: vi.fn(),
688+
state: { selection: { empty: true } },
689+
isEditable: true,
690+
doc: {
691+
selection: {
692+
current: vi.fn(() => ({
693+
empty: true,
694+
target: null,
695+
activeMarks: [],
696+
activeCommentIds: [],
697+
activeChangeIds: [],
698+
})),
699+
},
700+
},
701+
};
702+
(bodyEditor as unknown as { _presentationEditor: unknown })._presentationEditor = presentationEditor;
703+
704+
const superdoc = {
705+
activeEditor: bodyEditor as never,
706+
config: { documentMode: 'editing' as const },
707+
on: vi.fn(),
708+
off: vi.fn(),
709+
};
710+
711+
const ui = createSuperDocUI({ superdoc });
712+
teardown.push(() => ui.destroy());
713+
714+
const slice = ui.select((state) => state.selection).get();
715+
expect(slice.target).toEqual({
716+
kind: 'text',
717+
segments: [{ blockId: 'h1', range: { start: 0, end: 4 } }],
718+
story: headerStory,
719+
});
720+
});
721+
555722
it('state.selection.selectionTarget is null when target is null', () => {
556723
const superdoc = makeSuperdocStub();
557724
(superdoc.activeEditor as { doc: { selection: { current: unknown } } }).doc.selection.current = vi.fn(() => ({

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

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,59 @@ function textTargetToSelectionTarget(
302302
return story ? { kind: 'selection', start, end, story } : { kind: 'selection', start, end };
303303
}
304304

305+
/**
306+
* Reads the currently routed story from the host's PresentationEditor.
307+
* Returns `null` when the body editor is active or when no presentation
308+
* layer is reachable (older mounts, server-side stubs).
309+
*
310+
* Routes through `resolveToolbarSources` so all three documented
311+
* presentation-resolution paths surface the locator: the direct
312+
* `activeEditor.presentationEditor` field, the legacy
313+
* `activeEditor._presentationEditor` field, and the
314+
* `superdocStore.documents[].getPresentationEditor()` lookup that
315+
* non-Vue mounts rely on. Reading `hostEditor.presentationEditor`
316+
* directly would silently miss the latter two and the new selection
317+
* slice would stay body-scoped on those setups.
318+
*
319+
* The selection-info resolver runs against the routed editor and has
320+
* no path back to the host, so the controller stamps the locator onto
321+
* the live TextTarget at the seam where both editors are reachable.
322+
* Same shape SD-2943's `ui.viewport.positionAt` uses for the same
323+
* reason: without it, downstream doc-api ops fall back to body and
324+
* fail to locate the block.
325+
*/
326+
function readActiveStoryLocator(
327+
superdoc: SuperDocUIOptions['superdoc'],
328+
): import('@superdoc/document-api').StoryLocator | null {
329+
let presentation: { getActiveStoryLocator?: () => unknown } | null = null;
330+
try {
331+
const sources = resolveToolbarSources(superdoc as never);
332+
presentation = (sources.presentationEditor as never) ?? null;
333+
} catch {
334+
return null;
335+
}
336+
if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null;
337+
try {
338+
return (presentation.getActiveStoryLocator() ?? null) as import('@superdoc/document-api').StoryLocator | null;
339+
} catch {
340+
return null;
341+
}
342+
}
343+
344+
/**
345+
* Stamp `story` onto a live TextTarget when the routed editor is a
346+
* non-body story and the resolver didn't already attach it. Idempotent
347+
* when `story` is already present (resolver-attached or otherwise).
348+
*/
349+
function attachStoryToTextTarget(
350+
textTarget: import('@superdoc/document-api').TextTarget | null,
351+
story: import('@superdoc/document-api').StoryLocator | null,
352+
): import('@superdoc/document-api').TextTarget | null {
353+
if (!textTarget || !story) return textTarget;
354+
if ((textTarget as { story?: unknown }).story) return textTarget;
355+
return { ...textTarget, story };
356+
}
357+
305358
export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
306359
const { superdoc } = options;
307360

@@ -605,7 +658,20 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
605658
// inside the resolver) keeps the slice identity stable and lets
606659
// `shallowEqual` short-circuit `ui.select(s => s.selection)`
607660
// subscribers.
608-
const selectionTextTarget = (selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null;
661+
// SD-2954: when the routed editor is a non-body story, stamp the
662+
// active story locator onto the live TextTarget. The selection
663+
// resolver runs against the routed editor and has no path back to
664+
// the host's PresentationEditor, so the controller seam is the
665+
// only place where both are reachable. Direct
666+
// `editor.doc.selection.current()` calls are unaffected by design;
667+
// a deeper adapter change would be a separate ticket.
668+
const hostEditor = resolveHostEditor(superdoc);
669+
const routedIsStory = editor != null && hostEditor != null && editor !== hostEditor;
670+
const activeStory = routedIsStory ? readActiveStoryLocator(superdoc) : null;
671+
const selectionTextTarget = attachStoryToTextTarget(
672+
(selectionInfo?.target ?? null) as import('@superdoc/document-api').TextTarget | null,
673+
activeStory,
674+
);
609675
const selectionActiveMarks = (selectionInfo?.activeMarks ?? EMPTY_ACTIVE_IDS) as string[];
610676
const selectionKey = buildSelectionKey(
611677
empty,
@@ -1761,8 +1827,18 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
17611827
// time, the routed editor is body and resolution returns
17621828
// `'stale'` rather than placing the selection on the wrong
17631829
// surface.
1830+
//
1831+
// Story locator (SD-2954): pre-resolved here so the helper
1832+
// doesn't have to repeat the presentation-editor lookup.
1833+
// `readActiveStoryLocator` routes through `resolveToolbarSources`
1834+
// and covers the direct, legacy `_presentationEditor`, and
1835+
// `superdocStore.documents[].getPresentationEditor()` paths
1836+
// uniformly.
17641837
const editor = resolveRoutedEditor(superdoc);
1765-
return restoreSelection(editor as unknown as Parameters<typeof restoreSelection>[0], capture);
1838+
const activeStory = readActiveStoryLocator(superdoc);
1839+
return restoreSelection(editor as unknown as Parameters<typeof restoreSelection>[0], capture, {
1840+
activeStory,
1841+
});
17661842
},
17671843
};
17681844

0 commit comments

Comments
 (0)