mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
133 lines
4.2 KiB
TypeScript
133 lines
4.2 KiB
TypeScript
import { useEffect, useRef, useCallback, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import s from "./ContextMenu.module.css";
|
|
|
|
export interface ContextMenuItem {
|
|
label: string;
|
|
icon?: React.ReactNode;
|
|
onClick: () => void;
|
|
danger?: boolean;
|
|
disabled?: boolean;
|
|
separator?: never;
|
|
}
|
|
|
|
export interface ContextMenuSeparator {
|
|
separator: true;
|
|
label?: never;
|
|
icon?: never;
|
|
onClick?: never;
|
|
danger?: never;
|
|
disabled?: never;
|
|
}
|
|
|
|
export type ContextMenuEntry = ContextMenuItem | ContextMenuSeparator;
|
|
|
|
interface Props {
|
|
x: number;
|
|
y: number;
|
|
items: ContextMenuEntry[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const [focused, setFocused] = useState<number>(-1);
|
|
|
|
// Build list of actionable (non-separator, non-disabled) indices for keyboard nav
|
|
const actionable = items
|
|
.map((_, i) => i)
|
|
.filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled);
|
|
|
|
useEffect(() => {
|
|
function onDown(e: MouseEvent) {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
|
}
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setFocused((prev) => {
|
|
const cur = actionable.indexOf(prev);
|
|
return actionable[(cur + 1) % actionable.length] ?? actionable[0];
|
|
});
|
|
return;
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setFocused((prev) => {
|
|
const cur = actionable.indexOf(prev);
|
|
return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
|
|
});
|
|
return;
|
|
}
|
|
if (e.key === "Enter" && focused >= 0) {
|
|
e.preventDefault();
|
|
const item = items[focused] as ContextMenuItem;
|
|
if (item && !item.disabled) { item.onClick(); onClose(); }
|
|
return;
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", onDown, true);
|
|
document.addEventListener("keydown", onKey, true);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDown, true);
|
|
document.removeEventListener("keydown", onKey, true);
|
|
};
|
|
}, [onClose, focused, actionable, items]);
|
|
|
|
// Focus first item on open
|
|
useEffect(() => {
|
|
if (actionable.length) setFocused(actionable[0]);
|
|
}, []);
|
|
|
|
const getPosition = useCallback(() => {
|
|
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
|
const scaledX = x / zoom;
|
|
const scaledY = y / zoom;
|
|
const menuW = 200;
|
|
const menuH = items.length * 34;
|
|
const vw = window.innerWidth / zoom;
|
|
const vh = window.innerHeight / zoom;
|
|
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
|
const top = scaledY + menuH > vh ? scaledY - menuH : scaledY;
|
|
return { left: Math.max(4, left), top: Math.max(4, top) };
|
|
}, [x, y, items.length]);
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
className={s.menu}
|
|
style={getPosition()}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
>
|
|
{items.map((item, i) => {
|
|
if ("separator" in item && item.separator) {
|
|
return <div key={i} className={s.separator} />;
|
|
}
|
|
const mi = item as ContextMenuItem;
|
|
const isFocused = focused === i;
|
|
return (
|
|
<button
|
|
key={i}
|
|
className={[
|
|
s.item,
|
|
mi.danger ? s.itemDanger : "",
|
|
mi.disabled ? s.itemDisabled : "",
|
|
isFocused ? s.itemFocused : "",
|
|
].filter(Boolean).join(" ")}
|
|
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
|
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
|
onMouseLeave={() => setFocused(-1)}
|
|
disabled={mi.disabled}
|
|
>
|
|
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
|
{mi.icon ?? null}
|
|
</span>
|
|
<span className={s.itemLabel}>{mi.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>,
|
|
document.body
|
|
);
|
|
} |