feat(charts): manage investigation charts in a scrolling workspace
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user