Skip to content

Commit e5ef9a1

Browse files
committed
chore(layout-bridge): clean up RTL click-to-position code and add behavior tests
Follow-up to #2541: - Replace IIFE in log payload with a reusable local variable - Tighten RTL empty-element snap test assertion from loose (5 || 10) to exact (5) - Fix test comment to reference LTR counterpart by name instead of line number - Add Playwright behavior tests for RTL click-to-position mapping
1 parent 1a2fe03 commit e5ef9a1

4 files changed

Lines changed: 134 additions & 10 deletions

File tree

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -509,17 +509,15 @@ function resolveLinePosition(
509509

510510
const spanStart = Number(targetEl.dataset.pmStart ?? 'NaN');
511511
const spanEnd = Number(targetEl.dataset.pmEnd ?? 'NaN');
512+
const targetRect = targetEl.getBoundingClientRect();
512513

513514
log('Target element:', {
514515
tag: targetEl.tagName,
515516
pmStart: spanStart,
516517
pmEnd: spanEnd,
517518
text: targetEl.textContent?.substring(0, 30),
518519
visibility: targetEl.style.visibility,
519-
rect: (() => {
520-
const r = targetEl.getBoundingClientRect();
521-
return { left: r.left, right: r.right, width: r.width };
522-
})(),
520+
rect: { left: targetRect.left, right: targetRect.right, width: targetRect.width },
523521
});
524522

525523
if (!Number.isFinite(spanStart) || !Number.isFinite(spanEnd)) {
@@ -529,8 +527,7 @@ function resolveLinePosition(
529527

530528
const firstChild = targetEl.firstChild;
531529
if (!firstChild || firstChild.nodeType !== Node.TEXT_NODE || !firstChild.textContent) {
532-
const elRect = targetEl.getBoundingClientRect();
533-
const closerToLeft = Math.abs(viewX - elRect.left) <= Math.abs(viewX - elRect.right);
530+
const closerToLeft = Math.abs(viewX - targetRect.left) <= Math.abs(viewX - targetRect.right);
534531
const snapPos = rtl ? (closerToLeft ? spanEnd : spanStart) : closerToLeft ? spanStart : spanEnd;
535532
log('Empty/non-text element, snapping to:', { closerToLeft, rtl, snapPos });
536533
return snapPos;

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -598,12 +598,12 @@ describe('DOM-based click-to-position mapping', () => {
598598
</div>
599599
`;
600600

601-
// LTR version of this test (line 279) expects 5 or 10.
602-
// In RTL with a zero-sized rect the "closer to left" check resolves to
603-
// spanEnd (10) instead of spanStart (5).
601+
// In JSDOM all rects are zero-sized, so viewX (1) >= visualRight (0) triggers
602+
// the right-boundary snap which returns lineStart (5) for RTL.
603+
// The LTR counterpart ('handles empty text nodes gracefully') returns lineEnd (10).
604604
const spanRect = container.querySelector('span')!.getBoundingClientRect();
605605
const result = clickToPositionDom(container, spanRect.left + 1, spanRect.top + 1);
606-
expect(result === 5 || result === 10).toBe(true);
606+
expect(result).toBe(5);
607607
});
608608
});
609609

13.6 KB
Binary file not shown.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { test, expect } from '../../fixtures/superdoc.js';
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
const DOC_PATH = path.resolve(__dirname, 'fixtures/rtl-mixed-bidi.docx');
8+
9+
test.skip(!fs.existsSync(DOC_PATH), 'RTL fixture not available');
10+
11+
test.use({ config: { toolbar: 'none', showCaret: true, showSelection: true } });
12+
13+
test.describe('RTL click-to-position mapping', () => {
14+
test('clicking left and right of RTL line places cursor at different positions', async ({ superdoc }) => {
15+
await superdoc.loadDocument(DOC_PATH);
16+
await superdoc.waitForStable();
17+
18+
// First line is RTL Arabic text: "هذه فقرة كاملة باللغة العربية"
19+
const rtlLine = superdoc.page.locator('.superdoc-line').first();
20+
const box = await rtlLine.boundingBox();
21+
if (!box) throw new Error('RTL line not visible');
22+
23+
// Click near the right edge (logical start of RTL text)
24+
await superdoc.page.mouse.click(box.x + box.width - 10, box.y + box.height / 2);
25+
await superdoc.waitForStable();
26+
const selRight = await superdoc.getSelection();
27+
28+
// Click near the left edge (logical end of RTL text)
29+
await superdoc.page.mouse.click(box.x + 10, box.y + box.height / 2);
30+
await superdoc.waitForStable();
31+
const selLeft = await superdoc.getSelection();
32+
33+
expect(selRight.from).toBeGreaterThan(0);
34+
expect(selLeft.from).toBeGreaterThan(0);
35+
36+
// In RTL, clicking right (text start) should give a lower PM position
37+
// than clicking left (text end)
38+
expect(selRight.from).toBeLessThan(selLeft.from);
39+
});
40+
41+
test('clicking inside RTL text places cursor at valid position', async ({ superdoc }) => {
42+
await superdoc.loadDocument(DOC_PATH);
43+
await superdoc.waitForStable();
44+
45+
// First line is RTL: "هذه فقرة كاملة باللغة العربية"
46+
// RTL text renders right-aligned, so we need to click within the text area
47+
const rtlLine = superdoc.page.locator('.superdoc-line').first();
48+
const box = await rtlLine.boundingBox();
49+
if (!box) throw new Error('RTL line not visible');
50+
51+
// Find the first span to know where the visible text actually is
52+
const span = superdoc.page.locator('.superdoc-line').first().locator('span[data-pm-start]').first();
53+
const spanBox = await span.boundingBox();
54+
if (!spanBox) throw new Error('RTL span not visible');
55+
56+
// Click inside the visible text area (middle of the span)
57+
await superdoc.page.mouse.click(spanBox.x + spanBox.width / 2, spanBox.y + spanBox.height / 2);
58+
await superdoc.waitForStable();
59+
60+
const sel = await superdoc.getSelection();
61+
const lineStart = Number(await rtlLine.getAttribute('data-pm-start'));
62+
const lineEnd = Number(await rtlLine.getAttribute('data-pm-end'));
63+
64+
// Cursor should be within the line's PM range
65+
expect(sel.from).toBeGreaterThanOrEqual(lineStart);
66+
expect(sel.from).toBeLessThanOrEqual(lineEnd);
67+
});
68+
69+
test('clicking left edge of RTL and LTR lines gives opposite ends', async ({ superdoc }) => {
70+
await superdoc.loadDocument(DOC_PATH);
71+
await superdoc.waitForStable();
72+
73+
const lines = superdoc.page.locator('.superdoc-line');
74+
75+
// Line 0: RTL (pm 2-47), Line 1: LTR (pm 51-87)
76+
const rtlLine = lines.nth(0);
77+
const ltrLine = lines.nth(1);
78+
79+
const rtlBox = await rtlLine.boundingBox();
80+
const ltrBox = await ltrLine.boundingBox();
81+
if (!rtlBox || !ltrBox) throw new Error('Lines not visible');
82+
83+
// Click left edge of LTR line → should land near lineStart
84+
await superdoc.page.mouse.click(ltrBox.x + 10, ltrBox.y + ltrBox.height / 2);
85+
await superdoc.waitForStable();
86+
const selLtr = await superdoc.getSelection();
87+
const ltrStart = Number(await ltrLine.getAttribute('data-pm-start'));
88+
const ltrEnd = Number(await ltrLine.getAttribute('data-pm-end'));
89+
90+
// Click left edge of RTL line → should land near lineEnd (inverted)
91+
await superdoc.page.mouse.click(rtlBox.x + 10, rtlBox.y + rtlBox.height / 2);
92+
await superdoc.waitForStable();
93+
const selRtl = await superdoc.getSelection();
94+
const rtlStart = Number(await rtlLine.getAttribute('data-pm-start'));
95+
const rtlEnd = Number(await rtlLine.getAttribute('data-pm-end'));
96+
97+
// LTR: left click → near start of line
98+
expect(selLtr.from).toBeLessThan(ltrStart + (ltrEnd - ltrStart) / 2);
99+
100+
// RTL: left click → near end of line (inverted direction)
101+
expect(selRtl.from).toBeGreaterThan(rtlStart + (rtlEnd - rtlStart) / 2);
102+
});
103+
104+
test('clicking on mixed bidi RTL line places cursor correctly', async ({ superdoc }) => {
105+
await superdoc.loadDocument(DOC_PATH);
106+
await superdoc.waitForStable();
107+
108+
// Line 3: RTL mixed bidi "نص عربي ثم English text ثم عربي مرة أخرى"
109+
const mixedLine = superdoc.page.locator('.superdoc-line').nth(3);
110+
const box = await mixedLine.boundingBox();
111+
if (!box) throw new Error('Mixed bidi line not visible');
112+
113+
// Click left edge
114+
await superdoc.page.mouse.click(box.x + 10, box.y + box.height / 2);
115+
await superdoc.waitForStable();
116+
const selLeft = await superdoc.getSelection();
117+
118+
// Click right edge
119+
await superdoc.page.mouse.click(box.x + box.width - 10, box.y + box.height / 2);
120+
await superdoc.waitForStable();
121+
const selRight = await superdoc.getSelection();
122+
123+
expect(selLeft.from).toBeGreaterThan(0);
124+
expect(selRight.from).toBeGreaterThan(0);
125+
expect(selLeft.from).not.toBe(selRight.from);
126+
});
127+
});

0 commit comments

Comments
 (0)