Files
next-tjwater-frontend/src/shared/ui/use-floating-window-drag.ts
T
jiang ad1f7ca7a9
Generic Container CI/CD / test-build-publish (push) Successful in 1m20s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m20s
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.
2026-11-08 04:16:42 +08:00

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);
};
}