diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index a78f1b103fd..26847626853 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -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', diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index b0a223887b5..358d236e1eb 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -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') diff --git a/src/renderer/src/components/editor/rich-markdown-table-enter.ts b/src/renderer/src/components/editor/rich-markdown-table-enter.ts new file mode 100644 index 00000000000..c3ee2aa45aa --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-enter.ts @@ -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 +} diff --git a/src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts b/src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts new file mode 100644 index 00000000000..591e175ff3b --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-keyboard.test.ts @@ -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) + }) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-table-row-delete.ts b/src/renderer/src/components/editor/rich-markdown-table-row-delete.ts new file mode 100644 index 00000000000..f2a78af8bee --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-row-delete.ts @@ -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() +} diff --git a/src/renderer/src/components/editor/rich-markdown-table-tab.ts b/src/renderer/src/components/editor/rich-markdown-table-tab.ts new file mode 100644 index 00000000000..082b09dfa54 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-table-tab.ts @@ -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 +} diff --git a/tests/e2e/markdown-table-row-backspace.spec.ts b/tests/e2e/markdown-table-row-backspace.spec.ts new file mode 100644 index 00000000000..6a30f81f9f7 --- /dev/null +++ b/tests/e2e/markdown-table-row-backspace.spec.ts @@ -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 +}): Promise { + 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 +}): Promise { + 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) + } + }) +})