diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js index 5919035f2b..94b3717102 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.js @@ -141,6 +141,12 @@ export class ParagraphNodeView { if (paragraphProperties.styleId) { this.dom.setAttribute('styleid', paragraphProperties.styleId); } + + if (paragraphProperties.rightToLeft) { + this.dom.setAttribute('dir', 'rtl'); + } else { + this.dom.removeAttribute('dir'); + } } /** diff --git a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js index eb46ceb100..4575db07f8 100644 --- a/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js +++ b/packages/super-editor/src/editors/v1/extensions/paragraph/ParagraphNodeView.test.js @@ -301,4 +301,45 @@ describe('ParagraphNodeView', () => { expect(nodeView.dom.getAttribute('data-level')).toBeNull(); expect(nodeView.dom.classList.contains('sd-editor-dropcap')).toBe(false); }); + + it('sets dir="rtl" on RTL paragraphs', () => { + isList.mockReturnValue(false); + resolveParagraphProperties.mockReturnValue({ rightToLeft: true }); + + const { nodeView } = mountNodeView({ + attrs: { + paragraphProperties: { rightToLeft: true }, + listRendering: {}, + }, + }); + + expect(nodeView.dom.getAttribute('dir')).toBe('rtl'); + }); + + it('does not set dir on LTR paragraphs', () => { + isList.mockReturnValue(false); + resolveParagraphProperties.mockReturnValue({}); + + const { nodeView } = mountNodeView(); + + expect(nodeView.dom.getAttribute('dir')).toBeNull(); + }); + + it('removes dir="rtl" when paragraph changes from RTL to LTR', () => { + isList.mockReturnValue(false); + resolveParagraphProperties.mockReturnValueOnce({ rightToLeft: true }).mockReturnValueOnce({}); + + const { nodeView } = mountNodeView({ + attrs: { + paragraphProperties: { rightToLeft: true }, + listRendering: {}, + }, + }); + expect(nodeView.dom.getAttribute('dir')).toBe('rtl'); + + const ltrNode = createNode({ attrs: { paragraphProperties: {}, listRendering: {} } }); + nodeView.update(ltrNode, []); + + expect(nodeView.dom.getAttribute('dir')).toBeNull(); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js index 7f96f64fd9..d50d444830 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.js @@ -145,7 +145,7 @@ export const VerticalNavigation = Extension.create({ ) { // Hit test produced a position outside the adjacent line's range. // Resolve position directly from layout data using binary search at goalX. - hit = resolvePositionAtGoalX(editor, adjacent.pmStart, adjacent.pmEnd, goalX); + hit = resolvePositionAtGoalX(editor, adjacent.pmStart, adjacent.pmEnd, goalX, adjacent.isRtl); } } @@ -265,11 +265,16 @@ function getAdjacentLineClientTarget(editor, coords, direction) { const pmStart = Number(adjacentLine.dataset?.pmStart); const pmEnd = Number(adjacentLine.dataset?.pmEnd); + // Read direction from the visual DOM — DomPainter sets dir="rtl" on RTL lines + // using fully resolved properties (style cascade, not just inline attrs). + const isRtl = adjacentLine.closest?.('[dir="rtl"]') != null; + return { clientY, pageIndex: Number.isFinite(pageIndex) ? pageIndex : undefined, pmStart: Number.isFinite(pmStart) ? pmStart : undefined, pmEnd: Number.isFinite(pmEnd) ? pmEnd : undefined, + isRtl, }; } @@ -372,16 +377,15 @@ function findAdjacentLineElement(currentLine, direction, caretX) { * @param {number} pmStart - Start PM position of the target line. * @param {number} pmEnd - End PM position of the target line. * @param {number} goalX - Target X coordinate in layout space. + * @param {boolean} [isRtl=false] - Whether the target line is RTL. In RTL lines, + * X decreases as PM position increases, so the binary search must be inverted. * @returns {{ pos: number } | null} */ -export function resolvePositionAtGoalX(editor, pmStart, pmEnd, goalX) { +export function resolvePositionAtGoalX(editor, pmStart, pmEnd, goalX, isRtl = false) { const presentationEditor = editor.presentationEditor; let bestPos = pmStart; let bestDist = Infinity; - // Binary search: characters within a single line have monotonically increasing X. - // NOTE: assumes LTR text. For RTL, X decreases with position so the search - // direction would be inverted. bestPos/bestDist tracking limits the impact. let lo = pmStart; let hi = pmEnd; @@ -403,9 +407,13 @@ export function resolvePositionAtGoalX(editor, pmStart, pmEnd, goalX) { } if (rect.x < goalX) { - lo = mid + 1; + // In LTR, X < goalX means search higher positions (further right). + // In RTL, X < goalX means search lower positions (further right in RTL). + if (isRtl) hi = mid - 1; + else lo = mid + 1; } else if (rect.x > goalX) { - hi = mid - 1; + if (isRtl) lo = mid + 1; + else hi = mid - 1; } else { // Exact match break; diff --git a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js index 733da82109..a0e821ffde 100644 --- a/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js +++ b/packages/super-editor/src/editors/v1/extensions/vertical-navigation/vertical-navigation.test.js @@ -415,4 +415,43 @@ describe('resolvePositionAtGoalX', () => { const result = resolvePositionAtGoalX(editor, 10, 10, 50); expect(result).toEqual({ pos: 10 }); }); + + describe('RTL support', () => { + it('finds correct position in RTL line (X decreases with position)', () => { + // RTL: position 10 → x=40, position 14 → x=0 (X decreases with PM position) + const editor = makeEditor((pos) => ({ x: (14 - pos) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 25, true); + // goalX=25: pos 11 has x=30 (dist=5), pos 12 has x=20 (dist=5) + // Binary search with inverted direction should find pos 11 or 12 + expect(result.pos).toBeGreaterThanOrEqual(11); + expect(result.pos).toBeLessThanOrEqual(12); + }); + + it('returns pmStart for RTL when goalX matches the rightmost position', () => { + // RTL: pmStart has highest X + const editor = makeEditor((pos) => ({ x: (14 - pos) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 40, true); + expect(result).toEqual({ pos: 10 }); + }); + + it('returns pmEnd for RTL when goalX matches the leftmost position', () => { + // RTL: pmEnd has lowest X + const editor = makeEditor((pos) => ({ x: (14 - pos) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 0, true); + expect(result).toEqual({ pos: 14 }); + }); + + it('does not invert search when isRtl is false', () => { + // LTR: X increases with position (same as existing tests) + const editor = makeEditor((pos) => ({ x: (pos - 10) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 25, false); + expect(result).toEqual({ pos: 12 }); + }); + + it('defaults to LTR when isRtl is not provided', () => { + const editor = makeEditor((pos) => ({ x: (pos - 10) * 10 })); + const result = resolvePositionAtGoalX(editor, 10, 14, 25); + expect(result).toEqual({ pos: 12 }); + }); + }); }); diff --git a/tests/behavior/tests/selection/rtl-arrow-key-movement.spec.ts b/tests/behavior/tests/selection/rtl-arrow-key-movement.spec.ts new file mode 100644 index 0000000000..c52c0f4108 --- /dev/null +++ b/tests/behavior/tests/selection/rtl-arrow-key-movement.spec.ts @@ -0,0 +1,118 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve(__dirname, 'fixtures/rtl-mixed-bidi.docx'); + +test.skip(!fs.existsSync(DOC_PATH), 'RTL fixture not available'); + +test.use({ config: { toolbar: 'none', showCaret: true, showSelection: true } }); + +test.describe('RTL arrow key cursor movement (SD-2390)', () => { + test('ArrowLeft moves cursor visually left in RTL paragraph', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // First line is RTL Arabic: "هذه فقرة كاملة باللغة العربية" + const rtlLine = superdoc.page.locator('.superdoc-line').first(); + const box = await rtlLine.boundingBox(); + if (!box) throw new Error('RTL line not visible'); + + // Click near the right edge (logical start of RTL text) + await superdoc.page.mouse.click(box.x + box.width - 20, box.y + box.height / 2); + await superdoc.waitForStable(); + + const before = await superdoc.getSelection(); + + // Get caret X before + const xBefore = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, before.from); + + // Press ArrowLeft + await superdoc.page.keyboard.press('ArrowLeft'); + await superdoc.waitForStable(); + + const after = await superdoc.getSelection(); + const xAfter = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, after.from); + + // In RTL, ArrowLeft should move visually left (decreasing X) + expect(xAfter).toBeLessThan(xBefore); + // PM position should increase (moving toward end of line in document order) + expect(after.from).toBeGreaterThan(before.from); + }); + + test('ArrowRight moves cursor visually right in RTL paragraph', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + const rtlLine = superdoc.page.locator('.superdoc-line').first(); + const box = await rtlLine.boundingBox(); + if (!box) throw new Error('RTL line not visible'); + + // Click near the middle of the line + await superdoc.page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await superdoc.waitForStable(); + + const before = await superdoc.getSelection(); + const xBefore = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, before.from); + + // Press ArrowRight + await superdoc.page.keyboard.press('ArrowRight'); + await superdoc.waitForStable(); + + const after = await superdoc.getSelection(); + const xAfter = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, after.from); + + // In RTL, ArrowRight should move visually right (increasing X) + expect(xAfter).toBeGreaterThan(xBefore); + // PM position should decrease (moving toward start of line in document order) + expect(after.from).toBeLessThan(before.from); + }); + + test('ArrowLeft/Right in LTR paragraph still works correctly', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + + // Second line is LTR English: "This is a complete English paragraph" + const ltrLine = superdoc.page.locator('.superdoc-line').nth(1); + const box = await ltrLine.boundingBox(); + if (!box) throw new Error('LTR line not visible'); + + // Click near the left edge + await superdoc.page.mouse.click(box.x + 30, box.y + box.height / 2); + await superdoc.waitForStable(); + + const before = await superdoc.getSelection(); + const xBefore = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, before.from); + + // Press ArrowRight in LTR + await superdoc.page.keyboard.press('ArrowRight'); + await superdoc.waitForStable(); + + const after = await superdoc.getSelection(); + const xAfter = await superdoc.page.evaluate((pos) => { + const pe = (window as any).superdoc?.activeEditor?.presentationEditor; + return pe?.computeCaretLayoutRect(pos)?.x; + }, after.from); + + // In LTR, ArrowRight moves visually right (increasing X) and increases PM position + expect(xAfter).toBeGreaterThan(xBefore); + expect(after.from).toBeGreaterThan(before.from); + }); +});