Compare commits

..

1 Commits

Author SHA1 Message Date
agent_coder
703b883165 feat(temp-notes): add 'Move to trash' button to the temporary-note banner (closes #273)
The banner only offered 'Make permanent'. Add a secondary destructive
'Move to trash' button that soft-deletes the note now instead of waiting for
TTL expiry, reusing the tree/header soft-delete path (useTreeMutation.handleDelete):
optimistic tree removal, the undo-toast, the deletedAt cache stamp, and the
redirect to space home. No confirm modal (project convention = undo-toast).
Gated on the existing Edit permission. Client-only, no server/i18n changes
(both labels already exist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:20:01 +03:00
4 changed files with 40 additions and 103 deletions

View File

@@ -11,7 +11,6 @@ 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 {
@@ -88,7 +87,6 @@ export const CellChevron = React.memo(function CellChevron({
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
refocusEditorAfterMenuClose(editor);
}, [editor]);
if (!cellDom) return null;

View File

@@ -1,56 +0,0 @@
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,39 +11,6 @@ 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,
@@ -67,7 +34,6 @@ export function useColumnRowMenuLifecycle({
const onClose = useCallback(() => {
editor.commands.unfreezeHandles();
refocusEditorAfterMenuClose(editor);
}, [editor]);
return { onOpen, onClose };

View File

@@ -1,8 +1,10 @@
import { Button, Group, Paper, Text } from "@mantine/core";
import { IconClockHour4 } from "@tabler/icons-react";
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
import { useState } from "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,
@@ -31,6 +33,11 @@ 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.
@@ -38,6 +45,16 @@ 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({
@@ -70,16 +87,28 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
</Text>
</Group>
{canEdit && (
<Button
size="xs"
variant="light"
color="orange"
leftSection={<IconClockHour4 size={16} />}
onClick={handleMakePermanent}
loading={toggleTemporary.isPending}
>
{t("Make permanent")}
</Button>
<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>
)}
</Group>
</Paper>