mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(editor): richer rich-markdown table keyboard (Tab, Enter, Backspace) (#11724)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user