1import { type PointerEvent as ReactPointerEvent } from "react";
2import { type SensorProps } from "@dnd-kit/core";
3import { getOwnerDocument, getWindow } from "@dnd-kit/utilities";
4
5type Options = {
6 distance: number;
7 onAttach: (sensor: SidebarPointerSensor) => void;
8 onFinish: (started: boolean) => void;
9 /** Return true to claim the move and hide this position from sorting. */
10 onMove?: (point: { x: number; y: number }) => boolean;
11 /** Return true to consume the release and cancel sorting. */
12 onDrop?: (point: { x: number; y: number }) => boolean;
13};
14
15export class SidebarPointerSensor {
16 static activators = [
17 {
18 eventName: "onPointerDown" as const,
19 handler: ({ nativeEvent }: ReactPointerEvent) =>
20 nativeEvent.isPrimary && nativeEvent.button === 0,
21 },
22 ];
23 autoScrollEnabled = true;
24 private phase: "pending" | "dragging" | "finished" = "pending";
25 private readonly pointer: PointerEvent;
26 private readonly document: Document;
27 private readonly window: Window;
28
29 constructor(private readonly props: Omit<SensorProps<Options>, "activeNode" | "context">) {
30 this.pointer = props.event as PointerEvent;
31 this.document = getOwnerDocument(this.pointer.target);
32 this.window = getWindow(this.pointer.target);
33 this.document.addEventListener("pointermove", this.move, { passive: false, capture: true });
34 this.document.addEventListener("pointerup", this.end, { capture: true });
35 this.document.addEventListener("pointercancel", this.pointerCancel, { capture: true });
36 this.document.addEventListener("keydown", this.keydown, { capture: true });
37 this.document.addEventListener("visibilitychange", this.visibilityChange);
38 this.window.addEventListener("blur", this.cancel);
39 this.window.addEventListener("pagehide", this.cancel);
40 this.window.addEventListener("resize", this.cancel);
41 this.document.addEventListener("dragstart", this.preventDefault);
42 this.document.addEventListener("contextmenu", this.preventDefault);
43 props.options.onAttach(this);
44 props.onPending(props.active, { distance: props.options.distance }, this.coordinates());
45 }
46
47 private coordinates = () => ({ x: this.pointer.clientX, y: this.pointer.clientY });
48 private preventDefault = (event: Event) => event.preventDefault();
49 private clearClickSuppression = () => {
50 this.document.removeEventListener("click", this.suppressClick, { capture: true });
51 this.document.removeEventListener("pointerdown", this.clearClickSuppression, { capture: true });
52 };
53 private suppressClick = (event: Event) => {
54 event.stopPropagation();
55 this.clearClickSuppression();
56 };
57 private clearSelection = () => this.document.getSelection()?.removeAllRanges();
58
59 private move = (event: PointerEvent) => {
60 if (this.phase === "finished" || event.pointerId !== this.pointer.pointerId) return;
61 const coordinates = { x: event.clientX, y: event.clientY };
62 // Desktop may report buttons=0 before pointerup: a pending press is released like pointerup so its
63 // click lands; an active drag drops context before cancelling sort.
64 if ((event.buttons & 1) === 0) {
65 if (this.phase === "pending") return this.finish(false);
66 this.props.options.onDrop?.(coordinates);
67 return this.cancel();
68 }
69 if (this.phase === "pending") {
70 const offset = {
71 x: event.clientX - this.pointer.clientX,
72 y: event.clientY - this.pointer.clientY,
73 };
74 if (Math.hypot(offset.x, offset.y) <= this.props.options.distance) {
75 this.props.onPending(
76 this.props.active,
77 { distance: this.props.options.distance },
78 this.coordinates(),
79 offset,
80 );
81 return;
82 }
83 this.phase = "dragging";
84 this.document.addEventListener("click", this.suppressClick, { capture: true });
85 this.document.addEventListener("selectionchange", this.clearSelection);
86 this.clearSelection();
87 this.props.onStart(this.coordinates());
88 }
89 if (this.phase === "dragging") {
90 if (event.cancelable) event.preventDefault();
91 if (this.props.options.onMove?.(coordinates) === true) return;
92 this.props.onMove(coordinates);
93 }
94 };
95
96 private end = (event: PointerEvent) => {
97 if (event.pointerId !== this.pointer.pointerId) return;
98 const dropped =
99 this.phase === "dragging" &&
100 this.props.options.onDrop?.({ x: event.clientX, y: event.clientY }) === true;
101 this.finish(dropped);
102 };
103 private pointerCancel = (event: PointerEvent) => {
104 if (event.pointerId === this.pointer.pointerId) this.cancel();
105 };
106 private keydown = (event: KeyboardEvent) => {
107 if (event.code === "Escape") this.cancel();
108 };
109 private visibilityChange = () => {
110 if (this.document.hidden) this.cancel();
111 };
112 cancel = () => this.finish(true);
113
114 private finish(cancelled: boolean) {
115 if (this.phase === "finished") return;
116 const aborted = this.phase === "pending";
117 this.phase = "finished";
118 this.document.removeEventListener("pointermove", this.move, { capture: true });
119 this.document.removeEventListener("pointerup", this.end, { capture: true });
120 this.document.removeEventListener("pointercancel", this.pointerCancel, { capture: true });
121 this.document.removeEventListener("keydown", this.keydown, { capture: true });
122 this.document.removeEventListener("visibilitychange", this.visibilityChange);
123 this.window.removeEventListener("blur", this.cancel);
124 this.window.removeEventListener("pagehide", this.cancel);
125 this.window.removeEventListener("resize", this.cancel);
126 this.document.removeEventListener("dragstart", this.preventDefault);
127 this.document.removeEventListener("contextmenu", this.preventDefault);
128 this.document.removeEventListener("selectionchange", this.clearSelection);
129 // Suppress the eventual click after cancellation; a fresh pointerdown clears suppression after
130 // out-of-document release.
131 if (!aborted || cancelled) {
132 this.document.addEventListener("click", this.suppressClick, { capture: true });
133 this.document.addEventListener("pointerdown", this.clearClickSuppression, { capture: true });
134 }
135 try {
136 // Release the preview before dnd-kit clears transforms; end/cancel events may be omitted before first
137 // layout.
138 this.props.options.onFinish(!aborted);
139 } finally {
140 if (aborted) this.props.onAbort(this.props.active);
141 if (cancelled) this.props.onCancel();
142 else this.props.onEnd();
143 }
144 }
145}