From 454eafd7e00681f152f7eab56aeba61cafe026ae Mon Sep 17 00:00:00 2001 From: Mohammad Samadi Date: Mon, 17 Aug 2026 16:52:37 +0330 Subject: [PATCH] feat: add rtl support --- apps/desktop/src/main/app-config.ts | 1 + .../app-core/src/components/EditorPane.tsx | 40 ++++++++++ .../src/components/NoteHoverPreview.tsx | 3 + .../src/components/PinnedReferencePane.tsx | 26 +++++++ packages/app-core/src/components/Preview.tsx | 3 + .../app-core/src/components/SettingsModal.tsx | 14 ++++ packages/app-core/src/lib/bidi-dir.test.ts | 76 +++++++++++++++++++ packages/app-core/src/lib/bidi-dir.ts | 76 +++++++++++++++++++ packages/app-core/src/lib/commands.ts | 36 +++++++++ packages/app-core/src/store.ts | 21 +++++ packages/app-core/src/styles/index.css | 68 +++++++++++++++++ packages/shared-domain/src/app-config.ts | 2 + 12 files changed, 366 insertions(+) create mode 100644 packages/app-core/src/lib/bidi-dir.test.ts create mode 100644 packages/app-core/src/lib/bidi-dir.ts diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 7050a53f..4ec5da0c 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -254,6 +254,7 @@ const SCALAR_FIELDS: Partial> = { 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', diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 80474045..1193a339 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -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 @@ -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) @@ -962,6 +964,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const livePreviewCompartmentRef = useRef(null) const lineNumbersCompartmentRef = useRef(null) const wordWrapCompartmentRef = useRef(null) + const directionCompartmentRef = useRef(null) const scrolloffCompartmentRef = useRef(null) const drawSelectionCompartmentRef = useRef(null) const tabSizeCompartmentRef = useRef(null) @@ -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() @@ -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 @@ -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 @@ -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 && @@ -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 }, @@ -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 diff --git a/packages/app-core/src/components/NoteHoverPreview.tsx b/packages/app-core/src/components/NoteHoverPreview.tsx index 45cabeba..80c0355f 100644 --- a/packages/app-core/src/components/NoteHoverPreview.tsx +++ b/packages/app-core/src/components/NoteHoverPreview.tsx @@ -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 { @@ -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) @@ -183,6 +185,7 @@ export function NoteHoverPreview({
) : ( diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 6c2e4cd3..42243112 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -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' @@ -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) @@ -176,6 +178,7 @@ export function PinnedReferencePane(): JSX.Element | null { const viewPathRef = useRef(null) const vimCompartmentRef = useRef(null) const livePreviewCompartmentRef = useRef(null) + const directionCompartmentRef = useRef(null) const lineNumbersCompartmentRef = useRef(null) const headingCompartmentRef = useRef(null) const tabSizeCompartmentRef = useRef(null) @@ -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 @@ -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({ @@ -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(() => { diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index f5291036..58b07f87 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -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, @@ -185,6 +186,7 @@ export const Preview = memo(function Preview({ const ref = useRef(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); @@ -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 && ( 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); @@ -2993,6 +2995,18 @@ export function SettingsModal(): JSX.Element { ]} onChange={(next) => setContentAlign(next)} /> + setRtlMode(next)} + /> { + 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') + }) +}) diff --git a/packages/app-core/src/lib/bidi-dir.ts b/packages/app-core/src/lib/bidi-dir.ts new file mode 100644 index 00000000..01e12ad5 --- /dev/null +++ b/packages/app-core/src/lib/bidi-dir.ts @@ -0,0 +1,76 @@ +// Right-to-left text-direction support for the editor and preview. +// +// ZenNotes has no `dir` handling by default. These helpers decide a note's +// effective direction: a global pref (`rtlMode`) picks off/auto/on, an auto +// note falls back to a body heuristic, and a frontmatter `dir:` field +// overrides detection for that note. +import { + FRONTMATTER_BLOCK_RE, + parseFrontmatterFields +} from '@shared/frontmatter' + +/** RTL strong-directional code-point ranges (Hebrew, Arabic, supplementary + * Arabic, presentation forms). Everything here is BMP, so a code-unit + * test is sufficient. */ +const RTL_RE = + /[֐-׿؀-ۿݐ-ݿࢠ-ࣿיִ-ﭏﭐ-﷏ﷰ-﷿ﹰ-]/ + +/** LTR strong char: ASCII + Latin-1 + Greek/Cyrillic + CJK + Hangul. CJK + * counts as LTR because CJK text lays out left-to-right. */ +const LTR_RE = + /[A-Za-zÀ-ɏͰ-ϿЀ-ӿḀ-ỿ一-鿿가-힯]/ + +/** Direction of the first strong-directional character on a line. Lines with + * only weak/neutral characters (markup, numbers, punctuation) return null. */ +function lineDirection(line: string): 'rtl' | 'ltr' | null { + for (const ch of line) { + if (RTL_RE.test(ch)) return 'rtl' + if (LTR_RE.test(ch)) return 'ltr' + } + return null +} + +/** Heuristic: does the note body read right-to-left? Strips the frontmatter + * block (the `dir:` line itself must not bias the vote), skips fenced code + * (code is always LTR), then counts non-empty lines by their first strong + * character. Majority RTL wins; ties read LTR. */ +export function detectRtl(body: string): boolean { + const text = body.replace(FRONTMATTER_BLOCK_RE, '') + let rtl = 0 + let ltr = 0 + let inFence = false + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line) continue + if (line.startsWith('```')) { + inFence = !inFence + continue + } + if (inFence) continue + const d = lineDirection(line) + if (d === 'rtl') rtl += 1 + else if (d === 'ltr') ltr += 1 + } + return rtl > ltr +} + +/** Frontmatter `dir:` override: `rtl`/`ltr` return themselves; `auto`, + * a typo, or an absent field returns null so detection decides. */ +export function noteRtlOverride(body: string): 'rtl' | 'ltr' | null { + const m = FRONTMATTER_BLOCK_RE.exec(body) + if (!m) return null + const v = parseFrontmatterFields(m[1] ?? '').dir + if (v === 'rtl') return 'rtl' + if (v === 'ltr') return 'ltr' + return null +} + +/** Resolve a note's effective direction from the global mode. */ +export function resolveNoteDirection( + body: string, + rtlMode: 'off' | 'auto' | 'on' +): 'ltr' | 'rtl' { + if (rtlMode === 'on') return 'rtl' + if (rtlMode === 'off') return 'ltr' + return noteRtlOverride(body) ?? (detectRtl(body) ? 'rtl' : 'ltr') +} diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 7a48c313..cb016a9e 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -23,6 +23,8 @@ import { resolveSystemFolderLabels } from './system-folder-labels' import { isCalendarToggleAvailable, noteFolderSubpath } from './vault-layout' import { runWorkflowById } from './workflow-trigger' import { requestPublishNote } from './publish-note-requests' +import { updateFrontmatterFields } from '@shared/frontmatter' +import { detectRtl, noteRtlOverride } from './bidi-dir' import { DEMO_TOUR_START_PATH } from '@shared/demo-tour' const APP_WEBSITE_URL = 'https://zennotes.org' @@ -426,6 +428,22 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma window.zen.clipboardWriteText(active.path) } }, + { + id: 'note.rtl-cycle', + title: 'Toggle Note Direction (RTL/LTR)', + category: 'Note', + keywords: 'rtl ltr direction bidi right-to-left', + when: () => !!getState().activeNote, + run: () => { + const note = getState().activeNote + if (!note) return + const body = note.body + const current = noteRtlOverride(body) ?? (detectRtl(body) ? 'rtl' : 'ltr') + const next = current === 'rtl' ? 'ltr' : 'rtl' + const updated = updateFrontmatterFields(body, { dir: next }) + if (updated !== body) getState().updateNoteBody(note.path, updated) + } + }, { id: 'note.copy-absolute-path', title: `Copy Note ${pathLabel()}`, @@ -1170,6 +1188,24 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma getState().contentAlign === 'center' ? 'left' : 'center' ) }, + { + id: 'view.rtl-mode', + title: getState().rtlMode === 'off' + ? 'Text Direction: Auto' + : getState().rtlMode === 'auto' + ? 'Text Direction: RTL' + : 'Text Direction: Off', + category: 'View', + keywords: 'rtl ltr direction bidi right-to-left arabic hebrew', + run: () => + getState().setRtlMode( + getState().rtlMode === 'off' + ? 'auto' + : getState().rtlMode === 'auto' + ? 'on' + : 'off' + ) + }, { id: 'view.sort.name-asc', title: 'Sort Notes: Name (A → Z)', diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 58f25854..739e210e 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -632,6 +632,9 @@ interface Prefs { /** Whether the editor and preview content sit centered (with the * width capped) or are left-aligned to the pane edge. */ contentAlign: 'center' | 'left' + /** Text direction for editor + preview: off = LTR always, auto = detect + * per note (frontmatter `dir:` wins), on = RTL always. Persisted. */ + rtlMode: 'off' | 'auto' | 'on' /** Sidebar Tags section collapsed — keeps the tag pills hidden * without removing the section entirely. */ tagsCollapsed: boolean @@ -1026,6 +1029,7 @@ export const DEFAULT_PREFS: Prefs = { pinnedRefKind: 'note', noteRefs: {}, contentAlign: 'center', + rtlMode: 'auto', tagsCollapsed: false, nestedTags: true, // Off by default, deliberately: workflows can rewrite notes in bulk, and the @@ -1317,6 +1321,10 @@ function normalizePrefs(p: Partial): Prefs { p.contentAlign === 'left' || p.contentAlign === 'center' ? p.contentAlign : DEFAULT_PREFS.contentAlign, + rtlMode: + p.rtlMode === 'off' || p.rtlMode === 'auto' || p.rtlMode === 'on' + ? p.rtlMode + : DEFAULT_PREFS.rtlMode, tagsCollapsed: typeof p.tagsCollapsed === 'boolean' ? p.tagsCollapsed : DEFAULT_PREFS.tagsCollapsed, nestedTags: typeof p.nestedTags === 'boolean' ? p.nestedTags : DEFAULT_PREFS.nestedTags, @@ -2160,6 +2168,7 @@ function collectPrefs(s: { pinnedRefKind: 'note' | 'asset' noteRefs: Record contentAlign: 'center' | 'left' + rtlMode: 'off' | 'auto' | 'on' tagsCollapsed: boolean nestedTags: boolean workflowsEnabled: boolean @@ -2251,6 +2260,7 @@ function collectPrefs(s: { pinnedRefKind: s.pinnedRefKind, noteRefs: s.noteRefs, contentAlign: s.contentAlign, + rtlMode: s.rtlMode, tagsCollapsed: s.tagsCollapsed, nestedTags: s.nestedTags, workflowsEnabled: s.workflowsEnabled, @@ -2808,6 +2818,11 @@ interface Store { * left-align it to the pane edge. */ contentAlign: 'center' | 'left' + /** Text direction for editor + preview: off/auto/on. Auto resolves per + * note via frontmatter `dir:` + a body heuristic. Persisted. */ + rtlMode: 'off' | 'auto' | 'on' + setRtlMode: (mode: 'off' | 'auto' | 'on') => void + /** Sidebar Tags section collapsed — hides the pill rail but keeps * the section header visible as a toggle. Persisted. */ tagsCollapsed: boolean @@ -4521,6 +4536,7 @@ export const useStore = create((set, get) => { pinnedRefKind: loadPrefs().pinnedRefKind, noteRefs: loadPrefs().noteRefs, contentAlign: loadPrefs().contentAlign, + rtlMode: loadPrefs().rtlMode, tagsCollapsed: loadPrefs().tagsCollapsed, nestedTags: loadPrefs().nestedTags, workflowsEnabled: loadPrefs().workflowsEnabled, @@ -7935,6 +7951,11 @@ export const useStore = create((set, get) => { set({ contentAlign: align }) savePrefs(collectPrefs(get())) }, + + setRtlMode: (mode) => { + set({ rtlMode: mode }) + savePrefs(collectPrefs(get())) + }, setTagsCollapsed: (collapsed) => { set({ tagsCollapsed: collapsed }) savePrefs(collectPrefs(get())) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 3a334f52..8cf0de98 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -1544,6 +1544,74 @@ textarea, margin-left: 0; margin-right: 0; } +/* --- Right-to-left text direction -------------------------------------- */ +/* The editor's `dir` arrives via EditorView.contentAttributes, which CM reads + for its BiDi layout — only the alignment needs CSS here. The reading view + carries `dir` on the .prose-zen article itself. */ +.cm-editor .cm-content[dir="rtl"] { + text-align: right; +} +.prose-zen[dir="rtl"] { + direction: rtl; + text-align: right; +} +:root[data-content-align="left"] .prose-zen[dir="rtl"] { + margin-left: 0; + margin-right: 0; +} +/* Mirror rules that assume LTR geometry. Directional properties are flipped + so list markers, rails and checkboxes land on the right. */ +.prose-zen[dir="rtl"] ul, +.prose-zen[dir="rtl"] ol { + padding-left: 0; + padding-right: 1.4em; +} +.prose-zen[dir="rtl"] li.task-list-item { + margin-left: 0; + margin-right: -1.4em; + padding-left: 0; + padding-right: 1.5em; +} +.prose-zen[dir="rtl"] li.task-list-item input[type="checkbox"] { + left: auto; + right: 0; +} +.prose-zen[dir="rtl"] .zen-task-state { + left: auto; + right: 0; +} +.prose-zen[dir="rtl"] blockquote { + border-left: none; + border-right: 3px solid theme("colors.paper.400"); + border-radius: 8px 0 0 8px; +} +.prose-zen[dir="rtl"] .note-embed { + border-left: none; + border-right: 3px solid rgb(var(--z-accent) / 0.5); +} +.prose-zen[dir="rtl"] th, +.prose-zen[dir="rtl"] td { + text-align: right; +} +/* Code is always LTR, even inside an RTL document. */ +.prose-zen[dir="rtl"] pre, +.prose-zen[dir="rtl"] pre code, +.prose-zen[dir="rtl"] :not(pre) > code { + direction: ltr; + text-align: left; + unicode-bidi: isolate; +} +.cm-editor .cm-content[dir="rtl"] .cm-line.cm-markdown-list-line { + padding-left: 0; + padding-right: calc(var(--z-list-hanging-indent, 0) + var(--z-list-indent-pad, 0ch)); + text-indent: calc(var(--z-list-hanging-indent, 0) * 1); +} +.cm-wysiwyg .cm-editor .cm-content[dir="rtl"] .cm-wq-quote { + border-left: none; + border-right: 3px solid rgb(var(--z-accent) / 0.45); + padding-left: 0; + padding-right: 16px; +} .cm-editor .cm-line { padding: 0; line-height: var(--z-editor-line-height, 1.7); diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 2acc8f7d..65cca175 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -111,6 +111,7 @@ export const PORTABLE_PREF_KEYS = [ 'darkSidebar', 'showSidebarChevrons', 'contentAlign', + 'rtlMode', 'unifiedSidebar', // typography 'interfaceFont', @@ -224,6 +225,7 @@ export const PORTABLE_DEFAULTS: Record = { darkSidebar: true, showSidebarChevrons: true, contentAlign: 'center', + rtlMode: 'auto', unifiedSidebar: true, interfaceFont: null, textFont: null,