feat(editor): richer rich-markdown table keyboard (Tab, Enter, Backspace) (#11724)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-31 01:57:41 -07:00
committed by GitHub
co-authored by Orca
parent 9a2676023c
commit f936e7fc9c
7 changed files with 654 additions and 5 deletions
@@ -3,6 +3,8 @@ import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
// Why: keybinding matching resolves the platform from navigator.userAgent,
// which is environment-dependent under vitest; pin it for determinism.
@@ -20,6 +22,39 @@ function createEditor(content: object): Editor {
})
}
const TABLE_MARKDOWN = `| A | B |
| --- | --- |
| a1 | b1 |
| a2 | b2 |
`
// The StarterKit schema above has no table nodes, so table key paths need this one.
function createTableEditor(): Editor {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: TABLE_MARKDOWN,
contentType: 'markdown'
})
}
function caretAtText(editor: Editor, text: string): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (!node.isText || node.text !== text) {
return true
}
position = pos
return false
})
if (position === null) {
throw new Error(`Expected cell text: ${text}`)
}
return position
}
function firstEmptyParagraphPosition(editor: Editor): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
@@ -300,6 +335,26 @@ describe('rich markdown key handler', () => {
}
})
it('runs the selected slash command on Enter instead of the table cell-below move', () => {
const editor = createTableEditor()
try {
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
const ctx = createContext(editor, false)
const from = editor.state.selection.from
const run = vi.fn()
ctx.slashMenuRef.current = { query: '', from, to: from, left: 0, top: 0 }
ctx.filteredSlashCommandsRef.current = [{ id: 'heading-1', run } as never]
const event = keyEvent('Enter')
expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
expect(run).toHaveBeenCalledWith(editor)
expect(editor.state.selection.$from.parent.textContent).toBe('a1')
} finally {
editor.destroy()
}
})
it('dismisses the slash menu on Escape even when search has no matches', () => {
const editor = createEditor({
type: 'doc',
@@ -18,6 +18,9 @@ import {
exitTrailingEmptyOrderedListItem
} from './rich-markdown-list-continuation'
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
import { handleRichMarkdownTableBackspace } from './rich-markdown-table-row-delete'
import { handleRichMarkdownTableEnter } from './rich-markdown-table-enter'
import { handleRichMarkdownTableTab } from './rich-markdown-table-tab'
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
@@ -129,7 +132,8 @@ export function createRichMarkdownKeyHandler(
!isComposingMarkdownInput(event, ed) &&
(convertEmptyNestedOrderedItemToContinuation(ed) ||
collapseEmptyListContinuationParagraph(ed) ||
deleteAdjacentEmptyParagraph(ed, 'backward'))
deleteAdjacentEmptyParagraph(ed, 'backward') ||
handleRichMarkdownTableBackspace(ed))
) {
event.preventDefault()
return true
@@ -164,12 +168,25 @@ export function createRichMarkdownKeyHandler(
event.preventDefault()
return true
}
// Why: table Enter (cell below / add row) must run before ProseMirror
// inserts an in-cell paragraph that GFM serialization cannot keep — but
// the slash/doc-link menus own Enter while open (their blocks run later).
if (
ed &&
!ctx.slashMenuRef.current &&
!ctx.docLinkMenuRef.current &&
!isComposingMarkdownInput(event, ed) &&
handleRichMarkdownTableEnter(ed)
) {
event.preventDefault()
return true
}
}
// Tab/Shift-Tab: indent/outdent lists, insert spaces in code blocks,
// and prevent focus from escaping the editor. When the slash menu or
// doc-link menu is open, Tab selects a row instead (handled in the
// menu blocks below).
// Tab/Shift-Tab: table cell nav first, then list indent/outdent, code-block
// spaces, and prevent focus escaping the editor.
// When the slash menu or doc-link menu is open, Tab selects a row instead
// (handled in the menu blocks below).
if (event.key === 'Tab' && !ctx.slashMenuRef.current && !ctx.docLinkMenuRef.current) {
event.preventDefault()
const ed = ctx.editorRef.current
@@ -178,6 +195,12 @@ export function createRichMarkdownKeyHandler(
}
flushPendingProseMirrorSelection(ed)
// Why: Orca's Tab handler runs before TipTap Table shortcuts and used to
// always sink/lift lists, so table cell Tab/Shift-Tab never fired.
if (!isComposingMarkdownInput(event, ed) && handleRichMarkdownTableTab(ed, event.shiftKey)) {
return true
}
if (event.shiftKey) {
if (!ed.commands.liftListItem('listItem')) {
ed.commands.liftListItem('taskItem')
@@ -0,0 +1,44 @@
import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
import { isInTable, moveCellForward, nextCell, selectionCell } from '@tiptap/pm/tables'
function moveToVerticalNeighbor(editor: Editor, direction: 1 | -1): boolean {
const { state, view } = editor
const $nextCell = nextCell(selectionCell(state), 'vert', direction)
if (!$nextCell) {
return false
}
view.dispatch(
state.tr
.setSelection(TextSelection.between($nextCell, moveCellForward($nextCell)))
.scrollIntoView()
)
return true
}
/**
* Table Enter: move to the cell below instead of inserting an in-cell
* paragraph (GFM cannot keep multi-line table cells). On the last row,
* insert a row and move into it. Returns true when consumed inside a table.
*/
export function handleRichMarkdownTableEnter(editor: Editor): boolean {
if (!isInTable(editor.state)) {
return false
}
if (moveToVerticalNeighbor(editor, 1)) {
return true
}
// Why: last-row Enter grows the table rather than inserting an in-cell hard
// break that GFM serialization cannot keep.
if (!editor.can().addRowAfter()) {
return true
}
editor.commands.addRowAfter()
// Selection stays in the original row; step down into the new one.
moveToVerticalNeighbor(editor, 1)
return true
}
@@ -0,0 +1,271 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { handleRichMarkdownTableBackspace } from './rich-markdown-table-row-delete'
import { handleRichMarkdownTableEnter } from './rich-markdown-table-enter'
import { handleRichMarkdownTableTab } from './rich-markdown-table-tab'
const TABLE = `| A | B |
| --- | --- |
| a1 | b1 |
| a2 | b2 |
`
// Middle body row parses to two empty cells.
const TABLE_WITH_EMPTY_ROW = `| Name | Value |
| --- | --- |
| keep | a |
| | |
| stay | c |
`
function createEditor(content = TABLE): Editor {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content,
contentType: 'markdown'
})
}
function countRows(editor: Editor): number {
let count = 0
editor.state.doc.descendants((node) => {
if (node.type.name === 'tableRow') {
count += 1
}
})
return count
}
function hasTable(editor: Editor): boolean {
let found = false
editor.state.doc.descendants((node) => {
if (node.type.name === 'table') {
found = true
}
return !found
})
return found
}
/** Caret at the start of the text node holding `text`. */
function caretAtText(editor: Editor, text: string): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (!node.isText || node.text !== text) {
return true
}
position = pos
return false
})
if (position === null) {
throw new Error(`Expected cell text: ${text}`)
}
return position
}
/** Caret in the nth cell (0-based) of the first row where `predicate` holds. */
function caretInRow(
editor: Editor,
predicate: (rowText: string) => boolean,
cellIndex = 0
): number {
let position: number | null = null
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== 'tableRow' || !predicate(node.textContent)) {
return true
}
let offset = pos + 1
for (let index = 0; index < cellIndex; index += 1) {
offset += node.child(index).nodeSize
}
// cell open + paragraph open
position = offset + 2
return false
})
if (position === null) {
throw new Error('Expected a matching table row')
}
return position
}
function selectionText(editor: Editor): string {
return editor.state.selection.$from.parent.textContent
}
function withEditor(content: string, run: (editor: Editor) => void): void {
const editor = createEditor(content)
try {
run(editor)
} finally {
editor.destroy()
}
}
describe('handleRichMarkdownTableTab', () => {
it('moves Tab to the next cell', () => {
withEditor(TABLE, (editor) => {
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
expect(handleRichMarkdownTableTab(editor, false)).toBe(true)
expect(selectionText(editor)).toBe('b1')
})
})
it('moves Shift-Tab to the previous cell', () => {
withEditor(TABLE, (editor) => {
editor.commands.setTextSelection(caretAtText(editor, 'b1'))
expect(handleRichMarkdownTableTab(editor, true)).toBe(true)
expect(selectionText(editor)).toBe('a1')
})
})
it('wraps Tab to the next row', () => {
withEditor(TABLE, (editor) => {
editor.commands.setTextSelection(caretAtText(editor, 'b1'))
expect(handleRichMarkdownTableTab(editor, false)).toBe(true)
expect(selectionText(editor)).toBe('a2')
})
})
it('adds a row when Tab is pressed in the last cell', () => {
withEditor(TABLE, (editor) => {
const rowsBefore = countRows(editor)
editor.commands.setTextSelection(caretAtText(editor, 'b2') + 'b2'.length)
expect(handleRichMarkdownTableTab(editor, false)).toBe(true)
expect(countRows(editor)).toBe(rowsBefore + 1)
expect(selectionText(editor)).toBe('')
expect(editor.isActive('table')).toBe(true)
})
})
it('does not claim Tab outside tables', () => {
withEditor('Just a paragraph.\n', (editor) => {
editor.commands.setTextSelection(1)
expect(handleRichMarkdownTableTab(editor, false)).toBe(false)
expect(handleRichMarkdownTableTab(editor, true)).toBe(false)
})
})
})
describe('handleRichMarkdownTableEnter', () => {
it('moves Enter to the cell below', () => {
withEditor(TABLE, (editor) => {
editor.commands.setTextSelection(caretAtText(editor, 'a1'))
expect(handleRichMarkdownTableEnter(editor)).toBe(true)
expect(selectionText(editor)).toBe('a2')
})
})
it('adds a row when Enter is pressed on the last row', () => {
withEditor(TABLE, (editor) => {
const rowsBefore = countRows(editor)
editor.commands.setTextSelection(caretAtText(editor, 'a2'))
expect(handleRichMarkdownTableEnter(editor)).toBe(true)
expect(countRows(editor)).toBe(rowsBefore + 1)
expect(selectionText(editor)).toBe('')
expect(editor.isActive('table')).toBe(true)
})
})
it('does not claim Enter outside tables', () => {
withEditor('Just a paragraph.\n', (editor) => {
editor.commands.setTextSelection(1)
expect(handleRichMarkdownTableEnter(editor)).toBe(false)
})
})
})
describe('handleRichMarkdownTableBackspace', () => {
it('deletes a fully empty row and leaves sibling rows intact', () => {
withEditor(TABLE_WITH_EMPTY_ROW, (editor) => {
expect(countRows(editor)).toBe(4)
editor.commands.setTextSelection(caretInRow(editor, (text) => text.length === 0))
expect(handleRichMarkdownTableBackspace(editor)).toBe(true)
expect(countRows(editor)).toBe(3)
const markdown = editor.getMarkdown()
expect(markdown).toContain('keep')
expect(markdown).toContain('stay')
expect(markdown).toContain('| Name')
expect(hasTable(editor)).toBe(true)
})
})
it('removes the whole table once the last remaining row is deleted', () => {
withEditor('', (editor) => {
editor.commands.insertTable({ rows: 2, cols: 2, withHeaderRow: false })
expect(countRows(editor)).toBe(2)
editor.commands.setTextSelection(caretInRow(editor, () => true))
expect(handleRichMarkdownTableBackspace(editor)).toBe(true)
expect(countRows(editor)).toBe(1)
editor.commands.setTextSelection(caretInRow(editor, () => true))
expect(handleRichMarkdownTableBackspace(editor)).toBe(true)
expect(hasTable(editor)).toBe(false)
})
})
it('keeps an emptied header row while body rows remain', () => {
withEditor(
`| | |
| --- | --- |
| keep | a |
| stay | c |
`,
(editor) => {
const before = editor.getMarkdown()
editor.commands.setTextSelection(caretInRow(editor, (text) => text.length === 0))
// Consumed so ProseMirror cannot merge the table into what precedes it.
expect(handleRichMarkdownTableBackspace(editor)).toBe(true)
expect(countRows(editor)).toBe(3)
expect(editor.getMarkdown()).toBe(before)
}
)
})
it('does not hijack Backspace when the current cell still has content', () => {
withEditor(TABLE_WITH_EMPTY_ROW, (editor) => {
editor.commands.setTextSelection(caretAtText(editor, 'keep'))
expect(handleRichMarkdownTableBackspace(editor)).toBe(false)
expect(countRows(editor)).toBe(4)
})
})
it('steps to the previous cell from an empty cell in a row that has content', () => {
withEditor(TABLE, (editor) => {
// Empty the "b1" cell, leaving "a1" in place.
const cellStart = caretAtText(editor, 'b1')
editor.view.dispatch(editor.state.tr.delete(cellStart, cellStart + 'b1'.length))
editor.commands.setTextSelection(caretInRow(editor, (text) => text === 'a1', 1))
expect(handleRichMarkdownTableBackspace(editor)).toBe(true)
expect(countRows(editor)).toBe(3)
expect(selectionText(editor)).toBe('a1')
})
})
it('does not treat an image-only cell as empty', () => {
withEditor(TABLE_WITH_EMPTY_ROW, (editor) => {
const emptyRowCaret = caretInRow(editor, (text) => text.length === 0, 1)
editor.commands.insertContentAt(emptyRowCaret, {
type: 'image',
attrs: { src: 'shot.png' }
})
editor.commands.setTextSelection(caretInRow(editor, (text) => text.length === 0))
expect(handleRichMarkdownTableBackspace(editor)).toBe(true)
// Row keeps the image cell; Backspace steps back instead of deleting.
expect(countRows(editor)).toBe(4)
})
})
it('does not hijack Backspace outside tables', () => {
withEditor('Just a paragraph.\n', (editor) => {
editor.commands.setTextSelection(1)
expect(handleRichMarkdownTableBackspace(editor)).toBe(false)
})
})
})
@@ -0,0 +1,76 @@
import type { Editor } from '@tiptap/react'
import type { Node as PmNode } from '@tiptap/pm/model'
import { isInTable, selectionCell } from '@tiptap/pm/tables'
/** Why: textContent alone is empty for image/embed-only cells, which do have content. */
function isEmptyCell(cell: PmNode): boolean {
for (let index = 0; index < cell.childCount; index += 1) {
const child = cell.child(index)
if (!child.isTextblock || child.content.size > 0) {
return false
}
}
return true
}
function isHeaderRow(row: PmNode): boolean {
return row.firstChild?.type.spec.tableRole === 'header_cell'
}
function isEmptyRow(row: PmNode): boolean {
for (let index = 0; index < row.childCount; index += 1) {
if (!isEmptyCell(row.child(index))) {
return false
}
}
return row.childCount > 0
}
/**
* Structural Backspace inside tables:
* 1. Fully empty row → delete row (or the table if it was the last row)
* 2. Empty cell in a row that still has content → step to the previous cell
* 3. Otherwise fall through to the default content delete
*/
export function handleRichMarkdownTableBackspace(editor: Editor): boolean {
const { state } = editor
if (!state.selection.empty || !isInTable(state)) {
return false
}
// selectionCell resolves *before* the cell: nodeAfter is the cell, parent the
// row, node(-1) the table.
const $cell = selectionCell(state)
const cell = $cell.nodeAfter
if (!cell || !isEmptyCell(cell)) {
return false
}
// $cell.pos + 2 is the start of the cell's first textblock. A caret past it
// sits in a second empty paragraph, which should join rather than drop a row.
if (state.selection.from !== $cell.pos + 2) {
return false
}
if (!isEmptyRow($cell.parent)) {
// Step back a cell instead of joining across the cell boundary into the
// previous cell's text. Consume either way so ProseMirror never merges the
// table into whatever precedes it.
editor.commands.goToPreviousCell()
return true
}
// Why: prosemirror-tables deleteRow refuses when only one row remains;
// deleteTable is the correct last-row exit.
if ($cell.node(-1).childCount <= 1) {
return editor.commands.deleteTable()
}
// Why: GFM re-synthesizes an empty header on serialize, so deleting the
// header row would vanish from the editor but return on the next reload.
if (isHeaderRow($cell.parent)) {
return true
}
return editor.commands.deleteRow()
}
@@ -0,0 +1,28 @@
import type { Editor } from '@tiptap/react'
/**
* Table Tab: move between cells; Tab past the last cell inserts a row.
* Returns true when the key should be consumed (always inside a table).
*/
export function handleRichMarkdownTableTab(editor: Editor, shiftKey: boolean): boolean {
if (!editor.isActive('table')) {
return false
}
if (shiftKey) {
editor.commands.goToPreviousCell()
return true
}
if (editor.commands.goToNextCell()) {
return true
}
// Why: match TipTap Table shortcuts — last-cell Tab grows the table instead
// of letting focus escape the editor.
if (editor.can().addRowAfter()) {
editor.chain().addRowAfter().goToNextCell().run()
}
return true
}
@@ -0,0 +1,152 @@
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-ordered-list-exit'
// Middle body row starts empty so Backspace can structural-delete without
// relying on Meta+A (which selects the whole document in TipTap).
const TABLE_MARKDOWN = `| Name | Value |
| --- | --- |
| keep | a |
| | |
| stay | c |
`
const SCRATCH_DIR =
process.env.ORCA_TABLE_ROW_BACKSPACE_SCREENSHOT_DIR ??
path.join(process.cwd(), 'test-results', 'table-row-backspace')
async function selectionCellText(page: {
evaluate: (fn: () => string | null) => Promise<string | null>
}): Promise<string | null> {
return page.evaluate(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return null
}
const node = selection.anchorNode
if (!node) {
return null
}
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement
const cell = element?.closest('td, th')
return cell?.textContent?.trim() ?? null
})
}
async function tableRowCount(page: {
evaluate: (fn: () => number) => Promise<number>
}): Promise<number> {
return page.evaluate(() => {
const editorRoot = document.querySelector('.rich-markdown-editor')
if (!editorRoot) {
return -1
}
return editorRoot.querySelectorAll('tr').length
})
}
test.describe('Markdown table keyboard', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('Tab/Shift-Tab move between cells and empty-row Backspace deletes the row', async ({
orcaPage
}, testInfo) => {
const context = await getActiveWorktreeContext(orcaPage)
let filePath: string | null = null
try {
filePath = await createMarkdownFixture(
context,
'table-row-backspace',
testInfo.workerIndex,
TABLE_MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
const editor = await waitForRichMarkdownEditor(orcaPage)
await expect(editor.locator('tr')).toHaveCount(4, { timeout: 10_000 })
await expect(editor.getByText('keep')).toBeVisible()
await expect(editor.getByText('stay')).toBeVisible()
// ── Tab / Shift-Tab cell navigation ────────────────────────────
await editor.getByText('keep').click()
await orcaPage.keyboard.press('Tab')
await expect
.poll(async () => selectionCellText(orcaPage), {
timeout: 5_000,
message: 'Tab should move from keep → a'
})
.toBe('a')
// Next Tab lands in the empty body row (no text).
await orcaPage.keyboard.press('Tab')
await expect
.poll(async () => selectionCellText(orcaPage), {
timeout: 5_000,
message: 'Tab should wrap into the empty body row'
})
.toBe('')
await orcaPage.keyboard.press('Shift+Tab')
await expect
.poll(async () => selectionCellText(orcaPage), {
timeout: 5_000,
message: 'Shift-Tab should return to previous cell (a)'
})
.toBe('a')
// Enter moves down a column, landing in the empty body row.
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => selectionCellText(orcaPage), {
timeout: 5_000,
message: 'Enter should move down into the empty body row'
})
.toBe('')
// ── Empty-row Backspace deletes the whole row ──────────────────
// Enter above already left the caret in the empty body row.
await editor.screenshot({
path: path.join(SCRATCH_DIR, 'electron-table-row-backspace-before.png')
})
await orcaPage.screenshot({
path: path.join(SCRATCH_DIR, 'electron-table-row-backspace-before-window.png')
})
await orcaPage.keyboard.press('Backspace')
await expect
.poll(async () => tableRowCount(orcaPage), {
timeout: 5_000,
message: 'Empty body row should be removed after Backspace'
})
.toBe(3)
await expect(editor.getByText('keep')).toBeVisible()
await expect(editor.getByText('stay')).toBeVisible()
await editor.screenshot({
path: path.join(SCRATCH_DIR, 'electron-table-row-backspace-after.png')
})
await orcaPage.screenshot({
path: path.join(SCRATCH_DIR, 'electron-table-row-backspace-after-window.png')
})
// Hold a beat so the video recording captures the final table state.
await orcaPage.waitForTimeout(800)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
})