fix: support automatic rtl editor direction

This commit is contained in:
lucaronin
2026-05-03 10:40:38 +02:00
parent 67623d848b
commit d96d6efd57
6 changed files with 160 additions and 3 deletions

View File

@@ -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"
]
}
]
}

View File

@@ -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.

View File

@@ -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;

View File

@@ -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(() =>

View File

@@ -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]),

View File

@@ -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')
})