From d96d6efd579d2d00d54c74b35867cb99184027bb Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 3 May 2026 10:40:38 +0200 Subject: [PATCH] fix: support automatic rtl editor direction --- demo-vault-v2/.fixture-manifest.json | 8 ++- demo-vault-v2/rtl-mixed-direction-qa.md | 17 +++++++ src/components/EditorTheme.css | 6 +++ src/hooks/useCodeMirror.test.ts | 11 ++++ src/hooks/useCodeMirror.ts | 57 ++++++++++++++++++++- tests/smoke/rtl-editor-direction.spec.ts | 64 ++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 demo-vault-v2/rtl-mixed-direction-qa.md create mode 100644 tests/smoke/rtl-editor-direction.spec.ts diff --git a/demo-vault-v2/.fixture-manifest.json b/demo-vault-v2/.fixture-manifest.json index ee77194f..b9a7686f 100644 --- a/demo-vault-v2/.fixture-manifest.json +++ b/demo-vault-v2/.fixture-manifest.json @@ -49,7 +49,13 @@ "laputa-qa-reference.md", "attachments/laputa-reference.png" ] + }, + { + "id": "rtl-mixed-direction", + "reason": "Arabic and mixed English/Arabic paragraphs keep rich editor and raw editor BiDi QA anchored to the fixture.", + "files": [ + "rtl-mixed-direction-qa.md" + ] } ] } - diff --git a/demo-vault-v2/rtl-mixed-direction-qa.md b/demo-vault-v2/rtl-mixed-direction-qa.md new file mode 100644 index 00000000..2dec8197 --- /dev/null +++ b/demo-vault-v2/rtl-mixed-direction-qa.md @@ -0,0 +1,17 @@ +--- +type: Note +topics: + - "[[topic-writing]]" +--- + +# RTL Mixed Direction QA + +مرحبا بالعالم. هذه فقرة عربية لاختبار اتجاه النص من اليمين إلى اليسار داخل محرر تولاريا. + +English text should keep reading left to right when it appears next to Arabic content. + +English then مرحبا بالعالم keeps both scripts readable on one line. + +مرحبا بالعالم then English keeps the Arabic run anchored correctly while preserving the English words. + +Use this note when checking rich editor and raw Markdown editor behavior for automatic LTR/RTL direction. diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index d80d3083..a12fa4d9 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -23,6 +23,12 @@ padding-right: 0; } +/* Let each rich-text block resolve its own base direction for mixed LTR/RTL notes. */ +.editor__blocknote-container .bn-inline-content { + unicode-bidi: plaintext; + text-align: start; +} + /* Override BlockNote's default line-height on block-outer so our theme controls it */ .editor__blocknote-container .bn-block-outer { line-height: var(--editor-line-height) !important; diff --git a/src/hooks/useCodeMirror.test.ts b/src/hooks/useCodeMirror.test.ts index 1adcf83a..c178bceb 100644 --- a/src/hooks/useCodeMirror.test.ts +++ b/src/hooks/useCodeMirror.test.ts @@ -42,6 +42,17 @@ describe('useCodeMirror', () => { expect(result.current.current?.state.facet(EditorView.cspNonce)).toBe(RUNTIME_STYLE_NONCE) }) + it('enables per-line auto text direction for mixed LTR and RTL content', () => { + const ref = { current: container } + const { result } = renderHook(() => + useCodeMirror(ref, 'English\nمرحبا بالعالم', noopCallbacks), + ) + const view = result.current.current! + + expect(view.state.facet(EditorView.perLineTextDirection)).toBe(true) + expect([...container.querySelectorAll('.cm-line')].map(line => line.getAttribute('dir'))).toEqual(['auto', 'auto']) + }) + it('calls requestMeasure when laputa-zoom-change event fires', () => { const ref = { current: container } const { result } = renderHook(() => diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index 273ececb..38b16aaf 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -1,5 +1,14 @@ import { useRef, useEffect } from 'react' -import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view' +import { + Decoration, + type DecorationSet, + EditorView, + lineNumbers, + highlightActiveLine, + keymap, + ViewPlugin, + type ViewUpdate, +} from '@codemirror/view' import { EditorState, Prec } from '@codemirror/state' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight' @@ -18,6 +27,10 @@ const RAW_EDITOR_COLORS = { gutterBorder: 'var(--border-subtle)', gutterText: 'var(--text-muted)', } + +const AUTO_TEXT_DIRECTION_LINE = Decoration.line({ + attributes: { dir: 'auto' }, +}) interface MarkdownFence { character: '`' | '~' length: number @@ -105,10 +118,49 @@ function buildBaseTheme() { backgroundColor: RAW_EDITOR_COLORS.activeLineBackground, }, '&.cm-focused': { outline: 'none' }, - '.cm-line': { padding: '0' }, + '.cm-line': { + padding: '0', + unicodeBidi: 'plaintext', + textAlign: 'start', + }, }) } +function buildAutoTextDirectionDecorations(view: EditorView): DecorationSet { + const ranges = [] + + for (const visibleRange of view.visibleRanges) { + for (let pos = visibleRange.from; pos <= visibleRange.to;) { + const line = view.state.doc.lineAt(pos) + ranges.push(AUTO_TEXT_DIRECTION_LINE.range(line.from)) + pos = line.to + 1 + } + } + + return Decoration.set(ranges, true) +} + +function buildAutoTextDirectionExtension() { + return [ + EditorView.perLineTextDirection.of(true), + ViewPlugin.fromClass(class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = buildAutoTextDirectionDecorations(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildAutoTextDirectionDecorations(update.view) + } + } + }, { + decorations: plugin => plugin.decorations, + }), + ] +} + function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) { return Prec.highest(keymap.of([{ key: 'Mod-s', @@ -188,6 +240,7 @@ export function useCodeMirror( lineNumbers(), highlightActiveLine(), EditorView.lineWrapping, + buildAutoTextDirectionExtension(), history(), buildArrowLigaturesExtension(), keymap.of([...defaultKeymap, ...historyKeymap]), diff --git a/tests/smoke/rtl-editor-direction.spec.ts b/tests/smoke/rtl-editor-direction.spec.ts new file mode 100644 index 00000000..1c771176 --- /dev/null +++ b/tests/smoke/rtl-editor-direction.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, type Page } from '@playwright/test' +import fs from 'fs' +import path from 'path' +import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' +import { executeCommand, openCommandPalette } from './helpers' + +const RTL_TITLE = 'RTL Mixed Direction' +const RTL_PARAGRAPH = 'مرحبا بالعالم' +const MIXED_PARAGRAPH = 'English then مرحبا' + +let tempVaultDir: string + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + fs.writeFileSync( + path.join(tempVaultDir, 'note', 'rtl-mixed-direction.md'), + [ + '---', + 'Is A: Note', + '---', + '', + `# ${RTL_TITLE}`, + '', + RTL_PARAGRAPH, + '', + MIXED_PARAGRAPH, + '', + ].join('\n'), + ) + await openFixtureVault(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +async function openNote(page: Page, title: string) { + await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click() + await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) +} + +async function openRawMode(page: Page) { + await openCommandPalette(page) + await executeCommand(page, 'Toggle Raw') + await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 }) +} + +test('rich and raw editors resolve text direction per line for Arabic and mixed content', async ({ page }) => { + await openNote(page, RTL_TITLE) + + const rtlRichBlock = page.locator('.bn-inline-content', { hasText: RTL_PARAGRAPH }).first() + await expect(rtlRichBlock).toBeVisible({ timeout: 5_000 }) + await expect(rtlRichBlock).toHaveCSS('unicode-bidi', 'plaintext') + await expect(rtlRichBlock).toHaveCSS('text-align', 'start') + + await openRawMode(page) + + const rawLines = page.locator('.cm-line') + await expect(rawLines.filter({ hasText: RTL_PARAGRAPH })).toHaveAttribute('dir', 'auto') + await expect(rawLines.filter({ hasText: MIXED_PARAGRAPH })).toHaveAttribute('dir', 'auto') + await expect(rawLines.filter({ hasText: RTL_PARAGRAPH })).toHaveCSS('unicode-bidi', 'plaintext') + await expect(rawLines.filter({ hasText: RTL_PARAGRAPH })).toHaveCSS('text-align', 'start') +})