11import 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' ;
34import { Decoration , DecorationSet } from 'prosemirror-view' ;
45import { 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+
2465export type PositionTrackerState = {
2566 decorations : DecorationSet ;
2667 generation : number ;
@@ -33,6 +74,140 @@ type PositionTrackerMeta =
3374
3475export 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+
36211export 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