@@ -53,9 +53,23 @@ const AUTO_SCROLL_MAX_SPEED_PX = 24;
5353/** Tolerance for detecting scrollability to handle sub-pixel rounding in browsers */
5454const SCROLL_DETECTION_TOLERANCE_PX = 1 ;
5555const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight' ;
56+ const TRACK_CHANGE_SELECTOR = '[data-track-change-id]' ;
57+ const COMMENT_THREAD_HIT_TOLERANCE_PX = 3 ;
58+ const COMMENT_THREAD_HIT_SAMPLE_OFFSETS : ReadonlyArray < readonly [ number , number ] > = [
59+ [ 0 , 0 ] ,
60+ [ - COMMENT_THREAD_HIT_TOLERANCE_PX , 0 ] ,
61+ [ COMMENT_THREAD_HIT_TOLERANCE_PX , 0 ] ,
62+ [ 0 , - COMMENT_THREAD_HIT_TOLERANCE_PX ] ,
63+ [ 0 , COMMENT_THREAD_HIT_TOLERANCE_PX ] ,
64+ ] ;
5665
5766const clamp = ( value : number , min : number , max : number ) : number => Math . max ( min , Math . min ( max , value ) ) ;
5867
68+ type CommentThreadHit = {
69+ isAmbiguous : boolean ;
70+ threadId : string | null ;
71+ } ;
72+
5973/**
6074 * Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder).
6175 * Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from
@@ -83,6 +97,104 @@ function getCommentHighlightThreadIds(target: EventTarget | null): string[] {
8397 . filter ( Boolean ) ;
8498}
8599
100+ function resolveTrackChangeThreadId ( target : EventTarget | null ) : string | null {
101+ if ( ! ( target instanceof Element ) ) {
102+ return null ;
103+ }
104+
105+ const trackedChangeElement = target . closest ( TRACK_CHANGE_SELECTOR ) ;
106+ const threadId = trackedChangeElement ?. getAttribute ( 'data-track-change-id' ) ?. trim ( ) ;
107+
108+ return threadId ? threadId : null ;
109+ }
110+
111+ function resolveCommentThreadHit ( target : EventTarget | null ) : CommentThreadHit {
112+ const threadIds = getCommentHighlightThreadIds ( target ) ;
113+ if ( threadIds . length > 1 ) {
114+ return {
115+ isAmbiguous : true ,
116+ threadId : null ,
117+ } ;
118+ }
119+
120+ if ( threadIds . length === 1 ) {
121+ return {
122+ isAmbiguous : false ,
123+ threadId : threadIds [ 0 ] ,
124+ } ;
125+ }
126+
127+ return {
128+ isAmbiguous : false ,
129+ threadId : resolveTrackChangeThreadId ( target ) ,
130+ } ;
131+ }
132+
133+ function collectElementsNearPointerTarget ( target : EventTarget | null , clientX : number , clientY : number ) : Element [ ] {
134+ const candidates : Element [ ] = [ ] ;
135+ const seen = new Set < Element > ( ) ;
136+ const ownerDocument = target instanceof Element ? target . ownerDocument : document ;
137+ const ownerWindow = ownerDocument . defaultView ;
138+
139+ const addCandidate = ( candidate : Element | null ) : void => {
140+ if ( ! candidate || seen . has ( candidate ) ) {
141+ return ;
142+ }
143+ seen . add ( candidate ) ;
144+ candidates . push ( candidate ) ;
145+ } ;
146+
147+ if ( target instanceof Element ) {
148+ addCandidate ( target ) ;
149+ }
150+
151+ if ( typeof ownerDocument . elementsFromPoint !== 'function' || ! ownerWindow ) {
152+ return candidates ;
153+ }
154+
155+ const maxX = Math . max ( ownerWindow . innerWidth - 1 , 0 ) ;
156+ const maxY = Math . max ( ownerWindow . innerHeight - 1 , 0 ) ;
157+
158+ for ( const [ offsetX , offsetY ] of COMMENT_THREAD_HIT_SAMPLE_OFFSETS ) {
159+ const sampleX = clamp ( clientX + offsetX , 0 , maxX ) ;
160+ const sampleY = clamp ( clientY + offsetY , 0 , maxY ) ;
161+ const elements = ownerDocument . elementsFromPoint ( sampleX , sampleY ) ;
162+
163+ for ( const element of elements ) {
164+ addCandidate ( element ) ;
165+ }
166+ }
167+
168+ return candidates ;
169+ }
170+
171+ function resolveCommentThreadIdNearPointer (
172+ target : EventTarget | null ,
173+ clientX : number ,
174+ clientY : number ,
175+ ) : string | null {
176+ const directHit = resolveCommentThreadHit ( target ) ;
177+ if ( directHit . isAmbiguous || directHit . threadId ) {
178+ return directHit . threadId ;
179+ }
180+
181+ // Painter output can split one visible annotation into adjacent runs. Sampling
182+ // a few nearby points keeps narrow gaps from falling through to generic caret
183+ // placement while still refusing ambiguous overlapping highlights.
184+ const nearbyElements = collectElementsNearPointerTarget ( target , clientX , clientY ) ;
185+ for ( const element of nearbyElements ) {
186+ const hit = resolveCommentThreadHit ( element ) ;
187+ if ( hit . isAmbiguous ) {
188+ return null ;
189+ }
190+ if ( hit . threadId ) {
191+ return hit . threadId ;
192+ }
193+ }
194+
195+ return null ;
196+ }
197+
86198function getActiveCommentThreadId ( editor : Editor ) : string | null {
87199 const pluginState = CommentsPluginKey . getState ( editor . state ) as { activeThreadId ?: unknown } | null ;
88200 const activeThreadId = pluginState ?. activeThreadId ;
@@ -94,18 +206,17 @@ function getActiveCommentThreadId(editor: Editor): string | null {
94206 return activeThreadId ;
95207}
96208
97- function shouldIgnoreRepeatClickOnActiveComment ( target : EventTarget | null , activeThreadId : string | null ) : boolean {
209+ function shouldIgnoreRepeatClickOnActiveComment (
210+ target : EventTarget | null ,
211+ clientX : number ,
212+ clientY : number ,
213+ activeThreadId : string | null ,
214+ ) : boolean {
98215 if ( ! activeThreadId ) {
99216 return false ;
100217 }
101218
102- const clickedThreadIds = getCommentHighlightThreadIds ( target ) ;
103-
104- if ( clickedThreadIds . length !== 1 ) {
105- return false ;
106- }
107-
108- return clickedThreadIds [ 0 ] === activeThreadId ;
219+ return resolveCommentThreadIdNearPointer ( target , clientX , clientY ) === activeThreadId ;
109220}
110221
111222// =============================================================================
@@ -924,6 +1035,10 @@ export class EditorInputManager {
9241035 }
9251036
9261037 const editor = this . #deps. getEditor ( ) ;
1038+ if ( this . #handleSingleCommentHighlightClick( event , target , editor ) ) {
1039+ return ;
1040+ }
1041+
9271042 if ( this . #handleRepeatClickOnActiveComment( event , target , editor ) ) {
9281043 return ;
9291044 }
@@ -2098,17 +2213,43 @@ export class EditorInputManager {
20982213 #handleRepeatClickOnActiveComment( event : PointerEvent , target : HTMLElement | null , editor : Editor ) : boolean {
20992214 const activeThreadId = getActiveCommentThreadId ( editor ) ;
21002215
2101- if ( ! shouldIgnoreRepeatClickOnActiveComment ( target , activeThreadId ) ) {
2216+ if ( ! shouldIgnoreRepeatClickOnActiveComment ( target , event . clientX , event . clientY , activeThreadId ) ) {
21022217 return false ;
21032218 }
21042219
21052220 event . preventDefault ( ) ;
2106- this . #focusEditor( ) ;
21072221 editor . emit ?.( 'commentsUpdate' , {
21082222 type : comments_module_events . SELECTED ,
21092223 activeCommentId : activeThreadId ,
21102224 } ) ;
21112225
21122226 return true ;
21132227 }
2228+
2229+ #handleSingleCommentHighlightClick( event : PointerEvent , target : HTMLElement | null , editor : Editor ) : boolean {
2230+ const clickedThreadId = resolveCommentThreadIdNearPointer ( target , event . clientX , event . clientY ) ;
2231+ if ( ! clickedThreadId ) {
2232+ return false ;
2233+ }
2234+
2235+ const activeThreadId = getActiveCommentThreadId ( editor ) ;
2236+ if ( clickedThreadId === activeThreadId ) {
2237+ return false ;
2238+ }
2239+
2240+ event . preventDefault ( ) ;
2241+
2242+ const didSetCursor = editor . commands ?. setCursorById ?.( clickedThreadId , {
2243+ activeCommentId : clickedThreadId ,
2244+ } ) ;
2245+
2246+ if ( ! didSetCursor ) {
2247+ editor . emit ?.( 'commentsUpdate' , {
2248+ type : comments_module_events . SELECTED ,
2249+ activeCommentId : clickedThreadId ,
2250+ } ) ;
2251+ }
2252+
2253+ return true ;
2254+ }
21142255}
0 commit comments