Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder
6e70c7bd6a test(editor): cover refocusEditorAfterMenuClose guard (#269 review F1)
Unit-test the focus-restore guard: an external <input> active -> editor.view.focus
NOT called (deliberate move respected); a non-focusable element active -> focus
called once. Fake editor + fake timers (rAF via setTimeout stub); view.focus is a
spy. Regression lock for the guard that keeps focus out of the page-title input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:02:44 +03:00
agent_coder
2b36997c63 fix(editor): restore editor focus after table menu closes so Ctrl+Z works (closes #269)
The row/column grip and cell-chevron menus are Mantine <Menu>s with
returnFocus:true whose targets live outside the editor's contenteditable. After
a menu action focus returns to that outside target, so ProseMirror's undo keymap
never sees Ctrl+Z until the user clicks back into a cell. Add
refocusEditorAfterMenuClose(editor): on the next frame (after Mantine's
returnFocus) restore editor focus via view.focus(), unless the user intentionally
moved to another input/editable. Wired into both onClose paths (the shared
row/column lifecycle hook + cell-chevron).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:27:21 +03:00
7 changed files with 120 additions and 122 deletions

View File

@@ -1,68 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
// Covers the read-only render branch (PR #278): the language <Select> renders
// only when `editor.isEditable`; in read-only the copy button still shows.
// Mocks mirror the #146 structural harness (footnote-views.structure.test.tsx),
// except Select becomes a detectable node so we can assert its presence/absence.
vi.mock("@tiptap/react", () => ({
NodeViewWrapper: ({ children }: any) => <div>{children}</div>,
NodeViewContent: (props: any) => <div data-node-view-content="" {...props} />,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@mantine/core", () => ({
Group: ({ children }: any) => <div>{children}</div>,
Select: () => <div data-testid="language-select" />,
Tooltip: ({ children }: any) => <>{children}</>,
ActionIcon: ({ children, onClick }: any) => (
<button data-testid="copy-button" onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@/components/common/copy-button", () => ({
CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
}));
vi.mock("@tabler/icons-react", () => ({
IconCheck: () => null,
IconCopy: () => null,
}));
vi.mock("@/features/editor/components/code-block/mermaid-view.tsx", () => ({
default: () => null,
}));
import CodeBlockView from "./code-block-view";
const makeProps = (isEditable: boolean) =>
({
node: { attrs: { language: "javascript" }, textContent: "", nodeSize: 1 },
editor: {
state: { selection: { from: 0, to: 0 } },
isEditable,
commands: {},
on: vi.fn(),
off: vi.fn(),
},
extension: {
options: { lowlight: { listLanguages: () => ["javascript", "python"] } },
},
getPos: () => 0,
updateAttributes: () => {},
deleteNode: () => {},
}) as any;
describe("CodeBlockView language selector visibility (#278)", () => {
it("renders the language selector when the editor is editable", () => {
const { queryByTestId } = render(<CodeBlockView {...makeProps(true)} />);
expect(queryByTestId("language-select")).not.toBeNull();
expect(queryByTestId("copy-button")).not.toBeNull();
});
it("hides the language selector in read-only but keeps the copy button", () => {
const { queryByTestId } = render(<CodeBlockView {...makeProps(false)} />);
expect(queryByTestId("language-select")).toBeNull();
expect(queryByTestId("copy-button")).not.toBeNull();
});
});

View File

@@ -50,10 +50,10 @@ export default function CodeBlockView(props: NodeViewProps) {
{/* #146: the editable <pre><code> (contentDOM) MUST come first in the DOM.
With the non-editable menu rendered before it, the browser's click
hit-testing snapped the caret up one line. Render content first; the
menu is rendered after it and floated into the top-right corner as an
absolute overlay (see `.menuGroup` in code-block.module.css, anchored
to the `position: relative` `.codeBlock` wrapper in code.css). It no
longer takes a full-width row above the code. The second #146
menu is rendered after it and lifted back above visually via flex
`order: -1` (the `.codeBlock` wrapper is a flex column — see
code-block.module.css). It stays fully in flow as a full-width row
above the code: no overlay/absolute positioning. The second #146
mitigation lives in editor-paste-handler.tsx (reflowAfterPaste). */}
<pre
spellCheck="false"
@@ -67,23 +67,22 @@ export default function CodeBlockView(props: NodeViewProps) {
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
<Group contentEditable={false} className={classes.menuGroup}>
{/* In read-only (published) there is no language selector at all —
only the copy button. When editable the selector is hidden until
the block is hovered/focused (or its dropdown is open) via the
`.languageSelect` class (see code-block.module.css). */}
{editor.isEditable && (
<Select
placeholder="auto"
checkIconPosition="right"
data={extension.options.lowlight.listLanguages().sort()}
value={languageValue}
onChange={changeLanguage}
searchable
style={{ maxWidth: "130px" }}
classNames={{ root: classes.languageSelect, input: classes.selectInput }}
/>
)}
<Group
justify="flex-end"
contentEditable={false}
className={classes.menuGroup}
>
<Select
placeholder="auto"
checkIconPosition="right"
data={extension.options.lowlight.listLanguages().sort()}
value={languageValue}
onChange={changeLanguage}
searchable
style={{ maxWidth: "130px" }}
classNames={{ input: classes.selectInput }}
disabled={!editor.isEditable}
/>
<CopyButton value={node?.textContent} timeout={2000}>
{({ copied, copy }) => (

View File

@@ -17,37 +17,15 @@
justify-content: center;
}
/* #146: the menu follows the <pre> in the DOM (so the editable contentDOM is
FIRST and click hit-testing is correct). Instead of sitting in-flow, it is
floated into the top-right corner as an absolute overlay anchored to the
`position: relative` .codeBlock wrapper (see code.css), so it no longer
takes a full-width row above the code. The Mantine dropdown is portaled, so
it is never clipped by the overlay. */
/* #146: the menu now follows the <pre> in the DOM (so the editable contentDOM is
FIRST and click hit-testing is correct). Lift it back ABOVE the code visually
with flex `order` — the .codeBlock wrapper is a flex column (see code.css) —
so the menu still reads as a row above the code, exactly as before, without
sitting in-flow before the contentDOM. */
.menuGroup {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
gap: 4px;
order: -1;
@media print {
display: none;
}
}
/* The language selector is hidden until the block is hovered, or the selector
itself is focused / its dropdown is open. It keeps its width in the flex
Group (only opacity toggles) so the copy button never jumps, and
`pointer-events: none` while hidden lets clicks fall through to the code.
`.codeBlock` is the global NodeViewWrapper class → use :global(). */
.languageSelect {
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease;
}
:global(.codeBlock):hover .languageSelect,
.languageSelect:focus-within {
opacity: 1;
pointer-events: auto;
}

View File

@@ -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;

View File

@@ -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 <input> 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 <input> was blurred.
(document.activeElement as HTMLElement | null)?.blur();
expect(document.activeElement).toBe(document.body);
refocusEditorAfterMenuClose(editor);
vi.runAllTimers();
expect(focus).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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 `<Menu>`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 };

View File

@@ -1,12 +1,9 @@
.ProseMirror {
.codeBlock {
/* #146: flex column keeps the editable <pre> (first in the DOM so click
hit-testing is correct) laid out above any Mermaid diagram. `position:
relative` anchors the control panel, which is floated into the top-right
corner as an absolute overlay (see `.menuGroup` in code-block.module.css). */
/* #146: flex column so the menu (rendered AFTER <pre> in the DOM, so the
editable contentDOM is first) is lifted back above the code via `order`. */
display: flex;
flex-direction: column;
position: relative;
padding: 4px;
border-radius: var(--mantine-radius-default);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));