feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
|
||||
|
||||
type Bounds = { x: number; y: number; width: number; height: number };
|
||||
|
||||
function fitWindow(bounds: Bounds): Bounds {
|
||||
const width = Math.min(bounds.width, window.innerWidth - 16);
|
||||
const height = Math.min(bounds.height, window.innerHeight - 16);
|
||||
return { width, height,
|
||||
x: Math.max(8, Math.min(bounds.x, window.innerWidth - width - 8)),
|
||||
y: Math.max(8, Math.min(bounds.y, window.innerHeight - height - 8)) };
|
||||
}
|
||||
|
||||
export function FloatingWindow({ title, triggerLabel, fillContent = false, openRequestKey, children }: {
|
||||
title: string;
|
||||
triggerLabel?: string;
|
||||
fillContent?: boolean;
|
||||
/** A new display request opens the window without taking keyboard focus. */
|
||||
openRequestKey?: string;
|
||||
children: (size: { width: number; height: number }) => ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const titleId = useId();
|
||||
const [manuallyOpen, setManuallyOpen] = useState(false);
|
||||
const [request, setRequest] = useState({ key: openRequestKey, dismissed: false });
|
||||
// A replay can return to an earlier request. Dismissal applies only until the
|
||||
// displayed request changes, even when that next key was previously visited.
|
||||
if (request.key !== openRequestKey) setRequest({ key: openRequestKey, dismissed: false });
|
||||
const open = manuallyOpen || (openRequestKey !== undefined && !request.dismissed);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const [bounds, setBounds] = useState<Bounds>(() => fitWindow({ x: (window.innerWidth - 800) / 2, y: 64, width: 800, height: 560 }));
|
||||
const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight });
|
||||
const order = useSyncExternalStore(subscribeFloatingWindows, floatingWindowOrder);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const heading = useRef<HTMLDivElement>(null);
|
||||
const focusOnOpen = useRef(false);
|
||||
const raise = () => raiseFloatingWindow(id);
|
||||
const close = () => {
|
||||
setManuallyOpen(false);
|
||||
setRequest({ key: openRequestKey, dismissed: true });
|
||||
removeFloatingWindow(id);
|
||||
if (heading.current?.closest('[data-floating-window]')?.contains(document.activeElement))
|
||||
trigger.current?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
useEffect(() => () => removeFloatingWindow(id), [id]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24;
|
||||
setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset }));
|
||||
raiseFloatingWindow(id);
|
||||
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
|
||||
focusOnOpen.current = false;
|
||||
const resize = () => {
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds((current) => fitWindow(current));
|
||||
};
|
||||
window.addEventListener("resize", resize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
removeFloatingWindow(id);
|
||||
};
|
||||
}, [id, open]);
|
||||
|
||||
const current = maximized
|
||||
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
|
||||
: bounds;
|
||||
|
||||
return <>
|
||||
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0"
|
||||
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open}
|
||||
onClick={() => {
|
||||
if (!open) {
|
||||
const offset = (order.length % 5) * 24;
|
||||
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
||||
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 }));
|
||||
setMaximized(false);
|
||||
focusOnOpen.current = true;
|
||||
setManuallyOpen(true);
|
||||
} else heading.current?.focus({ preventScroll: true });
|
||||
raise();
|
||||
}}><Maximize2 size={15} aria-hidden /></Button>
|
||||
{open ? createPortal(
|
||||
<Rnd size={{ width: current.width, height: current.height }} position={{ x: current.x, y: current.y }}
|
||||
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
|
||||
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
|
||||
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
|
||||
disableDragging={maximized} enableResizing={!maximized}
|
||||
onDragStop={(_, position) => setBounds((previous) => fitWindow({ ...previous, x: position.x, y: position.y }))}
|
||||
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
|
||||
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
|
||||
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
|
||||
resizeHandleStyles={{
|
||||
top: { top: 0, height: 8 }, right: { right: 0, width: 8 },
|
||||
bottom: { bottom: 0, height: 8 }, left: { left: 0, width: 8 },
|
||||
topRight: { top: 0, right: 0, width: 18, height: 18 },
|
||||
topLeft: { top: 0, left: 0, width: 18, height: 18 },
|
||||
bottomLeft: { bottom: 0, left: 0, width: 18, height: 18 },
|
||||
bottomRight: { right: 0, bottom: 0, width: 18, height: 18 }
|
||||
}}
|
||||
resizeHandleComponent={{ bottomRight: <span aria-hidden className="absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> }}
|
||||
className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
|
||||
role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window=""
|
||||
onPointerDownCapture={raise} onFocusCapture={raise}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
|
||||
}}>
|
||||
<div ref={heading} tabIndex={0}
|
||||
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none items-center justify-between gap-3 border-b border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-ring"
|
||||
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const dx = event.key === "ArrowLeft" ? -16 : event.key === "ArrowRight" ? 16 : 0;
|
||||
const dy = event.key === "ArrowUp" ? -16 : event.key === "ArrowDown" ? 16 : 0;
|
||||
setBounds((previous) => fitWindow(event.shiftKey
|
||||
? { ...previous, width: Math.max(320, previous.width + dx), height: Math.max(260, previous.height + dy) }
|
||||
: { ...previous, x: previous.x + dx, y: previous.y + dy }));
|
||||
}}>
|
||||
<h2 id={titleId} className="min-w-0 break-words text-sm font-semibold leading-5">{title}</h2>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={maximized ? "还原窗口" : "最大化窗口"}
|
||||
title={maximized ? "还原窗口" : "最大化窗口"} onClick={() => setMaximized(!maximized)}>
|
||||
{maximized ? <Minimize2 size={15} aria-hidden /> : <Maximize2 size={15} aria-hidden />}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="关闭窗口" title="关闭窗口" onClick={close}>
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto overscroll-contain p-3" data-floating-window-content="">
|
||||
<div className={fillContent ? "h-full" : undefined} data-floating-window-document="">
|
||||
{children({ width: current.width - 26, height: current.height - 76 })}
|
||||
</div>
|
||||
</div>
|
||||
</Rnd>, floatingWindowHost()) : null}
|
||||
</>;
|
||||
}
|
||||
Reference in New Issue
Block a user