diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 53915bda..2a6a5ece 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -99,7 +99,7 @@ import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' import { livePreviewPlugin } from '../lib/cm-live-preview' import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair' -import { tablePlugin, tableVimEntry } from '../lib/cm-table' +import { tablePlugin, tableVimEntry, tableSelectionHighlight } from '../lib/cm-table' import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' import { hashtagExtension } from '../lib/cm-hashtags' import { taskMetadataExtension } from '../lib/cm-task-metadata' @@ -440,7 +440,7 @@ function wysiwygExtensions( codeBlockFlairPlugin, // Table widgets are gated on a setting — off keeps tables as plain editable // markdown for full keyboard/Vim editing (#232). - ...(renderTables ? [tablePlugin, tableVimEntry] : []), + ...(renderTables ? [tablePlugin, tableVimEntry, tableSelectionHighlight] : []), wysiwygBlocksPlugin, ...hashtagExtension, ...taskMetadataExtension, diff --git a/packages/app-core/src/lib/cm-slash-commands.ts b/packages/app-core/src/lib/cm-slash-commands.ts index d387975e..95718898 100644 --- a/packages/app-core/src/lib/cm-slash-commands.ts +++ b/packages/app-core/src/lib/cm-slash-commands.ts @@ -1,6 +1,7 @@ import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete' import type { EditorView } from '@codemirror/view' import { useStore } from '../store' +import { focusFirstTableCell } from './cm-table' interface SlashCmd { label: string @@ -232,6 +233,14 @@ export function slashCommandSource(context: CompletionContext): CompletionResult changes: { from: slashStart, to, insert }, selection: { anchor: cursorPos } }) + // `/table`: once the widget renders, drop into the first header cell + // so the user can rename the columns right away instead of landing on + // the trailing line. The CM caret stays put (#340); this only moves + // DOM focus into the cell. Retries internally until the parse catches + // up and the decoration exists. + if (tableCaretAfter) { + focusFirstTableCell(view, slashStart + leadPad.length) + } } } as Completion & { _icon: string }) ), diff --git a/packages/app-core/src/lib/cm-table-menu.ts b/packages/app-core/src/lib/cm-table-menu.ts index 99ab2f6f..3bd4b341 100644 --- a/packages/app-core/src/lib/cm-table-menu.ts +++ b/packages/app-core/src/lib/cm-table-menu.ts @@ -322,13 +322,16 @@ export function openTableContextMenu(req: TableMenuRequest): void { } } // Defer so the originating contextmenu/right-click doesn't immediately close it. + let tornDown = false setTimeout(() => { + if (tornDown) return window.addEventListener('mousedown', onDown, true) window.addEventListener('keydown', onKey, true) applyFilter() }, 0) teardown = () => { + tornDown = true window.removeEventListener('mousedown', onDown, true) window.removeEventListener('keydown', onKey, true) menu.remove() diff --git a/packages/app-core/src/lib/cm-table.test.ts b/packages/app-core/src/lib/cm-table.test.ts index 395103a7..2e2c387e 100644 --- a/packages/app-core/src/lib/cm-table.test.ts +++ b/packages/app-core/src/lib/cm-table.test.ts @@ -1,24 +1,28 @@ // @vitest-environment jsdom -import { markdown, markdownLanguage } from '@codemirror/lang-markdown' -import { forceParsing } from '@codemirror/language' -import { history } from '@codemirror/commands' -import { EditorState } from '@codemirror/state' -import { EditorView } from '@codemirror/view' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Text } from '@codemirror/state' +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { forceParsing } from "@codemirror/language"; +import { history } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { vim, getCM, Vim } from '@replit/codemirror-vim' +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Text } from "@codemirror/state"; import { renderInlineCell, tableBlockAt, tablePlugin, + tableVimEntry, + tableSelectionHighlight, + focusFirstTableCell, nextWordStart, prevWordStart, nextWordEnd, textObjectRange, - findChar -} from './cm-table' -import { closeTableContextMenu } from './cm-table-menu' -import { useStore } from '../store' + findChar, +} from "./cm-table"; +import { closeTableContextMenu } from "./cm-table-menu"; +import { useStore } from "../store"; const TABLE_DOC = `Intro text. @@ -27,486 +31,989 @@ const TABLE_DOC = `Intro text. | Alice | 30 | | Bob | 25 | -Outro text.` +Outro text.`; function mount(doc: string): EditorView { - const parent = document.createElement('div') - document.body.append(parent) + const parent = document.createElement("div"); + document.body.append(parent); const view = new EditorView({ parent, state: EditorState.create({ doc, - extensions: [markdown({ base: markdownLanguage }), history(), tablePlugin] - }) - }) + extensions: [ + markdown({ base: markdownLanguage }), + history(), + tablePlugin, + ], + }), + }); // Ensure the GFM table node is parsed, then nudge the field to rebuild. - forceParsing(view, doc.length, 5000) - view.dispatch({ changes: { from: 0, insert: ' ' } }) - view.dispatch({ changes: { from: 0, to: 1 } }) - return view + forceParsing(view, doc.length, 5000); + view.dispatch({ changes: { from: 0, insert: " " } }); + view.dispatch({ changes: { from: 0, to: 1 } }); + return view; } -describe('tablePlugin', () => { - it('renders a GFM table as an editable table widget without throwing', () => { - const view = mount(TABLE_DOC) - const widget = view.dom.querySelector('.cm-table-widget') - expect(widget).toBeTruthy() - const cells = widget?.querySelectorAll('.cm-table-cell') ?? [] +describe("tablePlugin", () => { + it("renders a GFM table as an editable table widget without throwing", () => { + const view = mount(TABLE_DOC); + const widget = view.dom.querySelector(".cm-table-widget"); + expect(widget).toBeTruthy(); + const cells = widget?.querySelectorAll(".cm-table-cell") ?? []; // 2 header + 4 body cells. - expect(cells.length).toBe(6) - expect(view.dom.textContent).toContain('Alice') - expect(view.dom.textContent).toContain('Age') + expect(cells.length).toBe(6); + expect(view.dom.textContent).toContain("Alice"); + expect(view.dom.textContent).toContain("Age"); // One row grip per body row (2), one column grip per column (2). - expect(widget?.querySelectorAll('.cm-table-row-handle').length).toBe(2) - expect(widget?.querySelectorAll('.cm-table-col-handle').length).toBe(2) - view.destroy() - }) + expect(widget?.querySelectorAll(".cm-table-row-handle").length).toBe(2); + expect(widget?.querySelectorAll(".cm-table-col-handle").length).toBe(2); + view.destroy(); + }); - it('renders a plain doc with no table widget', () => { - const view = mount('Just a paragraph, no table here.') - expect(view.dom.querySelector('.cm-table-widget')).toBeNull() - view.destroy() - }) + it("renders a plain doc with no table widget", () => { + const view = mount("Just a paragraph, no table here."); + expect(view.dom.querySelector(".cm-table-widget")).toBeNull(); + view.destroy(); + }); // Vim mode defaults on (DEFAULT_PREFS.vimMode), so cells start in NORMAL mode. - it('swallows vim normal-mode motion/printable keys inside a cell', () => { - const view = mount(TABLE_DOC) + it("swallows vim normal-mode motion/printable keys inside a cell", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; // h/j/k/l are consumed as motions, not typed. - for (const key of ['h', 'j', 'k', 'l']) { - const ev = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) - cell.dispatchEvent(ev) - expect(ev.defaultPrevented).toBe(true) + for (const key of ["h", "j", "k", "l"]) { + const ev = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + cell.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); } // A stray printable key is swallowed too (won't corrupt the cell text). - const xEv = new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }) - cell.dispatchEvent(xEv) - expect(xEv.defaultPrevented).toBe(true) - view.destroy() - }) + const xEv = new KeyboardEvent("keydown", { + key: "x", + bubbles: true, + cancelable: true, + }); + cell.dispatchEvent(xEv); + expect(xEv.defaultPrevented).toBe(true); + view.destroy(); + }); // #232: arrow keys used to fall through to CodeMirror and scroll the page; // they should navigate the cell like h/j/k/l (consumed, not propagated). - it('consumes arrow keys inside a cell instead of scrolling the page (#232)', () => { - const view = mount(TABLE_DOC) + it("consumes arrow keys inside a cell instead of scrolling the page (#232)", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - for (const key of ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight']) { - const ev = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) - cell.dispatchEvent(ev) - expect(ev.defaultPrevented).toBe(true) + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + for (const key of ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"]) { + const ev = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + cell.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); } - view.destroy() - }) + view.destroy(); + }); // #213: directional cell navigation honors the configurable nav keymaps. - it('honors a remapped nav key (nav.moveDown → n) inside a cell', () => { - const view = mount(TABLE_DOC) - const prev = useStore.getState().keymapOverrides - useStore.setState({ keymapOverrides: { ...prev, 'nav.moveDown': 'n' } }) + it("honors a remapped nav key (nav.moveDown → n) inside a cell", () => { + const view = mount(TABLE_DOC); + const prev = useStore.getState().keymapOverrides; + useStore.setState({ keymapOverrides: { ...prev, "nav.moveDown": "n" } }); try { const start = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - start.focus() - const ev = new KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true }) - start.dispatchEvent(ev) - expect(ev.defaultPrevented).toBe(true) + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + start.focus(); + const ev = new KeyboardEvent("keydown", { + key: "n", + bubbles: true, + cancelable: true, + }); + start.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); // The remapped key moved the focus down a row, like `j` would by default. - const below = view.dom.querySelector('.cm-table-widget [data-row="1"][data-col="0"]') - expect(document.activeElement).toBe(below) + const below = view.dom.querySelector( + '.cm-table-widget [data-row="1"][data-col="0"]', + ); + expect(document.activeElement).toBe(below); } finally { - useStore.setState({ keymapOverrides: prev }) + useStore.setState({ keymapOverrides: prev }); } - view.destroy() - }) + view.destroy(); + }); - it('enters insert mode on `i`, revealing the raw cell source', () => { - const view = mount(TABLE_DOC) + it("enters insert mode on `i`, revealing the raw cell source", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - expect(cell.dataset.rendered).toBe('true') + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + expect(cell.dataset.rendered).toBe("true"); // NORMAL cells are non-editable (no caret); editing turns it on. - expect(cell.getAttribute('contenteditable')).toBe('false') - const iEv = new KeyboardEvent('keydown', { key: 'i', bubbles: true, cancelable: true }) - cell.dispatchEvent(iEv) - expect(iEv.defaultPrevented).toBe(true) + expect(cell.getAttribute("contenteditable")).toBe("false"); + const iEv = new KeyboardEvent("keydown", { + key: "i", + bubbles: true, + cancelable: true, + }); + cell.dispatchEvent(iEv); + expect(iEv.defaultPrevented).toBe(true); // Now editing: cell is editable, shows raw markdown, accepts typed chars. - expect(cell.getAttribute('contenteditable')).toBe('true') - expect(cell.dataset.rendered).toBe('false') - const xEv = new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }) - cell.dispatchEvent(xEv) - expect(xEv.defaultPrevented).toBe(false) - view.destroy() - }) - - it('opens the keyboard-navigable action menu on `m`', () => { - const view = mount(TABLE_DOC) + expect(cell.getAttribute("contenteditable")).toBe("true"); + expect(cell.dataset.rendered).toBe("false"); + const xEv = new KeyboardEvent("keydown", { + key: "x", + bubbles: true, + cancelable: true, + }); + cell.dispatchEvent(xEv); + expect(xEv.defaultPrevented).toBe(false); + view.destroy(); + }); + + it("opens the keyboard-navigable action menu on `m`", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - const mEv = new KeyboardEvent('keydown', { key: 'm', bubbles: true, cancelable: true }) - cell.dispatchEvent(mEv) - expect(mEv.defaultPrevented).toBe(true) - const menu = document.querySelector('.cm-table-menu') - expect(menu).toBeTruthy() + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + const mEv = new KeyboardEvent("keydown", { + key: "m", + bubbles: true, + cancelable: true, + }); + cell.dispatchEvent(mEv); + expect(mEv.defaultPrevented).toBe(true); + const menu = document.querySelector(".cm-table-menu"); + expect(menu).toBeTruthy(); // The full Obsidian-style action set (add/move/dup/delete/align/sort). - expect(menu!.querySelectorAll('.cm-table-menu-item').length).toBeGreaterThan(10) - closeTableContextMenu() - view.destroy() - }) - - it('supports x / dd / D editing operators in a cell', () => { - const view = mount(TABLE_DOC) + expect( + menu!.querySelectorAll(".cm-table-menu-item").length, + ).toBeGreaterThan(10); + closeTableContextMenu(); + view.destroy(); + }); + + it("supports x / dd / D editing operators in a cell", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - expect(cell.dataset.raw).toBe('Alice') + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + expect(cell.dataset.raw).toBe("Alice"); // x deletes the char under the cursor (offset 0). - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true })) - expect(cell.dataset.raw).toBe('lice') + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "x", + bubbles: true, + cancelable: true, + }), + ); + expect(cell.dataset.raw).toBe("lice"); // D deletes to end of cell. - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'D', bubbles: true, cancelable: true })) - expect(cell.dataset.raw).toBe('') - view.destroy() - }) - - it('supports char-wise visual mode: v + motion + d deletes the selection', () => { - const view = mount(TABLE_DOC) + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "D", + bubbles: true, + cancelable: true, + }), + ); + expect(cell.dataset.raw).toBe(""); + view.destroy(); + }); + + it("supports char-wise visual mode: v + motion + d deletes the selection", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - expect(cell.dataset.raw).toBe('Alice') + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + expect(cell.dataset.raw).toBe("Alice"); // v (anchor at 0) → l (extend to 1) → d (delete [0,2) = "Al"). - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', bubbles: true, cancelable: true })) - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'l', bubbles: true, cancelable: true })) - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true, cancelable: true })) - expect(cell.dataset.raw).toBe('ice') - view.destroy() - }) - - it('u commits the pending cell edit and undoes it', () => { - const view = mount(TABLE_DOC) + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "v", + bubbles: true, + cancelable: true, + }), + ); + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "l", + bubbles: true, + cancelable: true, + }), + ); + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "d", + bubbles: true, + cancelable: true, + }), + ); + expect(cell.dataset.raw).toBe("ice"); + view.destroy(); + }); + + it("u commits the pending cell edit and undoes it", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true })) - expect(cell.dataset.raw).toBe('lice') - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'u', bubbles: true, cancelable: true })) + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "x", + bubbles: true, + cancelable: true, + }), + ); + expect(cell.dataset.raw).toBe("lice"); + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "u", + bubbles: true, + cancelable: true, + }), + ); // The committed edit is undone — the source table is back to "Alice". - expect(view.state.doc.toString()).toContain('| Alice |') - view.destroy() - }) + expect(view.state.doc.toString()).toContain("| Alice |"); + view.destroy(); + }); - it('diw deletes the inner word (operator + text object)', () => { - const view = mount(TABLE_DOC) + it("diw deletes the inner word (operator + text object)", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - expect(cell.dataset.raw).toBe('Alice') - for (const key of ['d', 'i', 'w']) { - cell.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + expect(cell.dataset.raw).toBe("Alice"); + for (const key of ["d", "i", "w"]) { + cell.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); } - expect(cell.dataset.raw).toBe('') - view.destroy() - }) + expect(cell.dataset.raw).toBe(""); + view.destroy(); + }); - it('Esc in a normal-mode cell is a no-op (stays put, no jump below)', () => { - const view = mount(TABLE_DOC) + it("Esc in a normal-mode cell is a no-op (stays put, no jump below)", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="0"][data-col="0"]' - )! - const ev = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) - cell.dispatchEvent(ev) - expect(ev.defaultPrevented).toBe(true) + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + const ev = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + cell.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); // Cell content untouched; widget still present (didn't tear down / jump out). - expect(cell.dataset.raw).toBe('Alice') - expect(view.dom.querySelector('.cm-table-widget')).toBeTruthy() - view.destroy() - }) + expect(cell.dataset.raw).toBe("Alice"); + expect(view.dom.querySelector(".cm-table-widget")).toBeTruthy(); + view.destroy(); + }); - it('supports the dd operator (clear cell) via operator-pending', () => { - const view = mount(TABLE_DOC) + it("supports the dd operator (clear cell) via operator-pending", () => { + const view = mount(TABLE_DOC); const cell = view.dom.querySelector( - '.cm-table-widget [data-row="1"][data-col="0"]' - )! - expect(cell.dataset.raw).toBe('Bob') - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true, cancelable: true })) - cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true, cancelable: true })) - expect(cell.dataset.raw).toBe('') - view.destroy() - }) -}) - -describe('vim word motions (cell cursor)', () => { - const t = 'foo bar baz' - it('w moves to the next word start', () => { - expect(nextWordStart(t, 0)).toBe(4) - expect(nextWordStart(t, 4)).toBe(8) - expect(nextWordStart(t, 8)).toBe(t.length - 1) // clamps at the last word - }) - it('b moves to the previous word start', () => { - expect(prevWordStart(t, 8)).toBe(4) - expect(prevWordStart(t, 4)).toBe(0) - expect(prevWordStart(t, 0)).toBe(0) - }) - it('e moves to the next word end', () => { - expect(nextWordEnd(t, 0)).toBe(2) - expect(nextWordEnd(t, 2)).toBe(6) - }) - it('treats punctuation as its own word', () => { + '.cm-table-widget [data-row="1"][data-col="0"]', + )!; + expect(cell.dataset.raw).toBe("Bob"); + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "d", + bubbles: true, + cancelable: true, + }), + ); + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "d", + bubbles: true, + cancelable: true, + }), + ); + expect(cell.dataset.raw).toBe(""); + view.destroy(); + }); +}); + +describe("vim word motions (cell cursor)", () => { + const t = "foo bar baz"; + it("w moves to the next word start", () => { + expect(nextWordStart(t, 0)).toBe(4); + expect(nextWordStart(t, 4)).toBe(8); + expect(nextWordStart(t, 8)).toBe(t.length - 1); // clamps at the last word + }); + it("b moves to the previous word start", () => { + expect(prevWordStart(t, 8)).toBe(4); + expect(prevWordStart(t, 4)).toBe(0); + expect(prevWordStart(t, 0)).toBe(0); + }); + it("e moves to the next word end", () => { + expect(nextWordEnd(t, 0)).toBe(2); + expect(nextWordEnd(t, 2)).toBe(6); + }); + it("treats punctuation as its own word", () => { // "a, b" → a(0) ,(1) space(2) b(3) - expect(nextWordStart('a, b', 0)).toBe(1) // 'a' → ',' - expect(nextWordStart('a, b', 1)).toBe(3) // ',' → 'b' - }) -}) - -describe('vim find-char (f / t / F / T, cell cursor)', () => { - const t = 'Engineer' // E n g i n e e r (indices 0..7) - it('f finds the next occurrence forward', () => { - expect(findChar(t, 0, 'n', 1, false)).toBe(1) - expect(findChar(t, 1, 'n', 1, false)).toBe(4) // skips the current char - expect(findChar(t, 0, 'r', 1, false)).toBe(7) - }) - it('t stops one char before the target (forward)', () => { - expect(findChar(t, 0, 'e', 1, true)).toBe(4) // 'e' at 5 → 4 - }) - it('F finds the next occurrence backward', () => { - expect(findChar(t, 7, 'n', -1, false)).toBe(4) - expect(findChar(t, 7, 'E', -1, false)).toBe(0) - }) - it('T stops one char after the target (backward)', () => { - expect(findChar(t, 7, 'g', -1, true)).toBe(3) // 'g' at 2 → 3 - }) - it('returns null when the char is not found', () => { - expect(findChar(t, 0, 'z', 1, false)).toBeNull() - expect(findChar(t, 7, 'z', -1, false)).toBeNull() - }) -}) - -describe('text objects (vi / va, di / ca)', () => { - it('iw / aw select the word (a includes trailing space)', () => { - const t = 'foo bar baz' - expect(textObjectRange(t, 5, 'i', 'w')).toEqual({ from: 4, to: 7 }) // "bar" - expect(textObjectRange(t, 5, 'a', 'w')).toEqual({ from: 4, to: 8 }) // "bar " - }) + expect(nextWordStart("a, b", 0)).toBe(1); // 'a' → ',' + expect(nextWordStart("a, b", 1)).toBe(3); // ',' → 'b' + }); +}); + +describe("vim find-char (f / t / F / T, cell cursor)", () => { + const t = "Engineer"; // E n g i n e e r (indices 0..7) + it("f finds the next occurrence forward", () => { + expect(findChar(t, 0, "n", 1, false)).toBe(1); + expect(findChar(t, 1, "n", 1, false)).toBe(4); // skips the current char + expect(findChar(t, 0, "r", 1, false)).toBe(7); + }); + it("t stops one char before the target (forward)", () => { + expect(findChar(t, 0, "e", 1, true)).toBe(4); // 'e' at 5 → 4 + }); + it("F finds the next occurrence backward", () => { + expect(findChar(t, 7, "n", -1, false)).toBe(4); + expect(findChar(t, 7, "E", -1, false)).toBe(0); + }); + it("T stops one char after the target (backward)", () => { + expect(findChar(t, 7, "g", -1, true)).toBe(3); // 'g' at 2 → 3 + }); + it("returns null when the char is not found", () => { + expect(findChar(t, 0, "z", 1, false)).toBeNull(); + expect(findChar(t, 7, "z", -1, false)).toBeNull(); + }); +}); + +describe("text objects (vi / va, di / ca)", () => { + it("iw / aw select the word (a includes trailing space)", () => { + const t = "foo bar baz"; + expect(textObjectRange(t, 5, "i", "w")).toEqual({ from: 4, to: 7 }); // "bar" + expect(textObjectRange(t, 5, "a", "w")).toEqual({ from: 4, to: 8 }); // "bar " + }); it('i" / a" select inside / around quotes', () => { - const t = 'say "hi" now' - expect(textObjectRange(t, 5, 'i', '"')).toEqual({ from: 5, to: 7 }) // hi - expect(textObjectRange(t, 5, 'a', '"')).toEqual({ from: 4, to: 8 }) // "hi" - }) - it('i( / a) select inside / around brackets', () => { - const t = 'f(x, y)' - expect(textObjectRange(t, 3, 'i', '(')).toEqual({ from: 2, to: 6 }) // x, y - expect(textObjectRange(t, 3, 'a', ')')).toEqual({ from: 1, to: 7 }) // (x, y) - }) - it('returns null when the object is absent', () => { - expect(textObjectRange('plain', 0, 'i', '"')).toBeNull() - }) -}) - -describe('tablePlugin — column widths (#294)', () => { + const t = 'say "hi" now'; + expect(textObjectRange(t, 5, "i", '"')).toEqual({ from: 5, to: 7 }); // hi + expect(textObjectRange(t, 5, "a", '"')).toEqual({ from: 4, to: 8 }); // "hi" + }); + it("i( / a) select inside / around brackets", () => { + const t = "f(x, y)"; + expect(textObjectRange(t, 3, "i", "(")).toEqual({ from: 2, to: 6 }); // x, y + expect(textObjectRange(t, 3, "a", ")")).toEqual({ from: 1, to: 7 }); // (x, y) + }); + it("returns null when the object is absent", () => { + expect(textObjectRange("plain", 0, "i", '"')).toBeNull(); + }); +}); + +describe("tablePlugin — column widths (#294)", () => { const WIDTH_DOC = `Intro. | Name | Age | | --- | --- | | Alice | 30 | -` +`; const PLAIN_DOC = `Intro. | A | B | | --- | --- | -| 1 | 2 |` - - it('renders persisted widths as a and swallows the marker', () => { - const view = mount(WIDTH_DOC) - const widget = view.dom.querySelector('.cm-table-widget')! - expect(widget).toBeTruthy() - const cols = widget.querySelectorAll('col') - expect(cols.length).toBe(2) - expect((cols[0] as HTMLElement).style.width).toBe('120px') - expect((cols[1] as HTMLElement).style.width).toBe('200px') - expect(widget.querySelector('table')?.classList.contains('cm-table-fixed')).toBe(true) +| 1 | 2 |`; + + it("renders persisted widths as a and swallows the marker", () => { + const view = mount(WIDTH_DOC); + const widget = view.dom.querySelector(".cm-table-widget")!; + expect(widget).toBeTruthy(); + const cols = widget.querySelectorAll("col"); + expect(cols.length).toBe(2); + expect((cols[0] as HTMLElement).style.width).toBe("120px"); + expect((cols[1] as HTMLElement).style.width).toBe("200px"); + expect( + widget.querySelector("table")?.classList.contains("cm-table-fixed"), + ).toBe(true); // The raw marker is inside the widget's atomic range — never visible text. - expect(view.dom.textContent ?? '').not.toContain('zen:cols') - view.destroy() - }) - - it('a table with no marker renders a colgroup but no fixed widths', () => { - const view = mount(PLAIN_DOC) - const widget = view.dom.querySelector('.cm-table-widget')! - const cols = widget.querySelectorAll('col') - expect(cols.length).toBe(2) - expect((cols[0] as HTMLElement).style.width).toBe('') - expect(widget.querySelector('table')?.classList.contains('cm-table-fixed')).toBe(false) - view.destroy() - }) + expect(view.dom.textContent ?? "").not.toContain("zen:cols"); + view.destroy(); + }); + + it("a table with no marker renders a colgroup but no fixed widths", () => { + const view = mount(PLAIN_DOC); + const widget = view.dom.querySelector(".cm-table-widget")!; + const cols = widget.querySelectorAll("col"); + expect(cols.length).toBe(2); + expect((cols[0] as HTMLElement).style.width).toBe(""); + expect( + widget.querySelector("table")?.classList.contains("cm-table-fixed"), + ).toBe(false); + view.destroy(); + }); const drag = (view: EditorView, from: number, to: number): void => { - const handle = view.dom.querySelector('.cm-table-col-resize') - if (!handle) throw new Error('no resize handle') - handle.dispatchEvent(new MouseEvent('pointerdown', { clientX: from, bubbles: true, cancelable: true })) - handle.dispatchEvent(new MouseEvent('pointermove', { clientX: to, bubbles: true })) - handle.dispatchEvent(new MouseEvent('pointerup', { clientX: to, bubbles: true })) - } - - it('dragging a column resize grip persists a zen:cols marker in the source', () => { - const view = mount(PLAIN_DOC) - drag(view, 100, 180) - expect(view.state.doc.toString()).toContain('\ntail"; + const t = doc(text); + const range = tableBlockAt(t, 3); + expect(text.slice(range!.from, range!.to)).toContain("zen:cols=273,227"); + }); + + it("refuses a lone pipe line — that is prose, not a table", () => { + const t = doc("a | b is not a table\n| stray |\nplain"); + expect(tableBlockAt(t, t.line(2).from + 2)).toBeNull(); + }); + + it("returns null off the table", () => { + const t = doc("prose here\n\n| A |\n| --- |"); + expect(tableBlockAt(t, 2)).toBeNull(); + }); + + it("handles a table at the very start and end of the document", () => { + const text = "| A | B |\n| --- | --- |"; + const t = doc(text); + const range = tableBlockAt(t, 0); + expect(range).toEqual({ from: 0, to: text.length }); + }); +}); + +describe("renderInlineCell", () => { + // A cell is inline content, so a leading block marker is just a character. + // These all rendered as empty blocks before, losing the text outright. (#559) + it("keeps a cell that is only a list marker visible", () => { + expect(renderInlineCell("-")).toBe("-"); + expect(renderInlineCell("+")).toBe("+"); + expect(renderInlineCell("*")).toBe("*"); + }); + + it("keeps the other block markers literal too", () => { + expect(renderInlineCell("#")).toBe("#"); + expect(renderInlineCell("1.")).toBe("1."); + expect(renderInlineCell("---")).toBe("---"); + expect(renderInlineCell("# H")).toBe("# H"); + expect(renderInlineCell("- x")).toBe("- x"); + expect(renderInlineCell("> q")).toBe("> q"); + }); + + it("still renders inline markup", () => { + expect(renderInlineCell("**b**")).toBe("b"); + expect(renderInlineCell("`c`")).toBe("c"); + expect(renderInlineCell("[[note]]")).toContain('class="wikilink"'); + expect(renderInlineCell("#tag")).toContain('class="hashtag"'); + }); + + it("renders a literal pipe rather than splitting the synthetic row", () => { + expect(renderInlineCell("a | b")).toBe("a | b"); + }); + + it("renders nothing for an empty cell", () => { + expect(renderInlineCell("")).toBe(""); + expect(renderInlineCell(" ")).toBe(""); + }); + + it("flattens a pasted newline instead of breaking out of the cell", () => { + expect(renderInlineCell("a\nb")).toBe("a b"); + }); +}); + +describe("table cell inline-mark shortcuts (Mod-b / Mod-i / Mod-e / Mod-Shift-x)", () => { + // The editor's `markdownKeymap` can't reach inside the atomic table widget, so + // the cell handles the chords itself. See `toggleInline` in cm-table.ts. + const saved = { + vimMode: useStore.getState().vimMode, + }; + afterEach(() => { + useStore.setState({ vimMode: saved.vimMode }); + vi.restoreAllMocks(); + }); + + /** Select [from, to) within a cell's text node (the raw source shown while + * editing). `from === to` collapses the caret there. */ + function selectIn(el: HTMLElement, from: number, to = from): void { + const node = el.firstChild; + const range = document.createRange(); + if (node && node.nodeType === Node.TEXT_NODE) { + const max = node.textContent?.length ?? 0; + range.setStart(node, Math.max(0, Math.min(from, max))); + range.setEnd(node, Math.max(0, Math.min(Math.max(from, to), max))); + } else { + range.selectNodeContents(el); + } + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + } + + const mod = (key: string, shift = false): KeyboardEvent => + new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + metaKey: true, + shiftKey: shift, + }); + + it("wraps a selection in **bold** (Mod-b) and commits to dataset.raw", () => { + useStore.setState({ vimMode: false }); + const view = mount("| A | B |\n| --- | --- |\n| hello | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + // Focus revealed the raw source "hello"; select "ell". + selectIn(cell, 1, 4); + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("h**ell**o"); + view.destroy(); + }); + + it("wraps the word under a collapsed caret when nothing is selected", () => { + useStore.setState({ vimMode: false }); + const view = mount("| A | B |\n| --- | --- |\n| hello | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + selectIn(cell, 2); // inside "hello" + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("**hello**"); + view.destroy(); + }); + + it("unwraps an already-marked selection (true toggle)", () => { + useStore.setState({ vimMode: false }); + const view = mount("| A | B |\n| --- | --- |\n| a**bc**d | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + // Source is "a**bc**d"; select the inner "bc" (offsets 3..5). + selectIn(cell, 3, 5); + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("abcd"); + view.destroy(); + }); + + it("toggles bold OFF from a collapsed caret inside a marked word", () => { + useStore.setState({ vimMode: false }); + const view = mount("| A | B |\n| --- | --- |\n| **hello** | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + // Caret on a letter inside **hello** (offset 4 = 'l'). + selectIn(cell, 4); + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("hello"); + view.destroy(); + }); + + it("toggles OFF when the whole marked span is selected (incl. markers)", () => { + useStore.setState({ vimMode: false }); + const view = mount("| A | B |\n| --- | --- |\n| **hello** | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + // Select-all: the markers are inside the selection, so before/after see + // nothing — the unwrap must come from detecting the inner word instead. + selectIn(cell, 0, 9); + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("hello"); + view.destroy(); + }); + + it("covers italic (*), code (`) and strikethrough (~~)", () => { + useStore.setState({ vimMode: false }); + const cases: Array<{ + key: string; + shift: boolean; + raw: string; + want: string; + }> = [ + { key: "i", shift: false, raw: "word", want: "*word*" }, + { key: "e", shift: false, raw: "word", want: "`word`" }, + { key: "x", shift: true, raw: "word", want: "~~word~~" }, + ]; + for (const c of cases) { + const view = mount(`| A |\n| --- |\n| ${c.raw} |`); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + selectIn(cell, 0, c.raw.length); + cell.dispatchEvent(mod(c.key, c.shift)); + expect(cell.dataset.raw).toBe(c.want); + view.destroy(); + } + }); + + it("inserts empty markers with the caret between them on an empty cell", () => { + useStore.setState({ vimMode: false }); + const view = mount("| A |\n| --- |\n| |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("****"); + view.destroy(); + }); + + it("works in Vim INSERT mode (after pressing `i`)", () => { + useStore.setState({ vimMode: true }); + const view = mount("| A | B |\n| --- | --- |\n| hello | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); // NORMAL mode + cell.dispatchEvent( + new KeyboardEvent("keydown", { + key: "i", + bubbles: true, + cancelable: true, + }), + ); + expect(cell.dataset.rendered).toBe("false"); + selectIn(cell, 0, 5); + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("**hello**"); + view.destroy(); + }); + + it("does not add markers in Vim NORMAL mode (the cell is not editable)", () => { + useStore.setState({ vimMode: true }); + const view = mount("| A | B |\n| --- | --- |\n| hello | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); // NORMAL mode — not editable + cell.dispatchEvent(mod("b")); + expect(cell.dataset.raw).toBe("hello"); + view.destroy(); + }); + + it("ignores Alt-modified combos so it never clobbers other chords", () => { + useStore.setState({ vimMode: false }); + const view = mount("| A | B |\n| --- | --- |\n| hello | x |"); + const cell = view.dom.querySelector( + '.cm-table-widget [data-row="0"][data-col="0"]', + )!; + cell.focus(); + selectIn(cell, 0, 5); + const ev = new KeyboardEvent("keydown", { + key: "b", + bubbles: true, + cancelable: true, + metaKey: true, + altKey: true, + }); + cell.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(false); + expect(cell.dataset.raw).toBe("hello"); + view.destroy(); + }); +}); + }) }) +}) + +describe('focusFirstTableCell — /table lands in the first header cell', () => { + // The table in TABLE_DOC starts at offset 13 (after "Intro text.\n\n"). + const TABLE_FROM = 13 - it('follows a [text](Note.md) link in a cell on Cmd/Ctrl-click', () => { - const selectNote = setup(false) - const view = mount(MDLINK_DOC) - const anchor = view.dom.querySelector('.cm-table-cell a[href="Target-Note.md"]') - expect(anchor).toBeTruthy() - anchor?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0, metaKey: true })) - expect(selectNote).toHaveBeenCalledWith('Target-Note.md') + it('focuses the first header cell once the widget is rendered', async () => { + const view = mount(TABLE_DOC) + focusFirstTableCell(view, TABLE_FROM) + // `mount` has already forced the parse, so the widget exists and the first + // rAF tick lands focus in the cell. + await new Promise((r) => requestAnimationFrame(() => r(null))) + const first = view.dom.querySelector( + '.cm-table-widget [data-row="-1"][data-col="0"]' + ) + expect(first).toBeTruthy() + expect(document.activeElement).toBe(first) view.destroy() }) - it('follows the link under the cursor on `gd` in a cell (Vim)', () => { - const selectNote = setup(true) - const view = mount(WIKILINK_DOC) - const cell = [...view.dom.querySelectorAll('.cm-table-cell')].find( - (c) => c.dataset.raw === '[[Target-Note]]' - ) - expect(cell).toBeTruthy() - cell?.focus() // Vim: enter NORMAL mode in the cell (cursor at offset 0) - cell?.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true })) - cell?.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true })) - expect(selectNote).toHaveBeenCalledWith('Target-Note.md') + it('is a harmless no-op when no table widget sits at the position', async () => { + const view = mount('Just a paragraph, no table here.') + expect(() => focusFirstTableCell(view, 0)).not.toThrow() + await new Promise((r) => requestAnimationFrame(() => r(null))) + expect(view.dom.querySelector('.cm-table-cell')).toBeNull() view.destroy() }) }) -describe('tableBlockAt — the fallback when the parse has not caught up (#485)', () => { - const doc = (text: string) => Text.of(text.split('\n')) - - it('finds the table around a position, header row to last row', () => { - const text = 'intro\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\nafter' - const t = doc(text) - const inside = text.indexOf('| 1 | 2 |') + 2 - const range = tableBlockAt(t, inside) - expect(range).not.toBeNull() - expect(text.slice(range!.from, range!.to)).toBe('| A | B |\n| --- | --- |\n| 1 | 2 |') +describe('visual-mode table selection — snap + highlight', () => { + // Vim's own vertical motion needs layout (getClientRects), which jsdom lacks, + // so this drives the snap through real key events: `tableVimEntry` intercepts + // the down motion and dispatches the selection directly (no coordinates), + // which is exactly what happens in a browser. + const saved = { vimMode: useStore.getState().vimMode } + afterEach(() => { + useStore.setState({ vimMode: saved.vimMode }) + vi.restoreAllMocks() }) - it('takes the trailing zen:cols marker with it, like the tree path does', () => { - const text = '| A | B |\n| --- | --- |\n| 1 | 2 |\n\ntail' - const t = doc(text) - const range = tableBlockAt(t, 3) - expect(text.slice(range!.from, range!.to)).toContain('zen:cols=273,227') - }) + const DOC = `line above - it('refuses a lone pipe line — that is prose, not a table', () => { - const t = doc('a | b is not a table\n| stray |\nplain') - expect(tableBlockAt(t, t.line(2).from + 2)).toBeNull() - }) +| A | B | +| --- | --- | +| 1 | 2 | + +line below` + + // Markdown always separates a table from surrounding text by a blank line, + // so the line "directly adjacent" to the table is that blank line. These are + // the positions on the blank lines above and below the table block. + const TABLE_FROM = DOC.indexOf('| A |') + const TABLE_TO = TABLE_FROM + '| A | B |\n| --- | --- |\n| 1 | 2 |'.length + const ABOVE_BLANK = DOC.indexOf('\n\n| A |') + 1 + const BELOW_BLANK = DOC.indexOf('\n\nline below') + 1 + + function mountVim(): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: DOC, + extensions: [ + vim(), + markdown({ base: markdownLanguage }), + tablePlugin, + tableVimEntry, + tableSelectionHighlight + ] + }) + }) + forceParsing(view, DOC.length, 5000) + return view + } - it('returns null off the table', () => { - const t = doc('prose here\n\n| A |\n| --- |') - expect(tableBlockAt(t, 2)).toBeNull() - }) + const press = (view: EditorView, key: string, keyCode: number): void => { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key, keyCode, bubbles: true, cancelable: true }) + ) + } - it('handles a table at the very start and end of the document', () => { - const text = '| A | B |\n| --- | --- |' - const t = doc(text) - const range = tableBlockAt(t, 0) - expect(range).toEqual({ from: 0, to: text.length }) - }) -}) + // Enter visual mode via the Vim API rather than a DOM keydown: the DOM path + // is fragile under jsdom once earlier describes in the file have mutated the + // global store / left stale listeners. Vim.handleKey drives vim's key + // processing directly. + function enterVisual(view: EditorView): void { + Vim.handleKey(getCM(view)!, 'v', 'user') + } -describe('renderInlineCell', () => { - // A cell is inline content, so a leading block marker is just a character. - // These all rendered as empty blocks before, losing the text outright. (#559) - it('keeps a cell that is only a list marker visible', () => { - expect(renderInlineCell('-')).toBe('-') - expect(renderInlineCell('+')).toBe('+') - expect(renderInlineCell('*')).toBe('*') + it('snaps a visual selection across the whole table on a down motion', () => { + useStore.setState({ vimMode: true }) + const view = mountVim() + view.focus() + // Caret on the blank line directly above the table, then visual mode. + view.dispatch({ selection: { anchor: ABOVE_BLANK } }) + enterVisual(view) + // One down motion → head snaps to the table's end, covering the whole table. + press(view, 'ArrowDown', 40) + const sel = view.state.selection.main + expect(sel.from).toBe(ABOVE_BLANK) + expect(sel.to).toBe(TABLE_TO) + view.destroy() }) - it('keeps the other block markers literal too', () => { - expect(renderInlineCell('#')).toBe('#') - expect(renderInlineCell('1.')).toBe('1.') - expect(renderInlineCell('---')).toBe('---') - expect(renderInlineCell('# H')).toBe('# H') - expect(renderInlineCell('- x')).toBe('- x') - expect(renderInlineCell('> q')).toBe('> q') + it('deletes the whole table cleanly with `d` after the snap', () => { + useStore.setState({ vimMode: true }) + const view = mountVim() + view.focus() + view.dispatch({ selection: { anchor: ABOVE_BLANK } }) + enterVisual(view) + press(view, 'ArrowDown', 40) + Vim.handleKey(getCM(view)!, 'd', 'user') + const after = view.state.doc.toString() + expect(after).not.toContain('| A |') + expect(after).not.toContain('---') + // The text after the table survives. + expect(after).toContain('line below') + view.destroy() }) - it('still renders inline markup', () => { - expect(renderInlineCell('**b**')).toBe('b') - expect(renderInlineCell('`c`')).toBe('c') - expect(renderInlineCell('[[note]]')).toContain('class="wikilink"') - expect(renderInlineCell('#tag')).toContain('class="hashtag"') + it('snaps upward from below the table too', () => { + useStore.setState({ vimMode: true }) + const view = mountVim() + view.focus() + view.dispatch({ selection: { anchor: BELOW_BLANK } }) + enterVisual(view) + press(view, 'ArrowUp', 38) + const sel = view.state.selection.main + // Head moved up to the table's start; anchor stayed on the blank line below. + expect(sel.head).toBe(TABLE_FROM) + expect(sel.anchor).toBe(BELOW_BLANK) + view.destroy() }) - it('renders a literal pipe rather than splitting the synthetic row', () => { - expect(renderInlineCell('a | b')).toBe('a | b') + it('lights the widget with .is-selected while the selection covers it', () => { + useStore.setState({ vimMode: true }) + const view = mountVim() + view.focus() + view.dispatch({ selection: { anchor: ABOVE_BLANK } }) + enterVisual(view) + press(view, 'ArrowDown', 40) + const widget = view.dom.querySelector('.cm-table-widget') + expect(widget?.classList.contains('is-selected')).toBe(true) + view.destroy() }) - it('renders nothing for an empty cell', () => { - expect(renderInlineCell('')).toBe('') - expect(renderInlineCell(' ')).toBe('') + it('clears .is-selected when the selection no longer covers the table', () => { + useStore.setState({ vimMode: true }) + const view = mountVim() + view.focus() + view.dispatch({ selection: { anchor: ABOVE_BLANK } }) + enterVisual(view) + press(view, 'ArrowDown', 40) + const widget = view.dom.querySelector('.cm-table-widget')! + expect(widget.classList.contains('is-selected')).toBe(true) + // Collapse the selection back to a point — the highlight must drop. + view.dispatch({ selection: { anchor: ABOVE_BLANK } }) + expect(widget.classList.contains('is-selected')).toBe(false) + view.destroy() }) - it('flattens a pasted newline instead of breaking out of the cell', () => { - expect(renderInlineCell('a\nb')).toBe('a b') + it('does not snap in non-vim mode (the handler is a no-op without vim)', () => { + useStore.setState({ vimMode: false }) + const view = mountVim() + view.focus() + view.dispatch({ selection: { anchor: ABOVE_BLANK } }) + enterVisual(view) + press(view, 'ArrowDown', 40) + // No table-snap dispatch occurred: head did not jump to the table end. + const sel = view.state.selection.main + expect(sel.to).toBeLessThan(TABLE_FROM) + view.destroy() }) }) diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index ebe1ced7..be676b0a 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -25,6 +25,8 @@ import { Decoration, type DecorationSet, EditorView, + ViewPlugin, + type ViewUpdate, WidgetType } from '@codemirror/view' import { @@ -744,6 +746,22 @@ class TableWidget extends WidgetType { const cols = this.model.headers.length const rowsCount = this.model.rows.length + // Inline markdown formatting (Mod-b / Mod-i / Mod-e / Mod-Shift-x) wraps + // the cell's own selection. The editor's `markdownKeymap` can't reach + // inside the atomic table widget — its commands act on the CM document + // selection, which is the atomic range, never the cell's caret — so the + // chords are honored here while the cell is editable (Vim INSERT, or + // always with Vim off). NORMAL mode lets them pass through unchanged. + if (!vimEnabled() || this.cellMode === 'insert') { + const marker = inlineMarkerFor(event) + if (marker) { + event.preventDefault() + event.stopPropagation() + this.toggleInline(editable, marker) + return + } + } + if (vimEnabled()) { if (this.cellMode === 'insert') { // INSERT: Escape (or the configurable insert-escape sequence, e.g. jk) @@ -1289,6 +1307,65 @@ class TableWidget extends WidgetType { this.renderCellCursor(cell) } + /** Toggle a markdown inline marker (`**` / `*` / `` ` `` / `~~`) around the + * cell's current text selection. Mirrors the editor's `markdownKeymap` + * (`toggleStrong`/`toggleEmphasis`/`toggleCode`/`toggleStrikethrough`), + * which can't operate here because the table widget is atomic — see + * `onCellKeydown`. A collapsed caret wraps the word under it; if it sits on + * whitespace/empty cell, empty markers are inserted with the caret between + * them. Selecting already-wrapped text unwraps it (a true toggle). */ + private toggleInline(editable: HTMLElement, marker: string): void { + const text = editable.textContent ?? '' + const sel = selectionOffsets(editable) + let from = sel?.from ?? 0 + let to = sel?.to ?? 0 + from = Math.max(0, Math.min(from, text.length)) + to = Math.max(from, Math.min(to, text.length)) + const collapsed = from === to + if (collapsed) { + // Expand to the word under the caret (a single char-class run, so + // markers are boundaries — see `wordRunAt`); on whitespace/empty, drop + // empty markers and park the caret between them. + const word = wordRunAt(text, from) + if (word) { + from = word.from + to = word.to + } + } + const L = marker.length + const inner = text.slice(from, to) + // Detect an existing wrap to toggle off. The markers may sit just outside + // the selection (…**[sel]**…) or be the selection's own edges ([**sel**]), + // so a select-all of `**hello**` unwraps rather than double-wrapping. + const outerBefore = text.slice(Math.max(0, from - L), from) + const outerAfter = text.slice(to, to + L) + const markedOutside = outerBefore === marker && outerAfter === marker + const markedInside = + inner.length >= 2 * L && inner.startsWith(marker) && inner.endsWith(marker) + let next: string + let caretFrom: number + let caretTo: number + if (markedOutside) { + next = text.slice(0, from - L) + inner + text.slice(to + L) + caretFrom = from - L + caretTo = caretFrom + inner.length + } else if (markedInside) { + const stripped = inner.slice(L, inner.length - L) + next = text.slice(0, from) + stripped + text.slice(to) + caretFrom = from + caretTo = caretFrom + stripped.length + } else { + next = text.slice(0, from) + marker + inner + marker + text.slice(to) + caretFrom = from + L + caretTo = caretFrom + inner.length + } + editable.textContent = next + editable.dataset.raw = next + editable.dataset.rendered = 'false' + this.dirty = true + setSelection(editable, caretFrom, caretTo) + } + /** 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. */ private applyOperator( @@ -1686,18 +1763,80 @@ export function textObjectRange( /** Collapse the caret at a character offset within the element's text node. */ function placeCaretAt(el: HTMLElement, offset: number): void { + setSelection(el, offset, offset) +} + +/** Select the text between char offsets `from` and `to` in `el`'s single text + * node (the cell source is one text node while editing). Clamped to bounds. */ +function setSelection(el: HTMLElement, from: number, to: number): void { const node = el.firstChild + const sel = window.getSelection() + if (!sel) return const range = document.createRange() if (node && node.nodeType === Node.TEXT_NODE) { const max = node.textContent?.length ?? 0 - range.setStart(node, Math.max(0, Math.min(offset, max))) + const a = Math.max(0, Math.min(from, max)) + const b = Math.max(0, Math.min(to, max)) + range.setStart(node, a) + range.setEnd(node, Math.max(a, b)) } else { range.selectNodeContents(el) } - range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) +} + +/** Read the [from, to) char offsets of the live Selection within `el`, measured + * over the rendered text (so it tracks the real caret even if the browser + * split the text node while editing). Collapsed selection → from == to. + * Returns null when the selection is outside `el`. */ +function selectionOffsets(el: HTMLElement): { from: number; to: number } | null { const sel = window.getSelection() - sel?.removeAllRanges() - sel?.addRange(range) + if (!sel || sel.rangeCount === 0) return null + const range = sel.getRangeAt(0) + if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return null + const measure = (container: Node, offset: number): number => { + const pre = document.createRange() + pre.selectNodeContents(el) + pre.setEnd(container, offset) + return pre.toString().length + } + return { + from: measure(range.startContainer, range.startOffset), + to: measure(range.endContainer, range.endOffset) + } +} + +/** The run of characters sharing the char class of `text[at]` around `at`, or + * null if `at` is on whitespace / out of range. Stopping at class changes (not + * just whitespace) means markdown markers (`*` `` ` `` `~`, all punctuation) + * are boundaries — so a caret inside `**hello**` wraps `hello`, and the + * surrounding markers make the next press toggle it off instead of re-wrapping. + * Matches the file's other word helpers (`nextWordStart`/…), which all use + * `charClass`. */ +function wordRunAt(text: string, at: number): { from: number; to: number } | null { + if (at < 0 || at >= text.length) return null + const cls = charClass(text[at]) + if (cls === 0) return null + let from = at + while (from > 0 && charClass(text[from - 1]) === cls) from-- + let to = at + while (to < text.length && charClass(text[to]) === cls) to++ + return { from, to } +} + +/** Map a Mod-b / Mod-i / Mod-e / Mod-Shift-x chord to its markdown inline + * marker, mirroring `markdownKeymap`'s inline-mark bindings. Returns null for + * any other key (or when a superfluous Alt is held, to avoid clobbering + * unrelated chords). */ +function inlineMarkerFor(event: KeyboardEvent): string | null { + if (!(event.metaKey || event.ctrlKey) || event.altKey) return null + const key = event.key.toLowerCase() + if (event.shiftKey) return key === 'x' ? '~~' : null + if (key === 'b') return '**' + if (key === 'i') return '*' + if (key === 'e') return '`' + return null } function buildDecorations(state: EditorState): DecorationSet { @@ -1814,12 +1953,41 @@ function focusTableEntryCell( return false } +/** + * After a `/table` insertion, move keyboard focus into the first header cell of + * the table starting at `tableFrom`, so the user lands in the table instead of + * on the trailing line. `#340` deliberately keeps the CM caret on that trailing + * line (a caret inside the atomic range smears into a tall bar); focusing the + * cell DOM is a separate concern and leaves the caret untouched. + * + * The widget is built from the parsed syntax tree, which can lag a frame or two + * behind the insert dispatch, so this retries (bounded) until the decoration + * appears. No-op if the table never renders — the extension isn't loaded in + * Split mode or the template editor, so `/table` there just falls back to the + * trailing-line caret. */ +export function focusFirstTableCell(view: EditorView, tableFrom: number): void { + let tries = 0 + const tick = (): void => { + if (focusTableEntryCell(view, tableFrom, 'first')) return + if (++tries < 12) requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} + /** * Vim integration: in NORMAL mode, `j` / `k` on a line directly adjacent to a * rendered table steps the caret into the table (first header cell from above, * last row from below) instead of jumping over the atomic block. Inside, the - * widget's own key handler takes over. Highest precedence so it runs before Vim - * consumes the key; a no-op (returns false) whenever there's no adjacent table. + * widget's own key handler takes over. + * + * In VISUAL mode, a down/up motion adjacent to a table extends the selection + * across the WHOLE table in one step — the head snaps to the table's far edge + * instead of landing inside the atomic interior (where it would corrupt a + * partial delete). CodeMirror draws no selection highlight over a block widget, + * so `tableSelectionHighlight` lights the table up to make the snap visible. + * + * Highest precedence so it runs before Vim consumes the key; a no-op (returns + * false) whenever there's no adjacent table. */ export const tableVimEntry = Prec.highest( EditorView.domEventHandlers({ @@ -1832,16 +2000,30 @@ export const tableVimEntry = Prec.highest( if (!down && !up) return false if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false // Keys from inside a table widget are the widget's own cell navigation — - // this handler only enters a table from the surrounding document. + // this handler only enters/extends across a table from the document. if ((event.target as HTMLElement | null)?.closest?.('.cm-table-widget')) return false - const vimState = getCM(view)?.state?.vim as { insertMode?: boolean } | undefined + const vimState = getCM(view)?.state?.vim as + | { insertMode?: boolean; visualMode?: boolean } + | undefined if (vimState?.insertMode) return false const sel = view.state.selection.main - if (!sel.empty) return false + const visual = !!vimState?.visualMode + if (!visual && !sel.empty) return false const line = view.state.doc.lineAt(sel.head).number const dir = down ? 'down' : 'up' const table = adjacentTableRange(view.state, line, dir) if (!table) return false + if (visual) { + // Extend the moving end (head) to the table's far edge. codemirror-vim + // adopts the change: its `handleExternalSelection` reads the CM + // selection into `vim.sel` whenever it changes in visual mode, so a + // dispatched selection becomes the live visual selection. `d` then + // deletes the whole table cleanly. + const targetHead = dir === 'down' ? table.to : table.from + view.dispatch({ selection: { anchor: sel.anchor, head: targetHead } }) + event.preventDefault() + return true + } if (focusTableEntryCell(view, table.from, dir === 'down' ? 'first' : 'last')) { event.preventDefault() return true @@ -1850,3 +2032,44 @@ export const tableVimEntry = Prec.highest( } }) ) + +/** + * Light up a table widget while the editor selection covers it. CodeMirror + * draws the selection background only on text lines — a block widget sits + * outside that flow, so a Vim visual selection over a table is otherwise + * invisible. This toggles `.is-selected` on each widget whose range intersects + * `selection.main`, so the snap from `tableVimEntry` (and any mouse selection) + * is actually visible. Cheap: the decoration set is iterated only on selection + * / doc / viewport changes, and tables are sparse. + */ +export const tableSelectionHighlight = ViewPlugin.fromClass( + class { + update(update: ViewUpdate): void { + if (!update.selectionSet && !update.docChanged && !update.viewportChanged) return + const view = update.view + const sel = view.state.selection.main + const field = view.state.field(tablePlugin, false) + if (!field) return + // Empty (normal-mode) selection → no table is highlighted. Toggle the + // class directly on the live widget DOM; tables are sparse, so a query + // per change beats bookkeeping widget instances. + const widgets = view.contentDOM.querySelectorAll('.cm-table-widget') + for (const widget of widgets) { + let from: number + try { + from = view.posAtDOM(widget) + } catch { + continue + } + let covered = false + if (!sel.empty) { + // The widget's decoration range includes `from`; inspect it there. + field.between(from, from + 1, (f, t) => { + if (sel.from <= t && sel.to >= f) covered = true + }) + } + widget.classList.toggle('is-selected', covered) + } + } + } +) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 70a4c4b9..8e3006a9 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -4921,6 +4921,15 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite .cm-editor:has(.cm-table-cell:focus) .cm-selectionLayer { display: none; } +/* A Vim visual selection (or mouse selection) covering the table block. CM + draws selection background only on text lines, so a block widget shows none; + `.is-selected` is toggled by `tableSelectionHighlight` to make it visible. + Uses the same accent tint as `.cm-selectionBackground`. */ +.cm-table-widget.is-selected .cm-table-cell, +.cm-table-widget.is-selected th, +.cm-table-widget.is-selected td { + background: rgb(var(--z-accent) / 0.18); +} /* Rendered inline markdown inside idle cells (the source pipeline emits real /// tags, so style them to match the editor chrome). */ .cm-table-widget .cm-table-cell code {