From e0a6e263cc56c7130ee6ea37b4b16ea219881835 Mon Sep 17 00:00:00 2001 From: Junerey Date: Sat, 15 Aug 2026 13:35:10 +0700 Subject: [PATCH] feat(app-core): table cells support the same inline formatting shortcuts as the editor --- packages/app-core/src/components/VimNav.tsx | 4 + packages/app-core/src/lib/cm-format.ts | 180 +++++++++++------- packages/app-core/src/lib/cm-table.test.ts | 181 ++++++++++++++++++ packages/app-core/src/lib/cm-table.ts | 192 ++++++++++++++++++++ 4 files changed, 493 insertions(+), 64 deletions(-) diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index d13ddec5..d9f648f2 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -517,6 +517,10 @@ export function VimNav(): JSX.Element | null { // the open menu. (#337) const fmtView = state.editorViewRef if (fmtView && isEditorFocused(fmtView) && completionStatus(fmtView.state) !== 'active') { + // Table cells have their own formatting handler; don't apply the + // editor-level toggle to the main selection while focus is inside a cell. + if (document.activeElement?.closest('.cm-table-widget')) return + // Focus the selection toolbar (when shown) for keyboard navigation. if (matchesShortcutBinding(e, 'Mod+/')) { const firstItem = document.querySelector( diff --git a/packages/app-core/src/lib/cm-format.ts b/packages/app-core/src/lib/cm-format.ts index d0b426ef..6d8f65d1 100644 --- a/packages/app-core/src/lib/cm-format.ts +++ b/packages/app-core/src/lib/cm-format.ts @@ -33,6 +33,18 @@ function longerMarkerPairAt(state: EditorState, at: number, marker: string): boo return WRAP_MARKERS.some((w) => w.length > marker.length && isEmptyPairAt(state, at, w)) } +function isEmptyPairAtText(text: string, at: number, marker: string): boolean { + if (at - marker.length < 0 || at + marker.length > text.length) return false + return ( + text.slice(at - marker.length, at) === marker && + text.slice(at, at + marker.length) === marker + ) +} + +function longerMarkerPairAtText(text: string, at: number, marker: string): boolean { + return WRAP_MARKERS.some((w) => w.length > marker.length && isEmptyPairAtText(text, at, w)) +} + /** * `text` (the line up to the cursor) leaves `marker` open — an odd number of * them, so the cursor is inside a span this marker started. A single `*` skips @@ -80,74 +92,98 @@ export function formatMarkerBackspaceTransaction(state: EditorState): Transactio } /** - * Toggle a symmetric inline marker around each selection range: wrap when it - * isn't wrapped, unwrap when the markers already sit just outside (or just - * inside) the selection. + * Pure-text version of the symmetric-marker toggle. Returns a single change + * range and the selection that should be active after applying it. */ -export function toggleWrap(view: EditorView, marker: string): boolean { +export function toggleWrapEdit( + text: string, + marker: string, + from: number, + to: number, + lineStart = 0 +): { from: number; to: number; insert: string; selection: { from: number; to: number } } { const m = marker - view.dispatch( - view.state.changeByRange((range) => { - const { from, to } = range - if (from === to) { - const before = view.state.sliceDoc(Math.max(0, from - m.length), from) - const after = view.state.sliceDoc(from, Math.min(view.state.doc.length, from + m.length)) - - if (after === m) { - if (before === m && !longerMarkerPairAt(view.state, from, m)) { - // Empty pair: pressing the shortcut again removes the markers. - return { - changes: { from: from - m.length, to: from + m.length, insert: '' }, - range: EditorSelection.cursor(from - m.length) - } - } - - const line = view.state.doc.lineAt(from) - const lineBefore = view.state.sliceDoc(line.from, from) - if (isInsideUnclosedMarker(lineBefore, m)) { - // Cursor is just before the closing marker from a previously inserted - // pair. Treat the shortcut as leaving/toggling off formatting instead - // of inserting another marker pair inside it. - return { - changes: [], - range: EditorSelection.cursor(from + m.length) - } - } - } + if (from === to) { + const before = text.slice(Math.max(0, from - m.length), from) + const after = text.slice(from, Math.min(text.length, from + m.length)) - // No selection: insert the pair and drop the cursor between them. - return { - changes: { from, insert: m + m }, - range: EditorSelection.cursor(from + m.length) - } - } - const before = view.state.sliceDoc(Math.max(0, from - m.length), from) - const after = view.state.sliceDoc(to, Math.min(view.state.doc.length, to + m.length)) - if (before === m && after === m) { - // Unwrap: drop the markers just outside the selection. - return { - changes: [ - { from: from - m.length, to: from, insert: '' }, - { from: to, to: to + m.length, insert: '' } - ], - range: EditorSelection.range(from - m.length, to - m.length) - } + if (after === m && before === m && !longerMarkerPairAtText(text, from, m)) { + // Empty pair: pressing the shortcut again removes the markers. + return { + from: from - m.length, + to: from + m.length, + insert: '', + selection: { from: from - m.length, to: from - m.length } } - const selected = view.state.sliceDoc(from, to) - if (selected.length >= m.length * 2 && selected.startsWith(m) && selected.endsWith(m)) { - // The selection itself includes the markers — strip them from inside. + } + + if (after === m) { + const lineBefore = text.slice(lineStart, from) + if (isInsideUnclosedMarker(lineBefore, m)) { + // Cursor is just before the closing marker from a previously inserted + // pair. Leave the span instead of inserting another marker pair. return { - changes: { from, to, insert: selected.slice(m.length, selected.length - m.length) }, - range: EditorSelection.range(from, to - m.length * 2) + from, + to, + insert: '', + selection: { from: from + m.length, to: from + m.length } } } - // Wrap. + } + + // No selection: insert the pair and drop the cursor between them. + return { + from, + to, + insert: m + m, + selection: { from: from + m.length, to: from + m.length } + } + } + + const before = text.slice(Math.max(0, from - m.length), from) + const after = text.slice(to, Math.min(text.length, to + m.length)) + if (before === m && after === m) { + // Unwrap: drop the markers just outside the selection. + return { + from: from - m.length, + to: to + m.length, + insert: text.slice(from, to), + selection: { from: from - m.length, to: to - m.length } + } + } + const selected = text.slice(from, to) + if (selected.length >= m.length * 2 && selected.startsWith(m) && selected.endsWith(m)) { + // The selection itself includes the markers — strip them from inside. + return { + from, + to, + insert: selected.slice(m.length, selected.length - m.length), + selection: { from, to: to - m.length * 2 } + } + } + // Wrap. + return { + from, + to, + insert: m + selected + m, + selection: { from: from + m.length, to: to + m.length } + } +} + +/** + * Toggle a symmetric inline marker around each selection range: wrap when it + * isn't wrapped, unwrap when the markers already sit just outside (or just + * inside) the selection. + */ +export function toggleWrap(view: EditorView, marker: string): boolean { + const text = view.state.doc.toString() + view.dispatch( + view.state.changeByRange((range) => { + const lineStart = view.state.doc.lineAt(range.from).from + const edit = toggleWrapEdit(text, marker, range.from, range.to, lineStart) return { - changes: [ - { from, insert: m }, - { from: to, insert: m } - ], - range: EditorSelection.range(from + m.length, to + m.length) + changes: { from: edit.from, to: edit.to, insert: edit.insert }, + range: EditorSelection.range(edit.selection.from, edit.selection.to) } }) ) @@ -155,6 +191,24 @@ export function toggleWrap(view: EditorView, marker: string): boolean { return true } +/** + * Pure-text version of link wrapping. Returns a single change range and the + * cursor position after the opening parenthesis. + */ +export function wrapLinkEdit( + selected: string, + from: number, + to: number +): { from: number; to: number; insert: string; cursor: number } { + const insert = `[${selected}]()` + return { + from, + to, + insert, + cursor: from + insert.length - 1 + } +} + /** * The block types offered by the selection toolbar's "Turn into" menu — a * lighter version of Notion's block menu. @@ -242,11 +296,9 @@ export function wrapLink(view: EditorView): boolean { view.dispatch( view.state.changeByRange((range) => { const { from, to } = range - const text = view.state.sliceDoc(from, to) - const insert = `[${text}]()` + const edit = wrapLinkEdit(view.state.sliceDoc(from, to), from, to) // Cursor between the parentheses: after `[text](`. - const cursor = from + 1 + text.length + 2 - return { changes: { from, to, insert }, range: EditorSelection.cursor(cursor) } + return { changes: { from: edit.from, to: edit.to, insert: edit.insert }, range: EditorSelection.cursor(edit.cursor) } }) ) view.focus() diff --git a/packages/app-core/src/lib/cm-table.test.ts b/packages/app-core/src/lib/cm-table.test.ts index 395103a7..23170ffc 100644 --- a/packages/app-core/src/lib/cm-table.test.ts +++ b/packages/app-core/src/lib/cm-table.test.ts @@ -510,3 +510,184 @@ describe('renderInlineCell', () => { expect(renderInlineCell('a\nb')).toBe('a b') }) }) + +describe('table cell formatting shortcuts', () => { + const saved = useStore.getState().vimMode + afterEach(() => { + useStore.setState({ vimMode: saved }) + }) + + function focusCell(view: EditorView, row: number, col: number): HTMLElement { + const cell = view.dom.querySelector( + `.cm-table-widget [data-row="${row}"][data-col="${col}"]` + )! + cell.focus() + return cell + } + + function setSelection(cell: HTMLElement, from: number, to: number): void { + const node = cell.firstChild + if (!node || node.nodeType !== Node.TEXT_NODE) return + const range = document.createRange() + range.setStart(node, from) + range.setEnd(node, to) + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) + } + + it('wraps selected text with Mod+B bold in a non-Vim cell', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + setSelection(cell, 0, 5) + const ev = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('**Alice**') + expect(window.getSelection()?.anchorOffset).toBe(2) + expect(window.getSelection()?.focusOffset).toBe(7) + view.destroy() + }) + + it('wraps selected text with Mod+I italic in a non-Vim cell', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + setSelection(cell, 0, 5) + const ev = new KeyboardEvent('keydown', { key: 'i', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('*Alice*') + view.destroy() + }) + + it('wraps selected text with Mod+E code in a non-Vim cell', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + setSelection(cell, 0, 5) + const ev = new KeyboardEvent('keydown', { key: 'e', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('`Alice`') + view.destroy() + }) + + it('wraps selected text with Shift+Mod+S strikethrough in a non-Vim cell', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + setSelection(cell, 0, 5) + const ev = new KeyboardEvent('keydown', { key: 'S', shiftKey: true, metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('~~Alice~~') + view.destroy() + }) + + it('wraps selected text with Shift+Mod+H highlight in a non-Vim cell', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + setSelection(cell, 0, 5) + const ev = new KeyboardEvent('keydown', { key: 'H', shiftKey: true, metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('==Alice==') + view.destroy() + }) + + it('wraps selected text with Shift+Mod+M math in a non-Vim cell', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + setSelection(cell, 0, 5) + const ev = new KeyboardEvent('keydown', { key: 'M', shiftKey: true, metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('$Alice$') + view.destroy() + }) + + it('turns selected text into a link with Mod+K in a non-Vim cell', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + setSelection(cell, 0, 5) + const ev = new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('[Alice]()') + expect(window.getSelection()?.anchorOffset).toBe(8) + view.destroy() + }) + + it('inserts an empty bold pair and places the cursor between markers', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 1, 0) + // jsdom does not reliably place the caret at the end of a focused contenteditable + // node, so pin it to the end of "Bob" before dispatching the shortcut. + setSelection(cell, 3, 3) + const ev = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('Bob****') + expect(window.getSelection()?.anchorOffset).toBe(5) + view.destroy() + }) + + it('removes an empty bold pair when the cursor is between the markers', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 1, 0) + cell.textContent = '****' + cell.dataset.raw = '****' + setSelection(cell, 2, 2) + const ev = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('') + expect(window.getSelection()?.anchorOffset).toBe(0) + view.destroy() + }) + + it('unwraps selected text that already has bold markers', () => { + useStore.setState({ vimMode: false }) + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + cell.textContent = '**Alice**' + cell.dataset.raw = '**Alice**' + setSelection(cell, 2, 7) + const ev = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('Alice') + view.destroy() + }) + + it('does not intercept plain b in Vim normal mode', () => { + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + const ev = new KeyboardEvent('keydown', { key: 'b', bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + // The cursor moved to the previous word start (already at 0, so stays 0). + expect(cell.dataset.raw).toBe('Alice') + view.destroy() + }) + + it('wraps a Vim visual selection with Mod+B', () => { + const view = mount(TABLE_DOC) + const cell = focusCell(view, 0, 0) + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', bubbles: true, cancelable: true })) + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'l', bubbles: true, cancelable: true })) + const ev = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + expect(cell.dataset.raw).toBe('**Al**ice') + view.destroy() + }) +}) + diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index ebe1ced7..bc1100b2 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -43,6 +43,7 @@ import { const MIN_COL_WIDTH = 48 import { openTableContextMenu } from './cm-table-menu' import { renderMarkdown } from './markdown' +import { toggleWrapEdit, wrapLinkEdit } from './cm-format' import { getCM } from '@replit/codemirror-vim' import { undo, redo } from '@codemirror/commands' import { useStore } from '../store' @@ -50,6 +51,71 @@ import { matchesSequenceToken } from './keymaps' import { followLinkTarget } from './follow-link' import { extractLinkAtCursor } from './internal-links' +/** Compute the total text length of a node and its descendants. */ +function textLength(node: Node): number { + return node.textContent?.length ?? 0 +} + +/** Convert a DOM boundary point into a character offset within `root`'s text. */ +function textOffsetAt(root: Node, container: Node, offset: number): number { + if (container === root) { + let off = 0 + for (let i = 0; i < offset; i++) off += textLength(root.childNodes[i]) + return off + } + if (container.nodeType === Node.TEXT_NODE) { + let off = offset + let node = container + while (node && node !== root) { + let prev = node.previousSibling + while (prev) { + off += textLength(prev) + prev = prev.previousSibling + } + node = node.parentNode! + } + return off + } + let off = 0 + for (let i = 0; i < offset; i++) off += textLength(container.childNodes[i]) + let node = container + while (node && node !== root) { + let prev = node.previousSibling + while (prev) { + off += textLength(prev) + prev = prev.previousSibling + } + node = node.parentNode! + } + return off +} + +/** Compute selection character offsets relative to a cell's text content. */ +function getCellSelectionOffsets(el: HTMLElement): { from: number; to: number; collapsed: boolean } | null { + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0) return null + const range = sel.getRangeAt(0) + if (!el.contains(range.commonAncestorContainer)) return null + const from = textOffsetAt(el, range.startContainer, range.startOffset) + const to = textOffsetAt(el, range.endContainer, range.endOffset) + return { from: Math.min(from, to), to: Math.max(from, to), collapsed: sel.isCollapsed } +} + +/** Restore a selection inside a cell after a programmatic text change. */ +function setCellSelection(el: HTMLElement, from: number, to: number = from): void { + const node = el.firstChild + if (!node || node.nodeType !== Node.TEXT_NODE) return + const max = node.textContent?.length ?? 0 + const a = Math.max(0, Math.min(from, max)) + const b = Math.max(0, Math.min(to, max)) + const range = document.createRange() + range.setStart(node, a) + range.setEnd(node, b) + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) +} + /** The follow target for a rendered link anchor inside a table cell: a * wikilink's name (`data-wikilink`) or a plain link's href. Returns null for * anchors that carry neither. */ @@ -744,6 +810,12 @@ class TableWidget extends WidgetType { const cols = this.model.headers.length const rowsCount = this.model.rows.length + // Formatting shortcuts (Mod+B bold, Mod+I italic, etc.) are handled by the + // editor keymap in the main document, but inside a table cell focus lives in + // the cell's own contenteditable so the global handler never runs. Apply + // the same wrap/unwrap/link behavior directly to the cell source. + if (this.applyFormatShortcut(event, editable)) return + if (vimEnabled()) { if (this.cellMode === 'insert') { // INSERT: Escape (or the configurable insert-escape sequence, e.g. jk) @@ -1503,6 +1575,126 @@ class TableWidget extends WidgetType { }) } + private formatShortcutFromEvent(event: KeyboardEvent): string | null { + if (event.altKey) return null + const mod = event.ctrlKey || event.metaKey + if (!mod) return null + const key = event.key.toLowerCase() + if (event.shiftKey) { + switch (key) { + case 's': + return 'strikethrough' + case 'h': + return 'highlight' + case 'm': + return 'math' + default: + return null + } + } + switch (key) { + case 'b': + return 'bold' + case 'i': + return 'italic' + case 'e': + return 'code' + case 'k': + return 'link' + default: + return null + } + } + + private getCellSelection(editable: HTMLElement): { from: number; to: number; empty: boolean } | null { + if (editable.getAttribute('contenteditable') === 'true') { + const offsets = getCellSelectionOffsets(editable) + if (!offsets) return null + return { + from: offsets.from, + to: offsets.to, + empty: offsets.collapsed + } + } + // Vim normal mode: visual selection or block cursor. + if (this.visualMode) { + const raw = editable.dataset.raw ?? '' + const from = Math.min(this.visualAnchor, this.cursorOffset) + const to = Math.min(raw.length, Math.max(this.visualAnchor, this.cursorOffset) + 1) + return { from, to, empty: false } + } + return { from: this.cursorOffset, to: this.cursorOffset, empty: true } + } + + private setCellCaret(editable: HTMLElement, from: number, to: number | null = from): void { + if (editable.getAttribute('contenteditable') === 'true') { + setCellSelection(editable, from, to ?? from) + } else { + this.cursorOffset = from + this.clearCellCursor(editable) + this.renderCellCursor(editable) + } + } + + private applyCellMarker( + editable: HTMLElement, + marker: string, + text: string, + sel: NonNullable> + ): void { + const edit = toggleWrapEdit(text, marker, sel.from, sel.to, 0) + editable.textContent = text.slice(0, edit.from) + edit.insert + text.slice(edit.to) + editable.dataset.raw = editable.textContent + this.dirty = true + if (editable.getAttribute('contenteditable') !== 'true') { + this.exitVisual() + } + this.setCellCaret(editable, edit.selection.from, edit.selection.to) + } + + private applyCellLink( + editable: HTMLElement, + text: string, + sel: NonNullable> + ): void { + const edit = wrapLinkEdit(text.slice(sel.from, sel.to), sel.from, sel.to) + editable.textContent = text.slice(0, edit.from) + edit.insert + text.slice(edit.to) + editable.dataset.raw = editable.textContent + this.dirty = true + if (editable.getAttribute('contenteditable') !== 'true') { + this.exitVisual() + } + this.setCellCaret(editable, edit.cursor, edit.cursor) + } + + private applyFormatShortcut(event: KeyboardEvent, editable: HTMLElement): boolean { + const kind = this.formatShortcutFromEvent(event) + if (!kind) return false + if (editable.dataset.rendered === 'true') { + editable.textContent = editable.dataset.raw ?? '' + editable.dataset.rendered = 'false' + } + const text = editable.textContent ?? '' + const sel = this.getCellSelection(editable) + if (!sel) return false + const markers: Record = { + bold: '**', + italic: '*', + code: '`', + strikethrough: '~~', + highlight: '==', + math: '$' + } + if (kind === 'link') { + this.applyCellLink(editable, text, sel) + } else { + this.applyCellMarker(editable, markers[kind], text, sel) + } + event.preventDefault() + event.stopPropagation() + return true + } + ignoreEvent(): boolean { return true }