fix(charts): move floating windows without per-event renders
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user