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]>
158 lines
5.2 KiB
TypeScript
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,
|
|
);
|
|
}
|