feat(charts): manage investigation charts in a scrolling workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m8s

This commit is contained in:
2026-11-10 17:56:23 +08:00
parent 865491cb23
commit 0ae0a41e61
80 changed files with 4672 additions and 470 deletions
@@ -0,0 +1,44 @@
import { afterEach, expect, it, vi } from "vitest";
import { floatingWindowEntries, floatingWindowOrder, raiseFloatingWindow, registerFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
afterEach(() => floatingWindowOrder().forEach(removeFloatingWindow));
it("keeps a stable snapshot, follows stacking order and removes window metadata", () => {
const notify = vi.fn();
const stop = subscribeFloatingWindows(notify);
const first = { id: "first", title: "压力图", focus: vi.fn(), close: vi.fn() };
registerFloatingWindow(first);
const snapshot = floatingWindowEntries();
expect(floatingWindowEntries()).toBe(snapshot);
registerFloatingWindow({ id: "second", title: "流量图", focus: vi.fn(), close: vi.fn() });
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["first", "second"]);
raiseFloatingWindow("first");
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["second", "first"]);
removeFloatingWindow("first");
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["second"]);
removeFloatingWindow("second");
expect(floatingWindowEntries()).toEqual([]);
expect(notify).toHaveBeenCalledTimes(5);
stop();
});
it("updates titles without moving windows and calls the latest actions without snapshot churn", () => {
const focus = vi.fn(), close = vi.fn();
registerFloatingWindow({ id: "chart", title: "旧标题", focus: vi.fn(), close: vi.fn() });
const initial = floatingWindowEntries();
registerFloatingWindow({ id: "chart", title: "旧标题", focus, close });
expect(floatingWindowEntries()).toBe(initial);
initial[0].focus();
initial[0].close();
expect(focus).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
registerFloatingWindow({ id: "chart", title: "新标题", focus, close });
expect(floatingWindowEntries()[0].title).toBe("新标题");
expect(initial[0].title).toBe("旧标题");
expect(floatingWindowOrder()).toEqual(["chart"]);
removeFloatingWindow("chart");
initial[0].focus();
initial[0].close();
expect(focus).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
+38 -3
View File
@@ -1,23 +1,58 @@
let order: string[] = [];
const listeners = new Set<() => void>();
let host: HTMLDivElement | undefined;
export type FloatingWindowEntry = { id: string; title: string; focus: () => void; close: () => void };
const registered = new Map<string, {
entry: FloatingWindowEntry;
actions: Pick<FloatingWindowEntry, "focus" | "close">;
}>();
let entries: readonly FloatingWindowEntry[] = [];
function publishWindows() {
entries = order.flatMap((id) => {
const record = registered.get(id);
return record ? [record.entry] : [];
});
listeners.forEach((listener) => listener());
}
export const floatingWindowOrder = () => order;
/** Stable between changes, ordered from the back window to the foreground. */
export const floatingWindowEntries = () => entries;
export const subscribeFloatingWindows = (listener: () => void) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
};
export function registerFloatingWindow(entry: FloatingWindowEntry) {
const previous = registered.get(entry.id);
if (previous) {
// Callbacks can change without invalidating the subscribed presentation.
previous.actions.focus = entry.focus;
previous.actions.close = entry.close;
if (previous.entry.title === entry.title) return;
previous.entry = { ...previous.entry, title: entry.title };
} else {
const actions = { focus: entry.focus, close: entry.close };
registered.set(entry.id, { actions, entry: { id: entry.id, title: entry.title,
focus: () => { if (registered.get(entry.id)?.actions === actions) actions.focus(); },
close: () => { if (registered.get(entry.id)?.actions === actions) actions.close(); } } });
if (!order.includes(entry.id)) order = [...order, entry.id];
}
publishWindows();
}
export function raiseFloatingWindow(id: string) {
if (order.at(-1) === id) return;
order = [...order.filter((item) => item !== id), id];
listeners.forEach((listener) => listener());
publishWindows();
}
export function removeFloatingWindow(id: string) {
if (!order.includes(id)) return;
if (!order.includes(id) && !registered.has(id)) return;
registered.delete(id);
order = order.filter((item) => item !== id);
listeners.forEach((listener) => listener());
publishWindows();
}
export function floatingWindowHost() {
@@ -0,0 +1,5 @@
/** Hosts can reserve a top navigation area without raising it above modal dialogs. */
export function floatingWindowTopInset() {
return Math.max(8, ...Array.from(document.querySelectorAll<HTMLElement>("[data-floating-window-inset-top]"))
.map((element) => element.getBoundingClientRect().bottom + 8));
}
+49 -31
View File
@@ -1,24 +1,39 @@
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";
import { floatingWindowHost, floatingWindowOrder, registerFloatingWindow, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
import { floatingWindowTopInset } from "./floating-window-viewport";
import { useFloatingWindowDrag } from "./use-floating-window-drag";
import { useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
type Bounds = { x: number; y: number; width: number; height: number };
const resizeHandles: Record<ResizeEdge, React.CSSProperties> = {
top: { top: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
right: { right: 0, top: 18, bottom: 18, width: 8, cursor: "ew-resize" },
bottom: { bottom: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
left: { left: 0, top: 18, bottom: 18, width: 8, cursor: "ew-resize" },
topRight: { top: 0, right: 0, width: 18, height: 18, cursor: "nesw-resize" },
topLeft: { top: 0, left: 0, width: 18, height: 18, cursor: "nwse-resize" },
bottomLeft: { bottom: 0, left: 0, width: 18, height: 18, cursor: "nesw-resize" },
bottomRight: { bottom: 0, right: 0, width: 18, height: 18, cursor: "nwse-resize" }
};
function fitWindow(bounds: Bounds): Bounds {
const top = floatingWindowTopInset();
const width = Math.min(bounds.width, window.innerWidth - 16);
const height = Math.min(bounds.height, window.innerHeight - 16);
const height = Math.min(bounds.height, window.innerHeight - top - 8);
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)) };
y: Math.max(top, Math.min(bounds.y, window.innerHeight - height - 8)) };
}
export function FloatingWindow({ title, triggerLabel, fillContent = false, openRequestKey, children }: {
export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, children }: {
title: string;
navigationTitle?: string;
triggerLabel?: string;
triggerContent?: ReactNode;
initialSize?: { width: number; height: number };
fillContent?: boolean;
/** A new display request opens the window without taking keyboard focus. */
openRequestKey?: string;
@@ -33,8 +48,9 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
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 [bounds, setBounds] = useState<Bounds>(() => fitWindow({ x: (window.innerWidth - initialSize.width) / 2, y: 64, ...initialSize }));
const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight });
const initialSizeRef = useRef(initialSize);
const order = useSyncExternalStore(subscribeFloatingWindows, floatingWindowOrder);
const trigger = useRef<HTMLButtonElement>(null);
const heading = useRef<HTMLDivElement>(null);
@@ -48,6 +64,14 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
trigger.current?.focus({ preventScroll: true });
};
const closeRef = useRef(close);
closeRef.current = close;
useLayoutEffect(() => {
if (open) registerFloatingWindow({ id, title: navigationTitle ?? title,
focus: () => { raiseFloatingWindow(id); heading.current?.focus({ preventScroll: true }); },
close: () => closeRef.current() });
}, [id, open, title, navigationTitle]);
useEffect(() => () => removeFloatingWindow(id), [id]);
useLayoutEffect(() => {
if (!open) return;
@@ -62,8 +86,8 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
// 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) }));
setBounds(fitWindow({ x: (window.innerWidth - initialSizeRef.current.width) / 2 + slot * 24, y,
width: initialSizeRef.current.width, height: Math.min(initialSizeRef.current.height, window.innerHeight - y - 8) }));
raiseFloatingWindow(id);
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
focusOnOpen.current = false;
@@ -83,15 +107,17 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
}, [id, open]);
const current = maximized
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
? { x: 8, y: floatingWindowTopInset(), width: viewport.width - 16, height: viewport.height - floatingWindowTopInset() - 8 }
: bounds;
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
width: current.width, height: current.height,
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
const startResize = useFloatingWindowResize({ enabled: open && !maximized, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
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}
<Button ref={trigger} type="button" variant="ghost" size={triggerContent ? "sm" : "icon-sm"} className="shrink-0"
aria-label={triggerLabel ?? `放大查看 ${title}`} title={triggerLabel ?? "放大查看"} aria-haspopup="dialog" aria-expanded={open}
onClick={() => {
if (!open) {
setViewport({ width: window.innerWidth, height: window.innerHeight });
@@ -100,27 +126,12 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
setManuallyOpen(true);
} else heading.current?.focus({ preventScroll: true });
raise();
}}><Maximize2 size={15} aria-hidden /></Button>
}}><Maximize2 size={15} aria-hidden />{triggerContent}</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 enableResizing={!maximized}
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" /> }}
<div style={{ position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
transform: `translate(${current.x}px, ${current.y}px)`, zIndex: order.indexOf(id) + 1 }}
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=""
role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window="" data-floating-window-id={id}
onPointerDownCapture={raise} onFocusCapture={raise}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
@@ -154,6 +165,13 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
{children({ width: current.width - 26, height: current.height - 76 })}
</div>
</div>
</Rnd>, floatingWindowHost()) : null}
{!maximized ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
<div key={edge} aria-hidden data-window-resize-handle={edge}
className={`absolute z-10 touch-none ${edge === "bottomRight" ? "floating-window-resize" : ""}`}
style={style} onPointerDown={(event) => startResize(event, edge)}>
{edge === "bottomRight" ? <span className="pointer-events-none absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> : null}
</div>
)) : null}
</div>, floatingWindowHost()) : null}
</>;
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { flushSync } from "react-dom";
import { floatingWindowTopInset } from "./floating-window-viewport";
type Position = { x: number; y: number };
@@ -15,9 +16,11 @@ export function useFloatingWindowDrag({ enabled, position, width, height, onComm
useLayoutEffect(() => {
const stop = () => cancel.current?.();
window.addEventListener("resize", stop);
window.addEventListener("blur", stop);
return () => {
stop();
window.removeEventListener("resize", stop);
window.removeEventListener("blur", stop);
};
}, [enabled]);
@@ -34,6 +37,7 @@ export function useFloatingWindowDrag({ enabled, position, width, height, onComm
const startY = event.clientY;
const maxX = window.innerWidth - width - 8;
const maxY = window.innerHeight - height - 8;
const minY = floatingWindowTopInset();
const previousTranslate = element.style.translate;
const previousWillChange = element.style.willChange;
let next = position;
@@ -42,7 +46,7 @@ export function useFloatingWindowDrag({ enabled, position, width, height, onComm
const updatePosition = (pointer: PointerEvent) => {
next = {
x: Math.max(8, Math.min(position.x + pointer.clientX - startX, maxX)),
y: Math.max(8, Math.min(position.y + pointer.clientY - startY, maxY))
y: Math.max(minY, Math.min(position.y + pointer.clientY - startY, maxY))
};
};
const move = (pointer: PointerEvent) => {
@@ -0,0 +1,131 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resizedFloatingBounds, useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
afterEach(() => {
cleanup();
document.body.replaceChildren();
document.body.style.userSelect = "";
vi.unstubAllGlobals();
});
describe("floating window resize constraints", () => {
const start = { x: 300, y: 200, width: 800, height: 560 };
const viewport = { width: 1440, height: 900 };
it("preserves opposite edges when resizing from the left and top", () => {
expect(resizedFloatingBounds(start, "topLeft", -400, -300, viewport)).toEqual({ x: 8, y: 8, width: 1092, height: 752 });
expect(resizedFloatingBounds(start, "topLeft", 1200, 1200, viewport)).toEqual({ x: 780, y: 500, width: 320, height: 260 });
});
it("limits the bottom and right edges to the viewport", () => {
expect(resizedFloatingBounds(start, "bottomRight", 2000, 2000, viewport)).toEqual({ x: 300, y: 200, width: 1132, height: 692 });
});
it("keeps a narrow window inside the viewport and respects reserved top navigation", () => {
expect(resizedFloatingBounds({ x: 8, y: 64, width: 304, height: 400 }, "topLeft", -200, -200, { width: 320, height: 720, top: 64 }))
.toEqual({ x: 8, y: 64, width: 304, height: 400 });
});
it.each<[ResizeEdge, { x: number; y: number; width: number; height: number }]>([
["top", { x: 300, y: 240, width: 800, height: 520 }],
["right", { x: 300, y: 200, width: 840, height: 560 }],
["bottom", { x: 300, y: 200, width: 800, height: 600 }],
["left", { x: 340, y: 200, width: 760, height: 560 }],
["topRight", { x: 300, y: 240, width: 840, height: 520 }],
["topLeft", { x: 340, y: 240, width: 760, height: 520 }],
["bottomRight", { x: 300, y: 200, width: 840, height: 600 }],
["bottomLeft", { x: 340, y: 200, width: 760, height: 600 }]
])("resizes %s while preserving the opposite edges", (edge, expected) => {
expect(resizedFloatingBounds(start, edge, 40, 40, viewport)).toEqual(expected);
});
});
function resizeFixture() {
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); });
const shell = document.createElement("div");
shell.dataset.floatingWindow = "";
Object.assign(shell.style, { width: "320px", height: "260px" });
const heading = document.createElement("div");
heading.className = "floating-window-heading";
heading.tabIndex = 0;
const content = document.createElement("div");
content.dataset.floatingWindowContent = "";
content.style.overflow = "auto";
const drawing = document.createElement("div");
drawing.dataset.floatingWindowDocument = "";
Object.assign(drawing.style, { width: "100%", height: "100%", fontSize: "12px" });
const handle = document.createElement("div");
content.append(drawing);
shell.append(heading, content, handle);
document.body.append(shell);
document.body.style.userSelect = "text";
const captures = new Set<number>();
handle.setPointerCapture = vi.fn((id: number) => { captures.add(id); });
handle.hasPointerCapture = (id: number) => captures.has(id);
handle.releasePointerCapture = vi.fn((id: number) => { captures.delete(id); });
const onCommit = vi.fn();
const hook = renderHook(({ enabled }) => useFloatingWindowResize({ enabled,
bounds: { x: 100, y: 100, width: 320, height: 260 }, onCommit
}), { initialProps: { enabled: true } });
const pointer = (type: string, x = 164, y = 132) => {
const event = new MouseEvent(type, { clientX: x, clientY: y, bubbles: true });
Object.defineProperty(event, "pointerId", { value: 7 });
handle.dispatchEvent(event);
};
const start = () => act(() => hook.result.current({
isPrimary: true, button: 0, currentTarget: handle, pointerId: 7, clientX: 100, clientY: 100,
preventDefault: vi.fn(), stopPropagation: vi.fn()
} as unknown as ReactPointerEvent<HTMLDivElement>, "bottomRight"));
const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach((callback) => callback(16)); };
return { shell, content, drawing, handle, hook, onCommit, pointer, start, paint, frames };
}
describe("floating resize lifecycle", () => {
it("coalesces shell updates per frame without freezing or scaling its document", () => {
const fixture = resizeFixture();
fixture.start();
fixture.pointer("pointermove", 132, 116);
fixture.pointer("pointermove");
expect(fixture.frames.size).toBe(1);
expect(fixture.shell.style.width).toBe("320px");
fixture.paint();
expect(fixture.shell.style.width).toBe("384px");
expect(fixture.shell.style.height).toBe("292px");
expect(fixture.drawing.style.cssText).toBe("width: 100%; height: 100%; font-size: 12px;");
expect(fixture.content.style.overflow).toBe("auto");
expect(fixture.onCommit).not.toHaveBeenCalled();
fixture.pointer("pointerup");
expect(fixture.onCommit).toHaveBeenCalledExactlyOnceWith({ x: 100, y: 100, width: 384, height: 292 });
expect(fixture.shell.dataset.floatingWindowResizing).toBeUndefined();
expect(fixture.handle.hasPointerCapture(7)).toBe(false);
expect(document.body.style.userSelect).toBe("text");
});
it.each(["pointercancel", "lostpointercapture", "blur", "resize", "Escape", "disable", "unmount"])(
"restores shell styles and releases capture on %s", (reason) => {
const fixture = resizeFixture();
fixture.start();
fixture.pointer("pointermove");
fixture.paint();
expect(fixture.shell.style.width).toBe("384px");
if (reason === "Escape") window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true }));
else if (reason === "blur" || reason === "resize") window.dispatchEvent(new Event(reason));
else if (reason === "disable") fixture.hook.rerender({ enabled: false });
else if (reason === "unmount") fixture.hook.unmount();
else fixture.pointer(reason);
expect(fixture.shell.style.width).toBe("320px");
expect(fixture.shell.style.height).toBe("260px");
expect(fixture.shell.style.translate).toBe("");
expect(fixture.drawing.style.cssText).toBe("width: 100%; height: 100%; font-size: 12px;");
expect(fixture.content.style.overflow).toBe("auto");
expect(fixture.shell.dataset.floatingWindowResizing).toBeUndefined();
expect(document.body.style.userSelect).toBe("text");
expect(fixture.handle.hasPointerCapture(7)).toBe(false);
expect(fixture.onCommit).not.toHaveBeenCalled();
fixture.paint();
expect(fixture.shell.style.width).toBe("320px");
}
);
});
+100
View File
@@ -0,0 +1,100 @@
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { flushSync } from "react-dom";
import { floatingWindowTopInset } from "./floating-window-viewport";
export type FloatingBounds = { x: number; y: number; width: number; height: number };
export type ResizeEdge = "top" | "right" | "bottom" | "left" | "topRight" | "topLeft" | "bottomRight" | "bottomLeft";
export function resizedFloatingBounds(start: FloatingBounds, edge: ResizeEdge, dx: number, dy: number, viewport: { width: number; height: number; top?: number }): FloatingBounds {
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max));
const minWidth = Math.min(320, viewport.width - 16);
const insetTop = viewport.top ?? 8;
const minHeight = Math.min(260, viewport.height - insetTop - 8);
let left = start.x, top = start.y, right = start.x + start.width, bottom = start.y + start.height;
const direction = edge.toLowerCase();
if (direction.includes("left")) left = clamp(left + dx, 8, right - minWidth);
if (direction.includes("right")) right = clamp(right + dx, left + minWidth, viewport.width - 8);
if (direction.includes("top")) top = clamp(top + dy, insetTop, bottom - minHeight);
if (direction.includes("bottom")) bottom = clamp(bottom + dy, top + minHeight, viewport.height - 8);
return { x: left, y: top, width: right - left, height: bottom - top };
}
/** Resize the shell per frame so its contents reflow naturally; commit bounds on release. */
export function useFloatingWindowResize({ enabled, bounds, onCommit }: {
enabled: boolean;
bounds: FloatingBounds;
onCommit: (bounds: FloatingBounds) => void;
}) {
const cancel = useRef<(() => void) | null>(null);
useLayoutEffect(() => {
const stop = () => cancel.current?.();
window.addEventListener("resize", stop);
window.addEventListener("blur", stop);
return () => { stop(); window.removeEventListener("resize", stop); window.removeEventListener("blur", stop); };
}, [enabled]);
return (event: ReactPointerEvent<HTMLDivElement>, edge: ResizeEdge) => {
if (!enabled || !event.isPrimary || event.button !== 0 || cancel.current) return;
const handle = event.currentTarget;
const shell = handle.closest<HTMLElement>("[data-floating-window]");
if (!shell) return;
event.preventDefault();
event.stopPropagation();
shell.querySelector<HTMLElement>(".floating-window-heading")?.focus({ preventScroll: true });
const pointerId = event.pointerId;
const startX = event.clientX, startY = event.clientY;
const viewport = { width: window.innerWidth, height: window.innerHeight, top: floatingWindowTopInset() };
const shellStyle = { width: shell.style.width, height: shell.style.height, translate: shell.style.translate };
const selection = window.document.body.style.userSelect;
let next = bounds;
let frame = 0;
shell.dataset.floatingWindowResizing = "true";
window.document.body.style.userSelect = "none";
const update = (pointer: PointerEvent) => {
next = resizedFloatingBounds(bounds, edge, pointer.clientX - startX, pointer.clientY - startY, viewport);
};
const paint = () => {
frame = 0;
shell.style.width = `${next.width}px`;
shell.style.height = `${next.height}px`;
shell.style.translate = `${next.x - bounds.x}px ${next.y - bounds.y}px`;
};
const move = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
update(pointer);
if (!frame) frame = requestAnimationFrame(paint);
};
const cleanup = () => {
cancel.current = null;
cancelAnimationFrame(frame);
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", abort);
handle.removeEventListener("lostpointercapture", abort);
window.removeEventListener("keydown", escape, true);
Object.assign(shell.style, shellStyle);
window.document.body.style.userSelect = selection;
delete shell.dataset.floatingWindowResizing;
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
};
const abort = (pointer: PointerEvent) => { if (pointer.pointerId === pointerId) cleanup(); };
const escape = (key: KeyboardEvent) => {
if (key.key !== "Escape") return;
key.preventDefault(); key.stopPropagation(); cleanup();
};
const finish = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
update(pointer);
cleanup();
if (next.x !== bounds.x || next.y !== bounds.y || next.width !== bounds.width || next.height !== bounds.height)
flushSync(() => onCommit(next));
};
cancel.current = cleanup;
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", abort);
handle.addEventListener("lostpointercapture", abort);
window.addEventListener("keydown", escape, true);
handle.setPointerCapture(pointerId);
};
}