Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions packages/app-core/src/lib/cm-slash-commands.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -14,6 +15,7 @@ import {
templateSlashCommandSource,
blockInsertPadding
} from './cm-slash-commands'
import { tablePlugin } from './cm-table'

type Source = typeof templateSlashCommandSource

Expand Down Expand Up @@ -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`)
})
Expand All @@ -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<boolean> {
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<void>((resolve) => requestAnimationFrame(() => resolve()))
const cell = view.dom.querySelector<HTMLElement>('.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)
})
})
16 changes: 16 additions & 0 deletions packages/app-core/src/lib/cm-slash-commands.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 })
),
Expand Down
27 changes: 27 additions & 0 deletions packages/app-core/src/lib/cm-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.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<HTMLElement>(`[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(
Expand Down