From d352dec8b05a1a91708cfd2927f829334a337f9a Mon Sep 17 00:00:00 2001 From: Junerey Date: Sat, 15 Aug 2026 11:54:38 +0700 Subject: [PATCH] fix(editor): focus first row when inserting a table via /table --- .../src/lib/cm-slash-commands.test.ts | 31 ++++++++++++------- .../app-core/src/lib/cm-slash-commands.ts | 16 ++++++++++ packages/app-core/src/lib/cm-table.ts | 27 ++++++++++++++++ 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/app-core/src/lib/cm-slash-commands.test.ts b/packages/app-core/src/lib/cm-slash-commands.test.ts index f92252d8..212983fd 100644 --- a/packages/app-core/src/lib/cm-slash-commands.test.ts +++ b/packages/app-core/src/lib/cm-slash-commands.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { CompletionContext } from '@codemirror/autocomplete' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { describe, expect, it, vi } from 'vitest' @@ -14,6 +15,7 @@ import { templateSlashCommandSource, blockInsertPadding } from './cm-slash-commands' +import { tablePlugin } from './cm-table' type Source = typeof templateSlashCommandSource @@ -124,9 +126,8 @@ describe('/table insertion separates the table into its own block (#294)', () => const TABLE = '| Column 1 | Column 2 |\n| --- | --- |\n| | |' - // A trailing newline is added at the end of the document so the caret can land - // on the line AFTER the table's block widget rather than inside its replaced - // range (a caret inside renders as a tall bar at the pane edge). (#340) + // The table renders as a block widget; focus should move into the first body + // cell instead of being left in the raw source or after the block. (#340) it('inserts the bare table at document start', () => { expect(applyTable('/')).toBe(`${TABLE}\n`) }) @@ -139,25 +140,31 @@ describe('/table insertion separates the table into its own block (#294)', () => expect(applyTable('Some text\n\n/')).toBe(`Some text\n\n${TABLE}\n`) }) - function applyTableCaretLine(doc: string): string { + async function focusAfterTableInsertion(doc: string): Promise { const parent = document.createElement('div') document.body.append(parent) - const view = new EditorView({ parent, state: EditorState.create({ doc }) }) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage }), tablePlugin] + }) + }) const result = templateSlashCommandSource(new CompletionContext(view.state, doc.length, true)) const table = result?.options.find((o) => (o.displayLabel ?? o.label) === 'Table') const apply = table?.apply if (typeof apply !== 'function') throw new Error('expected a Table apply handler') apply(view, table!, result!.from, view.state.doc.length) - const line = view.state.doc.lineAt(view.state.selection.main.head) + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + const cell = view.dom.querySelector('.cm-table-widget [data-row="-1"][data-col="0"]') + const focused = document.activeElement === cell view.destroy() parent.remove() - return line.text + return focused } - it('lands the caret on the empty line after the table, not inside it (#340)', () => { - // Every table source line contains a pipe; the landing line must not. - const caretLine = applyTableCaretLine('/') - expect(caretLine).toBe('') - expect(caretLine.includes('|')).toBe(false) + it('focuses the first cell of the inserted table (#340)', async () => { + const focused = await focusAfterTableInsertion('/') + expect(focused).toBe(true) }) }) diff --git a/packages/app-core/src/lib/cm-slash-commands.ts b/packages/app-core/src/lib/cm-slash-commands.ts index 24a47f8e..3809362d 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 { focusTableCell } from './cm-table' import { renderLatexCompletion } from './cm-latex-completions' import { renderTypstCompletion } from './cm-typst-completions' @@ -238,6 +239,21 @@ export function slashCommandSource(context: CompletionContext): CompletionResult changes: { from: slashStart, to, insert }, selection: { anchor: cursorPos } }) + if (cmd.label === 'Table') { + // The table renders as a block widget; once it appears, move focus + // into the first header cell so typing can start immediately. + const tableFrom = slashStart + leadPad.length + view.requestMeasure({ + key: {}, + read: (v) => focusTableCell(v, tableFrom, -1, 0), + write: (focused, v) => { + if (!focused) { + // Parsing may still be finishing; try again on the next frame. + requestAnimationFrame(() => focusTableCell(v, tableFrom, -1, 0)) + } + } + }) + } } } as Completion & { _icon: string }) ), diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts index ebe1ced7..6ac15069 100644 --- a/packages/app-core/src/lib/cm-table.ts +++ b/packages/app-core/src/lib/cm-table.ts @@ -1781,6 +1781,33 @@ function adjacentTableRange( return null } +/** Find the rendered table widget whose document position is `tableFrom` and + * focus a specific cell inside it. Used when creating a table from a slash + * command so the cursor lands in the first cell instead of after the block. */ +export function focusTableCell( + view: EditorView, + tableFrom: number, + row: number, + col: number +): boolean { + const widgets = view.contentDOM.querySelectorAll('.cm-table-widget') + for (const widget of widgets) { + let pos: number + try { + pos = view.posAtDOM(widget) + } catch { + continue + } + if (pos !== tableFrom) continue + const cell = widget.querySelector(`[data-row="${row}"][data-col="${col}"]`) + if (cell) { + cell.focus() + return true + } + } + return false +} + /** Focus the entry cell of the table widget at `tableFrom`: the first header * cell when entering from above, the first cell of the last row from below. */ function focusTableEntryCell(