diff --git a/apps/desktop/src/main/app-config.test.ts b/apps/desktop/src/main/app-config.test.ts index 6b948bcd..92366f01 100644 --- a/apps/desktop/src/main/app-config.test.ts +++ b/apps/desktop/src/main/app-config.test.ts @@ -98,6 +98,8 @@ describe('TOML serialization', () => { editorLineHeight: 1.6, themeFamily: 'nord', themeMode: 'dark', + autoPairs: false, + autoPairQuotesInProse: true, vaultTextSearchBackend: 'ripgrep', ripgrepBinaryPath: null, interfaceFont: null, @@ -117,6 +119,8 @@ 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.autoPairQuotesInProse).toBe(true) expect(round.vaultTextSearchBackend).toBe('ripgrep') expect(round.keymapOverrides).toEqual({ 'global.searchNotes': 'Mod+P' }) expect(round.kanbanColumnTitles).toEqual({ 'status:todo': 'To Do' }) @@ -137,6 +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_pair_quotes_in_prose = false # also auto-insert matching quotes outside Markdown code' + ) 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..b104f9fb 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -102,6 +102,16 @@ 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' + }, + autoPairQuotesInProse: { + section: 'editor', + tomlKey: 'auto_pair_quotes_in_prose', + comment: 'also auto-insert matching quotes outside Markdown code' + }, 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..853b175c 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -449,6 +449,12 @@ 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 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); @@ -1832,6 +1838,30 @@ export function SettingsModal(): JSX.Element { "completion", ], }, + { + id: "auto-pairs", + title: "Auto-pair brackets and delimiters", + description: + "Insert matching [] () and {} as you type; quotes pair in Markdown code.", + 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 Markdown code.", + keywords: ["auto pair", "autopair", "quotes", "prose", "code blocks"], + }, { id: "note-tabs", title: "Note tabs", @@ -2090,6 +2120,8 @@ export function SettingsModal(): JSX.Element { "live-preview", "render-tables", "markdown-overrides", + "auto-pairs", + "auto-pair-quotes-in-prose", "note-tabs", "wrap-note-tabs", "word-wrap", @@ -2165,6 +2197,22 @@ export function SettingsModal(): JSX.Element { settingId="markdown-overrides" onChange={setMarkdownSnippets} /> + + {autoPairs && ( + + )} { + 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.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() + 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() + }) + + 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('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(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) + }) +}) + +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..06ab1622 --- /dev/null +++ b/packages/app-core/src/lib/cm-auto-pairs.ts @@ -0,0 +1,129 @@ +import { EditorSelection, type EditorState, type Extension, type TransactionSpec } from '@codemirror/state' +import { syntaxTree } from '@codemirror/language' +import { EditorView, keymap } from '@codemirror/view' + +const STRUCTURAL_PAIRS: Readonly> = { + '(': ')', + '[': ']', + '{': '}' +} + +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 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' || node.name === 'InlineCode') return true + if (!node.parent) break + node = node.parent + } + return false +} + +function pairsForQuotes(includeQuotes: boolean): Readonly> { + return includeQuotes ? ALL_PAIRS : STRUCTURAL_PAIRS +} + +/** + * 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, + includeQuotes = false +): TransactionSpec | null { + if (text.length !== 1) return null + + 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 { + changes: { from, to, insert: text + selected + close }, + selection: EditorSelection.range(from + 1, to + 1) + } + } + + return 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 = pairsForQuotes(includeQuotes)[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, and + * 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) + + return [ + EditorView.inputHandler.of((view, from, to, text) => { + if (!shouldHandle(view)) return false + 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 + }), + keymap.of([ + { + key: 'Backspace', + run: (view): boolean => { + if (!shouldHandle(view)) return false + 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 72d98018..ed71f014 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, isInMarkdownCode } from './cm-auto-pairs' import { markdownSnippetExtension } from './cm-markdown-snippets' import { isEditorInsertMode } from './vim-nav' import { useStore } from '../store' @@ -12,11 +13,24 @@ 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), + shouldPairQuotes: (view, from) => { + const s = useStore.getState() + return s.autoPairQuotesInProse || isInMarkdownCode(view.state, from) + } + }), + 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..3873938d 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -411,6 +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. */ + autoPairs: boolean + /** 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 wrapTabs: boolean @@ -737,6 +741,8 @@ export const DEFAULT_PREFS: Prefs = { looseMathDelimiters: false, keepViewModeAcrossNotes: false, markdownSnippets: true, + autoPairs: true, + autoPairQuotesInProse: false, hideBuiltinTemplates: false, tabsEnabled: true, wrapTabs: false, @@ -880,6 +886,11 @@ function normalizePrefs(p: Partial): Prefs { typeof p.markdownSnippets === 'boolean' ? 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 @@ -1689,6 +1700,8 @@ function collectPrefs(s: { looseMathDelimiters: boolean keepViewModeAcrossNotes: boolean markdownSnippets: boolean + autoPairs: boolean + autoPairQuotesInProse: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -1765,6 +1778,8 @@ function collectPrefs(s: { looseMathDelimiters: s.looseMathDelimiters, keepViewModeAcrossNotes: s.keepViewModeAcrossNotes, markdownSnippets: s.markdownSnippets, + autoPairs: s.autoPairs, + autoPairQuotesInProse: s.autoPairQuotesInProse, hideBuiltinTemplates: s.hideBuiltinTemplates, tabsEnabled: s.tabsEnabled, wrapTabs: s.wrapTabs, @@ -2220,6 +2235,10 @@ interface Store { keepViewModeAcrossNotes: boolean /** Auto-close markdown delimiters while typing. Persisted. */ markdownSnippets: boolean + /** Auto-insert matching `[]`, `()`, and `{}` delimiters while typing. Persisted. */ + autoPairs: boolean + /** Also auto-insert matching quotes outside Markdown code spans and blocks. Persisted. */ + autoPairQuotesInProse: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -2613,6 +2632,8 @@ interface Store { setLooseMathDelimiters: (on: boolean) => void 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 @@ -3765,6 +3786,8 @@ export const useStore = create((set, get) => { looseMathDelimiters: loadPrefs().looseMathDelimiters, keepViewModeAcrossNotes: loadPrefs().keepViewModeAcrossNotes, markdownSnippets: loadPrefs().markdownSnippets, + autoPairs: loadPrefs().autoPairs, + autoPairQuotesInProse: loadPrefs().autoPairQuotesInProse, hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, tabsEnabled: loadPrefs().tabsEnabled, wrapTabs: loadPrefs().wrapTabs, @@ -5874,6 +5897,14 @@ export const useStore = create((set, get) => { set({ markdownSnippets: on }) savePrefs(collectPrefs(get())) }, + setAutoPairs: (on) => { + 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 f4ce65b8..94a4aafd 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -76,6 +76,8 @@ export const PORTABLE_PREF_KEYS = [ 'looseMathDelimiters', 'keepViewModeAcrossNotes', 'markdownSnippets', + 'autoPairs', + 'autoPairQuotesInProse', 'hideBuiltinTemplates', 'tabsEnabled', 'wrapTabs', @@ -175,6 +177,8 @@ export const PORTABLE_DEFAULTS: Record = { looseMathDelimiters: false, keepViewModeAcrossNotes: false, markdownSnippets: true, + autoPairs: true, + autoPairQuotesInProse: false, hideBuiltinTemplates: false, tabsEnabled: true, wrapTabs: false,