dnd-manager 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +0 -0
- package/README.md +286 -761
- package/dist/index.d.mts +162 -20
- package/dist/index.mjs +480 -75
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -15
package/dist/index.mjs
CHANGED
|
@@ -1,19 +1,142 @@
|
|
|
1
|
+
//#region src/auto-scroll.ts
|
|
2
|
+
const SCROLLABLE_OVERFLOW = /^(auto|scroll|overlay)$/;
|
|
3
|
+
/** Whether `element` may scroll on an axis: its overflow allows it and its content overflows. */
|
|
4
|
+
function scrollsOn(element, style, axis) {
|
|
5
|
+
return axis === "y" ? SCROLLABLE_OVERFLOW.test(style.overflowY) && element.scrollHeight > element.clientHeight : SCROLLABLE_OVERFLOW.test(style.overflowX) && element.scrollWidth > element.clientWidth;
|
|
6
|
+
}
|
|
7
|
+
/** The step on one axis: toward the edge the pointer is near, if the scroller can still go there. */
|
|
8
|
+
function stepOn(pointer, start, end, zone, speed, canGoBack, canGoForward) {
|
|
9
|
+
if (pointer < start + zone && canGoBack) return -speed;
|
|
10
|
+
if (pointer > end - zone && canGoForward) return speed;
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Scrolls one step toward the edge the pointer is near: the closest scroll container around
|
|
15
|
+
* `hit` (the element under the pointer) that can still scroll that way, or else the window.
|
|
16
|
+
* Returns whether anything scrolled, so the caller keeps going while the pointer rests there.
|
|
17
|
+
*/
|
|
18
|
+
function autoScrollStep(pos, hit, { threshold, speed }) {
|
|
19
|
+
if (speed <= 0 || threshold <= 0) return false;
|
|
20
|
+
for (let element = hit; element; element = element.parentElement) {
|
|
21
|
+
if (element === document.body || element === document.documentElement) break;
|
|
22
|
+
const style = getComputedStyle(element);
|
|
23
|
+
const scrollsX = scrollsOn(element, style, "x");
|
|
24
|
+
const scrollsY = scrollsOn(element, style, "y");
|
|
25
|
+
if (!scrollsX && !scrollsY) continue;
|
|
26
|
+
const rect = element.getBoundingClientRect();
|
|
27
|
+
const left = scrollsX ? stepOn(pos.x, rect.left, rect.right, Math.min(threshold, rect.width / 4), speed, element.scrollLeft > 0, element.scrollLeft + element.clientWidth < element.scrollWidth - 1) : 0;
|
|
28
|
+
const top = scrollsY ? stepOn(pos.y, rect.top, rect.bottom, Math.min(threshold, rect.height / 4), speed, element.scrollTop > 0, element.scrollTop + element.clientHeight < element.scrollHeight - 1) : 0;
|
|
29
|
+
if (left !== 0 || top !== 0) {
|
|
30
|
+
element.scrollBy({
|
|
31
|
+
left,
|
|
32
|
+
top,
|
|
33
|
+
behavior: "instant"
|
|
34
|
+
});
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const left = stepOn(pos.x, 0, window.innerWidth, threshold, speed, true, true);
|
|
39
|
+
const top = stepOn(pos.y, 0, window.innerHeight, threshold, speed, true, true);
|
|
40
|
+
if (left === 0 && top === 0) return false;
|
|
41
|
+
const before = [window.scrollX, window.scrollY];
|
|
42
|
+
window.scrollBy({
|
|
43
|
+
left,
|
|
44
|
+
top,
|
|
45
|
+
behavior: "instant"
|
|
46
|
+
});
|
|
47
|
+
return window.scrollX !== before[0] || window.scrollY !== before[1];
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
1
50
|
//#region src/drag-drop-manager.ts
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Framework-agnostic Drag and Drop Manager
|
|
53
|
+
*
|
|
54
|
+
* A data-driven drag-and-drop utility that works with React and vanilla JS.
|
|
55
|
+
* Uses data attributes and direct DOM manipulation for optimal performance.
|
|
56
|
+
*
|
|
57
|
+
* @template TItem - The type of data associated with draggable items
|
|
58
|
+
* @template TPosition - The type representing position/coordinates (e.g., {x, y}, {box, cell}, etc.)
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* Presses a manager took: with nested containers the inner manager hears the `pointerdown` first
|
|
62
|
+
* and takes it, and the outer ones leave it alone.
|
|
63
|
+
*/
|
|
64
|
+
const claimedPresses = /* @__PURE__ */ new WeakSet();
|
|
65
|
+
/** The pointer events of a press that a detached source still receives (see handlePointerDown). */
|
|
66
|
+
const SOURCE_POINTER_EVENTS = [
|
|
67
|
+
"pointermove",
|
|
68
|
+
"pointerup",
|
|
69
|
+
"pointercancel"
|
|
70
|
+
];
|
|
71
|
+
/** Moves the boolean attribute `name` from `from` to `to`, and returns `to`. */
|
|
72
|
+
function moveMark(name, from, to) {
|
|
73
|
+
if (from === to) return to;
|
|
74
|
+
from?.removeAttribute(name);
|
|
75
|
+
to?.setAttribute(name, "true");
|
|
76
|
+
return to;
|
|
77
|
+
}
|
|
78
|
+
function idleState() {
|
|
79
|
+
return {
|
|
7
80
|
startPosition: null,
|
|
8
81
|
sourceElement: null,
|
|
9
82
|
sourcePosition: null,
|
|
10
83
|
sourceItem: null,
|
|
11
84
|
activePointerId: null,
|
|
85
|
+
input: null,
|
|
12
86
|
isDragging: false,
|
|
13
|
-
lastHoveredElement: null
|
|
87
|
+
lastHoveredElement: null,
|
|
88
|
+
lastInvalidElement: null,
|
|
89
|
+
lastDropResult: null
|
|
14
90
|
};
|
|
91
|
+
}
|
|
92
|
+
const centerOf = (rect) => ({
|
|
93
|
+
x: rect.left + rect.width / 2,
|
|
94
|
+
y: rect.top + rect.height / 2
|
|
95
|
+
});
|
|
96
|
+
const ARROWS = {
|
|
97
|
+
ArrowLeft: {
|
|
98
|
+
axis: "x",
|
|
99
|
+
sign: -1
|
|
100
|
+
},
|
|
101
|
+
ArrowRight: {
|
|
102
|
+
axis: "x",
|
|
103
|
+
sign: 1
|
|
104
|
+
},
|
|
105
|
+
ArrowUp: {
|
|
106
|
+
axis: "y",
|
|
107
|
+
sign: -1
|
|
108
|
+
},
|
|
109
|
+
ArrowDown: {
|
|
110
|
+
axis: "y",
|
|
111
|
+
sign: 1
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const isActionKey = (e) => (e.key === " " || e.key === "Enter") && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
|
|
115
|
+
var DragDropManager = class {
|
|
116
|
+
container;
|
|
117
|
+
config;
|
|
118
|
+
draggableKinds;
|
|
119
|
+
droppableKinds;
|
|
120
|
+
callbacks;
|
|
121
|
+
state = idleState();
|
|
15
122
|
rafId = null;
|
|
16
123
|
lastPointerPos = null;
|
|
124
|
+
/**
|
|
125
|
+
* Set while a drag holds the pointer down: the browser follows the release with a native
|
|
126
|
+
* `click` (on the source element under pointer capture), which must not activate it.
|
|
127
|
+
*/
|
|
128
|
+
suppressClickOnRelease = false;
|
|
129
|
+
releaseClickBlocker = null;
|
|
130
|
+
/** The pending `dragDelay` of the current press. */
|
|
131
|
+
delayTimer = null;
|
|
132
|
+
activePointerType = "";
|
|
133
|
+
/** The droppable a keyboard drag points at. */
|
|
134
|
+
keyboardTarget = null;
|
|
135
|
+
/**
|
|
136
|
+
* The key that picked up or dropped: its `keyup` would activate a focused button (Space clicks
|
|
137
|
+
* on keyup), so it is swallowed.
|
|
138
|
+
*/
|
|
139
|
+
swallowKeyUp = null;
|
|
17
140
|
boundHandlers;
|
|
18
141
|
constructor(containerRef, config, callbacks) {
|
|
19
142
|
if (typeof containerRef === "string") {
|
|
@@ -26,14 +149,21 @@ var DragDropManager = class {
|
|
|
26
149
|
} else this.container = containerRef;
|
|
27
150
|
this.config = {
|
|
28
151
|
dragThreshold: config.dragThreshold ?? 10,
|
|
152
|
+
dragDelay: config.dragDelay ?? 0,
|
|
153
|
+
dragDelayTolerance: config.dragDelayTolerance ?? 5,
|
|
29
154
|
clickThreshold: config.clickThreshold ?? 10,
|
|
30
155
|
scrollThreshold: config.scrollThreshold ?? 100,
|
|
31
156
|
scrollSpeed: config.scrollSpeed ?? 10,
|
|
32
157
|
cancelOnEscape: config.cancelOnEscape ?? true,
|
|
33
158
|
cancelOnPointerLeave: config.cancelOnPointerLeave ?? true,
|
|
34
159
|
draggableKind: config.draggableKind,
|
|
35
|
-
droppableKind: config.droppableKind
|
|
160
|
+
droppableKind: config.droppableKind,
|
|
161
|
+
dropScope: config.dropScope ?? "document",
|
|
162
|
+
keyboard: config.keyboard ?? false,
|
|
163
|
+
focusOnPress: config.focusOnPress ?? false
|
|
36
164
|
};
|
|
165
|
+
this.draggableKinds = this.normalizeKindList(this.config.draggableKind);
|
|
166
|
+
this.droppableKinds = this.normalizeKindList(this.config.droppableKind);
|
|
37
167
|
this.callbacks = callbacks;
|
|
38
168
|
this.boundHandlers = {
|
|
39
169
|
pointerDown: this.handlePointerDown.bind(this),
|
|
@@ -41,43 +171,87 @@ var DragDropManager = class {
|
|
|
41
171
|
pointerUp: this.handlePointerUp.bind(this),
|
|
42
172
|
pointerCancel: this.handlePointerCancel.bind(this),
|
|
43
173
|
keyDown: this.handleKeyDown.bind(this),
|
|
44
|
-
mouseOut: this.handleMouseOut.bind(this)
|
|
174
|
+
mouseOut: this.handleMouseOut.bind(this),
|
|
175
|
+
touchMove: this.handleTouchMove.bind(this),
|
|
176
|
+
detachedSourcePointer: this.handleDetachedSourcePointer.bind(this),
|
|
177
|
+
contextMenu: this.handleContextMenu.bind(this),
|
|
178
|
+
preventDefault: (e) => e.preventDefault(),
|
|
179
|
+
pickUpKey: this.handlePickUpKey.bind(this),
|
|
180
|
+
keyboardDragKey: this.handleKeyboardDragKey.bind(this),
|
|
181
|
+
keyUp: this.handleKeyUp.bind(this),
|
|
182
|
+
focusOut: this.handleFocusOut.bind(this)
|
|
45
183
|
};
|
|
46
184
|
this.attachListeners();
|
|
47
185
|
}
|
|
48
186
|
attachListeners() {
|
|
49
187
|
this.container.addEventListener("pointerdown", this.boundHandlers.pointerDown);
|
|
50
|
-
this.container.addEventListener("pointermove", this.boundHandlers.pointerMove);
|
|
51
188
|
this.container.addEventListener("pointerup", this.boundHandlers.pointerUp);
|
|
52
189
|
this.container.addEventListener("pointercancel", this.boundHandlers.pointerCancel);
|
|
53
190
|
window.addEventListener("pointerup", this.boundHandlers.pointerUp);
|
|
54
191
|
window.addEventListener("pointercancel", this.boundHandlers.pointerCancel);
|
|
55
192
|
window.addEventListener("keydown", this.boundHandlers.keyDown);
|
|
56
193
|
window.addEventListener("mouseout", this.boundHandlers.mouseOut);
|
|
194
|
+
this.container.addEventListener("touchmove", this.boundHandlers.touchMove, { passive: false });
|
|
195
|
+
if (this.config.keyboard) {
|
|
196
|
+
this.container.addEventListener("keydown", this.boundHandlers.pickUpKey);
|
|
197
|
+
window.addEventListener("keydown", this.boundHandlers.keyboardDragKey, true);
|
|
198
|
+
window.addEventListener("keyup", this.boundHandlers.keyUp, true);
|
|
199
|
+
window.addEventListener("focusout", this.boundHandlers.focusOut, true);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The source position and item of the draggable `match`, if it may be dragged: it has a
|
|
204
|
+
* position, is not `data-empty`, `canDrag` allows it and it has item data.
|
|
205
|
+
*/
|
|
206
|
+
resolveSource(match) {
|
|
207
|
+
const position = this.callbacks.getItemPosition(match.element, match.kind);
|
|
208
|
+
if (!position) return null;
|
|
209
|
+
if (match.element.dataset.empty !== void 0) return null;
|
|
210
|
+
if (this.callbacks.canDrag && !this.callbacks.canDrag(match.element, position)) return null;
|
|
211
|
+
const item = this.callbacks.getItemData?.(match.element, position) ?? null;
|
|
212
|
+
if (!item) return null;
|
|
213
|
+
return {
|
|
214
|
+
position,
|
|
215
|
+
item
|
|
216
|
+
};
|
|
57
217
|
}
|
|
58
218
|
handlePointerDown(e) {
|
|
59
219
|
if (this.state.startPosition) return;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!
|
|
69
|
-
|
|
220
|
+
if (e.button !== 0 || e.pointerType === "touch" && !e.isPrimary) return;
|
|
221
|
+
if (claimedPresses.has(e)) return;
|
|
222
|
+
const draggableMatch = this.findElementByKinds(e.target, this.draggableKinds);
|
|
223
|
+
if (!draggableMatch) return;
|
|
224
|
+
const source = this.resolveSource(draggableMatch);
|
|
225
|
+
if (!source) return;
|
|
226
|
+
const draggableElement = draggableMatch.element;
|
|
227
|
+
claimedPresses.add(e);
|
|
228
|
+
if (!this.config.focusOnPress) e.preventDefault();
|
|
229
|
+
window.addEventListener("selectstart", this.boundHandlers.preventDefault, true);
|
|
230
|
+
window.addEventListener("dragstart", this.boundHandlers.preventDefault, true);
|
|
70
231
|
this.state.startPosition = {
|
|
71
232
|
x: e.clientX,
|
|
72
233
|
y: e.clientY
|
|
73
234
|
};
|
|
74
235
|
this.state.sourceElement = draggableElement;
|
|
75
|
-
this.state.sourcePosition = position;
|
|
76
|
-
this.state.sourceItem = item;
|
|
236
|
+
this.state.sourcePosition = source.position;
|
|
237
|
+
this.state.sourceItem = source.item;
|
|
238
|
+
this.state.input = "pointer";
|
|
77
239
|
this.state.activePointerId = this.getPointerId(e);
|
|
240
|
+
this.activePointerType = e.pointerType ?? "";
|
|
241
|
+
this.lastPointerPos = this.state.startPosition;
|
|
78
242
|
if (this.state.activePointerId !== null && typeof draggableElement.setPointerCapture === "function") try {
|
|
79
243
|
draggableElement.setPointerCapture(this.state.activePointerId);
|
|
80
244
|
} catch {}
|
|
245
|
+
window.addEventListener("pointermove", this.boundHandlers.pointerMove);
|
|
246
|
+
draggableElement.addEventListener("touchmove", this.boundHandlers.touchMove, { passive: false });
|
|
247
|
+
for (const type of SOURCE_POINTER_EVENTS) draggableElement.addEventListener(type, this.boundHandlers.detachedSourcePointer);
|
|
248
|
+
window.addEventListener("contextmenu", this.boundHandlers.contextMenu);
|
|
249
|
+
const delay = this.delayFor(this.activePointerType);
|
|
250
|
+
if (delay > 0) this.delayTimer = setTimeout(() => {
|
|
251
|
+
this.delayTimer = null;
|
|
252
|
+
this.startDrag();
|
|
253
|
+
this.scheduleFrame();
|
|
254
|
+
}, delay);
|
|
81
255
|
}
|
|
82
256
|
handlePointerMove(e) {
|
|
83
257
|
if (!this.isActivePointer(e)) return;
|
|
@@ -86,36 +260,82 @@ var DragDropManager = class {
|
|
|
86
260
|
y: e.clientY
|
|
87
261
|
};
|
|
88
262
|
if (this.state.startPosition && !this.state.isDragging) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
263
|
+
const distance = this.getDistance(this.state.startPosition, currentPos);
|
|
264
|
+
if (this.delayTimer !== null) {
|
|
265
|
+
if (distance > this.config.dragDelayTolerance) this.cleanup();
|
|
266
|
+
else this.lastPointerPos = currentPos;
|
|
267
|
+
return;
|
|
95
268
|
}
|
|
269
|
+
if (distance >= this.config.dragThreshold) this.startDrag();
|
|
96
270
|
return;
|
|
97
271
|
}
|
|
98
272
|
if (!this.state.isDragging) return;
|
|
99
273
|
this.lastPointerPos = currentPos;
|
|
100
|
-
|
|
274
|
+
this.scheduleFrame();
|
|
275
|
+
}
|
|
276
|
+
startDrag() {
|
|
277
|
+
this.state.isDragging = true;
|
|
278
|
+
this.suppressClickOnRelease = this.state.input === "pointer";
|
|
279
|
+
if (this.state.sourceElement && this.state.sourcePosition && this.state.sourceItem) {
|
|
280
|
+
this.state.sourceElement.setAttribute("data-dragging", "true");
|
|
281
|
+
this.callbacks.onDragStart?.(this.state.sourceElement, this.state.sourcePosition, this.state.sourceItem);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** The `dragDelay` for a pointer type (`''` from a constructed event counts as a mouse). */
|
|
285
|
+
delayFor(pointerType) {
|
|
286
|
+
const { dragDelay } = this.config;
|
|
287
|
+
if (typeof dragDelay === "number") return dragDelay;
|
|
288
|
+
return dragDelay[pointerType === "touch" || pointerType === "pen" ? pointerType : "mouse"] ?? 0;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Once a touch press is a drag, the finger moves the item, not the page: a browser that starts
|
|
292
|
+
* panning would cancel the pointer. Before that, touches stay the browser's.
|
|
293
|
+
*/
|
|
294
|
+
handleTouchMove(e) {
|
|
295
|
+
if (this.state.isDragging && e.cancelable) e.preventDefault();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The press's pointer events, heard on the source itself. While it is in the DOM they also reach
|
|
299
|
+
* the window's listeners, which handle them; once it is detached only these hear them.
|
|
300
|
+
*/
|
|
301
|
+
handleDetachedSourcePointer(e) {
|
|
302
|
+
if (this.state.sourceElement?.isConnected !== false) return;
|
|
303
|
+
if (e.type === "pointermove") this.handlePointerMove(e);
|
|
304
|
+
else if (e.type === "pointerup") this.handlePointerUp(e);
|
|
305
|
+
else this.handlePointerCancel();
|
|
306
|
+
}
|
|
307
|
+
/** A long press opens no context menu (Android, iOS) while it is a drag or about to be one. */
|
|
308
|
+
handleContextMenu(e) {
|
|
309
|
+
const pendingTouch = this.delayTimer !== null && this.activePointerType === "touch";
|
|
310
|
+
if (this.state.isDragging || pendingTouch) e.preventDefault();
|
|
311
|
+
}
|
|
312
|
+
scheduleFrame() {
|
|
313
|
+
if (this.rafId !== null) return;
|
|
314
|
+
this.rafId = requestAnimationFrame(() => {
|
|
101
315
|
this.rafId = null;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
this.callbacks.onDragMove?.(pos, this.state.lastHoveredElement);
|
|
316
|
+
this.runFrame();
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** One drag frame: hover feedback, auto-scroll and `onDragMove` at the latest pointer position. */
|
|
320
|
+
runFrame() {
|
|
321
|
+
const pos = this.lastPointerPos;
|
|
322
|
+
if (!pos || !this.state.isDragging) return;
|
|
323
|
+
const elementUnderCursor = document.elementFromPoint(pos.x, pos.y);
|
|
324
|
+
this.markTarget(this.findDroppable(elementUnderCursor));
|
|
325
|
+
const scrolled = autoScrollStep(pos, elementUnderCursor, {
|
|
326
|
+
threshold: this.config.scrollThreshold,
|
|
327
|
+
speed: this.config.scrollSpeed
|
|
115
328
|
});
|
|
329
|
+
this.callbacks.onDragMove?.(pos, this.state.lastHoveredElement);
|
|
330
|
+
if (scrolled) this.scheduleFrame();
|
|
116
331
|
}
|
|
117
332
|
handlePointerUp(e) {
|
|
333
|
+
if (this.state.input === "keyboard") return;
|
|
118
334
|
if (!this.isActivePointer(e)) return;
|
|
335
|
+
if (this.suppressClickOnRelease) {
|
|
336
|
+
this.suppressClickOnRelease = false;
|
|
337
|
+
this.blockNextClick();
|
|
338
|
+
}
|
|
119
339
|
if (this.state.startPosition && !this.state.isDragging) {
|
|
120
340
|
const currentPos = {
|
|
121
341
|
x: e.clientX,
|
|
@@ -130,14 +350,24 @@ var DragDropManager = class {
|
|
|
130
350
|
return;
|
|
131
351
|
}
|
|
132
352
|
const target = document.elementFromPoint(e.clientX, e.clientY);
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
const targetPosition = this.
|
|
136
|
-
if (targetPosition)
|
|
353
|
+
const droppableMatch = this.findDroppable(target);
|
|
354
|
+
if ((droppableMatch?.element ?? null) && droppableMatch) {
|
|
355
|
+
const targetPosition = this.acceptedPosition(droppableMatch);
|
|
356
|
+
if (targetPosition) {
|
|
357
|
+
const result = {
|
|
358
|
+
sourcePosition: this.state.sourcePosition,
|
|
359
|
+
targetPosition,
|
|
360
|
+
sourceItem: this.state.sourceItem
|
|
361
|
+
};
|
|
362
|
+
this.state.lastDropResult = result;
|
|
363
|
+
this.callbacks.onDrop?.(result.sourcePosition, result.targetPosition, result.sourceItem);
|
|
364
|
+
}
|
|
137
365
|
}
|
|
138
366
|
this.cleanup();
|
|
139
367
|
}
|
|
140
368
|
handlePointerCancel() {
|
|
369
|
+
if (this.state.input === "keyboard") return;
|
|
370
|
+
this.suppressClickOnRelease = false;
|
|
141
371
|
this.cleanup();
|
|
142
372
|
}
|
|
143
373
|
handleKeyDown(e) {
|
|
@@ -147,44 +377,162 @@ var DragDropManager = class {
|
|
|
147
377
|
this.cleanup();
|
|
148
378
|
}
|
|
149
379
|
handleMouseOut(e) {
|
|
150
|
-
if (!this.config.cancelOnPointerLeave) return;
|
|
151
|
-
if (!this.state.startPosition) return;
|
|
152
380
|
if (e.relatedTarget !== null) return;
|
|
381
|
+
this.suppressClickOnRelease = false;
|
|
382
|
+
if (!this.config.cancelOnPointerLeave) return;
|
|
383
|
+
if (this.state.input !== "pointer") return;
|
|
384
|
+
this.cleanup();
|
|
385
|
+
}
|
|
386
|
+
/** Space or Enter on a focused draggable picks it up (`keyboard: true`). */
|
|
387
|
+
handlePickUpKey(e) {
|
|
388
|
+
if (this.state.startPosition || e.repeat || !isActionKey(e) || claimedPresses.has(e)) return;
|
|
389
|
+
const match = this.findElementByKinds(e.target, this.draggableKinds);
|
|
390
|
+
if (!match) return;
|
|
391
|
+
const source = this.resolveSource(match);
|
|
392
|
+
if (!source) return;
|
|
393
|
+
claimedPresses.add(e);
|
|
394
|
+
e.preventDefault();
|
|
395
|
+
this.swallowKeyUp = e.key;
|
|
396
|
+
this.state.startPosition = centerOf(match.element.getBoundingClientRect());
|
|
397
|
+
this.state.sourceElement = match.element;
|
|
398
|
+
this.state.sourcePosition = source.position;
|
|
399
|
+
this.state.sourceItem = source.item;
|
|
400
|
+
this.state.input = "keyboard";
|
|
401
|
+
this.startDrag();
|
|
402
|
+
this.pointKeyboardAt(this.findDroppable(match.element));
|
|
403
|
+
}
|
|
404
|
+
/** The keys of a keyboard drag, ahead of anything else on the page. */
|
|
405
|
+
handleKeyboardDragKey(e) {
|
|
406
|
+
if (this.state.input !== "keyboard") {
|
|
407
|
+
if (!e.repeat) this.swallowKeyUp = null;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const arrow = ARROWS[e.key];
|
|
411
|
+
if (arrow) this.pointKeyboardAt(this.nearestDroppable(arrow.axis, arrow.sign) ?? this.keyboardTarget);
|
|
412
|
+
else if (isActionKey(e)) {
|
|
413
|
+
this.swallowKeyUp = e.key;
|
|
414
|
+
if (!e.repeat) this.dropWithKeyboard();
|
|
415
|
+
} else if (e.key === "Escape") this.cleanup();
|
|
416
|
+
else return;
|
|
417
|
+
e.preventDefault();
|
|
418
|
+
e.stopPropagation();
|
|
419
|
+
}
|
|
420
|
+
handleKeyUp(e) {
|
|
421
|
+
if (this.swallowKeyUp === null || e.key !== this.swallowKeyUp) return;
|
|
422
|
+
this.swallowKeyUp = null;
|
|
423
|
+
e.preventDefault();
|
|
424
|
+
e.stopPropagation();
|
|
425
|
+
}
|
|
426
|
+
/** Focus leaving the source (Tab, a click elsewhere) cancels a keyboard drag. */
|
|
427
|
+
handleFocusOut(e) {
|
|
428
|
+
const source = this.state.sourceElement;
|
|
429
|
+
if (this.state.input !== "keyboard" || !source) return;
|
|
430
|
+
if (!source.contains(e.target)) return;
|
|
431
|
+
if (source.contains(e.relatedTarget)) return;
|
|
153
432
|
this.cleanup();
|
|
154
433
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
434
|
+
/** Makes `match` the keyboard drag's target: marked, scrolled into view, sent to `onDragMove`. */
|
|
435
|
+
pointKeyboardAt(match) {
|
|
436
|
+
this.keyboardTarget = match;
|
|
437
|
+
this.markTarget(match);
|
|
438
|
+
if (!match) return;
|
|
439
|
+
match.element.scrollIntoView?.({
|
|
440
|
+
block: "nearest",
|
|
441
|
+
inline: "nearest"
|
|
442
|
+
});
|
|
443
|
+
this.callbacks.onDragMove?.(centerOf(match.element.getBoundingClientRect()), this.state.lastHoveredElement);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The droppable nearest the current target (or the source) in a direction: its center lies that
|
|
447
|
+
* way, and it is closest along the axis, with distance across the axis counting double, so the
|
|
448
|
+
* next cell in the same row or column wins over a diagonal one.
|
|
449
|
+
*/
|
|
450
|
+
nearestDroppable(axis, sign) {
|
|
451
|
+
const from = this.keyboardTarget?.element ?? this.state.sourceElement;
|
|
452
|
+
if (!from) return null;
|
|
453
|
+
const origin = centerOf(from.getBoundingClientRect());
|
|
454
|
+
const root = this.config.dropScope === "container" ? this.container : document;
|
|
455
|
+
let best = null;
|
|
456
|
+
let bestScore = Infinity;
|
|
457
|
+
for (const element of root.querySelectorAll("[data-kind]")) {
|
|
458
|
+
const kind = element.dataset.kind;
|
|
459
|
+
if (!kind || element === from || !this.droppableKinds.includes(kind)) continue;
|
|
460
|
+
const rect = element.getBoundingClientRect();
|
|
461
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
462
|
+
const center = centerOf(rect);
|
|
463
|
+
const along = (axis === "x" ? center.x - origin.x : center.y - origin.y) * sign;
|
|
464
|
+
const across = Math.abs(axis === "x" ? center.y - origin.y : center.x - origin.x);
|
|
465
|
+
if (along < 1) continue;
|
|
466
|
+
const score = along + across * 2;
|
|
467
|
+
if (score < bestScore) {
|
|
468
|
+
best = {
|
|
469
|
+
element,
|
|
470
|
+
kind
|
|
471
|
+
};
|
|
472
|
+
bestScore = score;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return best;
|
|
476
|
+
}
|
|
477
|
+
/** Drops a keyboard drag on its target, unless there is none or `canDrop` rejects it. */
|
|
478
|
+
dropWithKeyboard() {
|
|
479
|
+
const target = this.keyboardTarget;
|
|
480
|
+
const targetPosition = target && this.acceptedPosition(target);
|
|
481
|
+
const { sourcePosition, sourceItem } = this.state;
|
|
482
|
+
if (!targetPosition || sourcePosition === null || sourceItem === null) return;
|
|
483
|
+
this.state.lastDropResult = {
|
|
484
|
+
sourcePosition,
|
|
485
|
+
targetPosition,
|
|
486
|
+
sourceItem
|
|
487
|
+
};
|
|
488
|
+
this.callbacks.onDrop?.(sourcePosition, targetPosition, sourceItem);
|
|
489
|
+
this.cleanup();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Swallows the `click` the browser dispatches right after the release that ended a drag, in the
|
|
493
|
+
* same task; a timeout lets the blocker go when no click comes.
|
|
494
|
+
*/
|
|
495
|
+
blockNextClick() {
|
|
496
|
+
this.releaseClickBlocker?.();
|
|
497
|
+
const block = (event) => {
|
|
498
|
+
event.preventDefault();
|
|
499
|
+
event.stopImmediatePropagation();
|
|
500
|
+
release();
|
|
501
|
+
};
|
|
502
|
+
const release = () => {
|
|
503
|
+
window.removeEventListener("click", block, true);
|
|
504
|
+
clearTimeout(timer);
|
|
505
|
+
if (this.releaseClickBlocker === release) this.releaseClickBlocker = null;
|
|
506
|
+
};
|
|
507
|
+
window.addEventListener("click", block, true);
|
|
508
|
+
const timer = setTimeout(release, 0);
|
|
509
|
+
this.releaseClickBlocker = release;
|
|
166
510
|
}
|
|
167
511
|
cleanup() {
|
|
512
|
+
window.removeEventListener("pointermove", this.boundHandlers.pointerMove);
|
|
513
|
+
this.state.sourceElement?.removeEventListener("touchmove", this.boundHandlers.touchMove);
|
|
514
|
+
for (const type of SOURCE_POINTER_EVENTS) this.state.sourceElement?.removeEventListener(type, this.boundHandlers.detachedSourcePointer);
|
|
515
|
+
window.removeEventListener("contextmenu", this.boundHandlers.contextMenu);
|
|
516
|
+
window.removeEventListener("selectstart", this.boundHandlers.preventDefault, true);
|
|
517
|
+
window.removeEventListener("dragstart", this.boundHandlers.preventDefault, true);
|
|
518
|
+
if (this.delayTimer !== null) {
|
|
519
|
+
clearTimeout(this.delayTimer);
|
|
520
|
+
this.delayTimer = null;
|
|
521
|
+
}
|
|
522
|
+
this.activePointerType = "";
|
|
168
523
|
if (this.state.sourceElement && this.state.activePointerId !== null && typeof this.state.sourceElement.hasPointerCapture === "function" && typeof this.state.sourceElement.releasePointerCapture === "function" && this.state.sourceElement.hasPointerCapture(this.state.activePointerId)) try {
|
|
169
524
|
this.state.sourceElement.releasePointerCapture(this.state.activePointerId);
|
|
170
525
|
} catch {}
|
|
171
526
|
if (this.state.sourceElement) this.state.sourceElement.removeAttribute("data-dragging");
|
|
172
|
-
|
|
527
|
+
this.markTarget(null);
|
|
173
528
|
if (this.rafId !== null) {
|
|
174
529
|
cancelAnimationFrame(this.rafId);
|
|
175
530
|
this.rafId = null;
|
|
176
531
|
}
|
|
177
|
-
if (this.state.isDragging) this.callbacks.onDragEnd?.();
|
|
178
|
-
this.state =
|
|
179
|
-
startPosition: null,
|
|
180
|
-
sourceElement: null,
|
|
181
|
-
sourcePosition: null,
|
|
182
|
-
sourceItem: null,
|
|
183
|
-
activePointerId: null,
|
|
184
|
-
isDragging: false,
|
|
185
|
-
lastHoveredElement: null
|
|
186
|
-
};
|
|
532
|
+
if (this.state.isDragging) this.callbacks.onDragEnd?.(this.state.lastDropResult);
|
|
533
|
+
this.state = idleState();
|
|
187
534
|
this.lastPointerPos = null;
|
|
535
|
+
this.keyboardTarget = null;
|
|
188
536
|
}
|
|
189
537
|
getPointerId(e) {
|
|
190
538
|
return typeof e.pointerId === "number" ? e.pointerId : null;
|
|
@@ -194,13 +542,44 @@ var DragDropManager = class {
|
|
|
194
542
|
const pointerId = this.getPointerId(e);
|
|
195
543
|
return pointerId === null || pointerId === this.state.activePointerId;
|
|
196
544
|
}
|
|
545
|
+
normalizeKindList(kinds) {
|
|
546
|
+
return [...new Set((Array.isArray(kinds) ? kinds : [kinds]).map((value) => value.trim()).filter(Boolean))];
|
|
547
|
+
}
|
|
548
|
+
/** The target position of the droppable `match` if the current source may drop there. */
|
|
549
|
+
acceptedPosition(match) {
|
|
550
|
+
const { sourcePosition, sourceItem } = this.state;
|
|
551
|
+
const targetPosition = this.callbacks.getItemPosition(match.element, match.kind);
|
|
552
|
+
if (targetPosition === null || sourcePosition === null || sourceItem === null) return null;
|
|
553
|
+
if (this.callbacks.canDrop && !this.callbacks.canDrop(sourcePosition, targetPosition, sourceItem)) return null;
|
|
554
|
+
return targetPosition;
|
|
555
|
+
}
|
|
197
556
|
/**
|
|
198
|
-
*
|
|
557
|
+
* Marks the droppable under the pointer: `data-hovered` when it would receive a drop (also the
|
|
558
|
+
* source itself, for "drag over itself" styling), `data-drop-invalid` when `canDrop` rejects it.
|
|
559
|
+
* Without `canDrop` every droppable is marked hovered.
|
|
199
560
|
*/
|
|
200
|
-
|
|
561
|
+
markTarget(match) {
|
|
562
|
+
const accepted = match && (!this.callbacks.canDrop || this.acceptedPosition(match) !== null) ? match.element : null;
|
|
563
|
+
const rejected = match && !accepted ? match.element : null;
|
|
564
|
+
this.state.lastHoveredElement = moveMark("data-hovered", this.state.lastHoveredElement, accepted);
|
|
565
|
+
this.state.lastInvalidElement = moveMark("data-drop-invalid", this.state.lastInvalidElement, rejected);
|
|
566
|
+
}
|
|
567
|
+
/** The droppable around `element` (the one under the pointer), within `dropScope`. */
|
|
568
|
+
findDroppable(element) {
|
|
569
|
+
if (this.config.dropScope === "container" && !(element && this.container.contains(element))) return null;
|
|
570
|
+
return this.findElementByKinds(element, this.droppableKinds);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Find closest ancestor element with a `data-kind` matching any expected kind.
|
|
574
|
+
*/
|
|
575
|
+
findElementByKinds(element, kinds) {
|
|
201
576
|
let current = element;
|
|
202
577
|
while (current && current !== this.container) {
|
|
203
|
-
|
|
578
|
+
const elementKind = current.dataset.kind;
|
|
579
|
+
if (elementKind && kinds.includes(elementKind)) return {
|
|
580
|
+
element: current,
|
|
581
|
+
kind: elementKind
|
|
582
|
+
};
|
|
204
583
|
current = current.parentElement;
|
|
205
584
|
}
|
|
206
585
|
return null;
|
|
@@ -228,20 +607,43 @@ var DragDropManager = class {
|
|
|
228
607
|
*/
|
|
229
608
|
destroy() {
|
|
230
609
|
this.cleanup();
|
|
610
|
+
this.suppressClickOnRelease = false;
|
|
611
|
+
this.releaseClickBlocker?.();
|
|
231
612
|
this.container.removeEventListener("pointerdown", this.boundHandlers.pointerDown);
|
|
232
|
-
this.container.removeEventListener("pointermove", this.boundHandlers.pointerMove);
|
|
233
613
|
this.container.removeEventListener("pointerup", this.boundHandlers.pointerUp);
|
|
234
614
|
this.container.removeEventListener("pointercancel", this.boundHandlers.pointerCancel);
|
|
615
|
+
this.container.removeEventListener("touchmove", this.boundHandlers.touchMove);
|
|
235
616
|
window.removeEventListener("pointerup", this.boundHandlers.pointerUp);
|
|
236
617
|
window.removeEventListener("pointercancel", this.boundHandlers.pointerCancel);
|
|
237
618
|
window.removeEventListener("keydown", this.boundHandlers.keyDown);
|
|
238
619
|
window.removeEventListener("mouseout", this.boundHandlers.mouseOut);
|
|
620
|
+
if (this.config.keyboard) {
|
|
621
|
+
this.container.removeEventListener("keydown", this.boundHandlers.pickUpKey);
|
|
622
|
+
window.removeEventListener("keydown", this.boundHandlers.keyboardDragKey, true);
|
|
623
|
+
window.removeEventListener("keyup", this.boundHandlers.keyUp, true);
|
|
624
|
+
window.removeEventListener("focusout", this.boundHandlers.focusOut, true);
|
|
625
|
+
}
|
|
239
626
|
}
|
|
240
627
|
};
|
|
241
|
-
|
|
242
628
|
//#endregion
|
|
243
629
|
//#region src/drag-preview-controller.ts
|
|
244
630
|
const OFFSCREEN_TRANSFORM = "translate(-9999px, -9999px)";
|
|
631
|
+
/**
|
|
632
|
+
* Attributes of the source (and its descendants) that belong to its place in the page or to the
|
|
633
|
+
* drag, not to its look: a clone keeping them would duplicate ids, join radio groups, count as a
|
|
634
|
+
* draggable or droppable, and match `[data-dragging]` placeholder styles.
|
|
635
|
+
*/
|
|
636
|
+
const SOURCE_ONLY_ATTRIBUTES = [
|
|
637
|
+
"id",
|
|
638
|
+
"name",
|
|
639
|
+
"data-kind",
|
|
640
|
+
"data-dragging",
|
|
641
|
+
"data-hovered",
|
|
642
|
+
"data-drop-invalid"
|
|
643
|
+
];
|
|
644
|
+
function stripSourceAttributes(clone) {
|
|
645
|
+
for (const element of [clone, ...clone.querySelectorAll("*")]) for (const name of SOURCE_ONLY_ATTRIBUTES) element.removeAttribute(name);
|
|
646
|
+
}
|
|
245
647
|
function normalizeClassNames(className) {
|
|
246
648
|
if (!className) return [];
|
|
247
649
|
return Array.isArray(className) ? className : [className];
|
|
@@ -265,7 +667,10 @@ var DragPreviewController = class {
|
|
|
265
667
|
const clone = element.cloneNode(true);
|
|
266
668
|
const computed = window.getComputedStyle(element);
|
|
267
669
|
const appendTo = this.options.appendTo ?? document.body;
|
|
670
|
+
stripSourceAttributes(clone);
|
|
671
|
+
clone.setAttribute("inert", "");
|
|
268
672
|
clone.setAttribute("aria-hidden", "true");
|
|
673
|
+
clone.setAttribute("data-drag-preview", "");
|
|
269
674
|
clone.style.position = "fixed";
|
|
270
675
|
clone.style.left = "0";
|
|
271
676
|
clone.style.top = "0";
|
|
@@ -301,7 +706,7 @@ var DragPreviewController = class {
|
|
|
301
706
|
this.stop();
|
|
302
707
|
}
|
|
303
708
|
};
|
|
304
|
-
|
|
305
709
|
//#endregion
|
|
306
710
|
export { DragDropManager, DragPreviewController };
|
|
711
|
+
|
|
307
712
|
//# sourceMappingURL=index.mjs.map
|