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 @@ -2159,6 +2159,22 @@ export class PresentationEditor extends EventEmitter {
return this.#computeRangeRects(from, to, relativeTo);
}

/**
* Like {@link getRangeRects} but pins the body surface, ignoring any
* active header/footer/note session. Used by `ui.viewport.getRect`'s
* text-target path (SD-3329): a body-anchored target must return body
* geometry even while the user is editing a header/footer, where
* `getRangeRects` would otherwise route to the active non-body surface.
*
* @param from - Start position in the body ProseMirror document
* @param to - End position in the body ProseMirror document
* @param relativeTo - Optional element for coordinate reference (see {@link getRangeRects})
* @returns Array of body-surface rects (pageIndex + position data)
*/
getBodyRangeRects(from: number, to: number, relativeTo?: HTMLElement): RangeRect[] {
return this.#computeRangeRects(from, to, relativeTo, { forceBodySurface: true });
}

/**
* Get selection bounds for a document range with aggregated bounding box.
* Returns null if layout is unavailable or the range is invalid.
Expand Down
85 changes: 79 additions & 6 deletions packages/super-editor/src/ui/create-super-doc-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { scrollRangeIntoView } from './scroll-into-view.js';
import { getSelectionAnchorRect, getSelectionRects } from './selection-rects.js';
import { restoreSelection } from './selection-restore.js';
import { createCustomCommandsRegistry } from './custom-commands.js';
import { resolveTextTarget } from '../editors/v1/document-api-adapters/helpers/adapter-utils.js';
import { createScope } from './scope.js';
import type {
CommandHandle,
Expand Down Expand Up @@ -1950,6 +1951,71 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
pageIndex: rect.pageIndex,
});

// Text-target branch for `ui.viewport.getRect` (SD-3329). Resolves a
// Document API `TextAddress` (one block) or `TextTarget` (segments) to
// PM positions via `resolveTextTarget`, then to painted body rects via
// `getBodyRangeRects`. Body story only: a non-body `story` returns
// `unresolved` (valid target, story-aware text rects are a follow-up)
// rather than `invalid-target` (a caller-shape error). Multi-segment
// targets produce per-segment rects concatenated in document order, not
// a collapsed first→last range, so discontinuous / imported spans paint
// correctly.
const resolveTextTargetRects = (
hostEditor: SuperDocEditorLike,
presentation: NonNullable<SuperDocEditorLike['presentationEditor']>,
target: { story?: unknown; blockId?: unknown; range?: unknown; segments?: unknown },
): ViewportRectResult => {
const story = target.story as { storyType?: unknown } | undefined;
if (story && story.storyType !== undefined && story.storyType !== 'body') {
return { success: false, reason: 'unresolved' };
}
if (typeof presentation.getBodyRangeRects !== 'function') {
return { success: false, reason: 'not-ready' };
}
const segments = Array.isArray(target.segments)
? (target.segments as Array<{ blockId?: unknown; range?: unknown }>)
: [{ blockId: target.blockId, range: target.range }];
if (segments.length === 0) return { success: false, reason: 'invalid-target' };

const rects: ViewportRect[] = [];
for (const seg of segments) {
const blockId = seg?.blockId;
const range = seg?.range as { start?: unknown; end?: unknown } | undefined;
if (
typeof blockId !== 'string' ||
!blockId ||
!range ||
typeof range.start !== 'number' ||
typeof range.end !== 'number'
) {
return { success: false, reason: 'invalid-target' };
}
let resolved: { from: number; to: number } | null;
try {
resolved = resolveTextTarget(hostEditor as unknown as Parameters<typeof resolveTextTarget>[0], {
kind: 'text',
blockId,
range: { start: range.start, end: range.end },
});
} catch {
// `resolveTextTarget` throws on ambiguous block ids (and other
// adapter/target errors). A public geometry read must never throw
// out — surface it as a structured failure. The target shape is
// valid; it just can't be resolved to a unique range.
return { success: false, reason: 'unresolved' };
}
if (!resolved) return { success: false, reason: 'unresolved' };
for (const r of presentation.getBodyRangeRects(resolved.from, resolved.to)) {
rects.push(toViewportRect(r));
}
}
// All segments resolved to model positions but nothing is painted —
// the page/story is virtualized or offscreen (same posture as the
// entity path's `not-mounted`).
if (rects.length === 0) return { success: false, reason: 'not-mounted' };
return { success: true, rect: rects[0], rects, pageIndex: rects[0].pageIndex };
};

const viewport: ViewportHandle = {
getRect(input: ViewportGetRectInput): ViewportRectResult {
const target = input?.target;
Expand All @@ -1971,12 +2037,19 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI {
return { success: false, reason: 'not-ready' };
}

// Entity-anchored path. Text-anchored paths are deferred — the
// resolver needs story-aware routing through the active routed
// editor (header/footer/note vs body) to avoid silently reading
// body coords for a non-body target. Until that lands, surface
// an explicit `invalid-target` so consumers don't quietly get
// wrong rects.
// Text-anchored path (SD-3329): a `TextAddress` / `TextTarget`
// resolves to painted body rects. Body story only for now; non-body
// text targets return `unresolved` (story-aware text rects are a
// follow-up). Entity targets stay story-aware below.
if ('kind' in target && (target as { kind?: unknown }).kind === 'text') {
return resolveTextTargetRects(
editor,
presentation,
target as { story?: unknown; blockId?: unknown; range?: unknown; segments?: unknown },
);
}

// Entity-anchored path. Any other `kind` is a caller-shape error.
if (!('kind' in target) || (target as { kind?: unknown }).kind !== 'entity') {
return { success: false, reason: 'invalid-target' };
}
Expand Down
48 changes: 37 additions & 11 deletions packages/super-editor/src/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,25 @@ export interface SuperDocEditorLike {
width: number;
height: number;
}>;
/**
* Body-surface variant of `getRangeRects`, consumed by
* `ui.viewport.getRect`'s text-target path so a body-anchored target
* returns body geometry even while a header/footer/note session is
* active. Optional in the structural typing for stub validity.
*/
getBodyRangeRects?(
from: number,
to: number,
relativeTo?: HTMLElement,
): Array<{
pageIndex: number;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
}>;
/**
* Painted-DOM host element. `ui.viewport.entityAt` reads it to
* confirm the hit returned by `document.elementFromPoint` lives
Expand Down Expand Up @@ -1797,17 +1816,24 @@ export type ViewportEntityAddress = import('@superdoc/document-api').EntityAddre

export interface ViewportGetRectInput {
/**
* Entity to look up — comment, tracked change, or content control
* (SDT) by id. Today `getRect` resolves rects via the painter's
* data attributes (`data-comment-ids`, `data-track-change-id`,
* `data-sdt-id`) which only stamp entity addresses, not
* text-anchored ranges. Text targets (`TextAddress` / `TextTarget`)
* are intentionally not in the union: surface should match real
* behavior so a typed call site isn't lying about what works at
* runtime. They land via a follow-up that adds story-aware text
* resolution to the rect helper.
*/
target: ViewportEntityAddress;
* What to measure. Either:
* - an entity address (comment / tracked change / content control by
* id), resolved via the painter's data attributes; or
* - a Document API text address/target (`TextAddress` single block, or
* `TextTarget` multi-segment), resolved to document positions and
* then to painted rects. A `TextTarget` yields one set of rects per
* segment, concatenated in document order.
*
* Text targets are **body-story only** today: a target carrying a
* non-body `story` (header/footer/footnote/endnote) returns
* `{ success: false, reason: 'unresolved' }`. Story-aware text rects
* are a follow-up. Entity targets remain story-aware via their
* `story` field.
*/
target:
| ViewportEntityAddress
| import('@superdoc/document-api').TextAddress
| import('@superdoc/document-api').TextTarget;
}

export type ViewportRectResult =
Expand Down
Loading
Loading