Files
next-tjwater-frontend/src/shared/ui/floating-window.tsx
T
jiang e0fe4a5a10
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s
fix(charts): preserve comparison types and draggable window headings
Keep current lines and historical scatter points in a dedicated comparison chart type. Expose floating headings and use idempotent placement so StrictMode no longer masks production overlap.
2026-11-08 03:04:14 +08:00

156 lines
9.1 KiB
TypeScript

import { useEffect, useId, useLayoutEffect, 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]);
useLayoutEffect(() => {
if (!open) return;
const others = floatingWindowOrder().filter((windowId) => windowId !== id);
const headingHeight = Array.from(floatingWindowHost().querySelectorAll<HTMLElement>(".floating-window-heading"))
.reduce((height, element) => Math.max(height, element.offsetHeight), 48);
const step = headingHeight + 8;
const minHeight = Math.min(260, window.innerHeight - 16);
const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1);
const slot = others.length % slots;
const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8));
// Keep the preceding headings exposed. Shorter viewports reduce content
// height instead of clamping several windows onto the same title bar.
// Absolute placement also makes StrictMode's repeated setup idempotent.
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y,
width: 800, height: Math.min(560, window.innerHeight - y - 8) }));
raiseFloatingWindow(id);
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
focusOnOpen.current = false;
const resize = () => {
setViewport({ width: window.innerWidth, height: window.innerHeight });
setBounds((current) => {
const minimumHeight = Math.min(260, window.innerHeight - 16);
const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8));
return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) });
});
};
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) {
setViewport({ width: window.innerWidth, height: window.innerHeight });
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}
</>;
}