ai-chatbot/components/flow-board/xmind-command-menu.tsx
134 822c4cbfb9 fix(flow-board): align Xmind editor UI and restore marquee feedback
Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support.

Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
2026-10-04 18:57:02 +08:00

71 lines
3.0 KiB
TypeScript

"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ChevronDown, type LucideIcon } from "lucide-react";
export type XmindCommand = {
label: string;
icon?: LucideIcon;
shortcut?: string;
disabled?: boolean;
checked?: boolean;
separator?: boolean;
run: () => void;
};
export function XmindCommandMenu({ label, children, commands, className = "", align = "left" }: {
label: string;
children: ReactNode;
commands: XmindCommand[];
className?: string;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const host = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const dismiss = (event: PointerEvent) => {
if (!host.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [open]);
return (
<div className={`xmind-menu-host ${className}`} ref={host} data-flow-board-shell-control="true">
<button type="button" ref={trigger} aria-label={label} aria-haspopup="menu" aria-expanded={open}
className="xmind-menu-trigger" onClick={() => setOpen(value => !value)}
onKeyDown={event => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
requestAnimationFrame(() => host.current?.querySelector<HTMLButtonElement>("[role=menuitem]:not(:disabled)")?.focus());
}
}}>
{children}<ChevronDown size={12} />
</button>
{open ? <div role="menu" aria-label={label} className={`xmind-command-menu ${align === "right" ? "xmind-menu-right" : ""}`}
onKeyDown={event => {
if (event.key === "Escape") {
event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus();
}
const items = Array.from(host.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") || []);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}
}}>
{commands.map(({ label: title, icon: Icon, shortcut, disabled, checked, separator, run }) => <div key={title}>
{separator ? <hr className="xmind-menu-separator" /> : null}
<button type="button" role="menuitem" disabled={disabled} className="xmind-command" onClick={() => { setOpen(false); run(); }}>
{Icon ? <Icon size={16} /> : <span className="xmind-command-check">{checked ? "✓" : ""}</span>}
<span>{title}</span>{shortcut ? <kbd>{shortcut}</kbd> : null}
</button>
</div>)}
</div> : null}
</div>
);
}