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 (
+
+
+ );
+}
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(
+ event.stopPropagation()}
+ onContextMenu={(event) => event.preventDefault()}
+ >
+
+
+ {children}
+
+
+
,
+ 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]);
+}