Skip to content

Commit 33ea1e4

Browse files
authored
feat(navigation): universal scrollToElement API (SD-2519) (#2772)
* feat(navigation): add universal navigateTo for blocks, comments, and tracked changes (SD-2519) Add navigateTo(address) to PresentationEditor and SuperDoc that navigates to any element by its ID: - BlockNavigationAddress: navigate to paragraphs, headings, tables, images by nodeId using the existing block index (O(1) lookup) - CommentAddress: navigate to comments via setCursorById with active thread activation - TrackedChangeAddress: navigate to tracked changes with cascading fallback (setCursorById → rawId → scroll to position) All navigation building blocks already existed — this wires them behind a single unified API. Replaces the need for text-search workarounds in RAG citation linking. * feat(navigation): add scrollToElement — single-ID navigation for any element (SD-2519) Add scrollToElement(elementId) to PresentationEditor and SuperDoc. Takes any element ID (paragraph nodeId, comment entityId, tracked change entityId) and resolves the element type automatically: 1. Tries block index lookup (O(1) — paragraphs, headings, tables) 2. Falls back to comment/tracked-change mark lookup 3. Falls back to tracked change canonical ID resolution Consumer usage: await superdoc.scrollToElement('5AF80E61'); // any ID, any type navigateTo(address) is preserved as the typed foundation. scrollToElement is the DX layer for the common case (RAG citations, search results, cross-references). Also extracts #scrollToBlockCandidate as shared helper for block position resolution — handles the layout engine's content-position mapping (skips zero-width annotation nodes like bookmarkStart). * refactor(navigation): simplify API — scrollToElement delegates to navigateTo (SD-2519) Address review findings: - scrollToElement now delegates to navigateTo instead of reimplementing the same block/comment/tracked-change lookup cascade - Remove navigateTo from SuperDoc public API — scrollToElement(id) is the sole public entry point. navigateTo stays on PresentationEditor as the typed internal dispatcher. - Remove duplicate JSDoc address types from superdoc package - Fix tracked change fallback: check scrollToPositionAsync return value instead of returning true unconditionally * test(navigation): add behavior tests and consumer typecheck for scrollToElement (SD-2519) - Behavior tests: navigate to paragraph (by nodeId), comment (by entityId), tracked change (by entityId), non-existent ID returns false, sequential multi-block navigation - Consumer typecheck: verify scrollToElement and navigateTo compile with correct type signatures (block, comment, tracked change addresses) * docs(navigation): document scrollToElement API and cross-session navigation (SD-2519) - Add scrollToElement to SuperDoc methods reference with usage examples and full example covering paragraphs, comments, and tracked changes - Update cross-session block addressing workflow to show browser-side navigation with scrollToElement after headless extraction - Rewrite stable navigation guide to cover both approaches: scrollToElement for ID-based navigation (cross-session) and PositionTracker for tracking nodes during edits (single-session) * fix(navigation): scroll viewport after placing cursor for comments and tracked changes (SD-2519) setCursorById places the ProseMirror cursor but doesn't scroll the DomPainter viewport in presentation mode. Add scrollToPositionAsync after each successful setCursorById call so off-screen elements are actually scrolled into view.
1 parent fa9c3e9 commit 33ea1e4

File tree

8 files changed

+463
-9
lines changed

8 files changed

+463
-9
lines changed

apps/docs/core/superdoc/methods.mdx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,69 @@ const superdoc = new SuperDoc({
765765
766766
</CodeGroup>
767767
768+
### `scrollToElement`
769+
770+
Scroll to any document element by its ID. Pass a paragraph `nodeId`, comment `entityId`, or tracked change `entityId` — the method figures out what kind of element it is and scrolls there.
771+
772+
<ParamField path="elementId" type="string" required>
773+
The element's stable ID. Get this from `query.match` results (`address.nodeId`), `comments.list` (`entityId`), or `trackChanges.list` (`entityId`).
774+
</ParamField>
775+
776+
**Returns:** `Promise<boolean>``true` if the element was found and scrolled to, `false` otherwise. Never throws.
777+
778+
<CodeGroup>
779+
780+
```javascript Usage
781+
// Get a paragraph's nodeId from the Document API
782+
const result = editor.doc.query.match({
783+
select: { type: 'text', pattern: 'Introduction', mode: 'contains' },
784+
require: 'first',
785+
});
786+
const nodeId = result.items[0].address.nodeId;
787+
788+
// Scroll to it — one call, any element type
789+
await superdoc.scrollToElement(nodeId);
790+
```
791+
792+
```javascript Full Example
793+
import { SuperDoc } from 'superdoc';
794+
import 'superdoc/style.css';
795+
796+
const superdoc = new SuperDoc({
797+
selector: '#editor',
798+
document: yourFile,
799+
modules: { comments: {} },
800+
onReady: async (superdoc) => {
801+
const editor = superdoc.editor;
802+
803+
// Navigate to a paragraph
804+
const match = editor.doc.query.match({
805+
select: { type: 'text', pattern: 'Summary', mode: 'contains' },
806+
require: 'first',
807+
});
808+
await superdoc.scrollToElement(match.items[0].address.nodeId);
809+
810+
// Navigate to a comment
811+
const comments = editor.doc.comments.list();
812+
if (comments.items.length > 0) {
813+
await superdoc.scrollToElement(comments.items[0].entityId);
814+
}
815+
816+
// Navigate to a tracked change
817+
const changes = editor.doc.trackChanges.list();
818+
if (changes.items.length > 0) {
819+
await superdoc.scrollToElement(changes.items[0].entityId);
820+
}
821+
},
822+
});
823+
```
824+
825+
</CodeGroup>
826+
827+
<Info>
828+
For DOCX-imported documents, paragraph `nodeId` values come from the OOXML `paraId` attribute and are stable across sessions. See [cross-session block addressing](/document-api/common-workflows#cross-session-block-addressing) for the full pattern.
829+
</Info>
830+
768831
## User management
769832
770833
### `addSharedUser`

apps/docs/document-api/common-workflows.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,27 @@ for (const { address } of saved) {
279279
editor2.destroy();
280280
```
281281

282+
### Navigate to saved addresses in the browser
283+
284+
When the saved addresses are used in a browser-based viewer (RAG citations, search results, cross-references), pass the `nodeId` directly to `scrollToElement`:
285+
286+
```ts
287+
// In the browser — user clicks a citation
288+
const savedNodeId = citation.nodeId; // from your database / vector store
289+
const found = await superdoc.scrollToElement(savedNodeId);
290+
291+
if (!found) {
292+
console.warn('Element no longer exists in the document');
293+
}
294+
```
295+
296+
`scrollToElement` also works with comment and tracked change IDs:
297+
298+
```ts
299+
await superdoc.scrollToElement(commentEntityId);
300+
await superdoc.scrollToElement(trackedChangeEntityId);
301+
```
302+
282303
<Info>
283304
`nodeId` stability depends on the ID source. For DOCX-imported content, `nodeId` comes from `paraId` when available and is best-effort stable across loads. Runtime-created content is still not guaranteed stable across loads; many nodes use session-scoped editor identity, while some structures such as tables or table cells may expose deterministic fallback IDs instead of raw `sdBlockId`.
284305
</Info>

apps/docs/guides/general/stable-navigation.mdx

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,45 @@
11
---
22
title: Stable navigation after edits
33
sidebarTitle: Stable navigation
4-
description: Find nodes once and navigate to them reliably after document edits.
4+
description: Navigate to document elements reliably — during edits and across sessions.
55
---
66

7-
When users edit a document, stored positions can drift.
8-
Use `PositionTracker` so navigation targets stay stable.
7+
SuperDoc has two navigation approaches depending on your use case:
98

10-
## Hyperlinks example
9+
| Approach | Use case | Stability |
10+
|----------|----------|-----------|
11+
| `scrollToElement(id)` | Navigate to any element by its ID | Cross-session (for DOCX-imported content) |
12+
| `PositionTracker` | Track nodes that move during edits | Within a single session |
13+
14+
## Navigate by element ID
15+
16+
`scrollToElement` takes any element ID — paragraph, comment, or tracked change — and scrolls to it. The ID comes from the Document API.
17+
18+
```javascript
19+
// Get an element's ID
20+
const match = editor.doc.query.match({
21+
select: { type: 'text', pattern: 'Introduction', mode: 'contains' },
22+
require: 'first',
23+
});
24+
const nodeId = match.items[0].address.nodeId;
25+
26+
// Navigate to it — works for paragraphs, comments, tracked changes
27+
await superdoc.scrollToElement(nodeId);
28+
```
29+
30+
This is the approach to use for:
31+
- **RAG citations** — store `nodeId` alongside embeddings, navigate on click
32+
- **Cross-references** — link to specific paragraphs from external UI
33+
- **Search results** — scroll to the matching paragraph
34+
- **Cross-session addressing** — IDs from DOCX-imported content survive reloads
35+
36+
For the full cross-session pattern, see [cross-session block addressing](/document-api/common-workflows#cross-session-block-addressing).
37+
38+
## Track nodes during edits
39+
40+
When users edit a document, stored positions can drift. Use `PositionTracker` so navigation targets stay stable within the current session.
41+
42+
### Hyperlinks example
1143

1244
```javascript
1345
// 1) Find hyperlink nodes
@@ -30,7 +62,7 @@ function goToLink(link) {
3062
3163
## Best practices
3264
33-
- Track results right after `find()`.
34-
- Store `trackerId` in UI state, not raw positions.
35-
- Re-run `find()` and rebuild tracked IDs when refreshing your list.
36-
- Handle missing targets gracefully (`goToTracked` can return `false` if content was removed).
65+
- Use `scrollToElement` when you have an element ID from the Document API.
66+
- Use `PositionTracker` when you need to follow nodes that move during edits.
67+
- For cross-session use, store `nodeId` values (not `sdBlockId` — those regenerate on each open).
68+
- Handle missing targets gracefully — both APIs return `false` if the element no longer exists.

packages/document-api/src/types/address.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,32 @@ export type TrackedChangeAddress = {
128128
};
129129

130130
export type EntityAddress = CommentAddress | TrackedChangeAddress;
131+
132+
// ---------------------------------------------------------------------------
133+
// Navigation addressing
134+
// ---------------------------------------------------------------------------
135+
136+
/**
137+
* Address for navigating to a block-level element by its node ID.
138+
*
139+
* The `nodeId` maps to `paraId` (from OOXML) when available, with fallback
140+
* to `sdBlockId` (session-scoped). Use the value returned by Document API
141+
* queries (e.g. `query.match`, `find`, `getNode`) as the `nodeId`.
142+
*
143+
* When `nodeType` is omitted, the lookup searches across all block types.
144+
*/
145+
export type BlockNavigationAddress = {
146+
kind: 'block';
147+
nodeId: string;
148+
nodeType?: SelectionEdgeNodeType;
149+
};
150+
151+
/**
152+
* Union of all address types accepted by `navigateTo()`.
153+
*
154+
* Supports navigation to:
155+
* - Blocks by `nodeId` (paragraphs, headings, tables, images, SDTs)
156+
* - Comments by `entityId`
157+
* - Tracked changes by `entityId`
158+
*/
159+
export type NavigableAddress = BlockNavigationAddress | CommentAddress | TrackedChangeAddress;

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ import {
130130
ensureEditorFieldAnnotationInteractionStyles,
131131
} from './dom/EditorStyleInjector.js';
132132

133-
import type { ResolveRangeOutput, DocumentApi } from '@superdoc/document-api';
133+
import type { ResolveRangeOutput, DocumentApi, NavigableAddress, BlockNavigationAddress } from '@superdoc/document-api';
134+
import { getBlockIndex } from '../../document-api-adapters/helpers/index-cache.js';
135+
import { findBlockByNodeIdOnly, findBlockById } from '../../document-api-adapters/helpers/node-address-resolver.js';
136+
import { resolveTrackedChange } from '../../document-api-adapters/helpers/tracked-change-resolver.js';
134137
import type { SelectionHandle } from '../selection-state.js';
135138

136139
const DOCUMENT_RELS_PART_ID = 'word/_rels/document.xml.rels';
@@ -5843,6 +5846,171 @@ export class PresentationEditor extends EventEmitter {
58435846
*/
58445847
private static readonly ANCHOR_NAV_TIMEOUT_MS = 2000;
58455848

5849+
/**
5850+
* Scroll to any document element by its ID.
5851+
*
5852+
* Accepts any element ID — paragraph nodeId, comment entityId, or tracked
5853+
* change entityId. Resolves the element type automatically:
5854+
* 1. Tries block index lookup (paragraphs, headings, tables)
5855+
* 2. Tries comment navigation (activates comment thread)
5856+
* 3. Tries tracked change navigation (with raw ID fallback)
5857+
*
5858+
* @param elementId - The element's stable ID (nodeId, commentId, or trackedChangeId).
5859+
* @returns Promise resolving to true if the element was found and scrolled to.
5860+
*/
5861+
async scrollToElement(elementId: string): Promise<boolean> {
5862+
if (!elementId) return false;
5863+
5864+
// Try block first — O(1) index lookup, most common for RAG citations.
5865+
if (await this.navigateTo({ kind: 'block', nodeId: elementId })) return true;
5866+
5867+
// Try comment — setCursorById handles both comment and TC marks,
5868+
// but we try comment first to get full thread activation.
5869+
if (await this.navigateTo({ kind: 'entity', entityType: 'comment', entityId: elementId })) return true;
5870+
5871+
// Try tracked change — has its own fallback chain (canonical → raw ID → scroll).
5872+
if (await this.navigateTo({ kind: 'entity', entityType: 'trackedChange', entityId: elementId })) return true;
5873+
5874+
return false;
5875+
}
5876+
5877+
/**
5878+
* Navigate to a typed document element address.
5879+
*
5880+
* @param target - Typed address: block (nodeId), comment (entityId), or tracked change (entityId).
5881+
* @returns Promise resolving to true if navigation succeeded.
5882+
*/
5883+
async navigateTo(target: NavigableAddress): Promise<boolean> {
5884+
if (!target) return false;
5885+
5886+
try {
5887+
if (target.kind === 'block') {
5888+
return await this.#navigateToBlock(target);
5889+
}
5890+
5891+
if (target.kind === 'entity') {
5892+
if (target.entityType === 'comment') {
5893+
return await this.#navigateToComment(target.entityId);
5894+
}
5895+
if (target.entityType === 'trackedChange') {
5896+
return await this.#navigateToTrackedChange(target.entityId);
5897+
}
5898+
}
5899+
5900+
return false;
5901+
} catch (error) {
5902+
console.error('[PresentationEditor] navigateTo failed:', error);
5903+
this.emit('error', { error, context: 'navigateTo' });
5904+
return false;
5905+
}
5906+
}
5907+
5908+
async #navigateToBlock(target: BlockNavigationAddress): Promise<boolean> {
5909+
const editor = this.#editor;
5910+
if (!editor) return false;
5911+
5912+
const index = getBlockIndex(editor);
5913+
5914+
let candidate;
5915+
try {
5916+
if (target.nodeType) {
5917+
candidate = findBlockById(index, { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId });
5918+
} else {
5919+
candidate = findBlockByNodeIdOnly(index, target.nodeId);
5920+
}
5921+
} catch {
5922+
return false;
5923+
}
5924+
5925+
if (!candidate) return false;
5926+
return this.#scrollToBlockCandidate(editor, candidate);
5927+
}
5928+
5929+
/**
5930+
* Scroll to a resolved block candidate and place the cursor inside it.
5931+
*
5932+
* Resolves the first text-content position inside the block — the layout
5933+
* engine maps fragments to text content ranges, so block wrappers and
5934+
* zero-width annotation nodes (bookmarkStart, commentRangeStart) don't
5935+
* generate layout fragments. We walk the block's children to find the
5936+
* first inline node with text content (typically a `run` node).
5937+
*/
5938+
async #scrollToBlockCandidate(editor: Editor, candidate: { pos: number }): Promise<boolean> {
5939+
const blockNode = editor.state.doc.nodeAt(candidate.pos);
5940+
let contentPos = candidate.pos + 1;
5941+
if (blockNode) {
5942+
blockNode.forEach((child, offset) => {
5943+
if (contentPos !== candidate.pos + 1) return;
5944+
if (child.textContent.length > 0) {
5945+
contentPos = candidate.pos + 1 + offset + (child.isText ? 0 : 1);
5946+
}
5947+
});
5948+
}
5949+
5950+
const scrolled = await this.scrollToPositionAsync(contentPos, {
5951+
behavior: 'auto',
5952+
block: 'center',
5953+
});
5954+
if (!scrolled) return false;
5955+
5956+
editor.commands?.setTextSelection?.({ from: contentPos, to: contentPos });
5957+
editor.view?.focus?.();
5958+
return true;
5959+
}
5960+
5961+
async #navigateToComment(entityId: string): Promise<boolean> {
5962+
const editor = this.#editor;
5963+
if (!editor) return false;
5964+
5965+
const setCursorById = editor.commands?.setCursorById;
5966+
if (typeof setCursorById !== 'function') return false;
5967+
5968+
if (!setCursorById(entityId, { preferredActiveThreadId: entityId, activeCommentId: entityId })) {
5969+
return false;
5970+
}
5971+
5972+
// Scroll the viewport — setCursorById places the cursor but doesn't
5973+
// scroll in presentation mode where DomPainter renders the output.
5974+
await this.scrollToPositionAsync(editor.state.selection.from, { behavior: 'auto', block: 'center' });
5975+
return true;
5976+
}
5977+
5978+
async #navigateToTrackedChange(entityId: string): Promise<boolean> {
5979+
const editor = this.#editor;
5980+
if (!editor) return false;
5981+
5982+
const setCursorById = editor.commands?.setCursorById;
5983+
5984+
// Try direct cursor placement, then scroll to the new selection.
5985+
if (typeof setCursorById === 'function' && setCursorById(entityId, { preferredActiveThreadId: entityId })) {
5986+
await this.scrollToPositionAsync(editor.state.selection.from, { behavior: 'auto', block: 'center' });
5987+
return true;
5988+
}
5989+
5990+
// Fall back to resolving the tracked change position and scrolling.
5991+
const resolved = resolveTrackedChange(editor, entityId);
5992+
if (!resolved) return false;
5993+
5994+
// Try with the raw ID (tracked changes may use a different internal ID).
5995+
if (typeof setCursorById === 'function' && resolved.rawId !== entityId) {
5996+
if (setCursorById(resolved.rawId, { preferredActiveThreadId: resolved.rawId })) {
5997+
await this.scrollToPositionAsync(editor.state.selection.from, { behavior: 'auto', block: 'center' });
5998+
return true;
5999+
}
6000+
}
6001+
6002+
// Last resort: scroll to position directly.
6003+
const scrolled = await this.scrollToPositionAsync(resolved.from, {
6004+
behavior: 'auto',
6005+
block: 'center',
6006+
});
6007+
if (!scrolled) return false;
6008+
6009+
editor.commands?.setTextSelection?.({ from: resolved.from, to: resolved.from });
6010+
editor.view?.focus?.();
6011+
return true;
6012+
}
6013+
58466014
/**
58476015
* Navigate to a bookmark/anchor in the current document (e.g., TOC links).
58486016
*

0 commit comments

Comments
 (0)