1import { useLayoutEffect, type PointerEvent as ReactPointerEvent } from "react";
2import { type SensorProps } from "@dnd-kit/core";
3import { getOwnerDocument, getWindow } from "@dnd-kit/utilities";
4
5// Search unmounts the drag context while its owning Sidebar remains mounted.
6export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) {
7 useLayoutEffect(() => onUnmount, [onUnmount]);
8 return null;
9}
10
11type Options = {
12 distance: number;
13 onAttach: (sensor: SidebarPointerSensor) => void;
14 onFinish: (started: boolean) => void;
15 /** Return true to claim the move: the sort gesture then ignores this pointer position. */
16 onMove?: (point: { x: number; y: number }) => boolean;
17 /** Return true when the release was consumed elsewhere, so the sort is cancelled. */
18 onDrop?: (point: { x: number; y: number }) => boolean;
19};
20
21/** A sidebar gesture ends on release, cancellation, or loss of its window.
22 * Own the listeners so unmounting the list can cancel the sensor too. */
23export class SidebarPointerSensor {
24 static activators = [
25 {
26 eventName: "onPointerDown" as const,
27 handler: ({ nativeEvent }: ReactPointerEvent) =>
28 nativeEvent.isPrimary && nativeEvent.button === 0,
29 },
30 ];
31 autoScrollEnabled = true;
32 private phase: "pending" | "dragging" | "finished" = "pending";
33 private readonly pointer: PointerEvent;
34 private readonly document: Document;
35 private readonly window: Window;
36
37 // The settle sweep constructs this sensor directly, outside dnd-kit, so it
38 // takes only the props the gesture reads.
39 constructor(private readonly props: Omit<SensorProps<Options>, "activeNode" | "context">) {
40 this.pointer = props.event as PointerEvent;
41 this.document = getOwnerDocument(this.pointer.target);
42 this.window = getWindow(this.pointer.target);
43 this.document.addEventListener("pointermove", this.move, { passive: false, capture: true });
44 this.document.addEventListener("pointerup", this.end, { capture: true });
45 this.document.addEventListener("pointercancel", this.pointerCancel, { capture: true });
46 this.document.addEventListener("keydown", this.keydown, { capture: true });
47 this.document.addEventListener("visibilitychange", this.visibilityChange);
48 this.window.addEventListener("blur", this.cancel);
49 this.window.addEventListener("pagehide", this.cancel);
50 this.window.addEventListener("resize", this.cancel);
51 this.document.addEventListener("dragstart", this.preventDefault);
52 this.document.addEventListener("contextmenu", this.preventDefault);
53 props.options.onAttach(this);
54 props.onPending(props.active, { distance: props.options.distance }, this.coordinates());
55 }
56
57 private coordinates = () => ({ x: this.pointer.clientX, y: this.pointer.clientY });
58 private preventDefault = (event: Event) => event.preventDefault();
59 private clearClickSuppression = () => {
60 this.document.removeEventListener("click", this.suppressClick, { capture: true });
61 this.document.removeEventListener("pointerdown", this.clearClickSuppression, { capture: true });
62 };
63 private suppressClick = (event: Event) => {
64 event.stopPropagation();
65 this.clearClickSuppression();
66 };
67 private clearSelection = () => this.document.getSelection()?.removeAllRanges();
68
69 private move = (event: PointerEvent) => {
70 if (this.phase === "finished" || event.pointerId !== this.pointer.pointerId) return;
71 const coordinates = { x: event.clientX, y: event.clientY };
72 // Desktop can report buttons=0 on the final move before pointerup. Let an
73 // active context drag drop there before cancelling the sidebar sort.
74 if ((event.buttons & 1) === 0) {
75 if (this.phase === "dragging") this.props.options.onDrop?.(coordinates);
76 return this.cancel();
77 }
78 if (this.phase === "pending") {
79 const offset = {
80 x: event.clientX - this.pointer.clientX,
81 y: event.clientY - this.pointer.clientY,
82 };
83 if (Math.hypot(offset.x, offset.y) <= this.props.options.distance) {
84 this.props.onPending(
85 this.props.active,
86 { distance: this.props.options.distance },
87 this.coordinates(),
88 offset,
89 );
90 return;
91 }
92 this.phase = "dragging";
93 this.document.addEventListener("click", this.suppressClick, { capture: true });
94 this.document.addEventListener("selectionchange", this.clearSelection);
95 this.clearSelection();
96 this.props.onStart(this.coordinates());
97 }
98 // The move that starts a drag also moves it, so a release before the
99 // next pointermove still lands where the pointer is.
100 if (this.phase === "dragging") {
101 if (event.cancelable) event.preventDefault();
102 if (this.props.options.onMove?.(coordinates) === true) return;
103 this.props.onMove(coordinates);
104 }
105 };
106
107 private end = (event: PointerEvent) => {
108 if (event.pointerId !== this.pointer.pointerId) return;
109 const dropped =
110 this.phase === "dragging" &&
111 this.props.options.onDrop?.({ x: event.clientX, y: event.clientY }) === true;
112 this.finish(dropped);
113 };
114 private pointerCancel = (event: PointerEvent) => {
115 if (event.pointerId === this.pointer.pointerId) this.cancel();
116 };
117 private keydown = (event: KeyboardEvent) => {
118 if (event.code === "Escape") this.cancel();
119 };
120 private visibilityChange = () => {
121 if (this.document.hidden) this.cancel();
122 };
123 cancel = () => this.finish(true);
124
125 private finish(cancelled: boolean) {
126 if (this.phase === "finished") return;
127 const aborted = this.phase === "pending";
128 this.phase = "finished";
129 this.document.removeEventListener("pointermove", this.move, { capture: true });
130 this.document.removeEventListener("pointerup", this.end, { capture: true });
131 this.document.removeEventListener("pointercancel", this.pointerCancel, { capture: true });
132 this.document.removeEventListener("keydown", this.keydown, { capture: true });
133 this.document.removeEventListener("visibilitychange", this.visibilityChange);
134 this.window.removeEventListener("blur", this.cancel);
135 this.window.removeEventListener("pagehide", this.cancel);
136 this.window.removeEventListener("resize", this.cancel);
137 this.document.removeEventListener("dragstart", this.preventDefault);
138 this.document.removeEventListener("contextmenu", this.preventDefault);
139 this.document.removeEventListener("selectionchange", this.clearSelection);
140 // Cancellation can precede release by an arbitrary amount of time. Consume
141 // that release click, or let a fresh pointerdown end suppression if release
142 // happened outside the document. Ordinary clicks never install this guard.
143 if (!aborted || cancelled) {
144 this.document.addEventListener("click", this.suppressClick, { capture: true });
145 this.document.addEventListener("pointerdown", this.clearClickSuppression, { capture: true });
146 }
147 try {
148 // Release the sidebar preview before dnd-kit clears its transforms.
149 // Its public end/cancel event can be omitted before its first layout.
150 this.props.options.onFinish(!aborted);
151 } finally {
152 if (aborted) this.props.onAbort(this.props.active);
153 if (cancelled) this.props.onCancel();
154 else this.props.onEnd();
155 }
156 }
157}