@@ -31,6 +31,14 @@ const CLASS_NAMES = {
3131 tableFragment : DOM_CLASS_NAMES . TABLE_FRAGMENT ,
3232} as const ;
3333
34+ function isRtlLine ( lineEl : HTMLElement ) : boolean {
35+ return getComputedStyle ( lineEl ) . direction === 'rtl' ;
36+ }
37+
38+ function isVisibleRect ( rect : DOMRect ) : boolean {
39+ return rect . width > 0 && rect . height > 0 ;
40+ }
41+
3442/**
3543 * Maps a click coordinate to a ProseMirror document position using DOM data attributes.
3644 *
@@ -365,69 +373,7 @@ function processFragment(fragmentEl: HTMLElement, viewX: number, viewY: number):
365373 } ) ,
366374 ) ;
367375
368- if ( spanEls . length === 0 ) {
369- log ( 'No spans in line, returning lineStart:' , lineStart ) ;
370- return lineStart ;
371- }
372-
373- // Check if click is before first span or after last span
374- const firstRect = spanEls [ 0 ] . getBoundingClientRect ( ) ;
375- if ( viewX <= firstRect . left ) {
376- log ( 'Click before first span, returning lineStart:' , lineStart ) ;
377- return lineStart ;
378- }
379-
380- const lastRect = spanEls [ spanEls . length - 1 ] . getBoundingClientRect ( ) ;
381- if ( viewX >= lastRect . right ) {
382- log ( 'Click after last span, returning lineEnd:' , lineEnd ) ;
383- return lineEnd ;
384- }
385-
386- // Find the target element (span or anchor) containing or nearest to the X coordinate
387- const targetEl = findSpanAtX ( spanEls , viewX ) ;
388- if ( ! targetEl ) {
389- log ( 'No target element found, returning lineStart:' , lineStart ) ;
390- return lineStart ;
391- }
392-
393- const spanStart = Number ( targetEl . dataset . pmStart ?? 'NaN' ) ;
394- const spanEnd = Number ( targetEl . dataset . pmEnd ?? 'NaN' ) ;
395- const targetRect = targetEl . getBoundingClientRect ( ) ;
396-
397- log ( 'Target element:' , {
398- tag : targetEl . tagName ,
399- pmStart : spanStart ,
400- pmEnd : spanEnd ,
401- text : targetEl . textContent ?. substring ( 0 , 30 ) ,
402- visibility : targetEl . style . visibility ,
403- rect : { left : targetRect . left , right : targetRect . right , width : targetRect . width } ,
404- pageX : viewX ,
405- pageY : viewY ,
406- } ) ;
407-
408- if ( ! Number . isFinite ( spanStart ) || ! Number . isFinite ( spanEnd ) ) {
409- log ( 'Element has invalid PM positions' ) ;
410- return null ;
411- }
412-
413- // Get the text node and find the character index
414- const firstChild = targetEl . firstChild ;
415- if ( ! firstChild || firstChild . nodeType !== Node . TEXT_NODE || ! firstChild . textContent ) {
416- // Empty element or non-text node: choose closer edge
417- const elRect = targetEl . getBoundingClientRect ( ) ;
418- const closerToLeft = Math . abs ( viewX - elRect . left ) <= Math . abs ( viewX - elRect . right ) ;
419- const snapPos = closerToLeft ? spanStart : spanEnd ;
420- log ( 'Empty/non-text element, snapping to:' , { closerToLeft, snapPos } ) ;
421- return snapPos ;
422- }
423-
424- const textNode = firstChild as Text ;
425- const charIndex = findCharIndexAtX ( textNode , targetEl , viewX ) ;
426- const pos = mapCharIndexToPm ( spanStart , spanEnd , textNode . length , charIndex ) ;
427-
428- log ( 'Character position:' , { charIndex, spanStart, finalPos : pos } ) ;
429-
430- return pos ;
376+ return resolveLinePosition ( lineEl , lineStart , lineEnd , spanEls , viewX ) ;
431377}
432378
433379function mapCharIndexToPm ( spanStart : number , spanEnd : number , textLength : number , charIndex : number ) : number {
@@ -505,25 +451,56 @@ function processLineElement(lineEl: HTMLElement, viewX: number): number | null {
505451 } ) ,
506452 ) ;
507453
454+ return resolveLinePosition ( lineEl , lineStart , lineEnd , spanEls , viewX ) ;
455+ }
456+
457+ /**
458+ * Shared logic for resolving a click's X coordinate to a ProseMirror position
459+ * within a line. Used by both `processFragment` (after locating the line by Y)
460+ * and `processLineElement` (when the line is already known from the hit chain).
461+ *
462+ * Handles RTL-aware boundary snapping, hidden-span filtering, empty-element
463+ * snapping, and character-level position mapping via `findCharIndexAtX`.
464+ *
465+ * @internal
466+ */
467+ function resolveLinePosition (
468+ lineEl : HTMLElement ,
469+ lineStart : number ,
470+ lineEnd : number ,
471+ spanEls : HTMLElement [ ] ,
472+ viewX : number ,
473+ ) : number | null {
508474 if ( spanEls . length === 0 ) {
509475 log ( 'No spans in line, returning lineStart:' , lineStart ) ;
510476 return lineStart ;
511477 }
512478
513- // Check if click is before first span or after last span
514- const firstRect = spanEls [ 0 ] . getBoundingClientRect ( ) ;
515- if ( viewX <= firstRect . left ) {
516- log ( 'Click before first span, returning lineStart:' , lineStart ) ;
517- return lineStart ;
479+ const rtl = isRtlLine ( lineEl ) ;
480+
481+ // Filter out non-rendered spans (display:none field annotations, hidden tracked
482+ // changes, etc.) whose zero-sized rects would collapse the bounds to 0.
483+ // When every rect is zero-sized (e.g. JSDOM) fall back to the unfiltered set
484+ // so the downstream logic still runs.
485+ const allRects = spanEls . map ( ( el ) => el . getBoundingClientRect ( ) ) ;
486+ const visibleRects = allRects . filter ( isVisibleRect ) ;
487+ const boundsRects = visibleRects . length > 0 ? visibleRects : allRects ;
488+
489+ const visualLeft = Math . min ( ...boundsRects . map ( ( r ) => r . left ) ) ;
490+ const visualRight = Math . max ( ...boundsRects . map ( ( r ) => r . right ) ) ;
491+
492+ if ( viewX <= visualLeft ) {
493+ const pos = rtl ? lineEnd : lineStart ;
494+ log ( 'Click to visual left of all spans, returning:' , pos ) ;
495+ return pos ;
518496 }
519497
520- const lastRect = spanEls [ spanEls . length - 1 ] . getBoundingClientRect ( ) ;
521- if ( viewX >= lastRect . right ) {
522- log ( 'Click after last span , returning lineEnd :' , lineEnd ) ;
523- return lineEnd ;
498+ if ( viewX >= visualRight ) {
499+ const pos = rtl ? lineStart : lineEnd ;
500+ log ( 'Click to visual right of all spans , returning:' , pos ) ;
501+ return pos ;
524502 }
525503
526- // Find the target element containing or nearest to the X coordinate
527504 const targetEl = findSpanAtX ( spanEls , viewX ) ;
528505 if ( ! targetEl ) {
529506 log ( 'No target element found, returning lineStart:' , lineStart ) ;
@@ -532,38 +509,38 @@ function processLineElement(lineEl: HTMLElement, viewX: number): number | null {
532509
533510 const spanStart = Number ( targetEl . dataset . pmStart ?? 'NaN' ) ;
534511 const spanEnd = Number ( targetEl . dataset . pmEnd ?? 'NaN' ) ;
535- const targetRect = targetEl . getBoundingClientRect ( ) ;
536512
537513 log ( 'Target element:' , {
538514 tag : targetEl . tagName ,
539515 pmStart : spanStart ,
540516 pmEnd : spanEnd ,
541517 text : targetEl . textContent ?. substring ( 0 , 30 ) ,
542518 visibility : targetEl . style . visibility ,
543- rect : { left : targetRect . left , right : targetRect . right , width : targetRect . width } ,
519+ rect : ( ( ) => {
520+ const r = targetEl . getBoundingClientRect ( ) ;
521+ return { left : r . left , right : r . right , width : r . width } ;
522+ } ) ( ) ,
544523 } ) ;
545524
546525 if ( ! Number . isFinite ( spanStart ) || ! Number . isFinite ( spanEnd ) ) {
547526 log ( 'Element has invalid PM positions' ) ;
548527 return null ;
549528 }
550529
551- // Get the text node and find the character index
552530 const firstChild = targetEl . firstChild ;
553531 if ( ! firstChild || firstChild . nodeType !== Node . TEXT_NODE || ! firstChild . textContent ) {
554- // Empty element or non-text node: choose closer edge
555532 const elRect = targetEl . getBoundingClientRect ( ) ;
556533 const closerToLeft = Math . abs ( viewX - elRect . left ) <= Math . abs ( viewX - elRect . right ) ;
557- const snapPos = closerToLeft ? spanStart : spanEnd ;
558- log ( 'Empty/non-text element, snapping to:' , { closerToLeft, snapPos } ) ;
534+ const snapPos = rtl ? ( closerToLeft ? spanEnd : spanStart ) : closerToLeft ? spanStart : spanEnd ;
535+ log ( 'Empty/non-text element, snapping to:' , { closerToLeft, rtl , snapPos } ) ;
559536 return snapPos ;
560537 }
561538
562539 const textNode = firstChild as Text ;
563- const charIndex = findCharIndexAtX ( textNode , targetEl , viewX ) ;
564- const pos = spanStart + charIndex ;
540+ const charIndex = findCharIndexAtX ( textNode , viewX , rtl ) ;
541+ const pos = mapCharIndexToPm ( spanStart , spanEnd , textNode . length , charIndex ) ;
565542
566- log ( 'Character position:' , { charIndex, spanStart, finalPos : pos } ) ;
543+ log ( 'Character position:' , { charIndex, spanStart, rtl , finalPos : pos } ) ;
567544
568545 return pos ;
569546}
@@ -628,10 +605,12 @@ function findSpanAtX(spanEls: HTMLElement[], viewX: number): HTMLElement | null
628605 }
629606
630607 let targetSpan : HTMLElement = spanEls [ 0 ] ;
608+ let minDist = Infinity ;
631609
632610 for ( let i = 0 ; i < spanEls . length ; i ++ ) {
633611 const span = spanEls [ i ] ;
634612 const rect = span . getBoundingClientRect ( ) ;
613+ if ( ! isVisibleRect ( rect ) ) continue ;
635614 if ( viewX >= rect . left && viewX <= rect . right ) {
636615 log ( 'findSpanAtX: Found containing element at index' , i , {
637616 tag : span . tagName ,
@@ -642,8 +621,9 @@ function findSpanAtX(spanEls: HTMLElement[], viewX: number): HTMLElement | null
642621 } ) ;
643622 return span ;
644623 }
645- // Track nearest element to the right if none contain X
646- if ( viewX > rect . right ) {
624+ const dist = Math . min ( Math . abs ( viewX - rect . left ) , Math . abs ( viewX - rect . right ) ) ;
625+ if ( dist < minDist ) {
626+ minDist = dist ;
647627 targetSpan = span ;
648628 }
649629 }
@@ -660,61 +640,68 @@ function findSpanAtX(spanEls: HTMLElement[], viewX: number): HTMLElement | null
660640/**
661641 * Finds the character index in a text node closest to a given X coordinate.
662642 *
663- * Uses binary search with `document.createRange()` to efficiently find the
664- * character boundary nearest to the target X position. This provides accurate
665- * click-to-character mapping even with variable-width fonts, ligatures, and
666- * letter-spacing.
643+ * Uses `document.caretPositionFromPoint` / `document.caretRangeFromPoint` as
644+ * the primary strategy, which correctly handles RTL, bidi, and contextual
645+ * shaping (Arabic ligatures, etc.). Falls back to a binary search with
646+ * per-character Range rects when the caret API is unavailable or returns a
647+ * result outside the target text node.
667648 *
668649 * @param textNode - The Text node containing the characters
669- * @param container - The element containing the text node (span or anchor, for position reference)
670650 * @param targetX - The target X coordinate in viewport space
651+ * @param rtl - Whether the containing line has RTL direction
671652 * @returns Character index (0-based) within the text node
672- *
673- * @example
674- * ```typescript
675- * const textNode = element.firstChild as Text;
676- * const charIndex = findCharIndexAtX(textNode, element, 150);
677- * // charIndex might be 5 if the click was near the 5th character
678- * ```
679653 */
680- function findCharIndexAtX ( textNode : Text , container : HTMLElement , targetX : number ) : number {
654+ function findCharIndexAtX ( textNode : Text , targetX : number , rtl : boolean ) : number {
681655 const text = textNode . textContent ?? '' ;
682- const baseLeft = container . getBoundingClientRect ( ) . left ;
656+ if ( text . length === 0 ) return 0 ;
657+
658+ const container = textNode . parentElement ;
659+ if ( ! container ) return 0 ;
660+ const containerRect = container . getBoundingClientRect ( ) ;
661+ const targetY = containerRect . top + containerRect . height / 2 ;
662+
663+ // Strategy 1: Browser caret API — handles bidi / shaping natively.
664+ const caretIndex = caretOffsetFromPoint ( targetX , targetY , textNode ) ;
665+ if ( caretIndex != null ) {
666+ log ( 'findCharIndexAtX: caret API returned' , caretIndex ) ;
667+ return caretIndex ;
668+ }
669+
670+ // Strategy 2: Binary search using cumulative range rects.
671+ // For LTR the boundary edge is the right side of Range(0, i); for RTL it is the left side.
672+ // Using the actual rect edges avoids subpixel alignment issues with the container.
673+ log ( 'findCharIndexAtX: falling back to range binary search, rtl =' , rtl ) ;
683674 const range = document . createRange ( ) ;
684675
685- // Binary search for the first character where measured X >= target X
676+ const measureX = ( i : number ) : number => {
677+ if ( i <= 0 ) {
678+ return rtl ? containerRect . right : containerRect . left ;
679+ }
680+ range . setStart ( textNode , 0 ) ;
681+ range . setEnd ( textNode , i ) ;
682+ const r = range . getBoundingClientRect ( ) ;
683+ return rtl ? r . left : r . right ;
684+ } ;
685+
686686 let lo = 0 ;
687687 let hi = text . length ;
688688
689689 while ( lo < hi ) {
690690 const mid = Math . floor ( ( lo + hi ) / 2 ) ;
691- range . setStart ( textNode , 0 ) ;
692- range . setEnd ( textNode , mid ) ;
693- const w = range . getBoundingClientRect ( ) . width ;
694- const x = baseLeft + w ;
695- if ( x < targetX ) {
691+ const x = measureX ( mid ) ;
692+ if ( rtl ? x > targetX : x < targetX ) {
696693 lo = mid + 1 ;
697694 } else {
698695 hi = mid ;
699696 }
700697 }
701698
702- // lo is the first index where measured X >= click X
703- // Compare with previous boundary to find nearest
704699 const index = Math . max ( 0 , Math . min ( text . length , lo ) ) ;
705-
706- const measureAt = ( i : number ) : number => {
707- range . setStart ( textNode , 0 ) ;
708- range . setEnd ( textNode , i ) ;
709- return baseLeft + range . getBoundingClientRect ( ) . width ;
710- } ;
711-
712- const xAt = measureAt ( index ) ;
700+ const xAt = measureX ( index ) ;
713701 const distAt = Math . abs ( xAt - targetX ) ;
714702
715- // Check if previous boundary is closer
716703 if ( index > 0 ) {
717- const xPrev = measureAt ( index - 1 ) ;
704+ const xPrev = measureX ( index - 1 ) ;
718705 const distPrev = Math . abs ( xPrev - targetX ) ;
719706 if ( distPrev < distAt ) {
720707 return index - 1 ;
@@ -723,3 +710,31 @@ function findCharIndexAtX(textNode: Text, container: HTMLElement, targetX: numbe
723710
724711 return index ;
725712}
713+
714+ /**
715+ * Uses the browser's native caret-from-point API to find a character offset
716+ * within a specific text node. Returns null when the API is unavailable or
717+ * reports a node other than the expected text node.
718+ */
719+ function caretOffsetFromPoint ( x : number , y : number , expectedNode : Text ) : number | null {
720+ // Firefox / spec-track: caretPositionFromPoint
721+ const doc = document as Document & {
722+ caretPositionFromPoint ?( x : number , y : number ) : { offsetNode : Node ; offset : number } | null ;
723+ } ;
724+ if ( typeof doc . caretPositionFromPoint === 'function' ) {
725+ const cp = doc . caretPositionFromPoint ( x , y ) ;
726+ if ( cp && cp . offsetNode === expectedNode ) {
727+ return cp . offset ;
728+ }
729+ }
730+
731+ // WebKit / Blink: caretRangeFromPoint
732+ if ( typeof doc . caretRangeFromPoint === 'function' ) {
733+ const r = doc . caretRangeFromPoint ( x , y ) ;
734+ if ( r && r . startContainer === expectedNode ) {
735+ return r . startOffset ;
736+ }
737+ }
738+
739+ return null ;
740+ }
0 commit comments