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
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
20 changes: 20 additions & 0 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 @@ -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<typeof resolvePositionAt>[0],
routedEditor as unknown as Parameters<typeof resolvePositionAt>[1],
input.x,
input.y,
);
},
};

// ---- ui.selection ------------------------------------------------------
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
131 changes: 131 additions & 0 deletions packages/super-editor/src/ui/position-at.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const id = attrs.sdBlockId ?? attrs.id ?? attrs.blockId;
return typeof id === 'string' && id.length > 0 ? id : null;
}
86 changes: 86 additions & 0 deletions packages/super-editor/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading