fix(charts): move floating windows without per-event renders
Generic Container CI/CD / test-build-publish (push) Successful in 1m20s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m20s

The earlier placement fix exposed chart headings but retained react-rnd state updates on every mousemove, so dragging still stalled with several charts open. Move the active window through pointer capture and a frame-scheduled DOM translation, then commit its bounds once on release.

Cover cancellation, resize and restore controls, and eight-window dragging. The regression records 40 React transform writes on the old code and one on the fix while other windows and chart SVGs stay unchanged.
This commit is contained in:
2026-11-08 04:16:42 +08:00
parent aa9b12662e
commit ad1f7ca7a9
3 changed files with 224 additions and 6 deletions
+7 -3
View File
@@ -4,6 +4,7 @@ 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 { useFloatingWindowDrag } from "./use-floating-window-drag";
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
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 }
: bounds;
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
width: current.width, height: current.height,
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
return <>
<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)}
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 }))}
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" }}
@@ -123,7 +126,8 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
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"
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 加方向键调整大小"
onKeyDown={(event) => {
if (event.target !== event.currentTarget || maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
+84
View File
@@ -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);
// 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]) {
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 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 });
for (let index = 1; index <= 4; index++) {
const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true });
const heading = popup.locator(".floating-window-heading");
const box = (await heading.boundingBox())!;
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 };
expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"), target))
.toBe(await popup.getAttribute("aria-labelledby"));
await expect.poll(async () => {
const current = (await heading.boundingBox())!;
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}`, {
body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png"