From 11342932c9b37a94e829d73f584fd4d6ae7ef0d6 Mon Sep 17 00:00:00 2001 From: Ayden Garza Date: Wed, 22 Jul 2026 23:51:16 -0500 Subject: [PATCH 1/4] feat(editor): add optional auto pairs --- apps/desktop/src/main/app-config.test.ts | 3 + apps/desktop/src/main/app-config.ts | 5 + .../app-core/src/components/SettingsModal.tsx | 25 ++++ .../app-core/src/lib/cm-auto-pairs.test.ts | 111 ++++++++++++++++++ packages/app-core/src/lib/cm-auto-pairs.ts | 86 ++++++++++++++ .../src/lib/markdown-snippets-config.ts | 24 ++-- packages/app-core/src/store.ts | 14 +++ packages/shared-domain/src/app-config.ts | 2 + 8 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 packages/app-core/src/lib/cm-auto-pairs.test.ts create mode 100644 packages/app-core/src/lib/cm-auto-pairs.ts diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 6b948bcd..1a30872f 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -98,6 +98,7 @@ describe('TOML serialization', () => { editorLineHeight: 1.6, themeFamily: 'nord', themeMode: 'dark', + autoPairs: false, vaultTextSearchBackend: 'ripgrep', ripgrepBinaryPath: null, interfaceFont: null, @@ -117,6 +118,7 @@ describe('TOML serialization', () => { expect(round.editorFontSize).toBe(18) expect(round.editorLineHeight).toBeCloseTo(1.6) expect(round.themeFamily).toBe('nord') + expect(round.autoPairs).toBe(false) expect(round.vaultTextSearchBackend).toBe('ripgrep') expect(round.keymapOverrides).toEqual({ 'global.searchNotes': 'Mod+P' }) expect(round.kanbanColumnTitles).toEqual({ 'status:todo': 'To Do' }) @@ -137,6 +139,7 @@ describe('TOML serialization', () => { expect(text).toContain('theme_mode = "dark" # light | dark | auto') expect(text).toContain('backend = "auto" # auto | builtin | ripgrep | fzf') expect(text).toContain('font_size = 16') + expect(text).toContain('auto_pairs = true # auto-insert matching () and {} while typing') expect(text).toContain('[vim]') expect(text).toContain('[view]') // Keymaps: every action listed as a commented, grouped default reference. diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 98f159ac..948a7a0a 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -102,6 +102,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'markdown_snippets', comment: 'auto-close markdown delimiters while typing' }, + autoPairs: { + section: 'editor', + tomlKey: 'auto_pairs', + comment: 'auto-insert matching () and {} while typing' + }, hideBuiltinTemplates: { section: 'editor', tomlKey: 'hide_builtin_templates', diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index a4d56cf2..ccbb465a 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -449,6 +449,8 @@ export function SettingsModal(): JSX.Element { ); const markdownSnippets = useStore((s) => s.markdownSnippets); const setMarkdownSnippets = useStore((s) => s.setMarkdownSnippets); + const autoPairs = useStore((s) => s.autoPairs); + const setAutoPairs = useStore((s) => s.setAutoPairs); const tabsEnabled = useStore((s) => s.tabsEnabled); const setTabsEnabled = useStore((s) => s.setTabsEnabled); const wrapTabs = useStore((s) => s.wrapTabs); @@ -1832,6 +1834,21 @@ export function SettingsModal(): JSX.Element { "completion", ], }, + { + id: "auto-pairs", + title: "Auto-pair parentheses and braces", + description: + "Insert matching () and {} as you type, including in Vim insert mode.", + keywords: [ + "auto pair", + "autopair", + "parentheses", + "braces", + "brackets", + "completion", + "vim", + ], + }, { id: "note-tabs", title: "Note tabs", @@ -2090,6 +2107,7 @@ export function SettingsModal(): JSX.Element { "live-preview", "render-tables", "markdown-overrides", + "auto-pairs", "note-tabs", "wrap-note-tabs", "word-wrap", @@ -2165,6 +2183,13 @@ export function SettingsModal(): JSX.Element { settingId="markdown-overrides" onChange={setMarkdownSnippets} /> + { + it.each([ + ['(', ')'], + ['{', '}'] + ])('inserts %s with its matching closer', (open, close) => { + const next = state('').update(autoPairInputTransaction(state(''), 0, 0, open)!).state + + expect(next.doc.toString()).toBe(open + close) + expect(next.selection.main.head).toBe(1) + }) + + it('wraps the selected text', () => { + const current = state('note', 0, 4) + const next = current.update(autoPairInputTransaction(current, 0, 4, '{')!).state + + expect(next.doc.toString()).toBe('{note}') + expect(next.selection.main.from).toBe(1) + expect(next.selection.main.to).toBe(5) + }) + + it('moves over an existing closer instead of inserting another', () => { + const current = state('()', 1) + const next = current.update(autoPairInputTransaction(current, 1, 1, ')')!).state + + expect(next.doc.toString()).toBe('()') + expect(next.selection.main.head).toBe(2) + }) + + it('does not handle unrelated or multi-character input', () => { + const current = state('') + expect(autoPairInputTransaction(current, 0, 0, 'x')).toBeNull() + expect(autoPairInputTransaction(current, 0, 0, '()')).toBeNull() + }) +}) + +describe('autoPairBackspaceTransaction', () => { + it('removes an empty pair around the cursor', () => { + const current = state('{}', 1) + const next = current.update(autoPairBackspaceTransaction(current)!).state + + expect(next.doc.toString()).toBe('') + expect(next.selection.main.head).toBe(0) + }) + + it('leaves non-empty pairs to normal backspace behavior', () => { + expect(autoPairBackspaceTransaction(state('{x}', 1))).toBeNull() + }) +}) + +describe('disabled auto pairs', () => { + const views: EditorView[] = [] + afterEach(() => views.splice(0).forEach((view) => view.destroy())) + + function mount(withDisabledAutoPairs: boolean): EditorView { + const view = new EditorView({ + state: EditorState.create({ + doc: '{', + selection: { anchor: 1 }, + extensions: [ + vim(), + markdown({ base: markdownLanguage, addKeymap: false }), + vimAwareMarkdownKeymap, + ...(withDisabledAutoPairs ? [autoPairExtension({ shouldHandle: () => false })] : []), + keymap.of([...vimAwareDefaultKeymap(true)]) + ] + }), + parent: document.body + }) + views.push(view) + view.focus() + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key: 'i', keyCode: 73, bubbles: true, cancelable: true }) + ) + return view + } + + function pressEnter(view: EditorView): void { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }) + ) + } + + it('leaves Enter behavior unchanged from the editor without the extension', () => { + const baseline = mount(false) + const disabled = mount(true) + + pressEnter(baseline) + pressEnter(disabled) + + expect(baseline.state.doc.toString()).toBe('{\n') + expect(disabled.state.doc.toString()).toBe(baseline.state.doc.toString()) + expect(disabled.state.selection.main.head).toBe(baseline.state.selection.main.head) + }) +}) diff --git a/packages/app-core/src/lib/cm-auto-pairs.ts b/packages/app-core/src/lib/cm-auto-pairs.ts new file mode 100644 index 00000000..f093a94d --- /dev/null +++ b/packages/app-core/src/lib/cm-auto-pairs.ts @@ -0,0 +1,86 @@ +import { EditorSelection, type EditorState, type Extension, type TransactionSpec } from '@codemirror/state' +import { EditorView, keymap } from '@codemirror/view' + +const PAIRS: Readonly> = { + '(': ')', + '{': '}' +} + +const CLOSERS = new Set(Object.values(PAIRS)) + +export interface AutoPairExtensionConfig { + shouldHandle?: (view: EditorView) => boolean +} + +/** + * Builds the editor transaction for paired delimiters. Kept separate from the + * DOM extension so the edge cases can be tested without a mounted editor. + */ +export function autoPairInputTransaction( + state: EditorState, + from: number, + to: number, + text: string +): TransactionSpec | null { + if (text.length !== 1) return null + + const close = PAIRS[text] + if (close) { + const selected = state.sliceDoc(from, to) + return { + changes: { from, to, insert: text + selected + close }, + selection: EditorSelection.range(from + 1, to + 1) + } + } + + if (from === to && CLOSERS.has(text) && state.sliceDoc(from, from + 1) === text) { + return { selection: EditorSelection.cursor(from + 1) } + } + + return null +} + +export function autoPairBackspaceTransaction(state: EditorState): TransactionSpec | null { + const selection = state.selection.main + if (!selection.empty || selection.head === 0) return null + + const from = selection.head - 1 + const open = state.sliceDoc(from, selection.head) + const close = PAIRS[open] + if (!close || state.sliceDoc(selection.head, selection.head + 1) !== close) return null + + return { + changes: { from, to: selection.head + 1, insert: '' }, + selection: EditorSelection.cursor(from) + } +} + +/** + * Standard paired-delimiter editing, optionally restricted by the caller + * (ZenNotes uses that to allow it only outside Vim normal/visual modes). + */ +export function autoPairExtension(config: AutoPairExtensionConfig = {}): Extension { + const shouldHandle = (view: EditorView): boolean => !config.shouldHandle || config.shouldHandle(view) + + return [ + EditorView.inputHandler.of((view, from, to, text) => { + if (!shouldHandle(view)) return false + const transaction = autoPairInputTransaction(view.state, from, to, text) + if (!transaction) return false + view.dispatch(transaction) + return true + }), + keymap.of([ + { + key: 'Backspace', + run: (view): boolean => { + if (!shouldHandle(view)) return false + const transaction = autoPairBackspaceTransaction(view.state) + if (!transaction) return false + view.dispatch(transaction) + return true + } + } + ]) + ] +} diff --git a/packages/app-core/src/lib/markdown-snippets-config.ts b/packages/app-core/src/lib/markdown-snippets-config.ts index 72d98018..ec2f890b 100644 --- a/packages/app-core/src/lib/markdown-snippets-config.ts +++ b/packages/app-core/src/lib/markdown-snippets-config.ts @@ -1,5 +1,6 @@ import type { Extension } from '@codemirror/state' import type { EditorView } from '@codemirror/view' +import { autoPairExtension } from './cm-auto-pairs' import { markdownSnippetExtension } from './cm-markdown-snippets' import { isEditorInsertMode } from './vim-nav' import { useStore } from '../store' @@ -12,11 +13,20 @@ import { useStore } from '../store' * in Vim normal/visual mode, where Space/Enter belong to Vim. (songgenqing) */ export function appMarkdownSnippetExtension(): Extension { - return markdownSnippetExtension({ - shouldHandle: (view: EditorView) => { - const s = useStore.getState() - if (!s.markdownSnippets) return false - return !s.vimMode || isEditorInsertMode(view, s.vimMode) - } - }) + const isTyping = (view: EditorView): boolean => { + const s = useStore.getState() + return !s.vimMode || isEditorInsertMode(view, s.vimMode) + } + + return [ + autoPairExtension({ + shouldHandle: (view) => useStore.getState().autoPairs && isTyping(view) + }), + markdownSnippetExtension({ + shouldHandle: (view) => { + const s = useStore.getState() + return s.markdownSnippets && isTyping(view) + } + }) + ] } diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 6d73065d..d6eeed19 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -411,6 +411,8 @@ interface Prefs { /** Auto-close markdown delimiters while typing: `**`+Space → `**|**`, * ```` ``` ````+Enter expands a fenced block. Off restores plain typing. */ markdownSnippets: boolean + /** Auto-insert matching `()` and `{}` delimiters while typing. */ + autoPairs: boolean hideBuiltinTemplates: boolean // hide shipped built-in templates from the pickers tabsEnabled: boolean wrapTabs: boolean @@ -737,6 +739,7 @@ export const DEFAULT_PREFS: Prefs = { looseMathDelimiters: false, keepViewModeAcrossNotes: false, markdownSnippets: true, + autoPairs: true, hideBuiltinTemplates: false, tabsEnabled: true, wrapTabs: false, @@ -880,6 +883,7 @@ function normalizePrefs(p: Partial): Prefs { typeof p.markdownSnippets === 'boolean' ? p.markdownSnippets : DEFAULT_PREFS.markdownSnippets, + autoPairs: typeof p.autoPairs === 'boolean' ? p.autoPairs : DEFAULT_PREFS.autoPairs, hideBuiltinTemplates: typeof p.hideBuiltinTemplates === 'boolean' ? p.hideBuiltinTemplates @@ -1689,6 +1693,7 @@ function collectPrefs(s: { looseMathDelimiters: boolean keepViewModeAcrossNotes: boolean markdownSnippets: boolean + autoPairs: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -1765,6 +1770,7 @@ function collectPrefs(s: { looseMathDelimiters: s.looseMathDelimiters, keepViewModeAcrossNotes: s.keepViewModeAcrossNotes, markdownSnippets: s.markdownSnippets, + autoPairs: s.autoPairs, hideBuiltinTemplates: s.hideBuiltinTemplates, tabsEnabled: s.tabsEnabled, wrapTabs: s.wrapTabs, @@ -2220,6 +2226,8 @@ interface Store { keepViewModeAcrossNotes: boolean /** Auto-close markdown delimiters while typing. Persisted. */ markdownSnippets: boolean + /** Auto-insert matching `()` and `{}` delimiters while typing. Persisted. */ + autoPairs: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -2613,6 +2621,7 @@ interface Store { setLooseMathDelimiters: (on: boolean) => void setKeepViewModeAcrossNotes: (on: boolean) => void setMarkdownSnippets: (on: boolean) => void + setAutoPairs: (on: boolean) => void setHideBuiltinTemplates: (hidden: boolean) => void setTabsEnabled: (on: boolean) => void setWrapTabs: (on: boolean) => void @@ -3765,6 +3774,7 @@ export const useStore = create((set, get) => { looseMathDelimiters: loadPrefs().looseMathDelimiters, keepViewModeAcrossNotes: loadPrefs().keepViewModeAcrossNotes, markdownSnippets: loadPrefs().markdownSnippets, + autoPairs: loadPrefs().autoPairs, hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, tabsEnabled: loadPrefs().tabsEnabled, wrapTabs: loadPrefs().wrapTabs, @@ -5874,6 +5884,10 @@ export const useStore = create((set, get) => { set({ markdownSnippets: on }) savePrefs(collectPrefs(get())) }, + setAutoPairs: (on) => { + set({ autoPairs: on }) + savePrefs(collectPrefs(get())) + }, setHideBuiltinTemplates: (hidden) => { set({ hideBuiltinTemplates: hidden }) savePrefs(collectPrefs(get())) diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index f4ce65b8..6cb3c2b7 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -76,6 +76,7 @@ export const PORTABLE_PREF_KEYS = [ 'looseMathDelimiters', 'keepViewModeAcrossNotes', 'markdownSnippets', + 'autoPairs', 'hideBuiltinTemplates', 'tabsEnabled', 'wrapTabs', @@ -175,6 +176,7 @@ export const PORTABLE_DEFAULTS: Record = { looseMathDelimiters: false, keepViewModeAcrossNotes: false, markdownSnippets: true, + autoPairs: true, hideBuiltinTemplates: false, tabsEnabled: true, wrapTabs: false, From 1613a226c0fbe4737cd222e822e3dba075307acd Mon Sep 17 00:00:00 2001 From: Ayden Garza Date: Thu, 23 Jul 2026 08:04:25 -0500 Subject: [PATCH 2/4] feat(editor): expand optional auto pairs --- apps/desktop/src/main/app-config.test.ts | 7 +- apps/desktop/src/main/app-config.ts | 7 +- .../app-core/src/components/SettingsModal.tsx | 29 ++++++-- .../app-core/src/lib/cm-auto-pairs.test.ts | 40 ++++++++++- packages/app-core/src/lib/cm-auto-pairs.ts | 69 +++++++++++++++---- .../src/lib/markdown-snippets-config.ts | 8 ++- packages/app-core/src/store.ts | 21 +++++- packages/shared-domain/src/app-config.ts | 2 + 8 files changed, 159 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 1a30872f..00acea75 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -99,6 +99,7 @@ describe('TOML serialization', () => { themeFamily: 'nord', themeMode: 'dark', autoPairs: false, + autoPairQuotesInProse: true, vaultTextSearchBackend: 'ripgrep', ripgrepBinaryPath: null, interfaceFont: null, @@ -119,6 +120,7 @@ describe('TOML serialization', () => { expect(round.editorLineHeight).toBeCloseTo(1.6) expect(round.themeFamily).toBe('nord') expect(round.autoPairs).toBe(false) + expect(round.autoPairQuotesInProse).toBe(true) expect(round.vaultTextSearchBackend).toBe('ripgrep') expect(round.keymapOverrides).toEqual({ 'global.searchNotes': 'Mod+P' }) expect(round.kanbanColumnTitles).toEqual({ 'status:todo': 'To Do' }) @@ -139,7 +141,10 @@ describe('TOML serialization', () => { expect(text).toContain('theme_mode = "dark" # light | dark | auto') expect(text).toContain('backend = "auto" # auto | builtin | ripgrep | fzf') expect(text).toContain('font_size = 16') - expect(text).toContain('auto_pairs = true # auto-insert matching () and {} while typing') + expect(text).toContain('auto_pairs = true # auto-insert matching [] () and {} while typing') + expect(text).toContain( + 'auto_pair_quotes_in_prose = false # also auto-insert matching quotes outside fenced code blocks' + ) expect(text).toContain('[vim]') expect(text).toContain('[view]') // Keymaps: every action listed as a commented, grouped default reference. diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 948a7a0a..c6e3d2de 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -105,7 +105,12 @@ const SCALAR_FIELDS: Partial> = { autoPairs: { section: 'editor', tomlKey: 'auto_pairs', - comment: 'auto-insert matching () and {} while typing' + comment: 'auto-insert matching [] () and {} while typing' + }, + autoPairQuotesInProse: { + section: 'editor', + tomlKey: 'auto_pair_quotes_in_prose', + comment: 'also auto-insert matching quotes outside fenced code blocks' }, hideBuiltinTemplates: { section: 'editor', diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index ccbb465a..7b08ce3f 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -451,6 +451,10 @@ export function SettingsModal(): JSX.Element { const setMarkdownSnippets = useStore((s) => s.setMarkdownSnippets); const autoPairs = useStore((s) => s.autoPairs); const setAutoPairs = useStore((s) => s.setAutoPairs); + const autoPairQuotesInProse = useStore((s) => s.autoPairQuotesInProse); + const setAutoPairQuotesInProse = useStore( + (s) => s.setAutoPairQuotesInProse, + ); const tabsEnabled = useStore((s) => s.tabsEnabled); const setTabsEnabled = useStore((s) => s.setTabsEnabled); const wrapTabs = useStore((s) => s.wrapTabs); @@ -1836,19 +1840,28 @@ export function SettingsModal(): JSX.Element { }, { id: "auto-pairs", - title: "Auto-pair parentheses and braces", + title: "Auto-pair brackets and delimiters", description: - "Insert matching () and {} as you type, including in Vim insert mode.", + "Insert matching [] () and {} as you type; quotes pair in fenced code blocks.", keywords: [ "auto pair", "autopair", "parentheses", "braces", "brackets", + "quotes", + "code blocks", "completion", "vim", ], }, + { + id: "auto-pair-quotes-in-prose", + title: "Auto-pair quotes in prose", + description: + "Also insert matching quotes outside fenced code blocks.", + keywords: ["auto pair", "autopair", "quotes", "prose", "code blocks"], + }, { id: "note-tabs", title: "Note tabs", @@ -2108,6 +2121,7 @@ export function SettingsModal(): JSX.Element { "render-tables", "markdown-overrides", "auto-pairs", + "auto-pair-quotes-in-prose", "note-tabs", "wrap-note-tabs", "word-wrap", @@ -2184,12 +2198,19 @@ export function SettingsModal(): JSX.Element { onChange={setMarkdownSnippets} /> + { it.each([ ['(', ')'], + ['[', ']'], ['{', '}'] ])('inserts %s with its matching closer', (open, close) => { const next = state('').update(autoPairInputTransaction(state(''), 0, 0, open)!).state @@ -43,6 +45,23 @@ describe('autoPairInputTransaction', () => { expect(next.selection.main.head).toBe(2) }) + it.each(['"', "'"])('pairs %s only when quotes are enabled', (quote) => { + const current = state('') + expect(autoPairInputTransaction(current, 0, 0, quote)).toBeNull() + + const next = current.update(autoPairInputTransaction(current, 0, 0, quote, true)!).state + expect(next.doc.toString()).toBe(quote + quote) + expect(next.selection.main.head).toBe(1) + }) + + it('skips an existing generated quote and leaves contractions alone', () => { + const quoted = state('""', 1) + const skipped = quoted.update(autoPairInputTransaction(quoted, 1, 1, '"', true)!).state + expect(skipped.selection.main.head).toBe(2) + + expect(autoPairInputTransaction(state('don', 3), 3, 3, "'", true)).toBeNull() + }) + it('does not handle unrelated or multi-character input', () => { const current = state('') expect(autoPairInputTransaction(current, 0, 0, 'x')).toBeNull() @@ -62,6 +81,25 @@ describe('autoPairBackspaceTransaction', () => { it('leaves non-empty pairs to normal backspace behavior', () => { expect(autoPairBackspaceTransaction(state('{x}', 1))).toBeNull() }) + + it('removes empty quotes when quote pairing is enabled', () => { + const current = state("''", 1) + const next = current.update(autoPairBackspaceTransaction(current, true)!).state + expect(next.doc.toString()).toBe('') + }) +}) + +describe('isInFencedCodeBlock', () => { + it('identifies code content but not Markdown prose', () => { + const doc = 'Prose\n\n```ts\nconst value = \n```' + const current = EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage, addKeymap: false })] + }) + + expect(isInFencedCodeBlock(current, 2)).toBe(false) + expect(isInFencedCodeBlock(current, doc.indexOf('const') + 'const value = '.length)).toBe(true) + }) }) describe('disabled auto pairs', () => { diff --git a/packages/app-core/src/lib/cm-auto-pairs.ts b/packages/app-core/src/lib/cm-auto-pairs.ts index f093a94d..e5048f38 100644 --- a/packages/app-core/src/lib/cm-auto-pairs.ts +++ b/packages/app-core/src/lib/cm-auto-pairs.ts @@ -1,15 +1,39 @@ import { EditorSelection, type EditorState, type Extension, type TransactionSpec } from '@codemirror/state' +import { syntaxTree } from '@codemirror/language' import { EditorView, keymap } from '@codemirror/view' -const PAIRS: Readonly> = { +const STRUCTURAL_PAIRS: Readonly> = { '(': ')', + '[': ']', '{': '}' } -const CLOSERS = new Set(Object.values(PAIRS)) +const QUOTE_PAIRS: Readonly> = { + '"': '"', + "'": "'" +} + +const ALL_PAIRS: Readonly> = { ...STRUCTURAL_PAIRS, ...QUOTE_PAIRS } export interface AutoPairExtensionConfig { shouldHandle?: (view: EditorView) => boolean + /** Whether quotes should pair at this position. Structural delimiters always pair. */ + shouldPairQuotes?: (view: EditorView, from: number, to: number) => boolean +} + +/** True when a position is inside a Markdown fenced code block. */ +export function isInFencedCodeBlock(state: EditorState, pos: number): boolean { + let node = syntaxTree(state).resolveInner(pos, -1) + while (node) { + if (node.name === 'FencedCode') return true + if (!node.parent) break + node = node.parent + } + return false +} + +function pairsForQuotes(includeQuotes: boolean): Readonly> { + return includeQuotes ? ALL_PAIRS : STRUCTURAL_PAIRS } /** @@ -20,11 +44,24 @@ export function autoPairInputTransaction( state: EditorState, from: number, to: number, - text: string + text: string, + includeQuotes = false ): TransactionSpec | null { if (text.length !== 1) return null - const close = PAIRS[text] + const pairs = pairsForQuotes(includeQuotes) + const closers = new Set(Object.values(pairs)) + if (from === to && closers.has(text) && state.sliceDoc(from, from + 1) === text) { + return { selection: EditorSelection.cursor(from + 1) } + } + + // Apostrophes in contractions (don't, we're) are ordinary Markdown prose, + // not opening quotes. Leave the browser's normal text input untouched. + if (text === "'" && /[\p{L}\p{N}_]/u.test(state.sliceDoc(Math.max(0, from - 1), from))) { + return null + } + + const close = pairs[text] if (close) { const selected = state.sliceDoc(from, to) return { @@ -33,20 +70,19 @@ export function autoPairInputTransaction( } } - if (from === to && CLOSERS.has(text) && state.sliceDoc(from, from + 1) === text) { - return { selection: EditorSelection.cursor(from + 1) } - } - return null } -export function autoPairBackspaceTransaction(state: EditorState): TransactionSpec | null { +export function autoPairBackspaceTransaction( + state: EditorState, + includeQuotes = false +): TransactionSpec | null { const selection = state.selection.main if (!selection.empty || selection.head === 0) return null const from = selection.head - 1 const open = state.sliceDoc(from, selection.head) - const close = PAIRS[open] + const close = pairsForQuotes(includeQuotes)[open] if (!close || state.sliceDoc(selection.head, selection.head + 1) !== close) return null return { @@ -57,7 +93,8 @@ export function autoPairBackspaceTransaction(state: EditorState): TransactionSpe /** * Standard paired-delimiter editing, optionally restricted by the caller - * (ZenNotes uses that to allow it only outside Vim normal/visual modes). + * (ZenNotes uses that to allow it only outside Vim normal/visual modes, and + * keeps quote pairing inside fenced code unless the user opts into prose). */ export function autoPairExtension(config: AutoPairExtensionConfig = {}): Extension { const shouldHandle = (view: EditorView): boolean => !config.shouldHandle || config.shouldHandle(view) @@ -65,7 +102,8 @@ export function autoPairExtension(config: AutoPairExtensionConfig = {}): Extensi return [ EditorView.inputHandler.of((view, from, to, text) => { if (!shouldHandle(view)) return false - const transaction = autoPairInputTransaction(view.state, from, to, text) + const includeQuotes = config.shouldPairQuotes?.(view, from, to) ?? false + const transaction = autoPairInputTransaction(view.state, from, to, text, includeQuotes) if (!transaction) return false view.dispatch(transaction) return true @@ -75,7 +113,12 @@ export function autoPairExtension(config: AutoPairExtensionConfig = {}): Extensi key: 'Backspace', run: (view): boolean => { if (!shouldHandle(view)) return false - const transaction = autoPairBackspaceTransaction(view.state) + const includeQuotes = config.shouldPairQuotes?.( + view, + view.state.selection.main.from, + view.state.selection.main.to + ) ?? false + const transaction = autoPairBackspaceTransaction(view.state, includeQuotes) if (!transaction) return false view.dispatch(transaction) return true diff --git a/packages/app-core/src/lib/markdown-snippets-config.ts b/packages/app-core/src/lib/markdown-snippets-config.ts index ec2f890b..0415b773 100644 --- a/packages/app-core/src/lib/markdown-snippets-config.ts +++ b/packages/app-core/src/lib/markdown-snippets-config.ts @@ -1,6 +1,6 @@ import type { Extension } from '@codemirror/state' import type { EditorView } from '@codemirror/view' -import { autoPairExtension } from './cm-auto-pairs' +import { autoPairExtension, isInFencedCodeBlock } from './cm-auto-pairs' import { markdownSnippetExtension } from './cm-markdown-snippets' import { isEditorInsertMode } from './vim-nav' import { useStore } from '../store' @@ -20,7 +20,11 @@ export function appMarkdownSnippetExtension(): Extension { return [ autoPairExtension({ - shouldHandle: (view) => useStore.getState().autoPairs && isTyping(view) + shouldHandle: (view) => useStore.getState().autoPairs && isTyping(view), + shouldPairQuotes: (view, from) => { + const s = useStore.getState() + return s.autoPairQuotesInProse || isInFencedCodeBlock(view.state, from) + } }), markdownSnippetExtension({ shouldHandle: (view) => { diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index d6eeed19..63a36306 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -411,8 +411,10 @@ interface Prefs { /** Auto-close markdown delimiters while typing: `**`+Space → `**|**`, * ```` ``` ````+Enter expands a fenced block. Off restores plain typing. */ markdownSnippets: boolean - /** Auto-insert matching `()` and `{}` delimiters while typing. */ + /** Auto-insert matching `[]`, `()`, and `{}` delimiters while typing. */ autoPairs: boolean + /** Also auto-insert matching quotes outside fenced code blocks. */ + autoPairQuotesInProse: boolean hideBuiltinTemplates: boolean // hide shipped built-in templates from the pickers tabsEnabled: boolean wrapTabs: boolean @@ -740,6 +742,7 @@ export const DEFAULT_PREFS: Prefs = { keepViewModeAcrossNotes: false, markdownSnippets: true, autoPairs: true, + autoPairQuotesInProse: false, hideBuiltinTemplates: false, tabsEnabled: true, wrapTabs: false, @@ -884,6 +887,10 @@ function normalizePrefs(p: Partial): Prefs { ? p.markdownSnippets : DEFAULT_PREFS.markdownSnippets, autoPairs: typeof p.autoPairs === 'boolean' ? p.autoPairs : DEFAULT_PREFS.autoPairs, + autoPairQuotesInProse: + typeof p.autoPairQuotesInProse === 'boolean' + ? p.autoPairQuotesInProse + : DEFAULT_PREFS.autoPairQuotesInProse, hideBuiltinTemplates: typeof p.hideBuiltinTemplates === 'boolean' ? p.hideBuiltinTemplates @@ -1694,6 +1701,7 @@ function collectPrefs(s: { keepViewModeAcrossNotes: boolean markdownSnippets: boolean autoPairs: boolean + autoPairQuotesInProse: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -1771,6 +1779,7 @@ function collectPrefs(s: { keepViewModeAcrossNotes: s.keepViewModeAcrossNotes, markdownSnippets: s.markdownSnippets, autoPairs: s.autoPairs, + autoPairQuotesInProse: s.autoPairQuotesInProse, hideBuiltinTemplates: s.hideBuiltinTemplates, tabsEnabled: s.tabsEnabled, wrapTabs: s.wrapTabs, @@ -2226,8 +2235,10 @@ interface Store { keepViewModeAcrossNotes: boolean /** Auto-close markdown delimiters while typing. Persisted. */ markdownSnippets: boolean - /** Auto-insert matching `()` and `{}` delimiters while typing. Persisted. */ + /** Auto-insert matching `[]`, `()`, and `{}` delimiters while typing. Persisted. */ autoPairs: boolean + /** Also auto-insert matching quotes outside fenced code blocks. Persisted. */ + autoPairQuotesInProse: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -2622,6 +2633,7 @@ interface Store { setKeepViewModeAcrossNotes: (on: boolean) => void setMarkdownSnippets: (on: boolean) => void setAutoPairs: (on: boolean) => void + setAutoPairQuotesInProse: (on: boolean) => void setHideBuiltinTemplates: (hidden: boolean) => void setTabsEnabled: (on: boolean) => void setWrapTabs: (on: boolean) => void @@ -3775,6 +3787,7 @@ export const useStore = create((set, get) => { keepViewModeAcrossNotes: loadPrefs().keepViewModeAcrossNotes, markdownSnippets: loadPrefs().markdownSnippets, autoPairs: loadPrefs().autoPairs, + autoPairQuotesInProse: loadPrefs().autoPairQuotesInProse, hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, tabsEnabled: loadPrefs().tabsEnabled, wrapTabs: loadPrefs().wrapTabs, @@ -5888,6 +5901,10 @@ export const useStore = create((set, get) => { set({ autoPairs: on }) savePrefs(collectPrefs(get())) }, + setAutoPairQuotesInProse: (on) => { + set({ autoPairQuotesInProse: on }) + savePrefs(collectPrefs(get())) + }, setHideBuiltinTemplates: (hidden) => { set({ hideBuiltinTemplates: hidden }) savePrefs(collectPrefs(get())) diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 6cb3c2b7..94a4aafd 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -77,6 +77,7 @@ export const PORTABLE_PREF_KEYS = [ 'keepViewModeAcrossNotes', 'markdownSnippets', 'autoPairs', + 'autoPairQuotesInProse', 'hideBuiltinTemplates', 'tabsEnabled', 'wrapTabs', @@ -177,6 +178,7 @@ export const PORTABLE_DEFAULTS: Record = { keepViewModeAcrossNotes: false, markdownSnippets: true, autoPairs: true, + autoPairQuotesInProse: false, hideBuiltinTemplates: false, tabsEnabled: true, wrapTabs: false, From e3ad53c9fff31af440523491486d08fb701b3437 Mon Sep 17 00:00:00 2001 From: Ayden Garza Date: Thu, 23 Jul 2026 08:18:20 -0500 Subject: [PATCH 3/4] fix(settings): hide dependent auto-pair option --- .../app-core/src/components/SettingsModal.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 7b08ce3f..1f3ee95a 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -2204,13 +2204,15 @@ export function SettingsModal(): JSX.Element { settingId="auto-pairs" onChange={setAutoPairs} /> - + {autoPairs && ( + + )} Date: Thu, 23 Jul 2026 08:24:16 -0500 Subject: [PATCH 4/4] fix(editor): pair quotes in inline code --- apps/desktop/src/main/app-config.test.ts | 2 +- apps/desktop/src/main/app-config.ts | 2 +- packages/app-core/src/components/SettingsModal.tsx | 8 ++++---- packages/app-core/src/lib/cm-auto-pairs.test.ts | 13 +++++++------ packages/app-core/src/lib/cm-auto-pairs.ts | 8 ++++---- .../app-core/src/lib/markdown-snippets-config.ts | 4 ++-- packages/app-core/src/store.ts | 4 ++-- 7 files changed, 21 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 00acea75..92366f01 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -143,7 +143,7 @@ describe('TOML serialization', () => { expect(text).toContain('font_size = 16') expect(text).toContain('auto_pairs = true # auto-insert matching [] () and {} while typing') expect(text).toContain( - 'auto_pair_quotes_in_prose = false # also auto-insert matching quotes outside fenced code blocks' + 'auto_pair_quotes_in_prose = false # also auto-insert matching quotes outside Markdown code' ) expect(text).toContain('[vim]') expect(text).toContain('[view]') diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index c6e3d2de..b104f9fb 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -110,7 +110,7 @@ const SCALAR_FIELDS: Partial> = { autoPairQuotesInProse: { section: 'editor', tomlKey: 'auto_pair_quotes_in_prose', - comment: 'also auto-insert matching quotes outside fenced code blocks' + comment: 'also auto-insert matching quotes outside Markdown code' }, hideBuiltinTemplates: { section: 'editor', diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 1f3ee95a..853b175c 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -1842,7 +1842,7 @@ export function SettingsModal(): JSX.Element { id: "auto-pairs", title: "Auto-pair brackets and delimiters", description: - "Insert matching [] () and {} as you type; quotes pair in fenced code blocks.", + "Insert matching [] () and {} as you type; quotes pair in Markdown code.", keywords: [ "auto pair", "autopair", @@ -1859,7 +1859,7 @@ export function SettingsModal(): JSX.Element { id: "auto-pair-quotes-in-prose", title: "Auto-pair quotes in prose", description: - "Also insert matching quotes outside fenced code blocks.", + "Also insert matching quotes outside Markdown code.", keywords: ["auto pair", "autopair", "quotes", "prose", "code blocks"], }, { @@ -2199,7 +2199,7 @@ export function SettingsModal(): JSX.Element { /> { }) }) -describe('isInFencedCodeBlock', () => { - it('identifies code content but not Markdown prose', () => { - const doc = 'Prose\n\n```ts\nconst value = \n```' +describe('isInMarkdownCode', () => { + it('identifies fenced and inline code but not Markdown prose', () => { + const doc = 'Prose\n\n```ts\nconst fenced = \n```\n\nInline `const inline = `' const current = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage, addKeymap: false })] }) - expect(isInFencedCodeBlock(current, 2)).toBe(false) - expect(isInFencedCodeBlock(current, doc.indexOf('const') + 'const value = '.length)).toBe(true) + expect(isInMarkdownCode(current, 2)).toBe(false) + expect(isInMarkdownCode(current, doc.indexOf('const fenced') + 'const fenced = '.length)).toBe(true) + expect(isInMarkdownCode(current, doc.indexOf('const inline') + 'const inline = '.length)).toBe(true) }) }) diff --git a/packages/app-core/src/lib/cm-auto-pairs.ts b/packages/app-core/src/lib/cm-auto-pairs.ts index e5048f38..06ab1622 100644 --- a/packages/app-core/src/lib/cm-auto-pairs.ts +++ b/packages/app-core/src/lib/cm-auto-pairs.ts @@ -21,11 +21,11 @@ export interface AutoPairExtensionConfig { shouldPairQuotes?: (view: EditorView, from: number, to: number) => boolean } -/** True when a position is inside a Markdown fenced code block. */ -export function isInFencedCodeBlock(state: EditorState, pos: number): boolean { +/** True when a position is inside a Markdown code span or fenced code block. */ +export function isInMarkdownCode(state: EditorState, pos: number): boolean { let node = syntaxTree(state).resolveInner(pos, -1) while (node) { - if (node.name === 'FencedCode') return true + if (node.name === 'FencedCode' || node.name === 'InlineCode') return true if (!node.parent) break node = node.parent } @@ -94,7 +94,7 @@ export function autoPairBackspaceTransaction( /** * Standard paired-delimiter editing, optionally restricted by the caller * (ZenNotes uses that to allow it only outside Vim normal/visual modes, and - * keeps quote pairing inside fenced code unless the user opts into prose). + * keeps quote pairing inside Markdown code unless the user opts into prose). */ export function autoPairExtension(config: AutoPairExtensionConfig = {}): Extension { const shouldHandle = (view: EditorView): boolean => !config.shouldHandle || config.shouldHandle(view) diff --git a/packages/app-core/src/lib/markdown-snippets-config.ts b/packages/app-core/src/lib/markdown-snippets-config.ts index 0415b773..ed71f014 100644 --- a/packages/app-core/src/lib/markdown-snippets-config.ts +++ b/packages/app-core/src/lib/markdown-snippets-config.ts @@ -1,6 +1,6 @@ import type { Extension } from '@codemirror/state' import type { EditorView } from '@codemirror/view' -import { autoPairExtension, isInFencedCodeBlock } from './cm-auto-pairs' +import { autoPairExtension, isInMarkdownCode } from './cm-auto-pairs' import { markdownSnippetExtension } from './cm-markdown-snippets' import { isEditorInsertMode } from './vim-nav' import { useStore } from '../store' @@ -23,7 +23,7 @@ export function appMarkdownSnippetExtension(): Extension { shouldHandle: (view) => useStore.getState().autoPairs && isTyping(view), shouldPairQuotes: (view, from) => { const s = useStore.getState() - return s.autoPairQuotesInProse || isInFencedCodeBlock(view.state, from) + return s.autoPairQuotesInProse || isInMarkdownCode(view.state, from) } }), markdownSnippetExtension({ diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 63a36306..3873938d 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -413,7 +413,7 @@ interface Prefs { markdownSnippets: boolean /** Auto-insert matching `[]`, `()`, and `{}` delimiters while typing. */ autoPairs: boolean - /** Also auto-insert matching quotes outside fenced code blocks. */ + /** Also auto-insert matching quotes outside Markdown code spans and blocks. */ autoPairQuotesInProse: boolean hideBuiltinTemplates: boolean // hide shipped built-in templates from the pickers tabsEnabled: boolean @@ -2237,7 +2237,7 @@ interface Store { markdownSnippets: boolean /** Auto-insert matching `[]`, `()`, and `{}` delimiters while typing. Persisted. */ autoPairs: boolean - /** Also auto-insert matching quotes outside fenced code blocks. Persisted. */ + /** Also auto-insert matching quotes outside Markdown code spans and blocks. Persisted. */ autoPairQuotesInProse: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean