feat(workbench): group live presentation steps into expandable categories
Group adjacent live display steps into category menus with merged snapshots, original-step submenus and scoped playback. The submenu exposes the latest raw step, so explicit selections must remain pinned. Locate failed and cancelled steps using the full group sequence, and prevent an exiting menu from dismissing a trigger-owned reopen. Regression tests reproduce these paths before the fixes. Validation: lint, typecheck, 1178 unit tests and 10 focused browser cases passed; update the presentation history guide.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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<number | null>(null);
|
||||
const [activeGroupId, setActiveGroupId] = useState<number | null>(null);
|
||||
const compact = useSyncExternalStore(subscribeCompact, compactSnapshot, () => false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(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<HTMLElement>('[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) => <GroupHistoryItems
|
||||
group={group} entries={entries} selectedId={selectedId} onSelect={select} />;
|
||||
|
||||
return <DropdownMenu modal={false} open={open} onOpenChange={(next) => { if (next) setOpen(true); else close(); }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button ref={triggerRef} type="button" variant="outline" aria-label="切换展示动作" title={selected?.label ?? triggerLabel}
|
||||
className="form-field h-8 min-w-0 flex-1 justify-between gap-2 px-2 text-xs font-normal">
|
||||
<span className="min-w-0 truncate">{triggerLabel}</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent ref={contentRef} align="start" sideOffset={4} collisionPadding={12}
|
||||
onPointerDownOutside={(event) => {
|
||||
// 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 ? <>
|
||||
<DropdownMenuItem className={rowClass} onSelect={(event) => { event.preventDefault(); setActiveGroupId(null); }}>
|
||||
<ChevronLeft aria-hidden />返回类别
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{items(activeGroup)}
|
||||
</> : <>
|
||||
<DropdownMenuItem className={rowClass} aria-current={selectedId === null ? "true" : undefined} onSelect={() => select(null)}>
|
||||
<Radio aria-hidden />当前(最新)
|
||||
{selectedId === null ? <Check className="ml-auto" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{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 = <div className="flex items-center" data-history-group-row data-history-group-id={group.id}>
|
||||
<DropdownMenuItem className={`${rowClass} min-w-0 flex-1 justify-between`} aria-label={group.label}
|
||||
aria-current={selectedGroup ? "true" : undefined} disabled={!view}
|
||||
title={`步骤 ${entries.indexOf(group.entries[0]) + 1}–${entries.indexOf(group.entries.at(-1)!) + 1}`}
|
||||
onKeyDown={(event) => {
|
||||
if (!compact && event.key === "ArrowRight") { event.preventDefault(); setExpandedGroupId(group.id); }
|
||||
}} onSelect={() => { if (view) select(view.id); }}>
|
||||
{group.label}{selectedGroup ? <Check aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
{compact ? <DropdownMenuItem aria-label={`展开${group.label}步骤`} className={`${rowClass} size-8 justify-center px-2`}
|
||||
onSelect={(event) => { event.preventDefault(); setActiveGroupId(group.id); }}>
|
||||
<ChevronRight aria-hidden />
|
||||
</DropdownMenuItem> : <DropdownMenuSubTrigger aria-label={`展开${group.label}步骤`}
|
||||
className={`${rowClass} size-8 justify-center px-2 [&>svg]:ml-0`} />}
|
||||
</div>;
|
||||
return compact ? <div key={group.id}>{row}</div> : <DropdownMenuSub key={group.id}
|
||||
open={expandedGroupId === group.id} onOpenChange={(next) => setExpandedGroupId((previous) => next ? group.id : previous === group.id ? null : previous)}>
|
||||
{row}
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent aria-label={`${group.label}展示步骤`} aria-labelledby={undefined} collisionPadding={12} sideOffset={4}
|
||||
onFocusOutside={(event) => {
|
||||
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)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>;
|
||||
})}
|
||||
</>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>;
|
||||
}
|
||||
|
||||
function GroupHistoryItems({ group, entries, selectedId, onSelect }: {
|
||||
group: PresentationHistoryGroup;
|
||||
entries: PresentationHistoryEntry[];
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
}) {
|
||||
return group.entries.slice().reverse().map((entry) => <DropdownMenuItem key={entry.id}
|
||||
className={`${rowClass} items-start`} disabled={entry.status === "running"}
|
||||
aria-label={`${entries.indexOf(entry) + 1}. ${presentationHistoryStepLabel(entry)}`}
|
||||
aria-current={entry.id === selectedId ? "true" : undefined} onSelect={() => onSelect(entry.id)}>
|
||||
<span className="min-w-0 flex-1 break-words leading-5">{entries.indexOf(entry) + 1}. {presentationHistoryStepLabel(entry)}</span>
|
||||
{entry.status === "running" ? <LoaderCircle className="mt-0.5 animate-spin" aria-label="进行中" />
|
||||
: entry.status !== "completed" ? <CircleAlert className="mt-0.5 text-muted-foreground" aria-label={entry.status === "error" ? "未完成" : "已取消"} />
|
||||
: entry.id === selectedId ? <Check className="mt-0.5" aria-hidden /> : null}
|
||||
</DropdownMenuItem>);
|
||||
}
|
||||
Reference in New Issue
Block a user