Skip to content

Commit 1a2fe03

Browse files
authored
fix(layout-bridge): correct click-to-position mapping for RTL text (#2541)
* fix(layout-bridge): correct click-to-position mapping for RTL text * fix: ignore hidden spans when computing line visual bounds * fix: failing tests * chore: fixed pr comments and added RTL test
1 parent 6172c99 commit 1a2fe03

2 files changed

Lines changed: 193 additions & 116 deletions

File tree

packages/layout-engine/layout-bridge/src/dom-mapping.ts

Lines changed: 131 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -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

433379
function 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

Comments
 (0)