fix: support automatic rtl editor direction
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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]),
|
||||
|
||||
64
tests/smoke/rtl-editor-direction.spec.ts
Normal file
64
tests/smoke/rtl-editor-direction.spec.ts
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user