Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling. Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
199 lines
5.9 KiB
TypeScript
199 lines
5.9 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent,
|
|
type PointerEvent,
|
|
type ReactNode
|
|
} from "react";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import {
|
|
getMobileWorkbenchSheetHeight,
|
|
type MobileWorkbenchSheetSnap
|
|
} from "./mobile-workbench-sheet-layout";
|
|
|
|
export type { MobileWorkbenchSheetSnap } from "./mobile-workbench-sheet-layout";
|
|
|
|
type MobileWorkbenchSheetProps = {
|
|
label: string;
|
|
snap: MobileWorkbenchSheetSnap;
|
|
onSnapChange: (snap: MobileWorkbenchSheetSnap) => void;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
const SNAP_ORDER: MobileWorkbenchSheetSnap[] = ["half", "full"];
|
|
|
|
export function MobileWorkbenchSheet({
|
|
label,
|
|
snap,
|
|
onSnapChange,
|
|
onClose,
|
|
children,
|
|
className
|
|
}: MobileWorkbenchSheetProps) {
|
|
const pointerStartRef = useRef<{ y: number; height: number } | null>(null);
|
|
const draggedRef = useRef(false);
|
|
const [dragHeight, setDragHeight] = useState<number | null>(null);
|
|
const [viewportHeight, setViewportHeight] = useState(
|
|
typeof window === "undefined" ? 844 : window.innerHeight
|
|
);
|
|
const restingHeight = getMobileWorkbenchSheetHeight(snap, viewportHeight);
|
|
|
|
useEffect(() => {
|
|
const updateViewportHeight = () => setViewportHeight(window.innerHeight);
|
|
window.addEventListener("resize", updateViewportHeight);
|
|
window.visualViewport?.addEventListener("resize", updateViewportHeight);
|
|
return () => {
|
|
window.removeEventListener("resize", updateViewportHeight);
|
|
window.visualViewport?.removeEventListener("resize", updateViewportHeight);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleEscape);
|
|
return () => window.removeEventListener("keydown", handleEscape);
|
|
}, [onClose]);
|
|
|
|
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
|
|
pointerStartRef.current = {
|
|
y: event.clientY,
|
|
height: restingHeight
|
|
};
|
|
draggedRef.current = false;
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
}
|
|
|
|
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
|
|
const start = pointerStartRef.current;
|
|
if (!start) {
|
|
return;
|
|
}
|
|
|
|
const delta = start.y - event.clientY;
|
|
if (Math.abs(delta) > 4) {
|
|
draggedRef.current = true;
|
|
}
|
|
|
|
const minHeight = getMobileWorkbenchSheetHeight("half", window.innerHeight);
|
|
const maxHeight = getMobileWorkbenchSheetHeight("full", window.innerHeight);
|
|
setDragHeight(Math.min(maxHeight, Math.max(minHeight, start.height + delta)));
|
|
}
|
|
|
|
function handlePointerUp(event: PointerEvent<HTMLButtonElement>) {
|
|
if (!pointerStartRef.current) {
|
|
return;
|
|
}
|
|
|
|
const finalHeight = dragHeight ?? pointerStartRef.current.height;
|
|
pointerStartRef.current = null;
|
|
setDragHeight(null);
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
|
|
if (!draggedRef.current) {
|
|
return;
|
|
}
|
|
|
|
onSnapChange(getClosestSnap(finalHeight, window.innerHeight));
|
|
}
|
|
|
|
function handleClick() {
|
|
if (draggedRef.current) {
|
|
draggedRef.current = false;
|
|
return;
|
|
}
|
|
|
|
const currentIndex = SNAP_ORDER.indexOf(snap);
|
|
onSnapChange(SNAP_ORDER[(currentIndex + 1) % SNAP_ORDER.length] ?? "half");
|
|
}
|
|
|
|
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
onSnapChange(getAdjacentSnap(snap, 1));
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
onSnapChange(getAdjacentSnap(snap, -1));
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Home") {
|
|
event.preventDefault();
|
|
onSnapChange("half");
|
|
return;
|
|
}
|
|
|
|
if (event.key === "End") {
|
|
event.preventDefault();
|
|
onSnapChange("full");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section
|
|
aria-label={label}
|
|
data-snap={snap}
|
|
className={cn(
|
|
"mobile-workbench-sheet acrylic-panel pointer-events-auto absolute bottom-0 left-0 right-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-t-[20px] border-x border-t transition-[height] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none lg:hidden",
|
|
className
|
|
)}
|
|
style={{ height: dragHeight ?? restingHeight }}
|
|
>
|
|
<div className="mobile-workbench-sheet-handle relative z-40 flex h-7 shrink-0 items-center">
|
|
<button
|
|
type="button"
|
|
aria-label={`${label}高度,当前${getSnapLabel(snap)}`}
|
|
className="group flex h-7 flex-1 touch-none items-center justify-center"
|
|
onClick={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
>
|
|
<span className="h-1 w-10 rounded-full bg-slate-400/70 transition-[width,background-color] group-focus-visible:w-12 group-focus-visible:bg-blue-600" />
|
|
</button>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overscroll-contain">{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function getAdjacentSnap(
|
|
current: MobileWorkbenchSheetSnap,
|
|
direction: -1 | 1
|
|
): MobileWorkbenchSheetSnap {
|
|
const currentIndex = SNAP_ORDER.indexOf(current);
|
|
const nextIndex = Math.min(SNAP_ORDER.length - 1, Math.max(0, currentIndex + direction));
|
|
return SNAP_ORDER[nextIndex] ?? current;
|
|
}
|
|
|
|
function getClosestSnap(height: number, viewportHeight: number): MobileWorkbenchSheetSnap {
|
|
return SNAP_ORDER.reduce((closest, candidate) => {
|
|
const candidateDistance = Math.abs(
|
|
getMobileWorkbenchSheetHeight(candidate, viewportHeight) - height
|
|
);
|
|
const closestDistance = Math.abs(
|
|
getMobileWorkbenchSheetHeight(closest, viewportHeight) - height
|
|
);
|
|
return candidateDistance < closestDistance ? candidate : closest;
|
|
}, "half");
|
|
}
|
|
|
|
function getSnapLabel(snap: MobileWorkbenchSheetSnap) {
|
|
if (snap === "half") {
|
|
return "半屏档";
|
|
}
|
|
|
|
return "全屏档";
|
|
}
|