refactor(ui): unify context menus into a shared menu system
Five hand-rolled context menu implementations (image tiles, sidebar folders, sidebar albums, title bar theme switcher, plus duplicated close-listener effects in Gallery/Timeline) are replaced by shared primitives in src/components/menu/: - useDismissable: one outside-pointerdown + Escape dismissal hook - Menu.tsx: MenuPanel chrome, MenuItem (danger/disabled/checked/hint), MenuSeparator, MenuLabel, and SubMenu with viewport edge-flipping - ContextMenu: portal-rendered wrapper that measures and clamps to the viewport, fixing menus rendering off-screen and the latent fixed-inside-transform bug under framer-motion Reorder items The image right-click menu moves to ImageContextMenu.tsx, shared by Gallery and Timeline.
This commit is contained in:
+2
-105
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } fr
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
|
||||
import { BulkActionBar } from "./BulkActionBar";
|
||||
import { ImageContextMenu } from "./ImageContextMenu";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { mediaSrc } from "../lib/mediaSrc";
|
||||
|
||||
@@ -19,94 +20,6 @@ function formatDuration(durationMs: number | null): string | null {
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
image,
|
||||
onClose,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
image: ImageRecord;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const openImage = useGalleryStore((state) => state.openImage);
|
||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||
const findSimilar = useGalleryStore((state) => state.findSimilar);
|
||||
const canFindSimilar = image.embedding_status === "ready";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-gallery-context-menu
|
||||
className="fixed z-40 min-w-52 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur"
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
|
||||
onClick={() => { openImage(image); onClose(); }}
|
||||
>
|
||||
Open Preview
|
||||
</button>
|
||||
<button
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
|
||||
onClick={async () => { await updateImageDetails(image.id, { favorite: !image.favorite }); onClose(); }}
|
||||
>
|
||||
{image.favorite ? "Remove Favorite" : "Add to Favorites"}
|
||||
</button>
|
||||
<button
|
||||
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
canFindSimilar
|
||||
? "text-gray-200 hover:bg-white/5 hover:text-white"
|
||||
: "text-gray-600 cursor-not-allowed"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!canFindSimilar) return;
|
||||
findSimilar(image.id, image.folder_id);
|
||||
onClose();
|
||||
}}
|
||||
disabled={!canFindSimilar}
|
||||
>
|
||||
{canFindSimilar ? "Find Similar" : "Embeddings not ready"}
|
||||
</button>
|
||||
<div className="my-1 h-px bg-white/[0.06]" />
|
||||
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.18em] text-gray-600">Rating</div>
|
||||
<div className="flex items-center gap-0.5 px-2 pb-1.5">
|
||||
{Array.from({ length: 5 }, (_, index) => {
|
||||
const rating = index + 1;
|
||||
return (
|
||||
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
|
||||
<button
|
||||
className="rounded-md p-1 transition-colors hover:bg-white/5"
|
||||
onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }}
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
|
||||
fill="currentColor" viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{image.rating > 0 ? (
|
||||
<Tooltip label="Remove rating" followCursor>
|
||||
<button
|
||||
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors"
|
||||
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageTile({
|
||||
image,
|
||||
onClick,
|
||||
@@ -395,22 +308,6 @@ export function Gallery() {
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
|
||||
setContextMenu(null);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setContextMenu(null);
|
||||
};
|
||||
window.addEventListener("pointerdown", close);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", close);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
|
||||
@@ -515,7 +412,7 @@ export function Gallery() {
|
||||
) : null}
|
||||
|
||||
{contextMenu ? (
|
||||
<ContextMenu
|
||||
<ImageContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
image={contextMenu.image}
|
||||
|
||||
Reference in New Issue
Block a user