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
7 changed files with 41 additions and 98 deletions

View File

@@ -12,13 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. Unlike the
float modes, text does not wrap around inline images. The mode round-trips
losslessly through markdown as `data-align`, like the other alignment
values.
- **Editable captions for images.** Images gain an optional caption shown
below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip
losslessly through markdown as a `data-caption` attribute on the image, so

View File

@@ -1322,7 +1322,6 @@
"Move to space": "Move to space",
"Float left (wrap text)": "Float left (wrap text)",
"Float right (wrap text)": "Float right (wrap text)",
"Inline (side by side)": "Inline (side by side)",
"Switch to tree": "Switch to tree",
"Switch to flat list": "Switch to flat list",
"Toggle subpages display mode": "Toggle subpages display mode",

View File

@@ -1175,7 +1175,6 @@
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
"Float left (wrap text)": "Обтекание слева",
"Float right (wrap text)": "Обтекание справа",
"Inline (side by side)": "В ряд",
"Switch to tree": "Переключить на дерево",
"Switch to flat list": "Переключить на плоский список",
"Toggle subpages display mode": "Переключить режим отображения подстраниц",

View File

@@ -15,7 +15,6 @@ import {
IconLayoutAlignRight,
IconFloatLeft,
IconFloatRight,
IconLayoutColumns,
IconDownload,
IconRefresh,
IconTrash,
@@ -47,7 +46,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
isInline: ctx.editor.isActive("image", { align: "inline" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
caption: imageAttrs?.caption || "",
@@ -128,14 +126,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
const alignImageInline = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.setImageAlign("inline")
.run();
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -269,18 +259,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Inline (side by side)")} withinPortal={false}>
<ActionIcon
onClick={alignImageInline}
size="lg"
aria-label={t("Inline (side by side)")}
variant="subtle"
className={clsx({ [classes.active]: editorState?.isInline })}
>
<IconLayoutColumns size={18} />
</ActionIcon>
</Tooltip>
<div className={classes.divider} />
{altTextButton}

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>

View File

@@ -63,38 +63,6 @@ describe("applyAlignment", () => {
expect(el.dataset.imageAlign).toBe("center");
});
it("inline -> inline-block + top alignment + gap padding, no float", () => {
applyAlignment(el, "inline");
expect(el.style.display).toBe("inline-block");
expect(el.style.verticalAlign).toBe("top");
expect(el.style.padding).toBe("0px 10px 10px 0px");
expect(el.dataset.imageAlign).toBe("inline");
expect(el.style.cssFloat).toBe("");
});
it("clears inline-block when switching inline -> center (reset-then-apply)", () => {
applyAlignment(el, "inline");
expect(el.style.display).toBe("inline-block");
// Switching back to a flex alignment must replace the inline-block
// override with the constructor-style flex, not just clear it.
applyAlignment(el, "center");
expect(el.style.display).toBe("flex");
expect(el.style.verticalAlign).toBe("");
expect(el.style.padding).toBe("");
expect(el.dataset.imageAlign).toBe("center");
expect(el.style.justifyContent).toBe("center");
});
it("clears a previous float when switching floatLeft -> inline", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");
applyAlignment(el, "inline");
expect(el.style.cssFloat).toBe("");
expect(el.style.display).toBe("inline-block");
expect(el.style.verticalAlign).toBe("top");
expect(el.dataset.imageAlign).toBe("inline");
});
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
applyAlignment(el, "floatLeft");
expect(el.style.cssFloat).toBe("left");

View File

@@ -53,13 +53,7 @@ declare module "@tiptap/core" {
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (
align:
| "left"
| "center"
| "right"
| "floatLeft"
| "floatRight"
| "inline",
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
) => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
@@ -421,14 +415,6 @@ export function applyAlignment(container: HTMLElement, align: string) {
// (a previous float must not leak into a later left/center/right).
container.style.cssFloat = "";
container.style.padding = "";
// The ResizableNodeView constructor sets an inline `display: flex` on the
// container; the inline mode overrides it with `inline-block`, so the reset
// restores the constructor's flex here. This keeps the container's layout
// independent of any app-level CSS class (which also happens to set flex)
// and makes non-inline modes carry exactly the same inline styles as before
// the inline mode existed.
container.style.display = "flex";
container.style.verticalAlign = "";
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
// responsive stylesheet can neutralize the float on small screens (an inline
// `float` can only be overridden by `!important`, which keys off this attr).
@@ -444,15 +430,6 @@ export function applyAlignment(container: HTMLElement, align: string) {
container.style.cssFloat = "right";
container.style.padding = "0 0 0 10px";
container.style.justifyContent = "flex-end";
} else if (align === "inline") {
// Consecutive inline images sit side by side on one line box and wrap to
// the next line when the viewport is narrow. The right/bottom padding
// provides the gap between images in a row and between wrapped rows;
// vertical-align: top keeps rows of different-height images aligned by
// their top edge.
container.style.display = "inline-block";
container.style.verticalAlign = "top";
container.style.padding = "0 10px 10px 0";
} else if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {