diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.test.ts new file mode 100644 index 00000000..36b95520 --- /dev/null +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.test.ts @@ -0,0 +1,52 @@ +import { Editor } from '@tiptap/core'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { createMarkdownExtensions } from './markdown-editor-extensions'; +import { hasHeaderRow } from './markdown-editor-table-commands'; +import { setupEditorJsdom } from './markdown-editor-test-setup'; + +beforeAll(setupEditorJsdom); + +const makeEditor = (content: string) => + new Editor({ content, contentType: 'markdown', extensions: createMarkdownExtensions() }); + +const firstNodePos = (editor: Editor, typeName: string): number => { + let found = -1; + + editor.state.doc.descendants((node, pos) => { + if (found === -1 && node.type.name === typeName) { + found = pos; + } + }); + + return found; +}; + +describe('hasHeaderRow', () => { + it('detects a header row from a cell position inside the table', () => { + const editor = makeEditor('| h | i |\n| --- | --- |\n| a | b |'); + const headerCell = firstNodePos(editor, 'tableHeader'); + + expect(hasHeaderRow(editor, headerCell + 1)).toBe(true); + + editor.destroy(); + }); + + it('returns false for a position outside any table', () => { + const editor = makeEditor('just a paragraph'); + + expect(hasHeaderRow(editor, 1)).toBe(false); + + editor.destroy(); + }); + + it('returns false (never throws) for a stale position past the doc — a grip that outlived a shrinking edit', () => { + const editor = makeEditor('| h | i |\n| --- | --- |\n| a | b |'); + const stalePos = editor.state.doc.content.size + 100; + + expect(() => hasHeaderRow(editor, stalePos)).not.toThrow(); + expect(hasHeaderRow(editor, stalePos)).toBe(false); + + editor.destroy(); + }); +}); diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.ts index 2f2bde78..157133f3 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-commands.ts @@ -39,7 +39,16 @@ export function clearLineContents(editor: Editor, axis: 'column' | 'row'): void // caret; pass `pos` to read a specific cell's table instead — the hover grips open without moving the selection, // so they must resolve the hovered table by position rather than the caret's last table. export function hasHeaderRow(editor: Editor, pos?: number): boolean { - const $pos = pos == null ? editor.state.selection.$from : editor.state.doc.resolve(pos); + const { doc, selection } = editor.state; + + // A hover grip can outlive the edit that shrank the doc below its captured cellPos. doc.resolve throws a + // RangeError on an out-of-bounds position, and this runs inside a useEditorState selector, so an unguarded + // throw takes down the whole editor render tree. A stale position has no header row to report. + if (pos != null && pos > doc.content.size) { + return false; + } + + const $pos = pos == null ? selection.$from : doc.resolve(pos); for (let depth = $pos.depth; depth > 0; depth--) { if ($pos.node(depth).type.name === 'table') { diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-table-handles.tsx b/frontend/src/components/shared/markdown-editor/markdown-editor-table-handles.tsx index 3e5a7ffa..09cefa6f 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-table-handles.tsx +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-table-handles.tsx @@ -338,22 +338,27 @@ function useTableHandles(editor: Editor): TableHandlesController { document.addEventListener('mousemove', handleMove); - // Fixed-positioned grips go stale on scroll — drop them (they reappear on the next hover). + // A grip captured before a scroll or an edit points at a now-shifted position. Scroll moves it off the + // table visually; a doc edit shifts every position after it, so a menu action would resolve the stale + // cellPos against the current doc (wrong row/column, or an out-of-bounds RangeError in the header + // selector). Drop the target in both cases — it reappears on the next hover. const scrollParent = dom.closest('.tiptap-content') ?? window; - const handleScroll = () => { + const dropStaleTarget = () => { if (!openRef.current) { cellPosRef.current = null; setTarget(null); } }; - scrollParent.addEventListener('scroll', handleScroll, { passive: true }); + scrollParent.addEventListener('scroll', dropStaleTarget, { passive: true }); + editor.on('update', dropStaleTarget); return () => { cancelClear(); document.removeEventListener('mousemove', handleMove); - scrollParent.removeEventListener('scroll', handleScroll); + scrollParent.removeEventListener('scroll', dropStaleTarget); + editor.off('update', dropStaleTarget); }; }, [editor]);