mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-26 21:16:45 +00:00
fix(markdown-editor): stop a stale table hover-grip from crashing or misediting
A hover grip captures its cell position once on mousemove and was never invalidated when the document changed. Editing elsewhere (without moving the mouse) then left cellPos pointing past the shifted doc, so two things could happen: RowHeaderToggleItem's useEditorState selector called hasHeaderRow(editor, cellPos) → doc.resolve(pos) → a synchronous RangeError that takes down the whole editor render tree; and a "Delete row/column" ran setTextSelection against the stale offset, editing the wrong line. Guard doc.resolve in hasHeaderRow (an out-of-bounds position has no header row), and extend the grip's existing scroll-invalidation to fire on editor 'update' too, so the target drops on any doc change and reappears on the next hover. Adds the first unit tests for table-commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c87a31f38b
commit
3ea784bc2f
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user