Files
UHD_Media_Tool/apps/web/app/components/files/context-menu.tsx
T
rootandClaude Opus 5.5 89ea68465e feat(web): W5-B file manager page /console/files
Roots + lazy dir tree, virtualized grid with status badges and asset/slot/
download links, breadcrumb, sort, glob search, hidden toggle, filter chips,
paging and truncation notice; multi-select (Shift/Ctrl/all), action bar,
context menu, drag-to-move with dryRun preview, keyboard navigation,
offline (503) and empty/error states, mobile layout with tree drawer.
Rename/move/copy/delete dialogs with dryRun previews and reference checks,
4-step organize wizard, trash view (restore/restore-to/purge, expiry).
DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:16:35 +08:00

158 lines
5.2 KiB
TypeScript

import type { LucideIcon } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "~/lib/utils.ts";
export type MenuItem =
| {
key: string;
label: string;
icon?: LucideIcon;
/** 快捷键提示 */
hint?: string;
/** 禁用原因(显示为 title,并在行内小字说明) */
disabled?: string | null;
destructive?: boolean;
onSelect: () => void;
}
| { key: string; separator: true };
export type MenuAnchor = { x: number; y: number };
/**
* 右键菜单 / 行 ⋯ 菜单共用的浮层(pg-float)。按坐标定位,越界自动翻转;
* 点外面、Esc、滚动、失焦都会关闭;↑↓ 在可用项间移动,Enter 执行。
*/
export function ContextMenu({
anchor,
items,
onClose,
label = "操作",
}: {
anchor: MenuAnchor | null;
items: MenuItem[];
onClose: () => void;
label?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const [active, setActive] = useState(-1);
useLayoutEffect(() => {
if (!anchor || !ref.current) return;
const r = ref.current.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const left = anchor.x + r.width > vw - 8 ? Math.max(8, anchor.x - r.width) : anchor.x;
const top = anchor.y + r.height > vh - 8 ? Math.max(8, anchor.y - r.height) : anchor.y;
setPos({ left, top });
ref.current.focus();
}, [anchor]);
useEffect(() => {
if (!anchor) return;
const close = (e: Event) => {
if (e.type === "mousedown" && ref.current?.contains(e.target as Node)) return;
onClose();
};
window.addEventListener("mousedown", close);
window.addEventListener("resize", close);
window.addEventListener("blur", close);
document.addEventListener("scroll", close, true);
return () => {
window.removeEventListener("mousedown", close);
window.removeEventListener("resize", close);
window.removeEventListener("blur", close);
document.removeEventListener("scroll", close, true);
};
}, [anchor, onClose]);
useEffect(() => {
if (!anchor) {
setPos(null);
setActive(-1);
}
}, [anchor]);
if (!anchor || typeof document === "undefined") return null;
const actionable = items
.map((it, i) => ({ it, i }))
.filter(({ it }) => !("separator" in it) && !it.disabled)
.map(({ i }) => i);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!actionable.length) return;
const cur = actionable.indexOf(active);
const next =
e.key === "ArrowDown"
? actionable[(cur + 1) % actionable.length]
: actionable[(cur - 1 + actionable.length) % actionable.length];
setActive(next ?? -1);
} else if (e.key === "Enter" && active >= 0) {
e.preventDefault();
const it = items[active];
if (it && !("separator" in it) && !it.disabled) {
onClose();
it.onSelect();
}
}
};
return createPortal(
<div
ref={ref}
role="menu"
aria-label={label}
tabIndex={-1}
onKeyDown={onKeyDown}
onContextMenu={(e) => e.preventDefault()}
style={{ left: pos?.left ?? anchor.x, top: pos?.top ?? anchor.y, visibility: pos ? "visible" : "hidden" }}
className="pg-float fixed z-50 min-w-52 max-w-72 animate-pg-pop rounded-2xl p-1.5 outline-none"
>
{items.map((it, i) =>
"separator" in it ? (
<hr key={it.key} className="my-1 border-0 border-t border-border" />
) : (
<button
key={it.key}
type="button"
role="menuitem"
aria-disabled={!!it.disabled}
title={it.disabled ?? undefined}
onMouseEnter={() => setActive(i)}
onClick={() => {
if (it.disabled) return;
onClose();
it.onSelect();
}}
className={cn(
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-body transition-colors duration-micro",
it.disabled
? "cursor-not-allowed opacity-50"
: it.destructive
? "text-foreground hover:bg-destructive/10 hover:text-destructive"
: "text-foreground hover:bg-foreground/[0.06]",
active === i &&
!it.disabled &&
(it.destructive ? "bg-destructive/10 text-destructive" : "bg-foreground/[0.06]"),
)}
>
{it.icon ? <it.icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> : <span className="w-4" />}
<span className="min-w-0 flex-1">
<span className="block truncate">{it.label}</span>
{it.disabled ? <span className="block text-meta text-muted-foreground">{it.disabled}</span> : null}
</span>
{it.hint ? <kbd className="mt-0.5 font-mono text-meta text-muted-foreground">{it.hint}</kbd> : null}
</button>
),
)}
</div>,
document.body,
);
}