From 83081928f676f58d6a73df3dd3e0cbc0c25c2d9d Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 3 Jul 2026 23:54:28 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 3 + src/components/Gallery.tsx | 107 +------------- src/components/ImageContextMenu.tsx | 70 +++++++++ src/components/Sidebar.tsx | 187 ++++-------------------- src/components/Timeline.tsx | 21 +-- src/components/TitleBar.tsx | 57 ++------ src/components/menu/ContextMenu.tsx | 67 +++++++++ src/components/menu/Menu.tsx | 198 ++++++++++++++++++++++++++ src/components/menu/index.ts | 4 + src/components/menu/useDismissable.ts | 28 ++++ 10 files changed, 414 insertions(+), 328 deletions(-) create mode 100644 src/components/ImageContextMenu.tsx create mode 100644 src/components/menu/ContextMenu.tsx create mode 100644 src/components/menu/Menu.tsx create mode 100644 src/components/menu/index.ts create mode 100644 src/components/menu/useDismissable.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7e5b6..b4a310a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed +- **Consistent right-click menus** — the folder, album, image, and theme menus + now share one look and behavior: they stay inside the window instead of + spilling off the edge, close on Escape, and support proper submenus. - **Neater lightbox details** — image and video metadata now sits in two columns, so the info panel shows more at a glance with less scrolling. - **Faster Explore revisits** — returning to a folder's visual clusters should diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 85bc8f3..fd70c86 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -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 ( -
event.stopPropagation()} - > - - - -
-
Rating
-
- {Array.from({ length: 5 }, (_, index) => { - const rating = index + 1; - return ( - - - - ); - })} - {image.rating > 0 ? ( - - - - ) : null} -
-
- ); -} - 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 (
@@ -515,7 +412,7 @@ export function Gallery() { ) : null} {contextMenu ? ( - 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 ( + + openImage(image)} /> + void updateImageDetails(image.id, { favorite: !image.favorite })} + /> + findSimilar(image.id, image.folder_id)} + /> + + Rating +
+ {Array.from({ length: 5 }, (_, index) => { + const rating = index + 1; + return ( + + + + ); + })} + {image.rating > 0 ? ( + + + + ) : null} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cfe87cc..95a3031 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -3,88 +3,13 @@ import { Reorder, useDragControls } from "framer-motion"; import { open } from "@tauri-apps/plugin-dialog"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; import { ThemedDropdown } from "./ThemedDropdown"; +import { ContextMenu, MenuItem, MenuSeparator } from "./menu"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; -interface ContextMenuState { - folderId: number; - x: number; - y: number; -} - type LibrarySort = "az" | "za" | "custom"; const LIBRARY_SORT_KEY = "phokus-library-sort"; -function FolderContextMenu({ - menu, - folder, - isMuted, - isPausedAll, - onClose, - onRename, - onReindex, - onLocate, - onToggleMute, - onTogglePauseAll, - onRemove, -}: { - menu: ContextMenuState; - folder: Folder; - isMuted: boolean; - isPausedAll: boolean; - onClose: () => void; - onRename: () => void; - onReindex: () => void; - onLocate: () => void; - onToggleMute: () => void; - onTogglePauseAll: () => void; - onRemove: () => void; -}) { - const ref = useRef(null); - - useEffect(() => { - const handleDown = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; - document.addEventListener("mousedown", handleDown); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleDown); - document.removeEventListener("keydown", handleKey); - }; - }, [onClose]); - - const item = (label: string, onClick: () => void, danger = false) => ( - - ); - - return ( -
- {item("Reindex", onReindex)} - {item("Rename", onRename)} - {item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)} - {item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)} - {folder.scan_error && item("Locate Folder", onLocate)} -
- {item("Remove from app", onRemove, true)} -
- ); -} - function FolderItem({ folder, selected, @@ -116,7 +41,7 @@ function FolderItem({ const isIndexing = progress && !progress.done; const isMissing = !!folder.scan_error && !isIndexing; - const [contextMenu, setContextMenu] = useState(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(folder.name); const [confirmingRemoval, setConfirmingRemoval] = useState(false); @@ -132,10 +57,7 @@ function FolderItem({ const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - // Keep menu inside viewport - const x = Math.min(e.clientX, window.innerWidth - 180); - const y = Math.min(e.clientY, window.innerHeight - 160); - setContextMenu({ folderId: folder.id, x, y }); + setContextMenu({ x: e.clientX, y: e.clientY }); }; const handleLocateFolder = async () => { @@ -325,84 +247,27 @@ function FolderItem({
)} - {contextMenu && contextMenu.folderId === folder.id && ( - setContextMenu(null)} - onRename={() => setRenaming(true)} - onReindex={() => void reindexFolder(folder.id)} - onLocate={() => void handleLocateFolder()} - onToggleMute={() => toggleMutedFolder(folder.id)} - onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)} - onRemove={() => setConfirmingRemoval(true)} - /> + {contextMenu && ( + setContextMenu(null)}> + void reindexFolder(folder.id)} /> + setRenaming(true)} /> + setAllWorkersPaused(folder.id, !isPausedAll)} + /> + toggleMutedFolder(folder.id)} + /> + {folder.scan_error ? void handleLocateFolder()} /> : null} + + setConfirmingRemoval(true)} /> + )} ); } -function AlbumContextMenu({ - x, - y, - onClose, - onRename, - onDelete, -}: { - x: number; - y: number; - onClose: () => void; - onRename: () => void; - onDelete: () => void; -}) { - const ref = useRef(null); - useEffect(() => { - const handleDown = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("mousedown", handleDown); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleDown); - document.removeEventListener("keydown", handleKey); - }; - }, [onClose]); - - const item = (label: string, onClick: () => void, danger = false) => ( - - ); - - return ( -
- {item("Rename", onRename)} -
- {item("Delete album", onDelete, true)} -
- ); -} - function AlbumItem({ album, manageMode = false, @@ -485,7 +350,7 @@ function AlbumItem({ if (manageMode) return; e.preventDefault(); e.stopPropagation(); - setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) }); + setMenu({ x: e.clientX, y: e.clientY }); }} > {/* Manage-mode selection checkbox */} @@ -578,13 +443,11 @@ function AlbumItem({ ) : null} {menu ? ( - setMenu(null)} - onRename={() => setRenaming(true)} - onDelete={() => setConfirmingRemoval(true)} - /> + setMenu(null)}> + setRenaming(true)} /> + + setConfirmingRemoval(true)} /> + ) : null}
); diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index b150751..80b3137 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; -import { ContextMenu, ImageTile } from "./Gallery"; +import { ImageTile } from "./Gallery"; +import { ImageContextMenu } from "./ImageContextMenu"; import { Tooltip } from "./Tooltip"; const GAP = 6; @@ -204,22 +205,6 @@ export function Timeline() { return () => el.removeEventListener("scroll", handleScroll); }, [handleScroll]); - useEffect(() => { - const close = (e: PointerEvent) => { - if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; - setContextMenu(null); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setContextMenu(null); - }; - window.addEventListener("pointerdown", close); - window.addEventListener("keydown", onKey); - return () => { - window.removeEventListener("pointerdown", close); - window.removeEventListener("keydown", onKey); - }; - }, []); - const scrollToGroup = useCallback( (groupIndex: number) => { const row = groupFirstRowRef.current[groupIndex] ?? 0; @@ -353,7 +338,7 @@ export function Timeline() { ) : null} {contextMenu ? ( - (null); - const themeMenuRef = useRef(null); - const [themeMenu, setThemeMenu] = useState<{ top: number; right: number } | null>(null); - - useEffect(() => { - if (!themeMenu) return; - const handleDown = (e: MouseEvent) => { - if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) setThemeMenu(null); - }; - const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") setThemeMenu(null); }; - document.addEventListener("mousedown", handleDown); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleDown); - document.removeEventListener("keydown", handleKey); - }; - }, [themeMenu]); + const [themeMenu, setThemeMenu] = useState<{ x: number; y: number } | null>(null); const handleSettingsContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const rect = settingsBtnRef.current?.getBoundingClientRect(); if (!rect) return; - setThemeMenu({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + setThemeMenu({ x: rect.right, y: rect.bottom + 4 }); }; useEffect(() => { @@ -188,32 +174,17 @@ export function TitleBar() { {/* Quick theme switcher — opened by right-clicking the settings cog. */} {themeMenu && ( -
-
Theme
- {THEME_OPTIONS.map((opt) => { - const active = theme === opt.value; - return ( - - ); - })} -
+ setThemeMenu(null)}> + Theme + {THEME_OPTIONS.map((opt) => ( + setTheme(opt.value)} + /> + ))} + )}
); diff --git a/src/components/menu/ContextMenu.tsx b/src/components/menu/ContextMenu.tsx new file mode 100644 index 0000000..2a01698 --- /dev/null +++ b/src/components/menu/ContextMenu.tsx @@ -0,0 +1,67 @@ +import { ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { MenuCloseContext, MenuPanel, MenuSize } from "./Menu"; +import { useDismissable } from "./useDismissable"; + +/** + * Positioned floating menu. Renders through a portal so it is never caught + * inside transformed ancestors (framer-motion drag/layout items), and clamps + * itself to the viewport after measuring its real size. + * + * `align="start"` puts the panel's top-left at (x, y) — right-click menus. + * `align="end"` puts the top-right at (x, y) — menus anchored under a + * right-edge button. + */ +export function ContextMenu({ + x, + y, + onClose, + size = "md", + align = "start", + className, + children, +}: { + x: number; + y: number; + onClose: () => void; + size?: MenuSize; + align?: "start" | "end"; + className?: string; + children: ReactNode; +}) { + const ref = useRef(null); + const [pos, setPos] = useState<{ left: number; top: number } | null>(null); + + useDismissable(ref, onClose); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const margin = 8; + const rect = el.getBoundingClientRect(); + const desiredLeft = align === "end" ? x - rect.width : x; + setPos({ + left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - rect.width - margin)), + top: Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)), + }); + }, [x, y, align]); + + return createPortal( + , + document.body, + ); +} diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx new file mode 100644 index 0000000..ae44ab5 --- /dev/null +++ b/src/components/menu/Menu.tsx @@ -0,0 +1,198 @@ +import { + ReactNode, + createContext, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +export type MenuSize = "sm" | "md"; + +/** Provided by ContextMenu so any nested MenuItem (submenus included) can close the whole menu. */ +export const MenuCloseContext = createContext<() => void>(() => {}); +const MenuSizeContext = createContext("md"); + +function itemClass(size: MenuSize, danger: boolean, disabled: boolean): string { + const base = + size === "sm" + ? "w-full rounded-md px-3 py-1.5 text-[12px]" + : "w-full rounded-lg px-3 py-2 text-sm"; + const tone = disabled + ? "text-gray-600 cursor-not-allowed" + : danger + ? "text-red-400 hover:bg-red-500/15 hover:text-red-300" + : "text-gray-200 hover:bg-white/[0.06] hover:text-white"; + return `${base} ${tone} transition-colors`; +} + +/** + * The panel chrome shared by every menu surface. Size defaults to the + * enclosing menu's size so submenu panels match their parent automatically. + */ +export function MenuPanel({ + size, + className = "", + children, +}: { + size?: MenuSize; + className?: string; + children: ReactNode; +}) { + const inherited = useContext(MenuSizeContext); + const resolved = size ?? inherited; + return ( + +
+ {children} +
+
+ ); +} + +export function MenuItem({ + label, + onSelect, + danger = false, + disabled = false, + checked, + hint, + keepOpen = false, +}: { + label: ReactNode; + onSelect?: () => void; + danger?: boolean; + disabled?: boolean; + /** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */ + checked?: boolean; + hint?: ReactNode; + /** Skip the automatic menu close after selecting. */ + keepOpen?: boolean; +}) { + const size = useContext(MenuSizeContext); + const close = useContext(MenuCloseContext); + return ( + + ); +} + +export function MenuSeparator() { + return
; +} + +export function MenuLabel({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +/** + * A nested menu panel that opens beside its row on hover (or click, for + * touch). Flips to the left edge and shifts up when it would leave the + * viewport. Items inside close the entire menu tree via MenuCloseContext. + */ +export function SubMenu({ + label, + disabled = false, + children, + panelClassName = "", +}: { + label: ReactNode; + disabled?: boolean; + children: ReactNode; + panelClassName?: string; +}) { + const size = useContext(MenuSizeContext); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState({ flipX: false, shiftY: 0 }); + const panelRef = useRef(null); + const closeTimer = useRef | null>(null); + + useEffect( + () => () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + }, + [], + ); + + const openNow = () => { + if (disabled) return; + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + setOpen(true); + }; + // Grace delay so the pointer can cross the small gap to the panel. + const closeSoon = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + closeTimer.current = setTimeout(() => setOpen(false), 140); + }; + + useLayoutEffect(() => { + if (!open) { + setPlacement({ flipX: false, shiftY: 0 }); + return; + } + const panel = panelRef.current; + if (!panel) return; + const margin = 8; + const rect = panel.getBoundingClientRect(); + const overflowY = rect.bottom - (window.innerHeight - margin); + setPlacement({ + flipX: rect.right > window.innerWidth - margin, + shiftY: overflowY > 0 ? -overflowY : 0, + }); + }, [open]); + + return ( +
+ + {open ? ( +
+ {children} +
+ ) : null} +
+ ); +} diff --git a/src/components/menu/index.ts b/src/components/menu/index.ts new file mode 100644 index 0000000..b38115a --- /dev/null +++ b/src/components/menu/index.ts @@ -0,0 +1,4 @@ +export { ContextMenu } from "./ContextMenu"; +export { MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from "./Menu"; +export type { MenuSize } from "./Menu"; +export { useDismissable } from "./useDismissable"; diff --git a/src/components/menu/useDismissable.ts b/src/components/menu/useDismissable.ts new file mode 100644 index 0000000..61e3bc0 --- /dev/null +++ b/src/components/menu/useDismissable.ts @@ -0,0 +1,28 @@ +import { RefObject, useEffect } from "react"; + +/** + * Closes a floating element on pointer-down outside `ref` or on Escape. + * The single shared dismissal behavior for menus, dropdowns, and popovers. + */ +export function useDismissable( + ref: RefObject, + onClose: () => void, + enabled = true, +) { + useEffect(() => { + if (!enabled) return; + const handlePointerDown = (event: PointerEvent) => { + const el = ref.current; + if (el && !el.contains(event.target as Node)) onClose(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ref, onClose, enabled]); +}