From 1f1dbff4280da3254bb8506489476754bb54e7d1 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 1 Sep 2026 08:28:16 -0500 Subject: [PATCH 1/4] Fix(export): a note exported from the web build carries its title A note whose body does not open with its own `# heading` exported from the self-hosted web build as a PDF that starts mid-thought, with the title nowhere on the page. The same note exported from the desktop app got its title. A note that named itself in frontmatter (`title:`) also came out under its filename instead, both in the document and in the filename the browser's print dialog suggested. The rule already exists and is shared: `withExportTitle` resolves the title (a body H1 wins, then frontmatter `title:`, then the filename) and inserts it as a leading heading when the body does not state one, so it flows through the ordinary rendering pipeline. 4214c59 wired it into the desktop PDF window, the Word export and the copy-for-email path, and its own header note says it covers "every export format that builds on this module". The web export window predates that change by three months and was simply never connected to it. So this connects it. It is the same call the desktop window makes, on the same shared helper: no new rule, no second definition of what an export title is, and nothing changes for a note that already opens with its own H1. `document.title` is resolved through it too, because the browser seeds the saved PDF's filename from there and a note titled in frontmatter should not save itself under its filename. How to test locally: run `npm run dev:web` with the Go server on :7878, open a note whose body has no `# heading` (a frontmatter-titled note is the clearest case), and export it as a PDF. Before: the PDF opens on the first paragraph and the title appears nowhere. After: the note's title is the first line, and the print dialog offers it as the filename. Claude-Session: https://claude.ai/code/session_01GCwiToGRTKKsDENaY56Vm2 --- apps/web/src/export-window.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/export-window.tsx b/apps/web/src/export-window.tsx index 02857450..4fbf4815 100644 --- a/apps/web/src/export-window.tsx +++ b/apps/web/src/export-window.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client' import type { AssetMeta, NoteContent, NoteMeta, VaultInfo } from '@shared/ipc' import { LazyPreview as Preview } from '@renderer/components/LazyPreview' import { useStore } from '@renderer/store' +import { withExportTitle } from '@shared/export-title' import '@renderer/styles/index.css' const PREFS_KEY = 'zen:prefs:v2' @@ -136,7 +137,10 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { selectedPath: noteContent.path, activeNote: noteContent }) - document.title = `${noteContent.title}.pdf` + // The browser's print dialog seeds the filename from document.title, so + // it takes the resolved export title (frontmatter `title:` beats the + // filename) rather than the note's filename alone. + document.title = `${withExportTitle(noteContent.body, noteContent.title).title}.pdf` setNote(noteContent) } catch (err) { if (cancelled) return @@ -244,7 +248,11 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element { } `}
- void triggerPrint()} /> + void triggerPrint()} + />
) From 87b316f873de0fc4a3a87b3e6804a6638a74c912 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 1 Sep 2026 08:48:29 -0500 Subject: [PATCH 2/4] Fix(tables): yank and paste work inside table cells, through the real registers In Vim mode a WYSIWYG table cell runs its own modal normal mode, and that mini-Vim knew how to delete but not how to move text: `y` and `p` were swallowed as stray printable keys, and the one yank that existed (visual mode `y`) wrote straight to the system clipboard, where no `p` could reach it. You could destroy text in a table. You could not move it, and nothing yanked in the note body could be pasted into a cell. The fix is not a private cell clipboard, on purpose. The cell's y/d/p now go through codemirror-vim's register controller, the same module state the main editor's motions use ('"' is the unnamed register object itself in the vim dist), so text moves freely in both directions: yank a word in your prose and paste it into a cell, or `yy` a cell and `p` it onto a line. It also means cm-vim-clipboard's pushText patch applies unchanged, so with "Sync clipboard with Vim registers" on, table yanks and pastes ride the system clipboard exactly like editor yanks, with no second implementation of that setting. What the cell understands now: `y` as an operator (`yy`/`Y` for the cell, `yw`, `y$`, `yiw`, `ya"`, the motions the existing `d`/`c` operators already took), `p`/`P` around the block cursor, visual `y`, and visual `p` replacing the selection with the Vim register swap. Deletes and changes feed the register too, so `dd` then `p` moves text the way it does everywhere else. A multi-line register flattens to one line on paste (interior breaks become spaces) because a cell is one line of a row's markdown and a pasted newline must never break the table. One deliberate behavior change rides along: visual-mode `y` in a cell used to hit the system clipboard unconditionally; it now respects the clipboard-sync setting like every other yank in the app. Verified live over CDP against the built app, vim mode on: cell to cell, cell to body, body to cell, dd+p round-trip, visual yank and paste, and the serialized markdown on disk after blur carries every edit. How to test locally: npm run dev, vim mode on, a note with a table. Click a cell, `yy`, click another cell, `p`: the text lands there. Esc to the body, `yiw` on a word, back into a cell, `p`. Before: those keys did nothing in a cell. After: they behave like Vim. Closes #706 Claude-Session: https://claude.ai/code/session_01GCwiToGRTKKsDENaY56Vm2 --- packages/app-core/src/lib/cm-table.test.ts | 135 +++++++++++++ packages/app-core/src/lib/cm-table.ts | 214 +++++++++++++++++++-- packages/app-core/src/lib/help.ts | 2 +- 3 files changed, 329 insertions(+), 22 deletions(-) diff --git a/packages/app-core/src/lib/cm-table.test.ts b/packages/app-core/src/lib/cm-table.test.ts index 32a890ba..b22e05ce 100644 --- a/packages/app-core/src/lib/cm-table.test.ts +++ b/packages/app-core/src/lib/cm-table.test.ts @@ -20,6 +20,17 @@ import { import { closeTableContextMenu } from './cm-table-menu' import { isMacPlatform } from './keymaps' import { useStore } from '../store' +import { Vim } from '@replit/codemirror-vim' + +/** The Vim plugin's unnamed register — the interop point that #706 wires the + * table's y/d/p through, shared with the main editor. */ +function unnamedRegister(): { toString(): string; setText(text: string, linewise?: boolean): void } { + return ( + Vim.getRegisterController() as unknown as { + unnamedRegister: { toString(): string; setText(text: string, linewise?: boolean): void } + } + ).unnamedRegister +} const TABLE_DOC = `Intro text. @@ -393,6 +404,130 @@ describe('tablePlugin', () => { expect(cell.dataset.raw).toBe('') view.destroy() }) + + // #706: yank and paste inside table cells, through the shared Vim register. + const press = (cell: HTMLElement, ...keys: string[]): void => { + for (const key of keys) { + cell.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + } + } + + it('yy yanks the whole cell into the unnamed register (#706)', () => { + const view = mount(TABLE_DOC) + unnamedRegister().setText('') + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + press(cell, 'y', 'y') + expect(unnamedRegister().toString()).toBe('Alice') + // Yank leaves the cell untouched. + expect(cell.dataset.raw).toBe('Alice') + view.destroy() + }) + + it('y$ and yiw yank ranges; Y yanks the cell (#706)', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + unnamedRegister().setText('') + press(cell, 'y', '$') + expect(unnamedRegister().toString()).toBe('Alice') + unnamedRegister().setText('') + press(cell, 'y', 'i', 'w') + expect(unnamedRegister().toString()).toBe('Alice') + unnamedRegister().setText('') + press(cell, 'Y') + expect(unnamedRegister().toString()).toBe('Alice') + expect(cell.dataset.raw).toBe('Alice') + view.destroy() + }) + + it('p pastes the register after the cursor char, P before it (#706)', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="1"]' + )! + expect(cell.dataset.raw).toBe('30') + unnamedRegister().setText('X') + press(cell, 'p') // cursor on "3" → paste after it + expect(cell.dataset.raw).toBe('3X0') + unnamedRegister().setText('Y') + // After p the cursor sits on the pasted "X" (offset 1); P inserts before it. + press(cell, 'P') + expect(cell.dataset.raw).toBe('3YX0') + view.destroy() + }) + + it('yanks in one cell and pastes into another (#706)', () => { + const view = mount(TABLE_DOC) + const alice = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + const bob = view.dom.querySelector( + '.cm-table-widget [data-row="1"][data-col="0"]' + )! + press(alice, 'y', 'y') + press(bob, 'P') + expect(bob.dataset.raw).toBe('AliceBob') + view.destroy() + }) + + it('x and dd save the deleted text so it can be pasted back (#706)', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + press(cell, 'x') + expect(cell.dataset.raw).toBe('lice') + expect(unnamedRegister().toString()).toBe('A') + press(cell, 'P') + expect(cell.dataset.raw).toBe('Alice') + press(cell, 'd', 'd') + expect(cell.dataset.raw).toBe('') + expect(unnamedRegister().toString()).toBe('Alice') + press(cell, 'p') + expect(cell.dataset.raw).toBe('Alice') + view.destroy() + }) + + it('visual y yanks the selection into the register (#706)', () => { + const view = mount(TABLE_DOC) + unnamedRegister().setText('') + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + press(cell, 'v', 'l', 'y') + expect(unnamedRegister().toString()).toBe('Al') + expect(cell.dataset.raw).toBe('Alice') + view.destroy() + }) + + it('visual p replaces the selection and swaps it into the register (#706)', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + unnamedRegister().setText('Zed') + press(cell, 'v', 'l', 'p') + expect(cell.dataset.raw).toBe('Zedice') + // Vim swap: the replaced text is now in the unnamed register. + expect(unnamedRegister().toString()).toBe('Al') + view.destroy() + }) + + it('flattens a multi-line register to a single cell line on paste (#706)', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + // A linewise editor yank: interior break becomes a space, the trailing + // newline is shed, and the row's markdown stays intact. + unnamedRegister().setText('one\ntwo\n', true) + press(cell, 'P') + expect(cell.dataset.raw).toBe('one twoAlice') + view.destroy() + }) }) describe('vim word motions (cell cursor)', () => { diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index 2426101d..5fe5b434 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -43,7 +43,7 @@ import { const MIN_COL_WIDTH = 48 import { openTableContextMenu } from './cm-table-menu' import { renderMarkdown } from './markdown' -import { getCM } from '@replit/codemirror-vim' +import { getCM, Vim } from '@replit/codemirror-vim' import { undo, redo } from '@codemirror/commands' import { useStore } from '../store' import { matchesSequenceToken, matchesShortcutBinding } from './keymaps' @@ -65,6 +65,50 @@ function vimEnabled(): boolean { return useStore.getState().vimMode } +/** The Vim plugin's register controller, the same one the main editor's y/d/p + * go through. Sharing it is what lets text move between a table cell and the + * note body in either direction (#706). `pushText` is patched once by + * cm-vim-clipboard, so a table yank also mirrors to the system clipboard + * whenever the yank-to-clipboard setting is on, exactly like an editor yank. */ +interface VimRegisterController { + pushText: ( + registerName: string, + operator: string, + text: string, + linewise?: boolean, + blockwise?: boolean + ) => void + unnamedRegister?: { + toString(): string + setText?: (text: string, linewise?: boolean, blockwise?: boolean) => void + } +} + +function vimRegisters(): VimRegisterController | null { + try { + const controller = Vim.getRegisterController() as unknown as VimRegisterController | null + return controller && typeof controller.pushText === 'function' ? controller : null + } catch { + return null + } +} + +/** Store yanked/deleted cell text in the unnamed register. Cell content is a + * single line, so everything is charwise. */ +function saveVimRegister(operator: 'yank' | 'delete' | 'change', text: string): void { + if (!text) return + vimRegisters()?.pushText('"', operator, text, false, false) +} + +/** The unnamed register's content flattened to fit a single-line cell: a + * linewise yank sheds its trailing newline, and interior line/cell breaks + * (a multi-line yank from the editor, a multi-cell table yank) become + * spaces rather than breaking the row's markdown. */ +function registerPasteText(): string { + const raw = vimRegisters()?.unnamedRegister?.toString() ?? '' + return raw.replace(/\r?\n$/, '').replace(/[\t\r\n]+/g, ' ') +} + /** How long (ms) the pieces of a custom insert-escape sequence (e.g. jk) may be * spread apart and still count as the escape rather than typed text. Mirrors * Vim's default `timeoutlen`. (#341) */ @@ -228,8 +272,8 @@ class TableWidget extends WidgetType { /** Where to land the block cursor on the next cell focus: a char index, or * 'end' (last char). Lets h/l carry the cursor across cell boundaries. */ private pendingOffset: number | 'end' | null = null - /** A pending operator (`d`/`c`) waiting for its motion key (dw, cc, d$, …). */ - private pendingOp: 'd' | 'c' | null = null + /** A pending operator (`d`/`c`/`y`) waiting for its motion key (dw, cc, y$, …). */ + private pendingOp: 'd' | 'c' | 'y' | null = null /** Table-aware visual mode. Anchor and head retain cell coordinates so the * selection survives focus moving across cell and row boundaries. */ private visualMode: 'char' | 'line' | null = null @@ -829,8 +873,15 @@ class TableWidget extends WidgetType { this.pendingScope = null const r = textObjectRange(cellText, this.cursorOffset, scope, event.key) if (r) { - this.deleteRange(editable, r.from, r.to) - if (op === 'c') this.enterInsertMode(editable, r.from) + if (op === 'y') { + // yiw / ya" — yank the object, cursor to its start (Vim). + saveVimRegister('yank', cellText.slice(r.from, r.to)) + this.cursorOffset = Math.min(r.from, Math.max(0, cellText.length - 1)) + this.renderCellCursor(editable) + } else { + this.deleteRange(editable, r.from, r.to, op === 'c' ? 'change' : 'delete') + if (op === 'c') this.enterInsertMode(editable, r.from) + } } return } @@ -982,7 +1033,7 @@ class TableWidget extends WidgetType { case 'C': { event.preventDefault() const at = this.cursorOffset - this.deleteRange(editable, at, cellText.length) + this.deleteRange(editable, at, cellText.length, 'change') this.enterInsertMode(editable, at) return } @@ -999,6 +1050,20 @@ class TableWidget extends WidgetType { event.preventDefault() this.pendingOp = 'c' return + case 'y': + event.preventDefault() + this.pendingOp = 'y' + return + case 'Y': + // Vim's Y is yy: yank the whole (single-line) cell. (#706) + event.preventDefault() + saveVimRegister('yank', cellText) + return + case 'p': + case 'P': + event.preventDefault() + this.pasteIntoCell(editable, event.key === 'P') + return case 'v': event.preventDefault() this.visualMode = 'char' @@ -1303,12 +1368,20 @@ class TableWidget extends WidgetType { } /** Delete `[from, to)` from the focused cell's source in place (no dispatch — - * committed on blur, like typing). Re-renders the NORMAL block cursor. */ - private deleteRange(cell: HTMLElement, from: number, to: number): void { + * committed on blur, like typing). Re-renders the NORMAL block cursor. + * The removed text lands in the unnamed register like any Vim delete, so + * `dd` in one cell followed by `p` in another moves the text (#706). */ + private deleteRange( + cell: HTMLElement, + from: number, + to: number, + register: 'delete' | 'change' = 'delete' + ): void { const text = cell.dataset.raw ?? '' const a = Math.max(0, Math.min(from, text.length)) const b = Math.max(a, Math.min(to, text.length)) if (b === a) return + saveVimRegister(register, text.slice(a, b)) const next = text.slice(0, a) + text.slice(b) cell.dataset.raw = next this.dirty = true @@ -1337,6 +1410,45 @@ class TableWidget extends WidgetType { this.renderCellCursor(cell) } + /** Resolve what `p`/`P` should paste. Normally the unnamed register; with + * yank-to-clipboard on, the system clipboard is loaded into the register + * first, mirroring `vimClipboardPasteExtension` so the table and the editor + * paste the same thing (#706). The continuation may run async. */ + private resolvePasteText(then: (text: string) => void): void { + const clipboard = navigator.clipboard + if (!useStore.getState().vimYankToClipboard || !clipboard?.readText) { + then(registerPasteText()) + return + } + void clipboard + .readText() + .then((text) => { + if (text) vimRegisters()?.unnamedRegister?.setText?.(text, /\n$/.test(text)) + then(registerPasteText()) + }) + .catch(() => then(registerPasteText())) + } + + /** Vim `p` (after the cursor char) / `P` (before it): insert the unnamed + * register into the cell, cursor landing on the last pasted char. (#706) */ + private pasteIntoCell(cell: HTMLElement, before: boolean): void { + this.resolvePasteText((pasted) => { + if (!pasted) return + // Re-read the cell: the clipboard read may have resolved a tick later. + const text = cell.dataset.raw ?? '' + const at = before + ? Math.min(this.cursorOffset, text.length) + : Math.min(text.length, this.cursorOffset + (text.length > 0 ? 1 : 0)) + const next = text.slice(0, at) + pasted + text.slice(at) + cell.dataset.raw = next + this.dirty = true + cell.textContent = next + cell.dataset.rendered = 'false' + this.cursorOffset = Math.max(0, at + pasted.length - 1) + this.renderCellCursor(cell) + }) + } + /** `r`: replace the character under the block cursor. (#435) */ private replaceChar(cell: HTMLElement, ch: string): void { const text = cell.dataset.raw ?? '' @@ -1349,11 +1461,12 @@ class TableWidget extends WidgetType { this.renderCellCursor(cell) } - /** Apply a pending operator (`d`/`c`) over the motion in `key`: dd/cc clear - * the cell; dw/cw, d$/c$, d0/c0, dl, db act over that range. `c` then edits. */ + /** Apply a pending operator (`d`/`c`/`y`) over the motion in `key`: dd/cc/yy + * act on the whole cell; dw/cw/yw, d$/c$/y$, d0, dl, db act over that range. + * `c` then edits; `y` stores the range without touching the text (#706). */ private applyOperator( cell: HTMLElement, - op: 'd' | 'c', + op: 'd' | 'c' | 'y', key: string, text: string ): void { @@ -1361,7 +1474,7 @@ class TableWidget extends WidgetType { let from = off let to = off if (key === op) { - // dd / cc → the whole cell + // dd / cc / yy → the whole cell from = 0 to = text.length } else { @@ -1393,8 +1506,20 @@ class TableWidget extends WidgetType { } } const wholeCell = from === 0 && to === text.length + if (op === 'y') { + if (to > from) { + saveVimRegister('yank', text.slice(from, to)) + // Vim: a charwise yank parks the cursor at the start of the yanked + // range; yy (linewise) stays put. + if (key !== op) { + this.cursorOffset = Math.min(from, Math.max(0, text.length - 1)) + this.renderCellCursor(cell) + } + } + return + } if (to > from || wholeCell) { - this.deleteRange(cell, from, to) + this.deleteRange(cell, from, to, op === 'c' ? 'change' : 'delete') if (op === 'c') this.enterInsertMode(cell, from) } } @@ -1573,10 +1698,24 @@ class TableWidget extends WidgetType { if (insert) this.enterInsertMode(target, first.from) } + /** The visual selection's text, cells joined with tabs within a row and + * newlines across rows — the same shape a spreadsheet copy produces. */ + private visualSelectionText(ranges: VisualCellRange[]): string { + let previousRow: number | null = null + let selected = '' + for (const { cell, row, from, to } of ranges) { + if (previousRow !== null) selected += row === previousRow ? '\t' : '\n' + selected += (cell.dataset.raw ?? '').slice(from, to) + previousRow = row + } + return selected + } + private deleteVisualSelection(insert: boolean): void { const ranges = this.visualSelectionRanges() const first = ranges[0] if (!first) return + saveVimRegister(insert ? 'change' : 'delete', this.visualSelectionText(ranges)) let changed = false for (const { cell, from, to } of ranges) { const text = cell.dataset.raw ?? '' @@ -1594,17 +1733,46 @@ class TableWidget extends WidgetType { const ranges = this.visualSelectionRanges() const first = ranges[0] if (!first) return - let previousRow: number | null = null - let selected = '' - for (const { cell, row, from, to } of ranges) { - if (previousRow !== null) selected += row === previousRow ? '\t' : '\n' - selected += (cell.dataset.raw ?? '').slice(from, to) - previousRow = row - } - void navigator.clipboard?.writeText(selected) + // Through the shared Vim register, like the main editor's visual yank, so + // the text can be pasted anywhere with `p` — another cell or the note body. + // The system clipboard now follows the yank-to-clipboard setting (the + // register patch mirrors it) instead of being written unconditionally. + saveVimRegister('yank', this.visualSelectionText(ranges)) this.finishVisualAtStart(first, false) } + /** Visual `p`/`P`: the register replaces the selection; the replaced text + * swaps into the unnamed register, per Vim. (#706) */ + private pasteVisualSelection(): void { + this.resolvePasteText((pasted) => { + if (!pasted) return + const ranges = this.visualSelectionRanges() + const first = ranges[0] + if (!first) return + const replaced = this.visualSelectionText(ranges) + for (const { cell, from, to } of ranges) { + const text = cell.dataset.raw ?? '' + const next = + cell === first.cell + ? text.slice(0, from) + pasted + text.slice(to) + : text.slice(0, from) + text.slice(to) + cell.dataset.raw = next + cell.textContent = next + cell.dataset.rendered = 'false' + } + this.dirty = true + saveVimRegister('delete', replaced) + const offset = Math.max(0, first.from + pasted.length - 1) + this.exitVisual() + if (document.activeElement === first.cell) { + this.cursorOffset = offset + this.enterNormalCell(first.cell) + } else { + this.focusCellAtOffset(first.row, first.col, offset) + } + }) + } + /** Keys while in table visual mode. Motions update a coordinate-aware head; * edit and yank commands consume the same multi-cell range. */ private handleVisualKey( @@ -1650,6 +1818,10 @@ class TableWidget extends WidgetType { this.yankVisualSelection() return } + if (key === 'p' || key === 'P') { + this.pasteVisualSelection() + return + } if (key === 'v') { if (this.visualMode === 'char') { this.exitVisual() diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 56af478e..66ec5afc 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -1026,7 +1026,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Vault text search backend and binary paths', detail: 'Choose Auto, the built-in searcher, ripgrep, or fzf for vault-wide text search. Auto prefers system tools when they are installed and falls back cleanly when they are not, you can provide explicit binary paths for ripgrep or fzf if they are not on your PATH, and Settings now shows the resolved runtime backend that will actually be used.' }, { label: 'Live preview', detail: 'Hide markdown syntax on lines you are not actively editing. Also draws math, tables, and `mermaid` diagrams in place; each turns back into its source when the cursor enters it.' }, { label: 'Math size', detail: 'Scales inline `$…$` and block `$$…$$` math relative to the surrounding text, 50 to 200 percent, in the editor and the reading view, for both KaTeX and Typst; `math_font_scale` under `[editor]` in `config.toml`.' }, - { label: 'Render tables in live preview', detail: 'Show Markdown tables as interactive WYSIWYG widgets (edit cells, drag, right-click/`m` menu). Turn it off to keep tables as plain markdown text so you can edit them with the keyboard and Vim motions like any other line. When widgets are on, Arrow keys (and h/j/k/l) navigate cells; Shift+V then Shift+J/Shift+K move whole lines in the raw source.' }, + { label: 'Render tables in live preview', detail: 'Show Markdown tables as interactive WYSIWYG widgets (edit cells, drag, right-click/`m` menu). Turn it off to keep tables as plain markdown text so you can edit them with the keyboard and Vim motions like any other line. When widgets are on, Arrow keys (and h/j/k/l) navigate cells; Shift+V then Shift+J/Shift+K move whole lines in the raw source. In Vim mode the cell cursor speaks the editor\'s language: motions (w/b/e, f/t, 0/$), operators (d/c/y with motions and text objects), visual selections, and yank/paste (y/p/P) through the same registers as the rest of the note, so you can yank in a cell and paste in the body, or the other way around.' }, { label: 'Sync title heading on rename', detail: 'On by default. A new note is created as `# `, and with this on a rename carries that heading along — rename `Untitled` to `Groceries` and line one becomes `# Groceries`, from the breadcrumb, the sidebar, or the note list alike. Only an existing top-level `#` heading is rewritten and one is never invented, so a note that opens with prose, a list, or a `##` heading is untouched; deleting the `#` line opts that note out permanently. The heading is found after any frontmatter, and the rest of the note is left byte for byte as it was.' }, { label: 'Heading level labels', detail: 'Show H1 through H6 badges before headings. Heading fold arrows stay available whether labels are on or off.' }, { label: 'Tab size', detail: 'Choose how many spaces a tab occupies when rendered and when indenting in every Markdown editor surface. Nested list levels also render this many columns deep, whatever the note’s source spacing, so levels stay tellable apart on any monitor.' }, From 9cb20081e711d7167a584bf2f61480a3e0ac9457 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Tue, 1 Sep 2026 11:11:46 -0500 Subject: [PATCH 3/4] Fix(editor): Auto-close Markdown keeps its hands off code Typing a == comparison inside a fenced code block and following it with a space wrapped the pair into a ==highlight== snippet, because the Auto-close Markdown engine never asked where it was: every inline pair (==, **, ~~, a backtick, [[, %%) fired anywhere in the note. The only escape was turning the whole feature off, which trades one wrong trigger in code for losing auto-closed bold, fences, and math blocks everywhere else. That trade is what #718 was living with. The snippet engine now consults the syntax tree at trigger time: inside a FencedCode block or an InlineCode span, no inline pair fires, and a $$ or nested fence marker typed inside someone else's code block no longer expands into a block on Enter. This is a rule, not a setting, on purpose: markdown formatting means nothing inside code, so there is no situation where firing there is right, and no toggle is worth the explanation it would need. Two edges are deliberate. The check runs only at trigger time, on a settled state, never inside the pending-block StateField update, where the syntax tree can be one keystroke stale. And a fence's own opener line stays exempt: the moment ``` is typed it parses as an unclosed FencedCode, so a naive inside-code test would kill the Enter-to-close snippet that the feature is named for. Verified live over CDP against the built app: == then space inside a fence stays literal, the same keystrokes in prose still wrap, and a bare ``` still expands with Enter, with the saved markdown as proof. How to test locally: npm run dev, a note with a fenced code block, type `a == b ` inside it. Before: on the space, == wrapped into a highlight pair around the cursor. After: it stays code, and == followed by space in a prose paragraph still wraps like it always did. Closes #718 Claude-Session: https://claude.ai/code/session_01GCwiToGRTKKsDENaY56Vm2 --- .../app-core/src/components/SettingsModal.tsx | 2 +- .../src/lib/cm-markdown-snippets.test.ts | 66 +++++++++++++++++++ .../app-core/src/lib/cm-markdown-snippets.ts | 39 +++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index f27494c7..d29553ec 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -2472,7 +2472,7 @@ export function SettingsModal(): JSX.Element { /> <ToggleRow label="Auto-close Markdown" - description="Auto-close markdown as you type: ** / __ / ~~ / ` / == / [[ / %% then Space wrap the cursor, and ``` / ~~~ / $$ then Enter expand a fenced block. In Vim mode this only applies in insert mode." + description="Auto-close markdown as you type: ** / __ / ~~ / ` / == / [[ / %% then Space wrap the cursor, and ``` / ~~~ / $$ then Enter expand a fenced block. Never fires inside a code block or inline code, so a == comparison in code stays code. In Vim mode this only applies in insert mode." value={markdownSnippets} settingId="markdown-overrides" onChange={setMarkdownSnippets} diff --git a/packages/app-core/src/lib/cm-markdown-snippets.test.ts b/packages/app-core/src/lib/cm-markdown-snippets.test.ts index 3f1cf68a..7c68b41e 100644 --- a/packages/app-core/src/lib/cm-markdown-snippets.test.ts +++ b/packages/app-core/src/lib/cm-markdown-snippets.test.ts @@ -1,5 +1,7 @@ import { EditorState } from '@codemirror/state' import { describe, expect, it } from 'vitest' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { ensureSyntaxTree } from '@codemirror/language' import { markdownSnippetExtension, markdownSnippetTransaction } from './cm-markdown-snippets' function createState(doc: string, pos = doc.length): EditorState { @@ -186,3 +188,67 @@ describe('block snippets inside list items (#405)', () => { expect(state?.doc.toString()).toBe('- ```bash\n x\n ```\n- ```\n \n ```') }) }) + +// #718: no snippet may fire inside code. The plain harness above has no +// markdown parser, so its states carry no FencedCode/InlineCode nodes; this +// one parses the doc the way the real editor does. +describe('markdownSnippetTransaction inside code (#718)', () => { + function createParsedState(doc: string, pos = doc.length): EditorState { + const state = EditorState.create({ + doc, + selection: { anchor: pos }, + extensions: [markdownSnippetExtension(), markdown({ base: markdownLanguage })] + }) + ensureSyntaxTree(state, state.doc.length, 5000) + return state + } + + function parsedTrigger(doc: string, typed: string, key: string, pos = doc.length): EditorState | null { + let state = createParsedState(doc, pos) + for (const char of typed) { + const head = state.selection.main.head + state = state.update({ + changes: { from: head, to: head, insert: char }, + selection: { anchor: head + 1 }, + userEvent: 'input.type' + }).state + } + ensureSyntaxTree(state, state.doc.length, 5000) + const transaction = markdownSnippetTransaction(state, key) + return transaction ? state.update(transaction).state : null + } + + it('== followed by Space in a fenced code block stays code', () => { + // The report: typing a comparison in a code block wrapped == into a + // highlight pair the moment Space followed it. + expect(parsedTrigger('```js\nif (a ==', '', 'Space')).toBeNull() + }) + + it('other inline pairs stay quiet in a fenced block too', () => { + expect(parsedTrigger('```\nbold **', '', 'Space')).toBeNull() + expect(parsedTrigger('```\nstrike ~~', '', 'Space')).toBeNull() + expect(parsedTrigger('```\ntick `', '', 'Space')).toBeNull() + expect(parsedTrigger('```\nlink [[', '', 'Space')).toBeNull() + }) + + it('== inside an inline code span stays code', () => { + const doc = '`a ==` rest' + expect(parsedTrigger(doc, '', 'Space', doc.indexOf('`', 1))).toBeNull() + }) + + it('$$ typed inside an open fence does not become a math block', () => { + expect(parsedTrigger('```\n', '$$', 'Enter')).toBeNull() + }) + + it('a fence opener still expands with Enter when the parser is live', () => { + // The opener line parses as an unclosed FencedCode the moment it is typed; + // it must stay exempt or the Enter-to-close feature dies with the fix. + const state = parsedTrigger('', '```', 'Enter') + expect(state?.doc.toString()).toBe('```\n\n```') + }) + + it('== after a closed code block fires again', () => { + const state = parsedTrigger('```\nx\n```\n\n==', '', 'Space') + expect(state?.doc.toString()).toBe('```\nx\n```\n\n====') + }) +}) diff --git a/packages/app-core/src/lib/cm-markdown-snippets.ts b/packages/app-core/src/lib/cm-markdown-snippets.ts index ee7ee807..65dbf2ba 100644 --- a/packages/app-core/src/lib/cm-markdown-snippets.ts +++ b/packages/app-core/src/lib/cm-markdown-snippets.ts @@ -7,6 +7,7 @@ import { type TransactionSpec } from '@codemirror/state' import { keymap, type EditorView } from '@codemirror/view' +import { syntaxTree } from '@codemirror/language' export type MarkdownSnippetMode = 'inline' | 'block' @@ -157,6 +158,10 @@ function blockSnippetTransaction( if (line.from !== pending.lineFrom || selection.head !== line.to) return null if (!isBlockOpenerLine(rule, line.text)) return null if (hasUnclosedBlockOpenerAbove(state, line.number, rule)) return null + // A `$$` (or nested fence marker) typed inside someone else's code block is + // code content, not an opener; this line's own unclosed fence is the one + // legitimate case (#718). + if (isInsideCode(state, selection.head, line.number)) return null // Align to the fence column so a block opened inside a list item keeps its // content and closing fence inside the item instead of escaping to col 0 (#405). @@ -170,6 +175,36 @@ function blockSnippetTransaction( } } +/** + * True when `pos` sits in code the parser already recognizes: a fenced block or + * an inline code span. Markdown formatting means nothing there, so no snippet + * may fire; `==` in a code block used to wrap into a highlight pair the moment + * a comparison was followed by a space (#718). + * + * A fence's own opener line is exempt for block rules, via + * `exceptFenceOpenedAtLine`: typing ``` parses as an unclosed FencedCode + * immediately, and that line is exactly what the Enter-to-close snippet is + * for. Only checked at trigger time, on a settled state, so the tree is + * current rather than mid-update. + */ +function isInsideCode( + state: EditorState, + pos: number, + exceptFenceOpenedAtLine?: number +): boolean { + let node = syntaxTree(state).resolveInner(pos, -1) + while (node) { + if (node.name === 'InlineCode') return true + if (node.name === 'FencedCode') { + const openerLine = state.doc.lineAt(node.from).number + return exceptFenceOpenedAtLine == null || openerLine !== exceptFenceOpenedAtLine + } + if (!node.parent) break + node = node.parent + } + return false +} + function hasOddBackslashRun(text: string, before: number): boolean { let count = 0 for (let i = before - 1; i >= 0 && text[i] === '\\'; i--) count++ @@ -240,6 +275,10 @@ export function markdownSnippetTransaction( } } + // Inside a code block or inline code span, `==`, `**`, a backtick and the + // rest are code, and wrapping them in formatting pairs is never right (#718). + if (isInsideCode(state, selection.head)) return null + for (const rule of rules) { if (rule.mode === 'block') continue if (!rule.triggerKeys.includes(triggerKey)) continue From 18d9d2b5eaec5220bb92567f3bee0efe563549a4 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Tue, 1 Sep 2026 11:27:47 -0500 Subject: [PATCH 4/4] chore(release): 2.41.0 Claude-Session: https://claude.ai/code/session_01GCwiToGRTKKsDENaY56Vm2 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 89b3d38d..1f19e735 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.40.0", + "version": "2.41.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/server/package.json b/apps/server/package.json index ce6446b3..1badd83d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.40.0", + "version": "2.41.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index bcd25a03..2e5aea8f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.40.0", + "version": "2.41.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index 9ebb380d..22789af0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.40.0", + "version": "2.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.40.0", + "version": "2.41.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.40.0", + "version": "2.41.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -861,11 +861,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.40.0" + "version": "2.41.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.40.0", + "version": "2.41.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16242,7 +16242,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.40.0", + "version": "2.41.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16306,11 +16306,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.40.0" + "version": "2.41.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.40.0", + "version": "2.41.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16321,7 +16321,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.40.0" + "version": "2.41.0" } } } diff --git a/package.json b/package.json index baf67350..b1a184cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.40.0", + "version": "2.41.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index c5f9d362..33bd21fd 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.40.0", + "version": "2.41.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index b4aac74d..5fc12e67 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.40.0", + "version": "2.41.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 40dfb164..a924627f 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.40.0", + "version": "2.41.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 6e87cea0..2365b13e 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.40.0", + "version": "2.41.0", "type": "module", "exports": { ".": "./src/index.ts"