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/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()} + />
) 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/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 { /> { 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 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.' }, 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"