Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -254,6 +254,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 @@ -278,6 +278,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 @@ -866,6 +867,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 @@ -962,6 +964,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const livePreviewCompartmentRef = 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 @@ -1713,6 +1716,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element {
const livePreviewCompartment = 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 @@ -1724,6 +1728,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 @@ -1757,6 +1762,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 @@ -2054,6 +2066,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 =
pathChanged &&
Expand Down Expand Up @@ -2085,6 +2098,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()
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: nextBody },
Expand Down Expand Up @@ -2245,6 +2269,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 @@ -57,6 +57,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 @@ -163,6 +164,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 @@ -176,6 +178,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 @@ -194,11 +197,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 @@ -232,6 +237,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 @@ -350,6 +362,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 @@ -3,6 +3,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 {
setMarkdownLooseMathDelimiters,
setMarkdownMathRenderer,
Expand Down Expand Up @@ -185,6 +186,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 @@ -1017,6 +1019,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 @@ -531,6 +531,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 remoteWorkspaceInfo = useStore((s) => s.remoteWorkspaceInfo);
Expand Down Expand Up @@ -2993,6 +2995,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