import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, Radio } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu"; import { groupAdjacentPresentationHistory, presentationHistoryStepLabel, type PresentationHistoryGroup } from "../utils/presentation-history-categories"; import type { PresentationHistoryControls, PresentationHistoryEntry } from "../utils/presentation-view-history"; const compactQuery = "(max-width: 639px)"; const compactSnapshot = () => window.matchMedia(compactQuery).matches; function subscribeCompact(listener: () => void) { const media = window.matchMedia(compactQuery); media.addEventListener("change", listener); return () => media.removeEventListener("change", listener); } const rowClass = "min-h-8 gap-2 text-xs active:bg-accent"; export function PresentationHistoryMenu({ entries, selectedId, mergedEntry, mergedEntries, onSelect }: PresentationHistoryControls) { const [open, setOpen] = useState(false); const [expandedGroupId, setExpandedGroupId] = useState(null); const [activeGroupId, setActiveGroupId] = useState(null); const compact = useSyncExternalStore(subscribeCompact, compactSnapshot, () => false); const contentRef = useRef(null); const triggerRef = useRef(null); const choices = mergedEntries ?? (mergedEntry ? [mergedEntry] : []); const groups = groupAdjacentPresentationHistory(entries); const activeGroup = groups.find((group) => group.id === activeGroupId); const selected = mergedEntry?.id === selectedId ? mergedEntry : choices.find((entry) => entry.id === selectedId) ?? entries.find((entry) => entry.id === selectedId); const triggerLabel = selected?.mergedStepIds ? selected.label : selected ? `${entries.indexOf(selected) + 1}. ${presentationHistoryStepLabel(selected)}` : "当前(最新)"; useEffect(() => { if (!compact || !open) return; const frame = requestAnimationFrame(() => contentRef.current?.querySelector('[role="menuitem"]')?.focus()); return () => cancelAnimationFrame(frame); }, [activeGroupId, compact, open]); const close = () => { setOpen(false); setActiveGroupId(null); setExpandedGroupId(null); }; const select = (id: number | null) => { onSelect(id); close(); }; const items = (group: PresentationHistoryGroup) => ; return { if (next) setOpen(true); else close(); }}> { // The trigger owns toggling. An exiting menu must not dismiss a fresh // open from the same pointer event while its closing animation finishes. if (event.target instanceof Node && triggerRef.current?.contains(event.target)) event.preventDefault(); }} aria-label={compact && activeGroup ? `${activeGroup.label}展示步骤` : "展示类别"} aria-labelledby={undefined} className={`workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)] p-1 ${compact ? "w-72" : "w-40"}`}> {compact && activeGroup ? <> { event.preventDefault(); setActiveGroupId(null); }}> 返回类别 {items(activeGroup)} : <> select(null)}> 当前(最新) {selectedId === null ? : null} {groups.slice().reverse().map((group) => { const view = choices.find((entry) => entry.id === group.id) ?? group.entries.slice().reverse().find((entry) => entry.status !== "running"); const selectedGroup = selectedId === group.id || group.entries.some((entry) => entry.id === selectedId); const row =
{ if (!compact && event.key === "ArrowRight") { event.preventDefault(); setExpandedGroupId(group.id); } }} onSelect={() => { if (view) select(view.id); }}> {group.label}{selectedGroup ? : null} {compact ? { event.preventDefault(); setActiveGroupId(group.id); }}> : svg]:ml-0`} />}
; return compact ?
{row}
: setExpandedGroupId((previous) => next ? group.id : previous === group.id ? null : previous)}> {row} { const target = event.target; const parentMenu = contentRef.current; // Radix focuses the parent menu between pointer targets. Both actions // in this split row still belong to the same expanded category. if (target instanceof Node && (target === parentMenu || parentMenu?.querySelector(`[data-history-group-id="${group.id}"]`)?.contains(target))) event.preventDefault(); }} className="workbench-shadcn-surface z-[91] max-h-(--radix-dropdown-menu-content-available-height) w-80 max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain"> {items(group)} ; })} }
; } function GroupHistoryItems({ group, entries, selectedId, onSelect }: { group: PresentationHistoryGroup; entries: PresentationHistoryEntry[]; selectedId: number | null; onSelect: (id: number) => void; }) { return group.entries.slice().reverse().map((entry) => onSelect(entry.id)}> {entries.indexOf(entry) + 1}. {presentationHistoryStepLabel(entry)} {entry.status === "running" ? : entry.status !== "completed" ? : entry.id === selectedId ? : null} ); }