Skip to content

Commit 95f634e

Browse files
fix(scroll): wait for virtualized page mount and center text element (#2221)
* fix(scroll): wait for virtualized page mount and center text element * fix(super-editor): restore reliable search result scrolling and ignore header/footer scroll targets * fix(super-editor): improve position tracker, restore reliable search result scrolling * docs: add stable navigation docs * chore: fix test --------- Co-authored-by: Nick Bernal <nick@superdoc.dev>
1 parent 066b9eb commit 95f634e

10 files changed

Lines changed: 1692 additions & 77 deletions

File tree

apps/docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@
227227
"group": "Guides",
228228
"pages": [
229229
"guides/general/storage",
230+
"guides/general/stable-navigation",
230231
{
231232
"group": "Collaboration",
232233
"pages": [
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
title: Stable navigation after edits
3+
sidebarTitle: Stable navigation
4+
description: Find nodes once and navigate to them reliably after document edits.
5+
---
6+
7+
When users edit a document, stored positions can drift.
8+
Use `PositionTracker` so navigation targets stay stable.
9+
10+
## Hyperlinks example
11+
12+
```javascript
13+
// 1) Find hyperlink nodes
14+
const found = editor.doc.find({
15+
select: { type: 'node', nodeType: 'hyperlink', kind: 'inline' },
16+
});
17+
18+
// 2) Track each found node
19+
const links = found.items.map((item) => ({
20+
item,
21+
trackerId: editor.positionTracker.trackNode(item),
22+
}));
23+
24+
// 3) Navigate later (for example, from a sidebar click)
25+
function goToLink(link) {
26+
if (!link?.trackerId) return;
27+
editor.positionTracker.goToTracked(link.trackerId);
28+
}
29+
```
30+
31+
## Best practices
32+
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).

packages/super-editor/src/core/PositionTracker.ts

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Transaction } from 'prosemirror-state';
2-
import { Plugin, PluginKey } from 'prosemirror-state';
2+
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state';
3+
import type { Node as ProseMirrorNode } from 'prosemirror-model';
34
import { Decoration, DecorationSet } from 'prosemirror-view';
45
import { v4 as uuidv4 } from 'uuid';
56

@@ -21,6 +22,46 @@ export type ResolvedRange = {
2122
spec: TrackedRangeSpec;
2223
};
2324

25+
type TrackablePosition = {
26+
blockId: string;
27+
offset: number;
28+
};
29+
30+
type TrackableInlineAnchor = {
31+
start: TrackablePosition;
32+
end: TrackablePosition;
33+
};
34+
35+
type TrackableNodeAddress =
36+
| {
37+
kind: 'inline';
38+
anchor: TrackableInlineAnchor;
39+
}
40+
| {
41+
kind: 'block';
42+
nodeId: string;
43+
nodeType?: string;
44+
};
45+
46+
type TrackableFindNodeItem = {
47+
address?: TrackableNodeAddress;
48+
};
49+
50+
type TrackNodeInput = TrackableNodeAddress | TrackableFindNodeItem;
51+
52+
type ResolvedBlockCandidate = {
53+
node: ProseMirrorNode;
54+
pos: number;
55+
};
56+
57+
type OffsetRange = {
58+
start: number;
59+
end: number;
60+
};
61+
62+
const DEFAULT_TRACKED_NODE_TYPE = 'tracked-node';
63+
type ScrollBlock = 'start' | 'center' | 'end' | 'nearest';
64+
2465
export type PositionTrackerState = {
2566
decorations: DecorationSet;
2667
generation: number;
@@ -33,6 +74,140 @@ type PositionTrackerMeta =
3374

3475
export const positionTrackerKey = new PluginKey<PositionTrackerState>('positionTracker');
3576

77+
function getNodeIdCandidates(node: ProseMirrorNode): string[] {
78+
const attrs = (node.attrs ?? {}) as Record<string, unknown>;
79+
const candidateFields = ['paraId', 'sdBlockId', 'blockId', 'id', 'uuid'] as const;
80+
const ids: string[] = [];
81+
82+
for (const field of candidateFields) {
83+
const value = attrs[field];
84+
if (typeof value === 'string' && value.length > 0) {
85+
ids.push(value);
86+
}
87+
}
88+
89+
return ids;
90+
}
91+
92+
function findBlockCandidateById(doc: ProseMirrorNode, blockId: string): ResolvedBlockCandidate | null {
93+
let match: ResolvedBlockCandidate | null = null;
94+
let isAmbiguous = false;
95+
96+
doc.descendants((node, pos) => {
97+
if (!node.isBlock) return;
98+
const ids = getNodeIdCandidates(node);
99+
if (!ids.includes(blockId)) return;
100+
101+
if (match) {
102+
isAmbiguous = true;
103+
return false;
104+
}
105+
106+
match = { node, pos };
107+
return;
108+
});
109+
110+
if (isAmbiguous) return null;
111+
return match;
112+
}
113+
114+
function resolveSegmentPosition(
115+
targetOffset: number,
116+
segmentStart: number,
117+
segmentLength: number,
118+
docFrom: number,
119+
docTo: number,
120+
): number {
121+
if (segmentLength <= 1) {
122+
return targetOffset <= segmentStart ? docFrom : docTo;
123+
}
124+
return docFrom + (targetOffset - segmentStart);
125+
}
126+
127+
function resolveOffsetsInBlock(
128+
blockNode: ProseMirrorNode,
129+
blockPos: number,
130+
range: OffsetRange,
131+
): { from: number; to: number } | null {
132+
if (range.start < 0 || range.end < range.start) return null;
133+
134+
let flattenedOffset = 0;
135+
let fromPos: number | undefined;
136+
let toPos: number | undefined;
137+
138+
const advanceSegment = (segmentLength: number, docFrom: number, docTo: number) => {
139+
const segmentStart = flattenedOffset;
140+
const segmentEnd = flattenedOffset + segmentLength;
141+
142+
if (fromPos == null && range.start <= segmentEnd) {
143+
fromPos = resolveSegmentPosition(range.start, segmentStart, segmentLength, docFrom, docTo);
144+
}
145+
if (toPos == null && range.end <= segmentEnd) {
146+
toPos = resolveSegmentPosition(range.end, segmentStart, segmentLength, docFrom, docTo);
147+
}
148+
149+
flattenedOffset = segmentEnd;
150+
};
151+
152+
const walkNodeContent = (node: ProseMirrorNode, contentStart: number) => {
153+
let isFirstChild = true;
154+
let childOffset = 0;
155+
156+
for (let i = 0; i < node.childCount; i += 1) {
157+
const child = node.child(i);
158+
const childPos = contentStart + childOffset;
159+
160+
if (child.isBlock && !isFirstChild) {
161+
advanceSegment(1, childPos, childPos + 1);
162+
}
163+
164+
walkNode(child, childPos);
165+
childOffset += child.nodeSize;
166+
isFirstChild = false;
167+
}
168+
};
169+
170+
const walkNode = (node: ProseMirrorNode, docPos: number) => {
171+
if (node.isText) {
172+
const text = node.text ?? '';
173+
if (text.length > 0) {
174+
advanceSegment(text.length, docPos, docPos + text.length);
175+
}
176+
return;
177+
}
178+
179+
if (node.isLeaf) {
180+
advanceSegment(1, docPos, docPos + node.nodeSize);
181+
return;
182+
}
183+
184+
walkNodeContent(node, docPos + 1);
185+
};
186+
187+
walkNodeContent(blockNode, blockPos + 1);
188+
189+
if (flattenedOffset === 0 && range.start === 0 && range.end === 0) {
190+
const anchor = blockPos + 1;
191+
return { from: anchor, to: anchor };
192+
}
193+
194+
if (range.end > flattenedOffset) return null;
195+
if (fromPos == null || toPos == null) return null;
196+
return { from: fromPos, to: toPos };
197+
}
198+
199+
function getTrackableAddress(input: TrackNodeInput): TrackableNodeAddress | null {
200+
if (!input || typeof input !== 'object') return null;
201+
if ('kind' in input && (input.kind === 'inline' || input.kind === 'block')) {
202+
return input as TrackableNodeAddress;
203+
}
204+
if ('address' in input && input.address && typeof input.address === 'object') {
205+
const address = input.address as TrackableNodeAddress;
206+
if (address.kind === 'inline' || address.kind === 'block') return address;
207+
}
208+
return null;
209+
}
210+
36211
export function createPositionTrackerPlugin(): Plugin<PositionTrackerState> {
37212
return new Plugin<PositionTrackerState>({
38213
key: positionTrackerKey,
@@ -92,6 +267,30 @@ export class PositionTracker {
92267
return positionTrackerKey.getState(this.#editor.state) ?? null;
93268
}
94269

270+
#resolveTrackNodeAddress(address: TrackableNodeAddress): { from: number; to: number } | null {
271+
const doc = this.#editor?.state?.doc;
272+
if (!doc) return null;
273+
274+
if (address.kind === 'inline') {
275+
const { start, end } = address.anchor;
276+
if (!start || !end || start.blockId !== end.blockId) return null;
277+
278+
const block = findBlockCandidateById(doc, start.blockId);
279+
if (!block) return null;
280+
281+
return resolveOffsetsInBlock(block.node, block.pos, {
282+
start: start.offset,
283+
end: end.offset,
284+
});
285+
}
286+
287+
const block = findBlockCandidateById(doc, address.nodeId);
288+
if (!block) return null;
289+
290+
const anchor = block.pos + 1;
291+
return { from: anchor, to: anchor };
292+
}
293+
95294
track(from: number, to: number, spec: Omit<TrackedRangeSpec, 'id'>): string {
96295
const id = uuidv4();
97296
if (!this.#editor?.state) return id;
@@ -135,6 +334,45 @@ export class PositionTracker {
135334
return ids;
136335
}
137336

337+
trackNode(input: TrackNodeInput, spec?: Omit<TrackedRangeSpec, 'id' | 'kind'>): string | null {
338+
const [trackedId] = this.trackNodes([input], spec);
339+
return trackedId ?? null;
340+
}
341+
342+
trackNodes(inputs: TrackNodeInput[], spec?: Omit<TrackedRangeSpec, 'id' | 'kind'>): Array<string | null> {
343+
if (!Array.isArray(inputs) || inputs.length === 0) return [];
344+
345+
const trackSpec = { type: DEFAULT_TRACKED_NODE_TYPE, ...(spec ?? {}) };
346+
const pendingRanges: Array<{ from: number; to: number; spec: Omit<TrackedRangeSpec, 'id'> }> = [];
347+
const pendingInputIndexes: number[] = [];
348+
const results: Array<string | null> = Array.from({ length: inputs.length }, () => null);
349+
350+
for (let index = 0; index < inputs.length; index += 1) {
351+
const address = getTrackableAddress(inputs[index]);
352+
if (!address) continue;
353+
354+
const resolved = this.#resolveTrackNodeAddress(address);
355+
if (!resolved) continue;
356+
357+
pendingRanges.push({
358+
from: resolved.from,
359+
to: resolved.to,
360+
spec: trackSpec,
361+
});
362+
pendingInputIndexes.push(index);
363+
}
364+
365+
if (pendingRanges.length === 0) return results;
366+
367+
const trackedIds = this.trackMany(pendingRanges);
368+
for (let i = 0; i < pendingInputIndexes.length; i += 1) {
369+
const inputIndex = pendingInputIndexes[i];
370+
results[inputIndex] = trackedIds[i] ?? null;
371+
}
372+
373+
return results;
374+
}
375+
138376
untrack(id: string): void {
139377
if (!this.#editor?.state) return;
140378
const tr = this.#editor.state.tr
@@ -226,6 +464,44 @@ export class PositionTracker {
226464
});
227465
}
228466

467+
goToTracked(id: string, options?: { block?: ScrollBlock }): boolean {
468+
const resolved = this.resolve(id);
469+
if (!resolved) return false;
470+
471+
const from = Math.max(0, Math.min(resolved.from, this.#editor.state.doc.content.size));
472+
const to = Math.max(from, Math.min(resolved.to, this.#editor.state.doc.content.size));
473+
const block = options?.block ?? 'center';
474+
475+
if (this.#editor.commands?.setTextSelection) {
476+
this.#editor.commands.setTextSelection({ from, to });
477+
} else if (this.#editor.state) {
478+
const tr = this.#editor.state.tr
479+
.setSelection(TextSelection.create(this.#editor.state.doc, from, to))
480+
.scrollIntoView();
481+
this.#editor.dispatch(tr);
482+
}
483+
484+
const presentationEditor = this.#editor.presentationEditor;
485+
const didPresentationScroll = presentationEditor?.scrollToPosition?.(from, { block }) ?? false;
486+
487+
if (!didPresentationScroll) {
488+
Promise.resolve(presentationEditor?.scrollToPositionAsync?.(from, { block })).catch(() => {});
489+
490+
try {
491+
const { node } = this.#editor.view?.domAtPos(from) ?? { node: null };
492+
if (typeof Element !== 'undefined' && node instanceof Element) {
493+
node.scrollIntoView({ block, inline: 'nearest' });
494+
} else if ((node as Node | null)?.parentElement) {
495+
(node as Node).parentElement?.scrollIntoView({ block, inline: 'nearest' });
496+
}
497+
} catch {
498+
// Ignore scroll failures in environments with incomplete DOM APIs.
499+
}
500+
}
501+
502+
return true;
503+
}
504+
229505
get generation(): number {
230506
return this.#getState()?.generation ?? 0;
231507
}

0 commit comments

Comments
 (0)