diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 158a7851c6..ebd67cab4f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -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. * diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index 8a3a5c2877..ab2a938b69 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -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'; @@ -51,6 +52,8 @@ import type { ViewportEntityAtInput, ViewportEntityHit, ViewportGetRectInput, + ViewportPositionAtInput, + ViewportPositionHit, ViewportHandle, ViewportRect, ViewportRectResult, @@ -1658,6 +1661,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[0], + routedEditor as unknown as Parameters[1], + input.x, + input.y, + ); + }, }; // ---- ui.selection ------------------------------------------------------ diff --git a/packages/super-editor/src/ui/index.ts b/packages/super-editor/src/ui/index.ts index 84447bb5b1..e9a34bc72f 100644 --- a/packages/super-editor/src/ui/index.ts +++ b/packages/super-editor/src/ui/index.ts @@ -128,6 +128,8 @@ export type { ViewportEntityHit, ViewportGetRectInput, ViewportHandle, + ViewportPositionAtInput, + ViewportPositionHit, ViewportRect, ViewportRectResult, diff --git a/packages/super-editor/src/ui/position-at.ts b/packages/super-editor/src/ui/position-at.ts new file mode 100644 index 0000000000..e5924a722d --- /dev/null +++ b/packages/super-editor/src/ui/position-at.ts @@ -0,0 +1,131 @@ +/** + * `ui.viewport.positionAt({ x, y })` helper. Resolves a viewport + * coordinate to a {@link SelectionPoint} / {@link SelectionTarget} + * pair on the routed editor's PM document. 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 + * actions like "Paste here" / "Insert clause at this point" without + * dispatching against the user's previous selection. + */ + +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import type { SelectionPoint, SelectionTarget, StoryLocator } from '@superdoc/document-api'; +import type { Editor } from '../editors/v1/core/Editor.js'; +import { pmPositionToTextOffset } from '../editors/v1/document-api-adapters/helpers/text-offset-resolver.js'; +import type { ViewportPositionHit } from './types.js'; + +interface HostEditor { + presentationEditor?: { + posAtCoords?(coords: { clientX: number; clientY: number }): { pos: number; inside: number } | null; + visibleHost?: HTMLElement; + getActiveStoryLocator?(): StoryLocator | null; + } | null; +} + +/** + * Resolve a viewport (x, y) coordinate to the caret position under + * that point. Returns `null` for points outside the painted host or + * when no editor is mounted. + * + * `hostEditor` is the controller's host editor (where the painted + * host and the coord-to-position helper live). `routedEditor` is the + * editor whose PM document the caret belongs to — for clicks inside a + * header/footer/footnote story while focus is in that surface, the + * routed editor is the story editor; otherwise it is the host. + */ +export function resolvePositionAt( + hostEditor: (Editor & HostEditor) | null, + routedEditor: Editor | null, + x: number, + y: number, +): ViewportPositionHit | null { + if (!hostEditor || !routedEditor) return null; + const presentation = hostEditor.presentationEditor; + if (!presentation || typeof presentation.posAtCoords !== 'function') return null; + + // Scope to this controller's painted host. `posAtCoords` itself is + // already painter-scoped (returns null for points outside the + // layout), but checking the host upfront also avoids running the + // coord lookup for clicks that obviously aren't ours, e.g. on a + // sidebar that happens to overlap the painted page. + const host = presentation.visibleHost; + if (host && typeof document !== 'undefined') { + const elAtPoint = document.elementFromPoint?.(x, y); + if (elAtPoint && !host.contains(elAtPoint)) return null; + } + + let result: { pos: number; inside: number } | null = null; + try { + result = presentation.posAtCoords({ clientX: x, clientY: y }); + } catch { + return null; + } + if (!result) return null; + + const block = findContainingTextBlock(routedEditor.state?.doc as ProseMirrorNode | undefined, result.pos); + if (!block) return null; + + const offset = pmPositionToTextOffset(block.node, block.pos, result.pos); + // When the routed editor is a story (header/footer/note), the blockId + // resolves against the story's PM doc. Without `story`, downstream + // doc-api ops (`insert`, `replace`, etc.) default to body and fail to + // locate the block. Mirrors the locator the host editor would use when + // routing operations to the active story. + const story = readActiveStoryLocator(hostEditor); + const point: SelectionPoint = story + ? { kind: 'text', blockId: block.blockId, offset, story } + : { kind: 'text', blockId: block.blockId, offset }; + const target: SelectionTarget = story + ? { kind: 'selection', start: point, end: point, story } + : { kind: 'selection', start: point, end: point }; + return { point, target }; +} + +function readActiveStoryLocator(hostEditor: Editor & HostEditor): StoryLocator | null { + const presentation = hostEditor.presentationEditor; + if (!presentation || typeof presentation.getActiveStoryLocator !== 'function') return null; + try { + return presentation.getActiveStoryLocator() ?? null; + } catch { + return null; + } +} + +interface BlockMatch { + node: ProseMirrorNode; + pos: number; + blockId: string; +} + +/** + * Walk the doc to find the textblock that contains `pmPos`. Same + * shape `collectTextSegments` uses for selections, but specialized + * for a single point so we don't allocate a segments array. + */ +function findContainingTextBlock(doc: ProseMirrorNode | undefined, pmPos: number): BlockMatch | null { + if (!doc) return null; + let match: BlockMatch | null = null; + doc.descendants((node, pos) => { + if (match) return false; + if (!node.isTextblock) return true; + const blockStart = pos; + const blockEnd = pos + node.nodeSize; + if (pmPos < blockStart || pmPos > blockEnd) return false; + const blockId = readBlockId(node); + if (!blockId) return false; + match = { node, pos, blockId }; + return false; + }); + return match; +} + +function readBlockId(node: ProseMirrorNode): string | null { + // Match the canonical fallback used by `selection-info-resolver.ts`: + // paragraphs (the most common textblock) only set `sdBlockId`; reading + // `attrs.id` alone returns null for every paragraph and silently + // bricks `positionAt` for the bulk of click targets. + const attrs = (node.attrs ?? {}) as Record; + const id = attrs.sdBlockId ?? attrs.id ?? attrs.blockId; + return typeof id === 'string' && id.length > 0 ? id : null; +} diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index aea60fe988..5e04f9de85 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -177,6 +177,21 @@ export interface SuperDocEditorLike { * from the wrong instance. */ visibleHost?: HTMLElement; + /** + * Coordinate-to-position helper. Consumed by + * `ui.viewport.positionAt` to resolve a viewport `(x, y)` to a + * caret position in the editor's PM document. + */ + posAtCoords?(coords: { clientX: number; clientY: number }): { pos: number; inside: number } | null; + /** + * The story locator for the routed editor when the user is + * inside a header/footer/footnote/endnote, or `null` when the body + * editor is active. `ui.viewport.positionAt` threads this onto the + * returned `SelectionPoint` / `SelectionTarget` so consumers passing + * the target to `editor.doc.insert` / `replace` route to the right + * story instead of falling back to body. + */ + getActiveStoryLocator?(): import('@superdoc/document-api').StoryLocator | null; } | null; } @@ -1574,6 +1589,77 @@ export interface ViewportHandle { * compatible. */ entityAt(input: ViewportEntityAtInput): ViewportEntityHit[]; + /** + * The painted-DOM host element for this controller's editor, or + * `null` when no editor is mounted (SSR, post-destroy, before + * `onReady` fires). + * + * Custom UI consumers reach for the host element to scope their + * own DOM listeners — `contextmenu`, hover tooltips, drag-and-drop + * — to events that originate inside the editor. Without this, + * consumers either listen on `document` and filter by a CSS class + * they control (fragile, breaks when the wrapper class is renamed) + * or pass the editor's container down through their own component + * tree (verbose). + * + * The returned element is the host SuperDoc paints into. The + * editor's hidden ProseMirror DOM is appended elsewhere and is not + * inside this host — events whose target is in the hidden PM DOM + * (most keyboard events after focus moves into the editor) won't + * pass `host.contains(target)` checks. For coordinate-based hit + * tests use {@link entityAt} or {@link positionAt} instead, both of + * which scope correctly across painted-DOM and hidden-DOM events. + */ + getHost(): HTMLElement | null; + /** + * Resolve a viewport coordinate to a position in the editor's + * document, or `null` when the point is outside the painted host or + * no editor is mounted. + * + * The natural pair to {@link entityAt}: while `entityAt` answers + * "what entity is under this point?", `positionAt` answers "what + * caret position is under this point?". Right-click menus offering + * "Paste here", "Insert clause at this point", or "Add comment at + * this point" need this to dispatch their action against the click + * coordinate rather than the user's previous selection somewhere + * else in the document. + * + * Returns a {@link ViewportPositionHit} with both the resolved + * `point` (a `SelectionPoint` consumers can pass straight to + * `editor.doc.insert({ target })` and similar APIs) and the + * `target` (a `SelectionTarget` for selection-shaped operations). + * The two shapes are derived from the same underlying position, + * just packaged differently to match the doc-api method that's + * about to consume them. + */ + positionAt(input: ViewportPositionAtInput): ViewportPositionHit | null; +} + +/** + * Input shape for {@link ViewportHandle.positionAt}. Same coordinate + * space as `MouseEvent.clientX` / `clientY` and {@link ViewportRect}. + */ +export interface ViewportPositionAtInput { + x: number; + y: number; +} + +/** + * Resolved caret position returned by {@link ViewportHandle.positionAt}. + * + * `point` is the {@link import('@superdoc/document-api').SelectionPoint} + * shape used by point-anchored doc-api operations (`editor.doc.insert( + * { target: { kind: 'selection', start: point, end: point } })` for a + * collapsed insert at the click site). + * + * `target` is the equivalent {@link import('@superdoc/document-api').SelectionTarget} + * — a collapsed selection at the click point — for operations that + * accept a target shape directly. Same underlying position, two + * packagings; consumers pick the shape their downstream call needs. + */ +export interface ViewportPositionHit { + point: import('@superdoc/document-api').SelectionPoint; + target: import('@superdoc/document-api').SelectionTarget; } /** diff --git a/packages/super-editor/src/ui/viewport.test.ts b/packages/super-editor/src/ui/viewport.test.ts index 2d71ff3a34..63ba51e601 100644 --- a/packages/super-editor/src/ui/viewport.test.ts +++ b/packages/super-editor/src/ui/viewport.test.ts @@ -362,3 +362,161 @@ describe('ui.viewport.entityAt — host scoping', () => { ui.destroy(); }); }); + +describe('ui.viewport.getHost', () => { + it('returns the painted host element when one is mounted', () => { + const { superdoc } = makeStubs(); + const host = document.createElement('div'); + document.body.appendChild(host); + ( + superdoc.activeEditor as unknown as { presentationEditor: { visibleHost: HTMLElement } } + ).presentationEditor.visibleHost = host; + + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.getHost()).toBe(host); + + host.remove(); + ui.destroy(); + }); + + it('returns null when no editor is mounted', () => { + const { superdoc } = makeStubs(); + (superdoc.activeEditor as unknown as { presentationEditor: unknown }).presentationEditor = undefined; + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.getHost()).toBeNull(); + ui.destroy(); + }); +}); + +describe('ui.viewport.positionAt — input validation', () => { + it('returns null for invalid input (missing or non-numeric coordinates)', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + + expect(ui.viewport.positionAt({} as never)).toBeNull(); + expect(ui.viewport.positionAt({ x: 'a', y: 0 } as never)).toBeNull(); + + ui.destroy(); + }); + + it('returns null when posAtCoords is missing on the presentation stub', () => { + const { superdoc } = makeStubs(); + const ui = createSuperDocUI({ superdoc }); + expect(ui.viewport.positionAt({ x: 10, y: 10 })).toBeNull(); + ui.destroy(); + }); +}); + +describe('ui.viewport.positionAt - resolution', () => { + // Minimal PM-shaped textblock: a single paragraph carrying its block + // id under `sdBlockId` (the canonical attr the importer assigns to + // paragraphs). `id` and `blockId` are intentionally absent so the + // test catches a regression of the `attrs.id`-only readBlockId. + function makeDocWithParagraphAtSdBlockId(blockId: string, content: string) { + const text = { isText: true, isTextblock: false, text: content, nodeSize: content.length, attrs: {} }; + const paragraph = { + isText: false, + isTextblock: true, + nodeSize: content.length + 2, // open + content + close + attrs: { sdBlockId: blockId }, + content, + }; + const doc = { + descendants(callback: (node: unknown, pos: number) => boolean | void) { + // Walk: paragraph at pos=0, then its text child (skipped because + // !isTextblock returns true to descend, but we don't model the + // text child since findContainingTextBlock matches the textblock + // itself). + callback(paragraph, 0); + }, + }; + return { doc, paragraph, text }; + } + + function buildEditorStub( + doc: unknown, + posAtCoordsResult: { pos: number; inside: number } | null, + extras: { storyLocator?: unknown; visibleHost?: HTMLElement } = {}, + ) { + const editor: { + on: ReturnType; + off: ReturnType; + state: { doc: unknown }; + doc: unknown; + presentationEditor: { + getActiveEditor: () => unknown; + posAtCoords: (coords: { clientX: number; clientY: number }) => { pos: number; inside: number } | null; + visibleHost?: HTMLElement; + getActiveStoryLocator?: () => unknown; + }; + } = { + on: vi.fn(), + off: vi.fn(), + state: { doc }, + doc: { + selection: { current: vi.fn(() => ({ empty: true })) }, + comments: { + list: vi.fn(() => ({ + evaluatedRevision: 'r1', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + })), + }, + trackChanges: { + list: vi.fn(() => ({ + evaluatedRevision: 'r1', + total: 0, + items: [], + page: { limit: 0, offset: 0, returned: 0 }, + })), + }, + }, + presentationEditor: { + getActiveEditor: () => editor, + posAtCoords: vi.fn(() => posAtCoordsResult), + visibleHost: extras.visibleHost, + getActiveStoryLocator: extras.storyLocator !== undefined ? vi.fn(() => extras.storyLocator) : undefined, + }, + }; + return editor; + } + + it('resolves a paragraph whose block id is stored on `sdBlockId` (not `id`)', () => { + const { doc } = makeDocWithParagraphAtSdBlockId('p-42', 'hello'); + const editor = buildEditorStub(doc, { pos: 3, inside: 0 }); // pos inside paragraph + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + }; + const ui = createSuperDocUI({ superdoc }); + + const hit = ui.viewport.positionAt({ x: 10, y: 10 }); + expect(hit).not.toBeNull(); + expect(hit?.point.kind).toBe('text'); + expect((hit?.point as { blockId: string }).blockId).toBe('p-42'); + expect(hit?.target.kind).toBe('selection'); + expect((hit?.target.start as { blockId: string }).blockId).toBe('p-42'); + + ui.destroy(); + }); + + it('threads the active story locator onto the returned point and target', () => { + const { doc } = makeDocWithParagraphAtSdBlockId('hf-block-1', 'header'); + const storyLocator = { kind: 'story', storyType: 'headerFooterPart', refId: 'rId7' } as const; + const editor = buildEditorStub(doc, { pos: 4, inside: 0 }, { storyLocator }); + const superdoc: SuperDocLike = { + activeEditor: editor as never, + config: { documentMode: 'editing' }, + }; + const ui = createSuperDocUI({ superdoc }); + + const hit = ui.viewport.positionAt({ x: 10, y: 10 }); + expect(hit).not.toBeNull(); + expect(hit?.point).toMatchObject({ blockId: 'hf-block-1', story: storyLocator }); + expect(hit?.target).toMatchObject({ story: storyLocator }); + expect((hit?.target.start as { story?: unknown }).story).toEqual(storyLocator); + + ui.destroy(); + }); +}); diff --git a/packages/superdoc/src/ui.d.ts b/packages/superdoc/src/ui.d.ts index f64426a6ec..021e75a1f9 100644 --- a/packages/superdoc/src/ui.d.ts +++ b/packages/superdoc/src/ui.d.ts @@ -59,6 +59,8 @@ export { type ViewportEntityHit, type ViewportGetRectInput, type ViewportHandle, + type ViewportPositionAtInput, + type ViewportPositionHit, type ViewportRect, type ViewportRectResult, } from '@superdoc/super-editor/ui';