Compare commits
2 Commits
feat/273-t
...
fix/269-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e70c7bd6a | ||
|
|
2b36997c63 |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { IconClockHour4 } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
@@ -33,11 +31,6 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
const toggleTemporary = useToggleTemporaryMutation();
|
||||
// Reuse the exact soft-delete path the tree/header menus use: optimistic
|
||||
// tree removal, the "Page moved to trash" undo-toast, the deletedAt cache
|
||||
// stamp, and the redirect to space home (which unmounts this banner).
|
||||
const { handleDelete: trashPage } = useTreeMutation(page?.spaceId ?? "");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Don't show on a note that is already in trash; the deleted-page banner
|
||||
// owns that state.
|
||||
@@ -45,16 +38,6 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
|
||||
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
|
||||
|
||||
const handleTrashNow = async () => {
|
||||
// No confirm modal by convention — the undo-toast is the safety net.
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await trashPage(page.id);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakePermanent = async () => {
|
||||
try {
|
||||
const res = await toggleTemporary.mutateAsync({
|
||||
@@ -87,28 +70,16 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
Reference in New Issue
Block a user