Skip to content
1 change: 1 addition & 0 deletions apps/desktop/src/main/app-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ const SCALAR_FIELDS: Partial<Record<PortablePrefKey, ScalarFieldMap>> = {
comment: 'show disclosure arrows in the sidebar'
},
contentAlign: { section: 'appearance', tomlKey: 'content_align', comment: 'center | left' },
rtlMode: { section: 'appearance', tomlKey: 'rtl_mode', comment: 'off | auto | on' },
unifiedSidebar: {
section: 'appearance',
tomlKey: 'unified_sidebar',
Expand Down
40 changes: 40 additions & 0 deletions packages/app-core/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ import {
} from '../lib/keymaps'
import { isTabStripOverflowing } from '../lib/tab-strip-overflow'
import { editorTabSize } from '../lib/editor-tab-size'
import { resolveNoteDirection } from '../lib/bidi-dir'

const MODE_OPTIONS: Array<{
mode: PaneMode
Expand Down Expand Up @@ -937,6 +938,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const tabNavOverrides = useStore((s) => s.keymapOverrides)
const workspaceMode = useStore((s) => s.workspaceMode)
const wordWrap = useStore((s) => s.wordWrap)
const rtlMode = useStore((s) => s.rtlMode)
const cursorBlink = useStore((s) => s.cursorBlink)
const systemFolderLabels = useStore((s) => s.systemFolderLabels)
const folderLabels = resolveSystemFolderLabels(systemFolderLabels)
Expand Down Expand Up @@ -1091,6 +1093,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const harperCompartmentRef = useRef<Compartment | null>(null)
const lineNumbersCompartmentRef = useRef<Compartment | null>(null)
const wordWrapCompartmentRef = useRef<Compartment | null>(null)
const directionCompartmentRef = useRef<Compartment | null>(null)
const scrolloffCompartmentRef = useRef<Compartment | null>(null)
const drawSelectionCompartmentRef = useRef<Compartment | null>(null)
const tabSizeCompartmentRef = useRef<Compartment | null>(null)
Expand Down Expand Up @@ -1922,6 +1925,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const harperCompartment = new Compartment()
const lineNumbersCompartment = new Compartment()
const wordWrapCompartment = new Compartment()
const directionCompartment = new Compartment()
const scrolloffCompartment = new Compartment()
const drawSelectionCompartment = new Compartment()
const tabSizeCompartment = new Compartment()
Expand All @@ -1934,6 +1938,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
livePreviewCompartmentRef.current = livePreviewCompartment
lineNumbersCompartmentRef.current = lineNumbersCompartment
wordWrapCompartmentRef.current = wordWrapCompartment
directionCompartmentRef.current = directionCompartment
scrolloffCompartmentRef.current = scrolloffCompartment
drawSelectionCompartmentRef.current = drawSelectionCompartment
tabSizeCompartmentRef.current = tabSizeCompartment
Expand Down Expand Up @@ -1985,6 +1990,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
vimClipboardPasteExtension,
commentDecorationField,
wordWrapCompartment.of(s0.wordWrap ? EditorView.lineWrapping : []),
directionCompartment.of(
EditorView.contentAttributes.of(
resolveNoteDirection(initialBody, s0.rtlMode) === 'rtl'
? { dir: 'rtl' }
: { dir: 'ltr' }
)
),
scrolloffCompartment.of(scrollOff(s0.editorScrollOff)),
markdownCompartment.of(
deferInitialRichMarkdown
Expand Down Expand Up @@ -2362,6 +2374,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const markdownCompartment = markdownCompartmentRef.current
const markdownSyntaxCompartment = markdownSyntaxCompartmentRef.current
const livePreviewCompartment = livePreviewCompartmentRef.current
const directionCompartment = directionCompartmentRef.current
const livePreviewEnabled = useStore.getState().livePreview
const deferRichMarkdown =
switched &&
Expand Down Expand Up @@ -2393,6 +2406,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
effects.push(livePreviewCompartment.reconfigure(currentWysiwygExtensions(nextPath)))
}
}
if (directionCompartment) {
effects.push(
directionCompartment.reconfigure(
EditorView.contentAttributes.of(
resolveNoteDirection(nextBody, useStore.getState().rtlMode) === 'rtl'
? { dir: 'rtl' }
: { dir: 'ltr' }
)
)
)
}
const dispatchStartedAt = performance.now()
// While the editor still holds the outgoing note: its undo history is set
// aside under that note, to be handed back when it returns. (#793)
Expand Down Expand Up @@ -2628,6 +2652,22 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
effects: comp.reconfigure(wordWrap ? EditorView.lineWrapping : [])
})
}, [wordWrap])
useEffect(() => {
const view = viewRef.current
const comp = directionCompartmentRef.current
if (!view || !comp) return
view.dispatch({
effects: comp.reconfigure(
EditorView.contentAttributes.of(
resolveNoteDirection(content?.body ?? '', rtlMode) === 'rtl'
? { dir: 'rtl' }
: { dir: 'ltr' }
)
)
})
// content?.body keeps auto-detection live while typing; cheap for note-
// sized documents. ponytail: if perf traces flag it, gate to pathChanged.
}, [rtlMode, content?.path, content?.body])
useEffect(() => {
const view = viewRef.current
const comp = scrolloffCompartmentRef.current
Expand Down
3 changes: 3 additions & 0 deletions packages/app-core/src/components/NoteHoverPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
import type { NoteContent, NoteMeta } from '@shared/ipc'
import { useStore } from '../store'
import { renderMarkdown } from '../lib/markdown'
import { resolveNoteDirection } from '../lib/bidi-dir'
import { enhanceLocalAssetNodes } from '../lib/local-assets'
import { assetTabPath } from '../lib/asset-tabs'
import {
Expand Down Expand Up @@ -34,6 +35,7 @@ export function NoteHoverPreview({
const vault = useStore((s) => s.vault)
const assetFiles = useStore((s) => s.assetFiles)
const customCodeLanguagesRevision = useStore((s) => s.customCodeLanguagesRevision)
const rtlMode = useStore((s) => s.rtlMode)
const focusedPanel = useStore((s) => s.focusedPanel)
const setFocusedPanel = useStore((s) => s.setFocusedPanel)
const openNoteInTab = useStore((s) => s.openNoteInTab)
Expand Down Expand Up @@ -183,6 +185,7 @@ export function NoteHoverPreview({
<article
ref={articleRef}
className="prose-zen prose-hover-preview"
dir={resolveNoteDirection(content?.body ?? '', rtlMode)}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
Expand Down
26 changes: 26 additions & 0 deletions packages/app-core/src/components/PinnedReferencePane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { classifyLocalAssetHref, hrefFragment, type LocalAssetKind } from '../li
import { LazyPreview as Preview } from './LazyPreview'
import { CloseIcon, PanelLeftIcon, PinIcon } from './icons'
import { editorTabSize } from '../lib/editor-tab-size'
import { resolveNoteDirection } from '../lib/bidi-dir'
import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config'

const PINNED_REF_PANE_ID = 'pinned-ref'
Expand Down Expand Up @@ -166,6 +167,7 @@ export function PinnedReferencePane(): JSX.Element | null {
const persistNote = useStore((s) => s.persistNote)
const vimMode = useStore((s) => s.vimMode)
const livePreview = useStore((s) => s.livePreview)
const rtlMode = useStore((s) => s.rtlMode)
const showHeadingLevelLabels = useStore((s) => s.showHeadingLevelLabels)
const lineNumberMode = useStore((s) => s.lineNumberMode)
const editorTabSizeValue = useStore((s) => s.editorTabSize)
Expand All @@ -179,6 +181,7 @@ export function PinnedReferencePane(): JSX.Element | null {
const viewPathRef = useRef<string | null>(null)
const vimCompartmentRef = useRef<Compartment | null>(null)
const livePreviewCompartmentRef = useRef<Compartment | null>(null)
const directionCompartmentRef = useRef<Compartment | null>(null)
const lineNumbersCompartmentRef = useRef<Compartment | null>(null)
const headingCompartmentRef = useRef<Compartment | null>(null)
const tabSizeCompartmentRef = useRef<Compartment | null>(null)
Expand All @@ -197,11 +200,13 @@ export function PinnedReferencePane(): JSX.Element | null {
if (viewRef.current) return
const vimCompartment = new Compartment()
const livePreviewCompartment = new Compartment()
const directionCompartment = new Compartment()
const lineNumbersCompartment = new Compartment()
const headingCompartment = new Compartment()
const tabSizeCompartment = new Compartment()
vimCompartmentRef.current = vimCompartment
livePreviewCompartmentRef.current = livePreviewCompartment
directionCompartmentRef.current = directionCompartment
lineNumbersCompartmentRef.current = lineNumbersCompartment
headingCompartmentRef.current = headingCompartment
tabSizeCompartmentRef.current = tabSizeCompartment
Expand Down Expand Up @@ -238,6 +243,13 @@ export function PinnedReferencePane(): JSX.Element | null {
syntaxHighlighting(paperHighlight),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
livePreviewCompartment.of(s0.livePreview ? livePreviewPlugin : []),
directionCompartment.of(
EditorView.contentAttributes.of(
resolveNoteDirection(initialContent?.body ?? '', s0.rtlMode) === 'rtl'
? { dir: 'rtl' }
: { dir: 'ltr' }
)
),
lineNumbersCompartment.of(lineNumberExtension(s0.lineNumberMode)),
tooltips({ parent: document.body }),
autocompletion({
Expand Down Expand Up @@ -359,6 +371,20 @@ export function PinnedReferencePane(): JSX.Element | null {
])
})
}, [editorTabSizeValue, listIndentGuidesOn])
useEffect(() => {
const view = viewRef.current
const comp = directionCompartmentRef.current
if (!view || !comp) return
view.dispatch({
effects: comp.reconfigure(
EditorView.contentAttributes.of(
resolveNoteDirection(content?.body ?? '', rtlMode) === 'rtl'
? { dir: 'rtl' }
: { dir: 'ltr' }
)
)
})
}, [rtlMode, content?.body])

/* -------- Re-measure on font changes -------- */
useEffect(() => {
Expand Down
3 changes: 3 additions & 0 deletions packages/app-core/src/components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { NoteMeta } from "@shared/ipc";
import { renderMarkdown } from "../lib/markdown";
import { resolveNoteDirection } from "../lib/bidi-dir";
import { substituteLiveTokens } from "../lib/live-template-tokens";
import {
setMarkdownLooseMathDelimiters,
Expand Down Expand Up @@ -197,6 +198,7 @@ export const Preview = memo(function Preview({
const ref = useRef<HTMLDivElement | null>(null);
const mathRenderer = useStore((s) => s.mathRenderer);
const looseMathDelimiters = useStore((s) => s.looseMathDelimiters);
const rtlMode = useStore((s) => s.rtlMode);
const vault = useStore((s) => s.vault);
const notes = useStore((s) => s.notes);
const folders = useStore((s) => s.folders);
Expand Down Expand Up @@ -1128,6 +1130,7 @@ export const Preview = memo(function Preview({
data-preview-content
ref={ref}
className="prose-zen py-8"
dir={resolveNoteDirection(markdown, rtlMode)}
/>
{hovered && (
<NoteHoverPreview
Expand Down
14 changes: 14 additions & 0 deletions packages/app-core/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,8 @@ export function SettingsModal(): JSX.Element {
const setPdfEmbedInEditMode = useStore((s) => s.setPdfEmbedInEditMode);
const contentAlign = useStore((s) => s.contentAlign);
const setContentAlign = useStore((s) => s.setContentAlign);
const rtlMode = useStore((s) => s.rtlMode);
const setRtlMode = useStore((s) => s.setRtlMode);
const vault = useStore((s) => s.vault);
const workspaceMode = useStore((s) => s.workspaceMode);
const renameVault = useStore((s) => s.renameVault);
Expand Down Expand Up @@ -3530,6 +3532,18 @@ export function SettingsModal(): JSX.Element {
]}
onChange={(next) => setContentAlign(next)}
/>
<SegmentedRow
label="Text direction"
description="LTR forces left-to-right. Auto detects each note (a note-level dir: setting wins). RTL forces right-to-left."
value={rtlMode}
settingId="text-direction"
options={[
{ value: "off", label: "LTR" },
{ value: "auto", label: "Auto" },
{ value: "on", label: "RTL" },
]}
onChange={(next) => setRtlMode(next)}
/>
<SegmentedRow
label="Line numbers"
description="Show editor gutter numbers. Relative uses Vim-style numbering with the current line shown normally."
Expand Down
76 changes: 76 additions & 0 deletions packages/app-core/src/lib/bidi-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
detectRtl,
noteRtlOverride,
resolveNoteDirection
} from './bidi-dir'

describe('detectRtl', () => {
it('reads Arabic body as RTL', () => {
expect(detectRtl('مرحبا بالعالم\nهذه ملاحظة عربية')).toBe(true)
})

it('reads English body as LTR', () => {
expect(detectRtl('Hello world\nThis is an English note.')).toBe(false)
})

it('treats a tie as LTR', () => {
expect(detectRtl('مرحبا بالعالم\nenglish line')).toBe(false)
})

it('majority RTL wins', () => {
expect(detectRtl('مرحبا\nبالعالم\nhello')).toBe(true)
})

it('ignores the frontmatter block', () => {
expect(detectRtl('---\ndir: rtl\ntags: [x]\n---\n\ntext body')).toBe(false)
})

it('skips fenced code blocks', () => {
expect(detectRtl('```\nمرحبا داخل الكود\n```')).toBe(false)
})

it('handles an empty body as LTR', () => {
expect(detectRtl('')).toBe(false)
})
})

describe('noteRtlOverride', () => {
it('reads dir: rtl', () => {
expect(noteRtlOverride('---\ndir: rtl\n---\n\nbody')).toBe('rtl')
})

it('reads dir: ltr', () => {
expect(noteRtlOverride('---\ndir: ltr\n---\n\nbody')).toBe('ltr')
})

it('treats dir: auto as no override', () => {
expect(noteRtlOverride('---\ndir: auto\n---\n\nbody')).toBeNull()
})

it('returns null without frontmatter', () => {
expect(noteRtlOverride('plain body')).toBeNull()
})
})

describe('resolveNoteDirection', () => {
it('forces LTR in off mode', () => {
expect(resolveNoteDirection('مرحبا', 'off')).toBe('ltr')
})

it('forces RTL in on mode', () => {
expect(resolveNoteDirection('Hello world', 'on')).toBe('rtl')
})

it('auto: frontmatter dir: ltr overrides an Arabic body', () => {
expect(resolveNoteDirection('---\ndir: ltr\n---\n\nمرحبا', 'auto')).toBe('ltr')
})

it('auto: plain Arabic body detects RTL', () => {
expect(resolveNoteDirection('مرحبا بالعالم', 'auto')).toBe('rtl')
})

it('auto: English body detects LTR', () => {
expect(resolveNoteDirection('Hello world', 'auto')).toBe('ltr')
})
})
Loading