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.
85 lines
3.3 KiB
TypeScript
85 lines
3.3 KiB
TypeScript
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);
|
|
};
|
|
}
|