| 1 | import { useLayoutEffect, type PointerEvent as ReactPointerEvent } from "react"; |
| 2 | import { type SensorProps } from "@dnd-kit/core"; |
| 3 | import { getOwnerDocument, getWindow } from "@dnd-kit/utilities"; |
| 4 | |
| 5 | // Search unmounts the drag context while its owning Sidebar remains mounted. |
| 6 | export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { |
| 7 | useLayoutEffect(() => onUnmount, [onUnmount]); |
| 8 | return null; |
| 9 | } |
| 10 | |
| 11 | type 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. */ |
| 23 | export 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 | } |