Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1f7ca7a9 |
@@ -4,6 +4,7 @@ import { Rnd } from "react-rnd";
|
|||||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
|
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
|
||||||
|
import { useFloatingWindowDrag } from "./use-floating-window-drag";
|
||||||
|
|
||||||
type Bounds = { x: number; y: number; width: number; height: number };
|
type Bounds = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
@@ -84,6 +85,9 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
|||||||
const current = maximized
|
const current = maximized
|
||||||
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
|
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
|
||||||
: bounds;
|
: bounds;
|
||||||
|
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
|
||||||
|
width: current.width, height: current.height,
|
||||||
|
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0"
|
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0"
|
||||||
@@ -102,8 +106,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
|||||||
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
|
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)}
|
||||||
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
|
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
|
||||||
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
|
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
|
||||||
disableDragging={maximized} enableResizing={!maximized}
|
disableDragging 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 }))}
|
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
|
||||||
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
|
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
|
||||||
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
|
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
|
||||||
@@ -123,7 +126,8 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
|
|||||||
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
|
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
|
||||||
}}>
|
}}>
|
||||||
<div ref={heading} tabIndex={0}
|
<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"
|
onPointerDown={startDrag}
|
||||||
|
className="floating-window-heading flex min-h-12 shrink-0 cursor-move touch-none select-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 加方向键调整大小"
|
aria-label="窗口标题栏,可用方向键移动,Shift 加方向键调整大小"
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
|
||||||
|
type Position = { x: number; y: number };
|
||||||
|
|
||||||
|
/** Move only the active DOM layer per frame; persist React bounds on release. */
|
||||||
|
export function useFloatingWindowDrag({ enabled, position, width, height, onCommit }: {
|
||||||
|
enabled: boolean;
|
||||||
|
position: Position;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
onCommit: (position: Position) => void;
|
||||||
|
}) {
|
||||||
|
const cancel = useRef<(() => void) | null>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const stop = () => cancel.current?.();
|
||||||
|
window.addEventListener("resize", stop);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
window.removeEventListener("resize", stop);
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!enabled || !event.isPrimary || event.button !== 0 || cancel.current
|
||||||
|
|| (event.target as Element).closest("button,input,select,textarea,a")) return;
|
||||||
|
const heading = event.currentTarget;
|
||||||
|
const element = heading.closest<HTMLElement>("[data-floating-window]");
|
||||||
|
if (!element) return;
|
||||||
|
event.preventDefault();
|
||||||
|
heading.focus({ preventScroll: true });
|
||||||
|
const pointerId = event.pointerId;
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
const maxX = window.innerWidth - width - 8;
|
||||||
|
const maxY = window.innerHeight - height - 8;
|
||||||
|
const previousTranslate = element.style.translate;
|
||||||
|
const previousWillChange = element.style.willChange;
|
||||||
|
let next = position;
|
||||||
|
let frame = 0;
|
||||||
|
element.style.willChange = "translate";
|
||||||
|
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))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const move = (pointer: PointerEvent) => {
|
||||||
|
if (pointer.pointerId !== pointerId) return;
|
||||||
|
updatePosition(pointer);
|
||||||
|
if (!frame) frame = requestAnimationFrame(() => {
|
||||||
|
frame = 0;
|
||||||
|
element.style.translate = `${next.x - position.x}px ${next.y - position.y}px`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
cancel.current = null;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
heading.removeEventListener("pointermove", move);
|
||||||
|
heading.removeEventListener("pointerup", finish);
|
||||||
|
heading.removeEventListener("pointercancel", abort);
|
||||||
|
heading.removeEventListener("lostpointercapture", abort);
|
||||||
|
element.style.translate = previousTranslate;
|
||||||
|
element.style.willChange = previousWillChange;
|
||||||
|
if (heading.hasPointerCapture(pointerId)) heading.releasePointerCapture(pointerId);
|
||||||
|
};
|
||||||
|
const abort = (pointer: PointerEvent) => {
|
||||||
|
if (pointer.pointerId === pointerId) cleanup();
|
||||||
|
};
|
||||||
|
const finish = (pointer: PointerEvent) => {
|
||||||
|
if (pointer.pointerId !== pointerId) return;
|
||||||
|
updatePosition(pointer);
|
||||||
|
// Commit and remove the temporary offset in one turn to avoid a snap.
|
||||||
|
if (next.x !== position.x || next.y !== position.y) flushSync(() => onCommit(next));
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
cancel.current = cleanup;
|
||||||
|
heading.addEventListener("pointermove", move);
|
||||||
|
heading.addEventListener("pointerup", finish);
|
||||||
|
heading.addEventListener("pointercancel", abort);
|
||||||
|
heading.addEventListener("lostpointercapture", abort);
|
||||||
|
heading.setPointerCapture(pointerId);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,133 @@ const chart = (index: number) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
// Keep unrelated avatar animation out of chart geometry and drag measurements.
|
||||||
|
test.use({ reducedMotion: "reduce" });
|
||||||
|
|
||||||
|
test("cancelled drags release capture and preserve restore, resizing and keyboard controls", async ({ page }) => {
|
||||||
|
const task = investigationFixture(1, "图窗拖动取消核查");
|
||||||
|
const mock = await mockInvestigationPresentation(page, [task]);
|
||||||
|
await selectInvestigation(page, task.title);
|
||||||
|
await mock.emit(task, chart(1));
|
||||||
|
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
|
||||||
|
await expect(popup).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||||
|
const heading = popup.locator(".floating-window-heading");
|
||||||
|
await heading.click({ trial: true, position: { x: 200, y: 24 } });
|
||||||
|
const original = (await popup.boundingBox())!;
|
||||||
|
await heading.evaluate((node) => node.addEventListener("pointerdown", (event) => {
|
||||||
|
(node as HTMLElement).dataset.testPointerId = String((event as PointerEvent).pointerId);
|
||||||
|
}, { once: true }));
|
||||||
|
await page.mouse.move(original.x + 200, original.y + 24);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(original.x + 100, original.y + 64, { steps: 4 });
|
||||||
|
await expect.poll(async () => Math.round((await popup.boundingBox())!.x)).toBe(original.x - 100);
|
||||||
|
await heading.evaluate((node) => node.dispatchEvent(new PointerEvent("pointercancel", {
|
||||||
|
pointerId: Number((node as HTMLElement).dataset.testPointerId)
|
||||||
|
})));
|
||||||
|
await page.mouse.up();
|
||||||
|
expect(await popup.boundingBox()).toEqual(original);
|
||||||
|
expect(await popup.evaluate((node) => (node as HTMLElement).style.willChange)).toBe("");
|
||||||
|
await popup.getByRole("button", { name: "最大化窗口", exact: true }).click();
|
||||||
|
expect((await popup.boundingBox())!.x).toBe(8);
|
||||||
|
await popup.getByRole("button", { name: "还原窗口", exact: true }).click();
|
||||||
|
expect(await popup.boundingBox()).toEqual(original);
|
||||||
|
await heading.focus();
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
expect((await popup.boundingBox())!.x).toBe(original.x + 16);
|
||||||
|
await page.mouse.move(original.x + 16 + original.width - 3, original.y + original.height - 3);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(original.x + 16 + original.width + 37, original.y + original.height + 27, { steps: 4 });
|
||||||
|
await page.mouse.up();
|
||||||
|
expect((await popup.boundingBox())!.width).toBe(original.width + 40);
|
||||||
|
expect((await popup.boundingBox())!.height).toBe(original.height + 30);
|
||||||
|
await heading.focus();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(popup).toHaveCount(0);
|
||||||
|
expect(mock.mutations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("profiles a single drag with eight comparison chart windows", async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||||
|
const task = investigationFixture(1, "多图拖动性能核查");
|
||||||
|
const mock = await mockInvestigationPresentation(page, [task]);
|
||||||
|
await selectInvestigation(page, task.title);
|
||||||
|
const slots = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
|
||||||
|
for (let index = 1; index <= 8; index++) {
|
||||||
|
await mock.emit(task, { action: "open_chart", chart_id: `comparison-${index}`, evidence_refs: ["synthetic:pressure"], chart: {
|
||||||
|
title: `合成七日同期曲线 ${index}`, chart_type: "time_comparison", x_data: slots,
|
||||||
|
series: [{ name: "当前", type: "line", data: slots.map((_, point) => 30 - index + point / 10),
|
||||||
|
sample_times: slots.map((time) => `2026-07-16T${time}:00+08:00`) },
|
||||||
|
...Array.from({ length: 7 }, (_, day) => ({ name: `前${day + 1}天`, type: "scatter",
|
||||||
|
data: slots.map((_, point) => point < 10 ? null : 30 + day / 10),
|
||||||
|
sample_times: slots.map((time, point) => point < 10 ? null : `2026-07-${String(15 - day).padStart(2, "0")}T${time}:00+08:00`) }))]
|
||||||
|
} });
|
||||||
|
await expect(page.getByRole("dialog", { name: `合成七日同期曲线 ${index}`, exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
await expect(page.locator("[data-floating-window]")).toHaveCount(8);
|
||||||
|
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
const popup = page.getByRole("dialog", { name: "合成七日同期曲线 8", exact: true });
|
||||||
|
const otherWindows = page.locator('[data-floating-window]').filter({ hasNot: page.getByRole("heading", { name: "合成七日同期曲线 8", exact: true }) });
|
||||||
|
const otherPositions = await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
|
||||||
|
const { x, y } = node.getBoundingClientRect();
|
||||||
|
return { x, y };
|
||||||
|
}));
|
||||||
|
const heading = (await popup.locator(".floating-window-heading").boundingBox())!;
|
||||||
|
const before = (await popup.boundingBox())!;
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
||||||
|
await cdp.send("Performance.enable");
|
||||||
|
const first = await cdp.send("Performance.getMetrics");
|
||||||
|
const events: Array<{ name: string; dur?: number }> = [];
|
||||||
|
cdp.on("Tracing.dataCollected", ({ value }) => events.push(...value));
|
||||||
|
await cdp.send("Tracing.start", { categories: "devtools.timeline", transferMode: "ReportEvents" });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.assign(window, { dragSvgMutations: 0, dragTransformWrites: 0 });
|
||||||
|
const observer = new MutationObserver((records) => {
|
||||||
|
(window as unknown as { dragSvgMutations: number }).dragSvgMutations += records.length;
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-floating-window] svg").forEach((node) => observer.observe(node, { subtree: true, attributes: true, childList: true }));
|
||||||
|
const windowObserver = new MutationObserver((records) => {
|
||||||
|
for (const record of records) {
|
||||||
|
const previous = document.createElement("div");
|
||||||
|
previous.setAttribute("style", record.oldValue ?? "");
|
||||||
|
if (previous.style.transform !== (record.target as HTMLElement).style.transform)
|
||||||
|
(window as unknown as { dragTransformWrites: number }).dragTransformWrites++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-floating-window]").forEach((node) => windowObserver.observe(node, { attributes: true, attributeFilter: ["style"], attributeOldValue: true }));
|
||||||
|
});
|
||||||
|
const x = heading.x + heading.width - 110;
|
||||||
|
const y = heading.y + heading.height / 2;
|
||||||
|
await page.mouse.move(x, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(x - 100, y - 60, { steps: 20 });
|
||||||
|
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-100);
|
||||||
|
await page.mouse.move(x - 200, y - 120, { steps: 20 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-200);
|
||||||
|
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-120);
|
||||||
|
const last = await cdp.send("Performance.getMetrics");
|
||||||
|
const finished = new Promise<void>((resolve) => cdp.once("Tracing.tracingComplete", () => resolve()));
|
||||||
|
await cdp.send("Tracing.end");
|
||||||
|
await finished;
|
||||||
|
const metric = (name: string) => (last.metrics.find((item) => item.name === name)?.value ?? 0) - (first.metrics.find((item) => item.name === name)?.value ?? 0);
|
||||||
|
const summary = { metrics: Object.fromEntries(["ScriptDuration", "LayoutDuration", "RecalcStyleDuration", "TaskDuration", "LayoutCount", "RecalcStyleCount"].map((name) => [name, metric(name)])),
|
||||||
|
trace: Object.fromEntries(["Paint", "Layout", "UpdateLayoutTree", "FunctionCall"].map((name) => [name, { count: events.filter((event) => event.name === name).length,
|
||||||
|
ms: events.filter((event) => event.name === name).reduce((total, event) => total + (event.dur ?? 0), 0) / 1000 }])),
|
||||||
|
svgMutations: await page.evaluate(() => (window as unknown as { dragSvgMutations: number }).dragSvgMutations),
|
||||||
|
transformWrites: await page.evaluate(() => (window as unknown as { dragTransformWrites: number }).dragTransformWrites) };
|
||||||
|
await testInfo.attach("drag-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
|
||||||
|
console.info("Window drag metrics", JSON.stringify(summary));
|
||||||
|
expect(summary.transformWrites).toBeLessThanOrEqual(2);
|
||||||
|
expect(summary.svgMutations).toBe(0);
|
||||||
|
expect(await otherWindows.evaluateAll((nodes) => nodes.map((node) => {
|
||||||
|
const { x, y } = node.getBoundingClientRect();
|
||||||
|
return { x, y };
|
||||||
|
}))).toEqual(otherPositions);
|
||||||
|
await testInfo.attach("multiple-charts-after-drag", { body: await page.screenshot({ path: testInfo.outputPath("multiple-charts-after-drag.png") }), contentType: "image/png" });
|
||||||
|
});
|
||||||
|
|
||||||
for (const height of [900, 720]) {
|
for (const height of [900, 720]) {
|
||||||
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => {
|
test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => {
|
||||||
@@ -19,15 +146,18 @@ for (const height of [900, 720]) {
|
|||||||
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
|
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
|
||||||
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||||
if (height === 900) await page.setViewportSize({ width: 1440, height: 720 });
|
if (height === 900) await page.setViewportSize({ width: 1440, height: 720 });
|
||||||
for (let index = 1; index <= 4; index++) {
|
for (let index = 1; index <= 4; index++) {
|
||||||
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
|
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
|
||||||
const heading = popup.locator(".floating-window-heading");
|
const heading = popup.locator(".floating-window-heading");
|
||||||
const box = (await heading.boundingBox())!;
|
const box = (await heading.boundingBox())!;
|
||||||
expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8));
|
expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8));
|
||||||
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
await expect.poll(async () => {
|
||||||
expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"), target))
|
const current = (await heading.boundingBox())!;
|
||||||
.toBe(await popup.getAttribute("aria-labelledby"));
|
return page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"),
|
||||||
|
{ x: current.x + current.width / 2, y: current.y + current.height / 2 });
|
||||||
|
}).toBe(await popup.getAttribute("aria-labelledby"));
|
||||||
}
|
}
|
||||||
await testInfo.attach(`multiple-charts-before-drag-${height}`, {
|
await testInfo.attach(`multiple-charts-before-drag-${height}`, {
|
||||||
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
|
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"
|
||||||
|
|||||||
Reference in New Issue
Block a user