diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx
index 223ced700..1e002ab48 100644
--- a/apps/yaak-proxy/components/FlowListHeader.tsx
+++ b/apps/yaak-proxy/components/FlowListHeader.tsx
@@ -21,6 +21,7 @@ import { useAtomValue, useSetAtom } from "jotai";
import {
memo,
type MouseEvent,
+ type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
@@ -102,7 +103,7 @@ export const FlowListHeader = memo(function FlowListHeader({
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
// Widths when a drag began, so each move sets the width from where it started.
const startWidth = useRef(0);
- const { dragging, sensors, dndHandlers, gridRef } = useColumnDrag({
+ const { dragging, sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
columns,
scrollElement,
onDropLine,
@@ -222,6 +223,7 @@ export const FlowListHeader = memo(function FlowListHeader({
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
{
- if (state.current != null) state.current.x = e.clientX;
- }, []);
-
const stop = useCallback(() => {
const s = state.current;
state.current = null;
- window.removeEventListener("pointermove", track, true);
if (s != null) cancelAnimationFrame(s.frame);
setDragging(null);
latest.current.onDropLine(null);
return s;
}, []);
- // A drag never outlives the header
- useEffect(
- () => () => {
- stop();
- },
- [stop],
- );
+ const onDragStart = useCallback((e: DragStartEvent) => {
+ const column = latest.current.columns.find((c) => c.id === e.active.id);
+ if (column == null) return;
+ state.current = { id: column.id, x: pointer.current, to: null, line: null, frame: 0 };
+ setDragging(column);
- const onDragStart = useCallback(
- (e: DragStartEvent) => {
- const column = latest.current.columns.find((c) => c.id === e.active.id);
- if (column == null) return;
- state.current = { id: column.id, x: pointerX(e), to: null, line: null, frame: 0 };
- window.addEventListener("pointermove", track, true);
- setDragging(column);
-
- const tick = () => {
- const s = state.current;
- if (s == null) return;
- // Near an edge, the list scrolls that way, faster the nearer; the header follows it.
- const list = latest.current.scrollElement();
- if (list != null) {
- const { left, right } = list.getBoundingClientRect();
- const near =
- s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
- if (near !== 0) {
- const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
- list.scrollLeft += Math.sign(near) * Math.max(1, speed);
- }
+ const tick = () => {
+ const s = state.current;
+ if (s == null) return;
+ // Near an edge, the list scrolls that way, faster the nearer; the header follows it.
+ const list = latest.current.scrollElement();
+ if (list != null) {
+ const { left, right } = list.getBoundingClientRect();
+ const near =
+ s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
+ if (near !== 0) {
+ const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
+ list.scrollLeft += Math.sign(near) * Math.max(1, speed);
}
- target(s);
- s.frame = requestAnimationFrame(tick);
- };
- state.current.frame = requestAnimationFrame(tick);
- // oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
- },
- [track],
- );
+ }
+ target(s);
+ s.frame = requestAnimationFrame(tick);
+ };
+ state.current.frame = requestAnimationFrame(tick);
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
+ }, []);
- const onDragEnd = useCallback(() => {
+ /** Drops the column where the pointer is, once; a second call finds no drag */
+ const drop = useCallback(() => {
// Where the pointer is now, as a release can come before the next frame reads it
if (state.current != null) target(state.current);
const s = stop();
@@ -480,6 +464,51 @@ function useColumnDrag({
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, [stop]);
+ // The pointer from a press on the header until its release, read before dnd-kit sees it.
+ // dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the
+ // release only once it has rendered the drag, so a release right after a move would drop
+ // where the pointer was a frame before, or not at all. The release drops it here instead.
+ const pointer = useRef(0);
+ const track = useCallback((e: PointerEvent) => {
+ pointer.current = e.clientX;
+ if (state.current != null) state.current.x = e.clientX;
+ }, []);
+ const unlisten = useRef<(() => void) | null>(null);
+ const onPointerDownCapture = useCallback(
+ (e: ReactPointerEvent) => {
+ unlisten.current?.();
+ pointer.current = e.clientX;
+ const up = (e: PointerEvent) => {
+ unlisten.current?.();
+ track(e);
+ drop();
+ };
+ const cancel = () => {
+ unlisten.current?.();
+ stop();
+ };
+ window.addEventListener("pointermove", track, true);
+ window.addEventListener("pointerup", up, true);
+ window.addEventListener("pointercancel", cancel, true);
+ unlisten.current = () => {
+ unlisten.current = null;
+ window.removeEventListener("pointermove", track, true);
+ window.removeEventListener("pointerup", up, true);
+ window.removeEventListener("pointercancel", cancel, true);
+ };
+ },
+ [track, drop, stop],
+ );
+
+ // A drag never outlives the header
+ useEffect(
+ () => () => {
+ unlisten.current?.();
+ stop();
+ },
+ [stop],
+ );
+
/** Sets where the drag would drop, from the pointer and the labels where they are now */
function target(s: NonNullable) {
// The boundary before the first label whose middle is right of the pointer
@@ -503,12 +532,7 @@ function useColumnDrag({
dragging,
sensors,
gridRef,
- dndHandlers: { onDragStart, onDragEnd, onDragCancel: stop },
+ onPointerDownCapture,
+ dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: stop },
};
}
-
-/** Where the press that began a drag was, across the window */
-function pointerX(e: DragStartEvent): number {
- const event = e.activatorEvent;
- return event instanceof globalThis.MouseEvent ? event.clientX : 0;
-}