diff --git a/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx b/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx index db79844e..ebac82dd 100644 --- a/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx +++ b/apps/client/src/features/editor/components/table/handle/cell-chevron.tsx @@ -11,6 +11,7 @@ import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { isCellSelection } from "@docmost/editor-ext"; import { CellChevronMenu } from "./menus/cell-chevron-menu"; +import { refocusEditorAfterMenuClose } from "./hooks/use-column-row-menu-lifecycle"; import classes from "./handle.module.css"; interface CellChevronProps { @@ -87,6 +88,7 @@ export const CellChevron = React.memo(function CellChevron({ const onClose = useCallback(() => { editor.commands.unfreezeHandles(); + refocusEditorAfterMenuClose(editor); }, [editor]); if (!cellDom) return null; diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.test.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.test.ts new file mode 100644 index 00000000..ed478dee --- /dev/null +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Editor } from "@tiptap/react"; +import { refocusEditorAfterMenuClose } from "./use-column-row-menu-lifecycle"; + +// A minimal fake editor. `view.dom` is a real element so `.contains()` works, +// and `view.focus` is a spy so we assert on it without relying on real DOM +// focus (unreliable in jsdom). rAF is stubbed to a `setTimeout(0)` so fake +// timers can flush the deferred callback deterministically. +function makeEditor() { + const dom = document.createElement("div"); + document.body.appendChild(dom); + const focus = vi.fn(); + const editor = { isDestroyed: false, view: { dom, focus } }; + return { editor: editor as unknown as Editor, focus, dom }; +} + +describe("refocusEditorAfterMenuClose", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => + setTimeout(() => cb(0), 0), + ); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("(a) does not refocus the editor when an external is active", () => { + const { editor, focus } = makeEditor(); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + expect(document.activeElement).toBe(input); + + refocusEditorAfterMenuClose(editor); + vi.runAllTimers(); + + expect(focus).not.toHaveBeenCalled(); + }); + + it("(b) refocuses the editor when a non-focusable element (body) is active", () => { + const { editor, focus } = makeEditor(); + // Ensure focus rests on body: nothing is focused / an was blurred. + (document.activeElement as HTMLElement | null)?.blur(); + expect(document.activeElement).toBe(document.body); + + refocusEditorAfterMenuClose(editor); + vi.runAllTimers(); + + expect(focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts index a3059559..100750bf 100644 --- a/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts +++ b/apps/client/src/features/editor/components/table/handle/hooks/use-column-row-menu-lifecycle.ts @@ -11,6 +11,39 @@ interface Args { tablePos: number; } +/** + * Restore focus to the editor after a table handle/cell menu closes. + * + * The grip/chevron menus are Mantine ``s with `returnFocus: true`, and + * their targets live in a floating/portaled layer OUTSIDE the editor's + * contenteditable. After an action (delete row/column, insert, etc.) the menu + * closes and Mantine returns focus to that outside target, so ProseMirror's + * undo keymap never sees Ctrl+Z until the user clicks back into a cell. + * + * We defer with `requestAnimationFrame` so this runs AFTER Mantine's + * returnFocus, and guard against stealing focus if the user intentionally + * moved to another input/editable (e.g. the page title). + */ +export function refocusEditorAfterMenuClose(editor: Editor) { + requestAnimationFrame(() => { + if (editor.isDestroyed) return; + const active = document.activeElement as HTMLElement | null; + // Already inside the editor — nothing to do. + if (active && editor.view.dom.contains(active)) return; + // Respect a deliberate move to another field/editable. + const tag = active?.tagName; + if ( + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + active?.isContentEditable + ) { + return; + } + editor.view.focus(); // pure DOM focus, no extra transaction + }); +} + export function useColumnRowMenuLifecycle({ editor, orientation, @@ -34,6 +67,7 @@ export function useColumnRowMenuLifecycle({ const onClose = useCallback(() => { editor.commands.unfreezeHandles(); + refocusEditorAfterMenuClose(editor); }, [editor]); return { onOpen, onClose };