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 @@ -47,6 +47,11 @@ import { createLayoutMetrics as createLayoutMetricsFromHelper } from './layout/P
import { buildFootnotesInput, type NoteRenderOverride } from './layout/FootnotesBuilder.js';
import { safeCleanup } from './utils/SafeCleanup.js';
import { createHiddenHost } from './dom/HiddenHost.js';
import {
elementsToRangeRects,
findRenderedCommentElements,
findRenderedTrackedChangeElementsStrict,
} from './dom/EntityRectFinder.js';
import { RemoteCursorManager, type RenderDependencies } from './remote-cursors/RemoteCursorManager.js';
import { EditorInputManager } from './pointer-events/EditorInputManager.js';
import { SelectionSyncCoordinator } from './selection/SelectionSyncCoordinator.js';
Expand Down Expand Up @@ -2120,6 +2125,56 @@ export class PresentationEditor extends EventEmitter {
};
}

/**
* Viewport-coords rect lookup for an entity (comment / tracked
* change) painted in the editor surface. Drives the
* `superdoc/ui` `ui.viewport.getRect` substrate so consumers can
* pin sticky cards / floating toolbars next to inline highlights
* without reaching into DOM, PM positions, or painter selectors.
*
* Returns plain value rects (not live `DOMRect`) in viewport
* coordinates. An empty array means the entity isn't currently
* painted — virtualized page, story not active, or id not present
* in the document. Callers can choose to scroll first then retry,
* or render the card detached.
*
* @param target - The entity to locate. `entityType` is one of
* `'comment'` or `'trackedChange'`. `story` is
* optional; when provided, results are filtered to
* that story so an id that exists in body and a
* footer doesn't return rects from both.
*/
getEntityRects(target: { entityType?: unknown; entityId?: unknown; story?: unknown }): RangeRect[] {
if (!target || typeof target !== 'object') return [];
const entityType = target.entityType;
const entityId = target.entityId;
if (typeof entityType !== 'string' || typeof entityId !== 'string' || entityId.length === 0) {
return [];
}
const host = this.#visibleHost;
if (!host) return [];
const storyKey = resolveStoryKeyFromAddress(target.story);
let elements: HTMLElement[];
if (entityType === 'trackedChange') {
// Use a strict story filter for the viewport read path. The
// navigation helper `#findRenderedTrackedChangeElements` falls
// back to all same-id matches when no exact story match wins a
// heuristic — that's correct for "scroll to this change", but
// wrong here: a sticky card asked to anchor a header/footer
// change must not silently anchor to a body copy of the same
// id. Empty result when the requested story has no painted copy
// is the correct signal — the UI controller maps it to
// `not-mounted` so the consumer can pre-mount via
// `viewport.scrollIntoView` and retry.
elements = findRenderedTrackedChangeElementsStrict(host, entityId, escapeAttrValue, storyKey);
} else if (entityType === 'comment') {
elements = findRenderedCommentElements(host, entityId, storyKey);
} else {
return [];
}
return elementsToRangeRects(elements);
}

#getThreadSelectionBounds(
data: { storyKey?: unknown; start?: unknown; end?: unknown; pos?: unknown },
relativeTo: HTMLElement | undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest';
import {
elementsToRangeRects,
findRenderedCommentElements,
findRenderedTrackedChangeElementsStrict,
} from './EntityRectFinder.js';

const BODY_STORY_KEY = 'body';

function makeHost(): HTMLElement {
const host = document.createElement('div');
document.body.appendChild(host);
return host;
}

function paintCommentRun(host: HTMLElement, ids: string, opts: { storyKey?: string; pageIndex?: number } = {}) {
const page = document.createElement('div');
page.className = 'superdoc-page';
page.dataset.pageIndex = String(opts.pageIndex ?? 0);
const run = document.createElement('span');
run.dataset.commentIds = ids;
if (opts.storyKey != null) {
run.dataset.storyKey = opts.storyKey;
}
page.appendChild(run);
host.appendChild(page);
return run;
}

describe('findRenderedCommentElements', () => {
it('returns runs that include the comment id as an exact comma-separated token', () => {
const host = makeHost();
const a = paintCommentRun(host, 'c1');
const b = paintCommentRun(host, 'c2');
const ab = paintCommentRun(host, 'c1,c2');

const matches = findRenderedCommentElements(host, 'c1');
expect(matches).toHaveLength(2);
expect(matches).toContain(a);
expect(matches).toContain(ab);
expect(matches).not.toContain(b);
});

it('does NOT partial-match overlapping ids (c1 must not match c12)', () => {
const host = makeHost();
const c12 = paintCommentRun(host, 'c12');
const c123 = paintCommentRun(host, 'c12,c123');

const matches = findRenderedCommentElements(host, 'c1');
expect(matches).toHaveLength(0);
expect(matches).not.toContain(c12);
expect(matches).not.toContain(c123);

const c12Matches = findRenderedCommentElements(host, 'c12');
expect(c12Matches).toContain(c12);
expect(c12Matches).toContain(c123);
});

it('tolerates whitespace around comma-separated tokens', () => {
const host = makeHost();
const run = paintCommentRun(host, 'c1, c2 , c3');
expect(findRenderedCommentElements(host, 'c2')).toContain(run);
expect(findRenderedCommentElements(host, 'c3')).toContain(run);
});

it('returns [] when host or commentId is empty', () => {
expect(findRenderedCommentElements(null as unknown as HTMLElement, 'c1')).toEqual([]);
expect(findRenderedCommentElements(makeHost(), '')).toEqual([]);
});

it('filters by story key when provided', () => {
const host = makeHost();
const bodyRun = paintCommentRun(host, 'c1', { storyKey: BODY_STORY_KEY });
const headerRun = paintCommentRun(host, 'c1', { storyKey: 'story:headerFooterPart:rId1' });

const bodyOnly = findRenderedCommentElements(host, 'c1', BODY_STORY_KEY);
expect(bodyOnly).toContain(bodyRun);
expect(bodyOnly).not.toContain(headerRun);

const headerOnly = findRenderedCommentElements(host, 'c1', 'story:headerFooterPart:rId1');
expect(headerOnly).toContain(headerRun);
expect(headerOnly).not.toContain(bodyRun);
});

it('matches body-targeted lookups against runs whose data-story-key is missing', () => {
const host = makeHost();
const legacyRun = paintCommentRun(host, 'c1'); // no data-story-key
expect(findRenderedCommentElements(host, 'c1', BODY_STORY_KEY)).toContain(legacyRun);
});

it('returns runs across all stories when storyKey is omitted', () => {
const host = makeHost();
const bodyRun = paintCommentRun(host, 'c1', { storyKey: BODY_STORY_KEY });
const headerRun = paintCommentRun(host, 'c1', { storyKey: 'story:headerFooterPart:rId1' });

const all = findRenderedCommentElements(host, 'c1');
expect(all).toContain(bodyRun);
expect(all).toContain(headerRun);
});
});

describe('findRenderedTrackedChangeElementsStrict', () => {
function paintTrackedChangeRun(host: HTMLElement, id: string, opts: { storyKey?: string; pageIndex?: number } = {}) {
const page = document.createElement('div');
page.className = 'superdoc-page';
page.dataset.pageIndex = String(opts.pageIndex ?? 0);
const run = document.createElement('span');
run.dataset.trackChangeId = id;
if (opts.storyKey != null) run.dataset.storyKey = opts.storyKey;
page.appendChild(run);
host.appendChild(page);
return run;
}

const escape = (value: string) => value.replace(/["\\]/g, (c) => `\\${c}`);

it('returns only exact-story matches when a storyKey is provided (strict, no fallback)', () => {
const host = makeHost();
const bodyRun = paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' });
const headerRun = paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:headerFooterPart:rId1' });

const headerOnly = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape, 'story:headerFooterPart:rId1');
expect(headerOnly).toEqual([headerRun]);
expect(headerOnly).not.toContain(bodyRun);
});

it('returns [] when the requested story has no painted copy (strict, no cross-story fallback)', () => {
const host = makeHost();
paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' });
paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:footerPart:rId2' });

// Asking for a header copy must NOT fall back to body or footer rects
// — a sticky card asked to anchor a header tracked change would
// otherwise silently anchor to the wrong story.
const headerOnly = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape, 'story:headerFooterPart:rId1');
expect(headerOnly).toEqual([]);
});

it('returns every painted copy across stories when no storyKey is provided', () => {
const host = makeHost();
const a = paintTrackedChangeRun(host, 'tc1', { storyKey: 'body' });
const b = paintTrackedChangeRun(host, 'tc1', { storyKey: 'story:headerFooterPart:rId1' });
const all = findRenderedTrackedChangeElementsStrict(host, 'tc1', escape);
expect(all).toContain(a);
expect(all).toContain(b);
});

it('escapes ids that contain CSS-special characters', () => {
const host = makeHost();
const run = paintTrackedChangeRun(host, 'tc"with"quotes');
const cssEscape = (value: string) =>
typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, (c) => `\\${c}`);
const matches = findRenderedTrackedChangeElementsStrict(host, 'tc"with"quotes', cssEscape);
expect(matches).toContain(run);
});
});

describe('elementsToRangeRects', () => {
it('emits plain value rects (not live DOMRect) with pageIndex from enclosing .superdoc-page', () => {
const host = makeHost();
const run = paintCommentRun(host, 'c1', { pageIndex: 3 });
// jsdom returns zero-rects but they're finite, so the helper accepts them.
const [rect] = elementsToRangeRects([run]);
expect(rect).toBeDefined();
expect(rect).toMatchObject({
pageIndex: 3,
left: expect.any(Number),
top: expect.any(Number),
right: expect.any(Number),
bottom: expect.any(Number),
width: expect.any(Number),
height: expect.any(Number),
});
// The result must be a plain value object, not a DOMRect.
expect(typeof DOMRect !== 'undefined' ? rect instanceof DOMRect : false).toBe(false);
});

it('drops elements whose getBoundingClientRect returns non-finite numbers', () => {
const host = makeHost();
const run = paintCommentRun(host, 'c1');
const original = run.getBoundingClientRect.bind(run);
run.getBoundingClientRect = () =>
({
top: NaN,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
expect(elementsToRangeRects([run])).toEqual([]);
run.getBoundingClientRect = original;
});

it('defaults to pageIndex=0 when no .superdoc-page wrapper is present', () => {
const host = makeHost();
const run = document.createElement('span');
run.dataset.commentIds = 'c1';
host.appendChild(run); // no .superdoc-page wrapper

const [rect] = elementsToRangeRects([run]);
expect(rect.pageIndex).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { RangeRect } from '../types.js';
import { BODY_STORY_KEY } from '../../../document-api-adapters/story-runtime/story-key.js';

/**
* Pure DOM helpers shared by `PresentationEditor.getEntityRects` and
* tests. Kept module-local so the rendering lookup stays a private
* implementation detail of the presentation editor — `superdoc/ui`
* never sees the elements, only the resulting rect value objects.
*/

/**
* Find painted text-run elements that anchor a given comment.
*
* The painter writes `data-comment-ids="c1,c2,c3"` (comma-separated)
* on every text run that carries one or more comment annotations.
* CSS attribute selectors split tokens on whitespace, not commas, so
* a naive `[data-comment-ids~="c1"]` would miss every match and a
* naive `[data-comment-ids*="c1"]` would partial-match `c12` (and
* any other id whose string contains `c1`). Hand-parse the attribute
* and compare each token by exact equality.
*
* `storyKey` filters by the painted run's enclosing story:
* - undefined: match across all stories.
* - BODY_STORY_KEY: match runs whose `data-story-key` is body, or
* whose attribute is missing entirely (legacy / body runs may
* omit the attribute).
* - any other: exact match required.
*/
export function findRenderedCommentElements(host: HTMLElement, commentId: string, storyKey?: string): HTMLElement[] {
if (!host || !commentId) return [];
const candidates = Array.from(host.querySelectorAll<HTMLElement>('[data-comment-ids]'));
return candidates.filter((el) => {
const raw = el.dataset.commentIds;
if (!raw) return false;
const matchesId = raw.split(',').some((token) => token.trim() === commentId);
if (!matchesId) return false;
if (!storyKey) return true;
const elStoryKey = el.dataset.storyKey;
if (elStoryKey) return elStoryKey === storyKey;
return storyKey === BODY_STORY_KEY;
});
}

/**
* Find painted text-run elements that anchor a given tracked change.
*
* Strictly story-filtered. The PresentationEditor's existing
* navigation helper (`#findRenderedTrackedChangeElements`) deliberately
* falls back to *all* same-id matches when an exact story match
* doesn't satisfy a navigation heuristic — that fallback is correct
* for "scroll to this change" because it lets navigation jump to
* whichever copy is mounted, but it's wrong for `ui.viewport.getRect`:
* a sticky card asked to anchor a header/footer change must NOT get a
* body rect just because the body copy happens to be painted. When a
* `storyKey` is provided here we return *only* exact matches; when no
* story is provided we return every painted occurrence.
*
* The CSS escape inside the selector is mandatory because tracked
* change ids may contain attribute-special characters (quotes,
* backslashes); pass an escape function so this helper stays free of
* the platform-specific `CSS.escape` shim that PresentationEditor
* already owns.
*/
export function findRenderedTrackedChangeElementsStrict(
host: HTMLElement,
entityId: string,
escapeAttrValue: (value: string) => string,
storyKey?: string,
): HTMLElement[] {
if (!host || !entityId) return [];
const baseSelector = `[data-track-change-id="${escapeAttrValue(entityId)}"]`;
if (!storyKey) {
return Array.from(host.querySelectorAll<HTMLElement>(baseSelector));
}
const storySelector = `${baseSelector}[data-story-key="${escapeAttrValue(storyKey)}"]`;
return Array.from(host.querySelectorAll<HTMLElement>(storySelector));
}

/**
* Convert painted DOM elements to plain viewport-coord `RangeRect`
* value objects. Drops elements whose `getBoundingClientRect`
* returns non-finite numbers (defensive: jsdom can return `NaN` for
* unmounted nodes) and resolves the page index from the enclosing
* `.superdoc-page` wrapper so callers can route per-page geometry.
*/
export function elementsToRangeRects(elements: HTMLElement[]): RangeRect[] {
const result: RangeRect[] = [];
for (const element of elements) {
const rect = element.getBoundingClientRect();
if (![rect.top, rect.left, rect.right, rect.bottom, rect.width, rect.height].every(Number.isFinite)) {
continue;
}
const pageEl = element.closest<HTMLElement>('.superdoc-page');
const pageIndexAttr = Number(pageEl?.dataset?.pageIndex ?? 0);
result.push({
pageIndex: Number.isFinite(pageIndexAttr) ? pageIndexAttr : 0,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
});
}
return result;
}
4 changes: 4 additions & 0 deletions packages/super-editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,8 @@ export type {
SuperDocUI,
SuperDocUIOptions,
SuperDocUIState,
ViewportGetRectInput,
ViewportHandle,
ViewportRect,
ViewportRectResult,
} from './ui/types.js';
Loading
Loading