ngx-kit 7.4.0 → 8.0.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/drag-resize/README.md +25 -3
- package/fesm2022/ngx-kit-data-table.mjs +3 -3
- package/fesm2022/ngx-kit-data-table.mjs.map +1 -1
- package/fesm2022/ngx-kit-dialog.mjs +11 -7
- package/fesm2022/ngx-kit-dialog.mjs.map +1 -1
- package/fesm2022/ngx-kit-drag-resize.mjs +1311 -406
- package/fesm2022/ngx-kit-drag-resize.mjs.map +1 -1
- package/fesm2022/ngx-kit-image-viewer.mjs +348 -0
- package/fesm2022/ngx-kit-image-viewer.mjs.map +1 -0
- package/fesm2022/ngx-kit-notify.mjs +2 -2
- package/fesm2022/ngx-kit-notify.mjs.map +1 -1
- package/fesm2022/ngx-kit-side-nav.mjs +170 -0
- package/fesm2022/ngx-kit-side-nav.mjs.map +1 -0
- package/{gallery → image-viewer}/README.md +43 -7
- package/package.json +9 -9
- package/side-nav/README.md +11 -0
- package/types/ngx-kit-dialog.d.ts +6 -2
- package/types/ngx-kit-drag-resize.d.ts +135 -39
- package/types/{ngx-kit-gallery.d.ts → ngx-kit-image-viewer.d.ts} +5 -39
- package/types/ngx-kit-side-nav.d.ts +53 -0
- package/drawer-menu/README.md +0 -125
- package/fesm2022/ngx-kit-drawer-menu.mjs +0 -456
- package/fesm2022/ngx-kit-drawer-menu.mjs.map +0 -1
- package/fesm2022/ngx-kit-gallery.mjs +0 -419
- package/fesm2022/ngx-kit-gallery.mjs.map +0 -1
- package/types/ngx-kit-drawer-menu.d.ts +0 -161
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, EventEmitter, Injectable, InjectionToken, Directive,
|
|
2
|
+
import { signal, EventEmitter, Injectable, InjectionToken, inject, ElementRef, Injector, Directive, ContentChild, Output, Input, model, input, output, effect, HostListener } from '@angular/core';
|
|
3
3
|
import { DOCUMENT } from '@angular/common';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -79,22 +79,97 @@ function adjustForZoom(value) {
|
|
|
79
79
|
return zoom ? value / zoom : value;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Copy the browser-resolved visual state of an element.
|
|
84
|
+
*
|
|
85
|
+
* A drag preview is moved to <body>, so selectors depending on ancestors no
|
|
86
|
+
* longer apply. Copying computed styles (including custom properties) makes
|
|
87
|
+
* the preview visually independent from the original DOM subtree.
|
|
88
|
+
*/
|
|
89
|
+
function copyComputedStyle(source, target) {
|
|
90
|
+
copyStyleValues(source, target);
|
|
91
|
+
// Make the preview deterministic once it is taken out of normal flow (root only:
|
|
92
|
+
// descendants keep their own margins so the internal layout is unchanged).
|
|
93
|
+
target.style.setProperty('margin', '0', 'important');
|
|
94
|
+
target.style.setProperty('box-sizing', 'border-box', 'important');
|
|
95
|
+
}
|
|
96
|
+
function copyStyleValues(source, target) {
|
|
97
|
+
const computed = window.getComputedStyle(source);
|
|
98
|
+
for (let i = 0; i < computed.length; i++) {
|
|
99
|
+
const property = computed.item(i);
|
|
100
|
+
const value = computed.getPropertyValue(property);
|
|
101
|
+
if (value) {
|
|
102
|
+
target.style.setProperty(property, value, computed.getPropertyPriority(property));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function copyComputedStyleTree(source, target) {
|
|
107
|
+
copyComputedStyle(source, target);
|
|
108
|
+
copyDescendants(source, target);
|
|
109
|
+
}
|
|
110
|
+
function copyDescendants(source, target) {
|
|
111
|
+
const sourceChildren = Array.from(source.children);
|
|
112
|
+
const targetChildren = Array.from(target.children);
|
|
113
|
+
const count = Math.min(sourceChildren.length, targetChildren.length);
|
|
114
|
+
for (let i = 0; i < count; i++) {
|
|
115
|
+
copyStyleValues(sourceChildren[i], targetChildren[i]);
|
|
116
|
+
copyDescendants(sourceChildren[i], targetChildren[i]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Backward-compatible helper retained for consumers of older versions. */
|
|
120
|
+
function copyEssentialStyles(source, target) {
|
|
121
|
+
copyComputedStyle(source, target);
|
|
122
|
+
target.style.setProperty('box-sizing', 'border-box');
|
|
123
|
+
}
|
|
124
|
+
function combineTransforms(transform, initialTransform) {
|
|
125
|
+
return initialTransform && initialTransform !== 'none'
|
|
126
|
+
? transform + ' ' + initialTransform
|
|
127
|
+
: transform;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Above any app chrome (modals, sticky headers) but below the browser's own UI. */
|
|
131
|
+
const PREVIEW_Z_INDEX = '2147483000';
|
|
132
|
+
/**
|
|
133
|
+
* Creates the visual that follows the pointer while an item of a drop list is dragged.
|
|
134
|
+
*
|
|
135
|
+
* It is appended to `<body>` with `position: fixed`, so no ancestor of the original item
|
|
136
|
+
* (`overflow: hidden`, scroll containers, clipping, transformed/positioned parents) can clip
|
|
137
|
+
* or offset it. Computed styles are copied recursively first, because once the clone leaves
|
|
138
|
+
* its list every selector that depended on an ancestor (`.column .card {}`) and every
|
|
139
|
+
* inherited property (font, colour, `direction`) would otherwise be lost.
|
|
140
|
+
*
|
|
141
|
+
* Only used for drop-list drags. Free drag never calls this.
|
|
142
|
+
*/
|
|
82
143
|
function cloneDragElementInBody(dragEl, rect, pointerX, pointerY) {
|
|
144
|
+
const doc = dragEl.ownerDocument;
|
|
83
145
|
const preview = dragEl.cloneNode(true);
|
|
84
146
|
// Avoid duplicated ids in the document.
|
|
85
147
|
preview.removeAttribute('id');
|
|
148
|
+
preview.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));
|
|
86
149
|
preview.classList.add('ngx-drag-preview');
|
|
150
|
+
preview.setAttribute('aria-hidden', 'true');
|
|
151
|
+
preview.setAttribute('inert', '');
|
|
152
|
+
copyComputedStyleTree(dragEl, preview);
|
|
87
153
|
Object.assign(preview.style, {
|
|
88
154
|
position: 'fixed',
|
|
89
155
|
top: `${rect.top}px`,
|
|
90
156
|
left: `${rect.left}px`,
|
|
157
|
+
right: 'auto',
|
|
158
|
+
bottom: 'auto',
|
|
91
159
|
width: `${rect.width}px`,
|
|
92
160
|
height: `${rect.height}px`,
|
|
93
161
|
margin: '0',
|
|
94
162
|
pointerEvents: 'none',
|
|
95
163
|
boxSizing: 'border-box',
|
|
164
|
+
transition: 'none',
|
|
165
|
+
animation: 'none',
|
|
166
|
+
transform: 'none',
|
|
167
|
+
visibility: 'visible',
|
|
168
|
+
display: preview.style.display === 'none' ? '' : preview.style.display,
|
|
169
|
+
willChange: 'transform',
|
|
170
|
+
zIndex: PREVIEW_Z_INDEX,
|
|
96
171
|
});
|
|
97
|
-
|
|
172
|
+
doc.body.appendChild(preview);
|
|
98
173
|
return {
|
|
99
174
|
element: preview,
|
|
100
175
|
offsetX: pointerX - rect.left,
|
|
@@ -108,8 +183,16 @@ class DragRef {
|
|
|
108
183
|
el;
|
|
109
184
|
boundary;
|
|
110
185
|
lockAxis;
|
|
186
|
+
/**
|
|
187
|
+
* The list this draggable BELONGS to (set by the template). Dragging never changes it.
|
|
188
|
+
* The list the pointer is currently over is `activeDropList`.
|
|
189
|
+
*/
|
|
111
190
|
dropList = null;
|
|
112
191
|
dropListGroup;
|
|
192
|
+
/** Used to find the drop list under the pointer. Set by the directive; optional. */
|
|
193
|
+
service;
|
|
194
|
+
/** Drop list currently under the pointer during a drop-list drag (null = outside every list). */
|
|
195
|
+
activeDropList = null;
|
|
113
196
|
placeholder;
|
|
114
197
|
originDropList = null;
|
|
115
198
|
originIndex = -1;
|
|
@@ -131,6 +214,13 @@ class DragRef {
|
|
|
131
214
|
sourceVisibility = '';
|
|
132
215
|
sourceDisplay = '';
|
|
133
216
|
sourcePointerEvents = '';
|
|
217
|
+
sourceTransition = '';
|
|
218
|
+
/** True while a drop-list drag (body preview + placeholder) is in progress. Free drag: always false. */
|
|
219
|
+
listDrag = false;
|
|
220
|
+
cachedLists = [];
|
|
221
|
+
lastEffective = { x: 0, y: 0 };
|
|
222
|
+
scrollListener;
|
|
223
|
+
resizeListener;
|
|
134
224
|
init() {
|
|
135
225
|
this.previousTransform = getComputedStyle(this.el).getPropertyValue('transform');
|
|
136
226
|
}
|
|
@@ -157,14 +247,18 @@ class DragRef {
|
|
|
157
247
|
this.moveDx = 0;
|
|
158
248
|
this.moveDy = 0;
|
|
159
249
|
this.scrollCompensation = { x: 0, y: 0 };
|
|
160
|
-
|
|
161
|
-
|
|
250
|
+
// Only draggables that are direct children of their drop list take part in sorting.
|
|
251
|
+
// Everything else is a plain free drag.
|
|
252
|
+
const list = this.dropList && this.dropList.el === this.el.parentElement ? this.dropList : null;
|
|
253
|
+
this.originDropList = list;
|
|
254
|
+
this.originIndex = list?.getItemIndex(this) ?? -1;
|
|
162
255
|
this.previousTransform = getComputedStyle(this.el).getPropertyValue('transform');
|
|
163
256
|
this.isDragging.set(true);
|
|
164
257
|
this.previousZIndex = this.el.style.zIndex;
|
|
165
258
|
this.sourceVisibility = this.el.style.visibility;
|
|
166
259
|
this.sourceDisplay = this.el.style.display;
|
|
167
260
|
this.sourcePointerEvents = this.el.style.pointerEvents;
|
|
261
|
+
this.sourceTransition = this.el.style.transition;
|
|
168
262
|
this.el.classList.add('ngx-draggable--dragging');
|
|
169
263
|
this.el.style.willChange = 'transform';
|
|
170
264
|
this.el.style.transition = 'none';
|
|
@@ -179,8 +273,10 @@ class DragRef {
|
|
|
179
273
|
* create a body-level preview because the original element can be
|
|
180
274
|
* inside an overflow:auto/hidden container such as a Kanban column.
|
|
181
275
|
*/
|
|
182
|
-
if (
|
|
183
|
-
this.
|
|
276
|
+
if (list) {
|
|
277
|
+
this.listDrag = true;
|
|
278
|
+
// Order matters: clone while the source is still visible and fully styled, hide it,
|
|
279
|
+
// and only THEN measure (the placeholder takes over the source's slot).
|
|
184
280
|
const preview = cloneDragElementInBody(this.el, this.startRect, pointer.x, pointer.y);
|
|
185
281
|
this.preview = preview.element;
|
|
186
282
|
this.previewOffsetX = preview.offsetX;
|
|
@@ -188,6 +284,7 @@ class DragRef {
|
|
|
188
284
|
this.el.style.visibility = 'hidden';
|
|
189
285
|
this.el.style.display = 'none';
|
|
190
286
|
this.el.style.pointerEvents = 'none';
|
|
287
|
+
this.beginListDrag(list, pointer);
|
|
191
288
|
this.updatePreview();
|
|
192
289
|
}
|
|
193
290
|
else {
|
|
@@ -215,8 +312,11 @@ class DragRef {
|
|
|
215
312
|
x: dx,
|
|
216
313
|
y: dy,
|
|
217
314
|
});
|
|
218
|
-
if (this.
|
|
315
|
+
if (this.listDrag) {
|
|
219
316
|
this.updatePreview();
|
|
317
|
+
// Sorting follows the (axis/boundary constrained) pointer, not the raw one.
|
|
318
|
+
this.lastEffective = { x: this.startPointer.x + dx, y: this.startPointer.y + dy };
|
|
319
|
+
this.updateDropTarget(this.lastEffective);
|
|
220
320
|
}
|
|
221
321
|
else {
|
|
222
322
|
this.applyTransform();
|
|
@@ -233,16 +333,15 @@ class DragRef {
|
|
|
233
333
|
endDrag() {
|
|
234
334
|
if (!this.isDragging())
|
|
235
335
|
return;
|
|
236
|
-
|
|
336
|
+
if (this.listDrag) {
|
|
337
|
+
this.isDragging.set(false);
|
|
338
|
+
this.finishListDrag(true);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// ---- free drag: unchanged ----
|
|
237
342
|
this.isDragging.set(false);
|
|
238
|
-
// Normal Drag MUST behave exactly like before.
|
|
239
343
|
this.placeholder?.detach();
|
|
240
344
|
this.placeholder = undefined;
|
|
241
|
-
if (dropList) {
|
|
242
|
-
dropList.finishDrag(this);
|
|
243
|
-
this.el.style.transform = this.previousTransform;
|
|
244
|
-
}
|
|
245
|
-
// Remove body preview after DropList has finished reading its state.
|
|
246
345
|
this.preview?.remove();
|
|
247
346
|
this.preview = undefined;
|
|
248
347
|
this.el.classList.remove('ngx-draggable--dragging');
|
|
@@ -258,14 +357,34 @@ class DragRef {
|
|
|
258
357
|
this.moveDx = 0;
|
|
259
358
|
this.moveDy = 0;
|
|
260
359
|
this.position.set({ x: 0, y: 0 });
|
|
261
|
-
this.
|
|
262
|
-
|
|
360
|
+
if (this.listDrag) {
|
|
361
|
+
// Rollback: nothing was ever committed to the DOM or the model, so restoring is just cleanup.
|
|
362
|
+
this.isDragging.set(false);
|
|
363
|
+
this.finishListDrag(false);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// ---- free drag: unchanged ----
|
|
263
367
|
this.el.style.transform = this.previousTransform;
|
|
264
368
|
this.el.style.visibility = this.sourceVisibility;
|
|
265
369
|
this.el.style.pointerEvents = this.sourcePointerEvents;
|
|
266
370
|
this.scrollCompensation = { x: 0, y: 0 };
|
|
267
371
|
this.endDrag();
|
|
268
372
|
}
|
|
373
|
+
/** Abort without emitting anything (the directive was destroyed mid-drag). Idempotent. */
|
|
374
|
+
dispose() {
|
|
375
|
+
if (!this.listDrag)
|
|
376
|
+
return;
|
|
377
|
+
this.isDragging.set(false);
|
|
378
|
+
this.finishListDrag(false);
|
|
379
|
+
}
|
|
380
|
+
/** True while a drop-list drag is running. */
|
|
381
|
+
get isListDrag() {
|
|
382
|
+
return this.listDrag;
|
|
383
|
+
}
|
|
384
|
+
/** Inline `display` the source element had before it was hidden for the drag. */
|
|
385
|
+
get originalDisplay() {
|
|
386
|
+
return this.sourceDisplay;
|
|
387
|
+
}
|
|
269
388
|
get x() {
|
|
270
389
|
return this.moveDx;
|
|
271
390
|
}
|
|
@@ -289,16 +408,15 @@ class DragRef {
|
|
|
289
408
|
notifyScroll(dx, dy) {
|
|
290
409
|
if (!this.isDragging())
|
|
291
410
|
return;
|
|
411
|
+
// Drop-list drags are position:fixed in <body> and follow the pointer in viewport space, so
|
|
412
|
+
// scrolling needs no compensation; sorting listens to scroll on its own (see beginListDrag).
|
|
413
|
+
if (this.listDrag)
|
|
414
|
+
return;
|
|
292
415
|
this.scrollCompensation = {
|
|
293
416
|
x: this.scrollCompensation.x + dx,
|
|
294
417
|
y: this.scrollCompensation.y + dy,
|
|
295
418
|
};
|
|
296
|
-
|
|
297
|
-
this.updatePreview();
|
|
298
|
-
}
|
|
299
|
-
else {
|
|
300
|
-
this.applyTransform();
|
|
301
|
-
}
|
|
419
|
+
this.applyTransform();
|
|
302
420
|
}
|
|
303
421
|
applyTransform() {
|
|
304
422
|
const base = this.previousTransform && this.previousTransform !== 'none'
|
|
@@ -308,6 +426,92 @@ class DragRef {
|
|
|
308
426
|
const ty = this.moveDy + this.scrollCompensation.y;
|
|
309
427
|
this.el.style.transform = `${base}translate3d(${tx}px, ${ty}px, 0)`;
|
|
310
428
|
}
|
|
429
|
+
// -------------------------------------------------------------------------------------
|
|
430
|
+
// drop-list drag
|
|
431
|
+
// -------------------------------------------------------------------------------------
|
|
432
|
+
beginListDrag(origin, pointer) {
|
|
433
|
+
const doc = this.el.ownerDocument;
|
|
434
|
+
const win = doc.defaultView ?? window;
|
|
435
|
+
const lists = new Set([origin]);
|
|
436
|
+
for (const l of this.service?.dropLists() ?? [])
|
|
437
|
+
lists.add(l);
|
|
438
|
+
this.cachedLists = Array.from(lists);
|
|
439
|
+
// Foreign lists are measured as they are now (no placeholder in them yet)...
|
|
440
|
+
for (const l of this.cachedLists)
|
|
441
|
+
if (l !== origin)
|
|
442
|
+
l._cacheGeometry();
|
|
443
|
+
this.lastEffective = { ...pointer };
|
|
444
|
+
this.activeDropList = origin;
|
|
445
|
+
origin.enter(this, pointer.x, pointer.y);
|
|
446
|
+
// ...the origin list AFTER its placeholder took over the source's slot, so its box still
|
|
447
|
+
// covers the whole list (the hidden source no longer occupies space).
|
|
448
|
+
origin._cacheGeometry();
|
|
449
|
+
this.scrollListener = () => {
|
|
450
|
+
for (const l of this.cachedLists)
|
|
451
|
+
l._onScroll();
|
|
452
|
+
this.updateDropTarget(this.lastEffective);
|
|
453
|
+
};
|
|
454
|
+
this.resizeListener = () => {
|
|
455
|
+
for (const l of this.cachedLists)
|
|
456
|
+
l._refresh();
|
|
457
|
+
this.updateDropTarget(this.lastEffective);
|
|
458
|
+
};
|
|
459
|
+
// Capture: `scroll` does not bubble, and any ancestor (or the page) may be the one scrolling.
|
|
460
|
+
doc.addEventListener('scroll', this.scrollListener, { capture: true, passive: true });
|
|
461
|
+
win.addEventListener('resize', this.resizeListener, { passive: true });
|
|
462
|
+
}
|
|
463
|
+
/** Decide which list is under the pointer, switch if needed, then re-sort in the active one. */
|
|
464
|
+
updateDropTarget(pointer) {
|
|
465
|
+
const origin = this.originDropList;
|
|
466
|
+
if (!origin)
|
|
467
|
+
return;
|
|
468
|
+
const next = this.service
|
|
469
|
+
? this.service.findDropList(pointer, this.activeDropList, origin, this.el)
|
|
470
|
+
: origin._containsPoint(pointer.x, pointer.y)
|
|
471
|
+
? origin
|
|
472
|
+
: null;
|
|
473
|
+
if (next !== this.activeDropList) {
|
|
474
|
+
this.activeDropList?.exit(this);
|
|
475
|
+
this.activeDropList = next;
|
|
476
|
+
next?.enter(this, pointer.x, pointer.y);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
next?.sortItem(this, pointer);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Single teardown path for every way a drop-list drag can end (drop on a list, drop outside,
|
|
484
|
+
* Escape, pointercancel, destroy). Everything transient is removed here, so nothing can leak
|
|
485
|
+
* regardless of how the drag ended. Only then is the drop event emitted.
|
|
486
|
+
*/
|
|
487
|
+
finishListDrag(commit) {
|
|
488
|
+
const doc = this.el.ownerDocument;
|
|
489
|
+
if (this.scrollListener)
|
|
490
|
+
doc.removeEventListener('scroll', this.scrollListener, { capture: true });
|
|
491
|
+
if (this.resizeListener)
|
|
492
|
+
(doc.defaultView ?? window).removeEventListener('resize', this.resizeListener);
|
|
493
|
+
this.scrollListener = this.resizeListener = undefined;
|
|
494
|
+
const target = commit ? this.activeDropList : null;
|
|
495
|
+
const event = target?._finish(this, true) ?? null;
|
|
496
|
+
for (const l of this.cachedLists)
|
|
497
|
+
l._release(this);
|
|
498
|
+
this.preview?.remove();
|
|
499
|
+
this.preview = undefined;
|
|
500
|
+
this.placeholder = undefined;
|
|
501
|
+
this.activeDropList = null;
|
|
502
|
+
this.cachedLists = [];
|
|
503
|
+
this.listDrag = false;
|
|
504
|
+
// The source element is put back exactly as it was (its transform was never touched).
|
|
505
|
+
this.el.classList.remove('ngx-draggable--dragging');
|
|
506
|
+
this.el.style.visibility = this.sourceVisibility;
|
|
507
|
+
this.el.style.display = this.sourceDisplay;
|
|
508
|
+
this.el.style.pointerEvents = this.sourcePointerEvents;
|
|
509
|
+
this.el.style.transition = this.sourceTransition;
|
|
510
|
+
this.el.style.willChange = '';
|
|
511
|
+
this.el.style.zIndex = this.previousZIndex;
|
|
512
|
+
if (event && target)
|
|
513
|
+
target.onDrop.emit(event);
|
|
514
|
+
}
|
|
311
515
|
updatePreview() {
|
|
312
516
|
if (!this.preview)
|
|
313
517
|
return;
|
|
@@ -315,6 +519,660 @@ class DragRef {
|
|
|
315
519
|
}
|
|
316
520
|
}
|
|
317
521
|
|
|
522
|
+
class PlaceHolderRef {
|
|
523
|
+
tpl;
|
|
524
|
+
dropList;
|
|
525
|
+
element;
|
|
526
|
+
_visible = false;
|
|
527
|
+
/**
|
|
528
|
+
* Angular embedded view created from `tpl`.
|
|
529
|
+
*/
|
|
530
|
+
_view;
|
|
531
|
+
attach(container, source, reference) {
|
|
532
|
+
/**
|
|
533
|
+
* If the placeholder has a template, render the template instead of
|
|
534
|
+
* cloning the dragged element.
|
|
535
|
+
*
|
|
536
|
+
* Example:
|
|
537
|
+
*
|
|
538
|
+
* <span class="placeholder" *ngxPlaceholder></span>
|
|
539
|
+
*/
|
|
540
|
+
if (!this.element) {
|
|
541
|
+
if (this.tpl) {
|
|
542
|
+
this._view = this.tpl.createEmbeddedView({});
|
|
543
|
+
// Render bindings inside the embedded view before we move its DOM nodes.
|
|
544
|
+
this._view.detectChanges();
|
|
545
|
+
const element = this._view.rootNodes.find((node) => node instanceof HTMLElement);
|
|
546
|
+
if (!element) {
|
|
547
|
+
this._view.destroy();
|
|
548
|
+
this._view = undefined;
|
|
549
|
+
throw new Error('ngxPlaceholder template must contain at least one HTMLElement root node.');
|
|
550
|
+
}
|
|
551
|
+
this.element = element;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
/**
|
|
555
|
+
* Backward-compatible fallback:
|
|
556
|
+
* if no ngxPlaceholder template was supplied, keep the old behavior.
|
|
557
|
+
*/
|
|
558
|
+
// ".list > article" and grid/flex item rules continue to apply.
|
|
559
|
+
this.element = source.cloneNode(false);
|
|
560
|
+
this.element.removeAttribute('id');
|
|
561
|
+
this.element.classList.add('ngx-drag-placeholder');
|
|
562
|
+
this.element.setAttribute('aria-hidden', 'true');
|
|
563
|
+
this.element.setAttribute('inert', '');
|
|
564
|
+
this.element.removeAttribute('tabindex');
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// The placeholder must never inherit a live drag transform/animation. `transform` and
|
|
568
|
+
// `transition` are deliberately NOT forced with !important: the sort session drives them
|
|
569
|
+
// to slide the placeholder (translate3d) to the current insertion slot.
|
|
570
|
+
this.element.style.removeProperty('transform');
|
|
571
|
+
this.element.style.removeProperty('transition');
|
|
572
|
+
this.element.style.removeProperty('will-change');
|
|
573
|
+
this.element.style.setProperty('animation', 'none', 'important');
|
|
574
|
+
this.element.style.setProperty('pointer-events', 'none', 'important');
|
|
575
|
+
this.element.style.setProperty('visibility', 'visible', 'important');
|
|
576
|
+
this.element.style.setProperty('opacity', '0.16', 'important');
|
|
577
|
+
this.element.style.setProperty('outline', '2px dashed currentColor', 'important');
|
|
578
|
+
this.element.style.setProperty('outline-offset', '-2px', 'important');
|
|
579
|
+
this._visible = true;
|
|
580
|
+
if (reference && reference.parentNode === container) {
|
|
581
|
+
if (this.element !== reference)
|
|
582
|
+
container.insertBefore(this.element, reference);
|
|
583
|
+
}
|
|
584
|
+
else if (this.element.parentNode !== container) {
|
|
585
|
+
container.appendChild(this.element);
|
|
586
|
+
}
|
|
587
|
+
return this.element;
|
|
588
|
+
}
|
|
589
|
+
detach() {
|
|
590
|
+
this.element?.remove();
|
|
591
|
+
this._view?.destroy();
|
|
592
|
+
this._view = undefined;
|
|
593
|
+
this.element = undefined;
|
|
594
|
+
this._visible = false;
|
|
595
|
+
}
|
|
596
|
+
get visible() {
|
|
597
|
+
return this._visible && !!this.element?.isConnected;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Pure geometry used by the drop-list sorting engine.
|
|
603
|
+
*
|
|
604
|
+
* Nothing in this file touches the DOM. Everything is a deterministic function of
|
|
605
|
+
* `(rectangles, pointer)`, which is what makes sorting reversible: the same pointer over the
|
|
606
|
+
* same snapshot always yields the same insertion index, regardless of what the previous
|
|
607
|
+
* pointer positions were.
|
|
608
|
+
*
|
|
609
|
+
* Layout direction is *derived from the rectangles* (row clustering + the order in which DOM
|
|
610
|
+
* siblings actually appear on screen) rather than from `flex-direction` / `direction`. That is
|
|
611
|
+
* what lets one algorithm handle block flow, inline flow, flex (row/column/wrap/reverse),
|
|
612
|
+
* grid, RTL and `order:`-reshuffled layouts. Computed style is only a fallback for lists that
|
|
613
|
+
* are too small (0 or 1 item) to reveal their own orientation.
|
|
614
|
+
*/
|
|
615
|
+
/**
|
|
616
|
+
* Hysteresis is the only "magic" number in the algorithm and it is deliberate: without it a
|
|
617
|
+
* pointer resting exactly on a sibling's mid-line flips the placeholder on every sub-pixel
|
|
618
|
+
* jitter (and on every rounding difference between two layouts). It is proportional to the
|
|
619
|
+
* sibling's size and capped, so it can never make a small item feel sticky.
|
|
620
|
+
*/
|
|
621
|
+
const HYSTERESIS_RATIO = 0.1;
|
|
622
|
+
const HYSTERESIS_MAX_PX = 6;
|
|
623
|
+
function hysteresis(size) {
|
|
624
|
+
return Math.min(HYSTERESIS_MAX_PX, Math.max(0, size) * HYSTERESIS_RATIO);
|
|
625
|
+
}
|
|
626
|
+
const cx = (b) => (b.left + b.right) / 2;
|
|
627
|
+
const cy = (b) => (b.top + b.bottom) / 2;
|
|
628
|
+
const width = (b) => b.right - b.left;
|
|
629
|
+
const height = (b) => b.bottom - b.top;
|
|
630
|
+
const sign = (n) => (n < 0 ? -1 : 1);
|
|
631
|
+
/**
|
|
632
|
+
* Builds a layout model from DOM-ordered boxes.
|
|
633
|
+
*
|
|
634
|
+
* @param fallbackAxis orientation to assume when there are fewer than two boxes.
|
|
635
|
+
* @param fallbackRtl inline direction to assume when no row contains two items.
|
|
636
|
+
*/
|
|
637
|
+
function analyzeLayout(boxes, fallbackAxis = 'y', fallbackRtl = false) {
|
|
638
|
+
const rows = [];
|
|
639
|
+
const rowOf = [];
|
|
640
|
+
boxes.forEach((b, i) => {
|
|
641
|
+
const row = rows[rows.length - 1];
|
|
642
|
+
if (row) {
|
|
643
|
+
const overlap = Math.min(b.bottom, row.bottom) - Math.max(b.top, row.top);
|
|
644
|
+
const smaller = Math.min(height(b), row.bottom - row.top);
|
|
645
|
+
// Same row when the boxes share at least half of the smaller one's height. Touching or
|
|
646
|
+
// sub-pixel-overlapping boxes (fractional heights) therefore start a new row.
|
|
647
|
+
if (overlap > 0 && overlap >= smaller * 0.5) {
|
|
648
|
+
row.top = Math.min(row.top, b.top);
|
|
649
|
+
row.bottom = Math.max(row.bottom, b.bottom);
|
|
650
|
+
row.last = i;
|
|
651
|
+
row.count++;
|
|
652
|
+
rowOf.push(rows.length - 1);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
rows.push({ top: b.top, bottom: b.bottom, first: i, last: i, count: 1 });
|
|
657
|
+
rowOf.push(rows.length - 1);
|
|
658
|
+
});
|
|
659
|
+
const rowCount = rows.length;
|
|
660
|
+
// Vertical direction: which way do successive rows travel?
|
|
661
|
+
const dirY = rowCount > 1
|
|
662
|
+
? sign((rows[rowCount - 1].top + rows[rowCount - 1].bottom) / 2 - (rows[0].top + rows[0].bottom) / 2)
|
|
663
|
+
: 1;
|
|
664
|
+
// Horizontal direction per row (from the first/last item of that row) + a global vote.
|
|
665
|
+
const rowDirX = [];
|
|
666
|
+
let votes = 0;
|
|
667
|
+
for (const r of rows) {
|
|
668
|
+
if (r.count > 1) {
|
|
669
|
+
const d = sign(cx(boxes[r.last]) - cx(boxes[r.first]));
|
|
670
|
+
rowDirX.push(d);
|
|
671
|
+
votes += d;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
rowDirX.push(0); // resolved below once the global vote is known
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const dirX = votes !== 0 ? sign(votes) : fallbackRtl ? -1 : 1;
|
|
678
|
+
for (let i = 0; i < rowDirX.length; i++)
|
|
679
|
+
if (!rowDirX[i])
|
|
680
|
+
rowDirX[i] = dirX;
|
|
681
|
+
const rowBounds = [];
|
|
682
|
+
for (let r = 0; r < rowCount - 1; r++) {
|
|
683
|
+
rowBounds.push(dirY === 1 ? (rows[r].bottom + rows[r + 1].top) / 2 : (rows[r].top + rows[r + 1].bottom) / 2);
|
|
684
|
+
}
|
|
685
|
+
const first = rows[0];
|
|
686
|
+
const lastRow = rows[rowCount - 1];
|
|
687
|
+
const yStart = first ? (dirY === 1 ? first.top : first.bottom) : 0;
|
|
688
|
+
const yEnd = lastRow ? (dirY === 1 ? lastRow.bottom : lastRow.top) : 0;
|
|
689
|
+
let axis;
|
|
690
|
+
if (boxes.length < 2)
|
|
691
|
+
axis = fallbackAxis;
|
|
692
|
+
else if (rowCount === 1)
|
|
693
|
+
axis = 'x';
|
|
694
|
+
else if (rows.every((r) => r.count === 1))
|
|
695
|
+
axis = 'y';
|
|
696
|
+
else
|
|
697
|
+
axis = 'grid';
|
|
698
|
+
return { axis, dirX, dirY, rowOf, rowDirX, rowBounds, yStart, yEnd, rowCount };
|
|
699
|
+
}
|
|
700
|
+
function rowAt(model, y) {
|
|
701
|
+
let r = 0;
|
|
702
|
+
for (const bound of model.rowBounds) {
|
|
703
|
+
if (model.dirY === 1 ? y > bound : y < bound)
|
|
704
|
+
r++;
|
|
705
|
+
else
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
return r;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Where should the dragged item be inserted, given the pointer?
|
|
712
|
+
*
|
|
713
|
+
* The result is the number of *other* items that end up before the dragged one, i.e. the
|
|
714
|
+
* dragged item's final index. `slot` is the index (in `boxes`) of the dragged item's own slot
|
|
715
|
+
* (its placeholder), or -1 when the list does not contain one yet.
|
|
716
|
+
*
|
|
717
|
+
* `current` is the index the slot currently has in "others space"; it only decides on which
|
|
718
|
+
* side of a sibling's mid-line the small hysteresis band applies. Pass `null` for a first
|
|
719
|
+
* placement (no history, no hysteresis).
|
|
720
|
+
*
|
|
721
|
+
* Pure: depends only on its arguments.
|
|
722
|
+
*/
|
|
723
|
+
function resolveInsertionIndex(model, boxes, slot, pointer, current) {
|
|
724
|
+
let before = 0;
|
|
725
|
+
let others = 0;
|
|
726
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
727
|
+
if (i === slot)
|
|
728
|
+
continue;
|
|
729
|
+
const o = others++;
|
|
730
|
+
const b = boxes[i];
|
|
731
|
+
// Sign of the hysteresis band: +1 favours "stays before the pointer", -1 favours "stays after".
|
|
732
|
+
const bias = current === null ? 0 : o < current ? 1 : -1;
|
|
733
|
+
let isBefore;
|
|
734
|
+
if (model.axis === 'x') {
|
|
735
|
+
isBefore = model.dirX * (pointer.x - cx(b)) + bias * hysteresis(width(b)) > 0;
|
|
736
|
+
}
|
|
737
|
+
else if (model.axis === 'y') {
|
|
738
|
+
isBefore = model.dirY * (pointer.y - cy(b)) + bias * hysteresis(height(b)) > 0;
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
const row = model.rowOf[i];
|
|
742
|
+
const py = pointer.y + model.dirY * bias * hysteresis(height(b));
|
|
743
|
+
const pointerRow = rowAt(model, py);
|
|
744
|
+
if (model.dirY * (py - model.yStart) < 0) {
|
|
745
|
+
isBefore = false; // above the first row: nothing precedes the pointer
|
|
746
|
+
}
|
|
747
|
+
else if (model.dirY * (py - model.yEnd) > 0) {
|
|
748
|
+
isBefore = true; // below the last row: everything does (append)
|
|
749
|
+
}
|
|
750
|
+
else if (pointerRow !== row) {
|
|
751
|
+
isBefore = pointerRow > row;
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
isBefore = model.rowDirX[row] * (pointer.x - cx(b)) + bias * hysteresis(width(b)) > 0;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (isBefore)
|
|
758
|
+
before++;
|
|
759
|
+
}
|
|
760
|
+
return before;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Along-axis displacement of every box when the slot moves to `target` (others-space index).
|
|
764
|
+
*
|
|
765
|
+
* The result is where each box has to be translated so that the boxes, re-ordered, occupy the
|
|
766
|
+
* same run of space they occupied before, using each box's own size and the *measured* gaps
|
|
767
|
+
* between the original slots (so `gap`, margins and inline whitespace are handled by
|
|
768
|
+
* construction, not by assumption). Only meaningful for `x` / `y` axes; `grid` returns zeros.
|
|
769
|
+
*/
|
|
770
|
+
function computeAxisOffsets(model, boxes, slot, target) {
|
|
771
|
+
const n = boxes.length;
|
|
772
|
+
const offsets = new Array(n).fill(0);
|
|
773
|
+
if (model.axis === 'grid' || n < 2 || slot < 0)
|
|
774
|
+
return offsets;
|
|
775
|
+
const horizontal = model.axis === 'x';
|
|
776
|
+
const dir = horizontal ? model.dirX : model.dirY;
|
|
777
|
+
// Mirror into a frame where DOM order is ascending, so one code path serves LTR and RTL.
|
|
778
|
+
const lo = boxes.map((b) => (horizontal ? (dir === 1 ? b.left : -b.right) : dir === 1 ? b.top : -b.bottom));
|
|
779
|
+
const hi = boxes.map((b) => (horizontal ? (dir === 1 ? b.right : -b.left) : dir === 1 ? b.bottom : -b.top));
|
|
780
|
+
const order = [];
|
|
781
|
+
for (let i = 0; i < n; i++)
|
|
782
|
+
if (i !== slot)
|
|
783
|
+
order.push(i);
|
|
784
|
+
order.splice(Math.max(0, Math.min(target, n - 1)), 0, slot);
|
|
785
|
+
let cursor = lo[0];
|
|
786
|
+
for (let p = 0; p < n; p++) {
|
|
787
|
+
const item = order[p];
|
|
788
|
+
const delta = cursor - lo[item];
|
|
789
|
+
offsets[item] = Math.abs(delta) < 1e-6 ? 0 : dir * delta;
|
|
790
|
+
cursor += hi[item] - lo[item] + (p < n - 1 ? lo[p + 1] - hi[p] : 0);
|
|
791
|
+
}
|
|
792
|
+
return offsets;
|
|
793
|
+
}
|
|
794
|
+
/** Intersection of two boxes, or `null` when they do not overlap. */
|
|
795
|
+
function intersectBoxes(a, b) {
|
|
796
|
+
const left = Math.max(a.left, b.left);
|
|
797
|
+
const top = Math.max(a.top, b.top);
|
|
798
|
+
const right = Math.min(a.right, b.right);
|
|
799
|
+
const bottom = Math.min(a.bottom, b.bottom);
|
|
800
|
+
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
|
801
|
+
}
|
|
802
|
+
function boxContains(b, x, y) {
|
|
803
|
+
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
|
|
804
|
+
}
|
|
805
|
+
function toBox(r) {
|
|
806
|
+
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Tracks how far the scrollable ancestors of an element (and the window) have scrolled since
|
|
811
|
+
* the frame was created.
|
|
812
|
+
*
|
|
813
|
+
* Geometry is measured once, when a drag enters a list. Scrolling afterwards moves the
|
|
814
|
+
* content under a stationary pointer, which is equivalent to moving the pointer by the scroll
|
|
815
|
+
* delta in the frame the geometry was measured in. Shifting the *pointer* (one point) instead
|
|
816
|
+
* of re-measuring every item keeps cached rectangles valid across page scroll, parent scroll
|
|
817
|
+
* and nested scroll without any layout reads on the hot path.
|
|
818
|
+
*
|
|
819
|
+
* `delta()` only reads `scrollLeft/Top` after `invalidate()` (called from a scroll event), so
|
|
820
|
+
* ordinary pointer moves perform zero scroll reads.
|
|
821
|
+
*/
|
|
822
|
+
class ScrollFrame {
|
|
823
|
+
win;
|
|
824
|
+
targets = [];
|
|
825
|
+
origin = { x: 0, y: 0 };
|
|
826
|
+
cached = { x: 0, y: 0 };
|
|
827
|
+
dirty = false;
|
|
828
|
+
/**
|
|
829
|
+
* @param start first element to inspect (walks up through its ancestors)
|
|
830
|
+
* @param includeStart whether `start` itself counts (true for a list whose own scroll moves its items)
|
|
831
|
+
*/
|
|
832
|
+
constructor(start, includeStart = true, win = window) {
|
|
833
|
+
this.win = win;
|
|
834
|
+
let node = includeStart ? start : (start?.parentElement ?? null);
|
|
835
|
+
const root = start?.ownerDocument.documentElement;
|
|
836
|
+
while (node) {
|
|
837
|
+
// <html> scrolling is reported through `window`; counting both would double the delta.
|
|
838
|
+
if (node !== root && scrolls(node))
|
|
839
|
+
this.targets.push(node);
|
|
840
|
+
node = node.parentElement;
|
|
841
|
+
}
|
|
842
|
+
this.targets.push(win);
|
|
843
|
+
this.origin = this.read();
|
|
844
|
+
}
|
|
845
|
+
/** Call when any scroll event fires. */
|
|
846
|
+
invalidate() {
|
|
847
|
+
this.dirty = true;
|
|
848
|
+
}
|
|
849
|
+
/** Total scroll distance (px) of all tracked containers since construction. */
|
|
850
|
+
delta() {
|
|
851
|
+
if (this.dirty) {
|
|
852
|
+
const now = this.read();
|
|
853
|
+
this.cached = { x: now.x - this.origin.x, y: now.y - this.origin.y };
|
|
854
|
+
this.dirty = false;
|
|
855
|
+
}
|
|
856
|
+
return this.cached;
|
|
857
|
+
}
|
|
858
|
+
read() {
|
|
859
|
+
let x = 0;
|
|
860
|
+
let y = 0;
|
|
861
|
+
for (const t of this.targets) {
|
|
862
|
+
if (t === this.win) {
|
|
863
|
+
x += this.win.scrollX;
|
|
864
|
+
y += this.win.scrollY;
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
x += t.scrollLeft;
|
|
868
|
+
y += t.scrollTop;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return { x, y };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function scrolls(el) {
|
|
875
|
+
const s = getComputedStyle(el);
|
|
876
|
+
// `hidden` containers still scroll programmatically; `clip` never does.
|
|
877
|
+
const scrollish = (v) => v === 'auto' || v === 'scroll' || v === 'overlay' || v === 'hidden';
|
|
878
|
+
return scrollish(s.overflowX) || scrollish(s.overflowY);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Inline / inline-block layouts get their inter-item gap from whitespace TEXT nodes. A
|
|
883
|
+
* placeholder that is moved (or freshly inserted) without one would collapse that gap and shift
|
|
884
|
+
* everything after it by a space width, so it carries a separator of its own. Returns `null`
|
|
885
|
+
* for layouts that have no whitespace nodes (flex, grid, Angular-trimmed templates).
|
|
886
|
+
*/
|
|
887
|
+
function makeSpacer(container) {
|
|
888
|
+
for (const n of Array.from(container.childNodes)) {
|
|
889
|
+
if (n.nodeType === Node.TEXT_NODE && n.nodeValue && /^\s+$/.test(n.nodeValue)) {
|
|
890
|
+
return container.ownerDocument.createTextNode(n.nodeValue);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
const EASING = 'cubic-bezier(0, 0, 0.2, 1)';
|
|
896
|
+
/**
|
|
897
|
+
* One drag's view of one drop list.
|
|
898
|
+
*
|
|
899
|
+
* Lifecycle
|
|
900
|
+
* ---------
|
|
901
|
+
* snapshot ──► resolve(pointer) ──► apply(index) ──► ... ──► dispose()
|
|
902
|
+
*
|
|
903
|
+
* The snapshot (`boxes`) is taken once, with the dragged item's slot already in place, and is
|
|
904
|
+
* never re-read while the drag is over the list. `resolve` is a pure function of the pointer
|
|
905
|
+
* and that snapshot, so moving the pointer back always restores the previous state.
|
|
906
|
+
*
|
|
907
|
+
* Two ways to *show* the result, chosen from the measured layout:
|
|
908
|
+
*
|
|
909
|
+
* - `transform` (one row or one column; the common case): the DOM is never touched. Siblings
|
|
910
|
+
* and the placeholder are displaced with `translate3d`, computed from measured sizes and
|
|
911
|
+
* gaps, exactly like Angular CDK's single-axis strategy.
|
|
912
|
+
* - `flow` (grid / wrapping layouts): an exact translate cannot be derived without
|
|
913
|
+
* re-implementing the browser's layout engine, so the single placeholder node is moved and
|
|
914
|
+
* the browser lays the list out. Siblings are then animated from their old to new position
|
|
915
|
+
* with FLIP `translate3d` so the motion is still transform-driven and smooth. Decisions
|
|
916
|
+
* keep using the immutable snapshot, so the reflow can never feed back into them.
|
|
917
|
+
*/
|
|
918
|
+
class SortSession {
|
|
919
|
+
mode;
|
|
920
|
+
container;
|
|
921
|
+
placeholder;
|
|
922
|
+
itemElements;
|
|
923
|
+
animationMs;
|
|
924
|
+
fallbackAxis;
|
|
925
|
+
fallbackRtl;
|
|
926
|
+
entries = [];
|
|
927
|
+
boxes = [];
|
|
928
|
+
model;
|
|
929
|
+
/** Index of the slot (placeholder) inside `entries`/`boxes` at snapshot time. */
|
|
930
|
+
slotIdx = -1;
|
|
931
|
+
/** For each visible "other" item: its index among ALL other items (hidden ones included). */
|
|
932
|
+
otherModelIndex = [];
|
|
933
|
+
frame;
|
|
934
|
+
applied = new Map();
|
|
935
|
+
targetIdx = 0;
|
|
936
|
+
disposed = false;
|
|
937
|
+
/** undefined = not created yet, null = this layout has no whitespace separators. */
|
|
938
|
+
spacer;
|
|
939
|
+
constructor(init) {
|
|
940
|
+
this.spacer = init.spacer;
|
|
941
|
+
this.container = init.container;
|
|
942
|
+
this.placeholder = init.placeholder;
|
|
943
|
+
this.itemElements = init.itemElements;
|
|
944
|
+
this.animationMs = init.animationMs;
|
|
945
|
+
this.fallbackAxis = init.fallbackAxis;
|
|
946
|
+
this.fallbackRtl = init.fallbackRtl;
|
|
947
|
+
this.snapshot();
|
|
948
|
+
this.frame = new ScrollFrame(this.container, true);
|
|
949
|
+
this.mode = this.model.axis === 'grid' ? 'flow' : 'transform';
|
|
950
|
+
this.targetIdx = this.slotIdx;
|
|
951
|
+
const transition = this.animationMs > 0 ? `transform ${this.animationMs}ms ${EASING}` : '';
|
|
952
|
+
for (const e of this.entries) {
|
|
953
|
+
e.el.style.willChange = 'transform';
|
|
954
|
+
if (transition)
|
|
955
|
+
e.el.style.transition = transition;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/** Detected orientation (useful for tests/diagnostics). */
|
|
959
|
+
get axis() {
|
|
960
|
+
return this.model.axis;
|
|
961
|
+
}
|
|
962
|
+
/** Current insertion index among the *visible other* items. */
|
|
963
|
+
get target() {
|
|
964
|
+
return this.targetIdx;
|
|
965
|
+
}
|
|
966
|
+
/** Index at which the session started (the slot's original position). */
|
|
967
|
+
get origin() {
|
|
968
|
+
return this.slotIdx;
|
|
969
|
+
}
|
|
970
|
+
/** Insertion index among ALL other items of the list (what consumers see as `currentIndex`). */
|
|
971
|
+
get modelIndex() {
|
|
972
|
+
const idx = this.otherModelIndex;
|
|
973
|
+
if (this.targetIdx < idx.length)
|
|
974
|
+
return idx[this.targetIdx];
|
|
975
|
+
return idx.length ? idx[idx.length - 1] + 1 : 0;
|
|
976
|
+
}
|
|
977
|
+
/** Where would the dragged item be inserted if released at `pointer`? Pure; no side effects. */
|
|
978
|
+
resolve(pointer) {
|
|
979
|
+
const d = this.frame.delta();
|
|
980
|
+
return resolveInsertionIndex(this.model, this.boxes, this.slotIdx, { x: pointer.x + d.x, y: pointer.y + d.y }, this.targetIdx);
|
|
981
|
+
}
|
|
982
|
+
/** Visually place the slot at `index`. Idempotent. */
|
|
983
|
+
apply(index) {
|
|
984
|
+
if (this.disposed || index === this.targetIdx)
|
|
985
|
+
return;
|
|
986
|
+
this.targetIdx = index;
|
|
987
|
+
if (this.mode === 'transform')
|
|
988
|
+
this.applyTransforms(index);
|
|
989
|
+
else
|
|
990
|
+
this.applyFlow(index);
|
|
991
|
+
}
|
|
992
|
+
/** Put everything back where it started (slot at its original index). */
|
|
993
|
+
reset() {
|
|
994
|
+
this.apply(this.slotIdx);
|
|
995
|
+
}
|
|
996
|
+
/** A scroll event happened somewhere: cached scroll deltas must be re-read once. */
|
|
997
|
+
onScroll() {
|
|
998
|
+
this.frame.invalidate();
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Re-baseline from the live layout (e.g. the window was resized mid-drag). Not called during
|
|
1002
|
+
* normal sorting, because re-measuring while items are displaced is exactly what would
|
|
1003
|
+
* re-introduce feedback into the decision.
|
|
1004
|
+
*/
|
|
1005
|
+
refresh() {
|
|
1006
|
+
if (this.disposed)
|
|
1007
|
+
return;
|
|
1008
|
+
const target = this.targetIdx;
|
|
1009
|
+
this.clearDisplacement();
|
|
1010
|
+
this.snapshot();
|
|
1011
|
+
this.frame = new ScrollFrame(this.container, true);
|
|
1012
|
+
if (this.mode === 'transform') {
|
|
1013
|
+
this.targetIdx = this.slotIdx;
|
|
1014
|
+
this.apply(Math.min(target, this.boxes.length - 1));
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
this.targetIdx = this.slotIdx;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/** Restore every inline style this session touched. Safe to call more than once. */
|
|
1021
|
+
dispose() {
|
|
1022
|
+
if (this.disposed)
|
|
1023
|
+
return;
|
|
1024
|
+
this.disposed = true;
|
|
1025
|
+
for (const e of this.entries) {
|
|
1026
|
+
e.el.style.transform = e.savedTransform;
|
|
1027
|
+
e.el.style.transition = e.savedTransition;
|
|
1028
|
+
e.el.style.willChange = e.savedWillChange;
|
|
1029
|
+
}
|
|
1030
|
+
this.applied.clear();
|
|
1031
|
+
this.spacer?.remove();
|
|
1032
|
+
}
|
|
1033
|
+
// ---------------------------------------------------------------------------------------
|
|
1034
|
+
// snapshot
|
|
1035
|
+
// ---------------------------------------------------------------------------------------
|
|
1036
|
+
snapshot() {
|
|
1037
|
+
const previous = new Map(this.entries.map((e) => [e.el, e]));
|
|
1038
|
+
const entries = [];
|
|
1039
|
+
const boxes = [];
|
|
1040
|
+
const otherModelIndex = [];
|
|
1041
|
+
let slot = -1;
|
|
1042
|
+
let allOthers = 0;
|
|
1043
|
+
for (const node of Array.from(this.container.children)) {
|
|
1044
|
+
if (!(node instanceof HTMLElement))
|
|
1045
|
+
continue;
|
|
1046
|
+
const isSlot = node === this.placeholder;
|
|
1047
|
+
if (!isSlot && !this.itemElements.has(node))
|
|
1048
|
+
continue;
|
|
1049
|
+
const modelIdx = isSlot ? -1 : allOthers++;
|
|
1050
|
+
const rect = node.getBoundingClientRect();
|
|
1051
|
+
const off = this.applied.get(node);
|
|
1052
|
+
const box = toBox(rect);
|
|
1053
|
+
if (off) {
|
|
1054
|
+
// Strip our own displacement so the snapshot always describes the *undisplaced* layout.
|
|
1055
|
+
box.left -= off.x;
|
|
1056
|
+
box.right -= off.x;
|
|
1057
|
+
box.top -= off.y;
|
|
1058
|
+
box.bottom -= off.y;
|
|
1059
|
+
}
|
|
1060
|
+
const hidden = !isSlot && rect.width === 0 && rect.height === 0;
|
|
1061
|
+
if (hidden)
|
|
1062
|
+
continue;
|
|
1063
|
+
if (isSlot)
|
|
1064
|
+
slot = entries.length;
|
|
1065
|
+
else
|
|
1066
|
+
otherModelIndex.push(modelIdx);
|
|
1067
|
+
const known = previous.get(node);
|
|
1068
|
+
entries.push(known ?? {
|
|
1069
|
+
el: node,
|
|
1070
|
+
isSlot,
|
|
1071
|
+
base: initialTransform(node),
|
|
1072
|
+
savedTransform: node.style.transform,
|
|
1073
|
+
savedTransition: node.style.transition,
|
|
1074
|
+
savedWillChange: node.style.willChange,
|
|
1075
|
+
});
|
|
1076
|
+
boxes.push(box);
|
|
1077
|
+
}
|
|
1078
|
+
this.entries = entries;
|
|
1079
|
+
this.boxes = boxes;
|
|
1080
|
+
this.slotIdx = slot;
|
|
1081
|
+
this.otherModelIndex = otherModelIndex;
|
|
1082
|
+
this.model = analyzeLayout(boxes, this.fallbackAxis, this.fallbackRtl);
|
|
1083
|
+
}
|
|
1084
|
+
// ---------------------------------------------------------------------------------------
|
|
1085
|
+
// transform strategy (DOM stays untouched)
|
|
1086
|
+
// ---------------------------------------------------------------------------------------
|
|
1087
|
+
applyTransforms(target) {
|
|
1088
|
+
const offsets = computeAxisOffsets(this.model, this.boxes, this.slotIdx, target);
|
|
1089
|
+
const horizontal = this.model.axis === 'x';
|
|
1090
|
+
this.entries.forEach((e, i) => {
|
|
1091
|
+
const o = offsets[i];
|
|
1092
|
+
this.setOffset(e, horizontal ? { x: o, y: 0 } : { x: 0, y: o });
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
setOffset(e, off) {
|
|
1096
|
+
const prev = this.applied.get(e.el);
|
|
1097
|
+
if ((prev?.x ?? 0) === off.x && (prev?.y ?? 0) === off.y)
|
|
1098
|
+
return;
|
|
1099
|
+
if (off.x === 0 && off.y === 0) {
|
|
1100
|
+
this.applied.delete(e.el);
|
|
1101
|
+
e.el.style.transform = e.savedTransform;
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
this.applied.set(e.el, off);
|
|
1105
|
+
e.el.style.transform = `translate3d(${off.x}px, ${off.y}px, 0)${e.base}`;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
// ---------------------------------------------------------------------------------------
|
|
1109
|
+
// flow strategy (single placeholder move + FLIP transforms)
|
|
1110
|
+
// ---------------------------------------------------------------------------------------
|
|
1111
|
+
applyFlow(target) {
|
|
1112
|
+
const entries = this.entries;
|
|
1113
|
+
// 1. READ: where everything visually is right now (including in-flight animations).
|
|
1114
|
+
const first = entries.map((e) => e.el.getBoundingClientRect());
|
|
1115
|
+
// 2. WRITE: move the one placeholder node; drop any FLIP transforms.
|
|
1116
|
+
this.placeAt(target);
|
|
1117
|
+
for (const e of entries) {
|
|
1118
|
+
e.el.style.transition = 'none';
|
|
1119
|
+
e.el.style.transform = e.savedTransform;
|
|
1120
|
+
}
|
|
1121
|
+
// 3. READ: where everything is after the browser re-flowed the list.
|
|
1122
|
+
const last = entries.map((e) => e.el.getBoundingClientRect());
|
|
1123
|
+
// 4. WRITE: invert (put each item back where it was), then play (animate to its new home).
|
|
1124
|
+
const moved = [];
|
|
1125
|
+
entries.forEach((e, i) => {
|
|
1126
|
+
const dx = first[i].left - last[i].left;
|
|
1127
|
+
const dy = first[i].top - last[i].top;
|
|
1128
|
+
if (this.animationMs > 0 && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) {
|
|
1129
|
+
e.el.style.transform = `translate3d(${dx}px, ${dy}px, 0)${e.base}`;
|
|
1130
|
+
moved.push(e);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
const transition = this.animationMs > 0 ? `transform ${this.animationMs}ms ${EASING}` : '';
|
|
1134
|
+
if (moved.length)
|
|
1135
|
+
void this.container.offsetWidth; // commit the inverted frame
|
|
1136
|
+
for (const e of entries)
|
|
1137
|
+
e.el.style.transition = transition || e.savedTransition;
|
|
1138
|
+
for (const e of moved)
|
|
1139
|
+
e.el.style.transform = e.savedTransform;
|
|
1140
|
+
}
|
|
1141
|
+
placeAt(target) {
|
|
1142
|
+
const container = this.container;
|
|
1143
|
+
if (this.spacer === undefined)
|
|
1144
|
+
this.spacer = makeSpacer(container);
|
|
1145
|
+
const spacer = this.spacer;
|
|
1146
|
+
spacer?.remove();
|
|
1147
|
+
const others = this.entries.filter((e) => !e.isSlot);
|
|
1148
|
+
const ref = others[target]?.el;
|
|
1149
|
+
if (ref) {
|
|
1150
|
+
// ... [placeholder][separator][ref]: the separator that used to sit before `ref` still
|
|
1151
|
+
// separates it from its previous sibling, the new one separates it from the placeholder.
|
|
1152
|
+
container.insertBefore(this.placeholder, ref);
|
|
1153
|
+
if (spacer)
|
|
1154
|
+
container.insertBefore(spacer, ref);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const last = others[others.length - 1]?.el;
|
|
1158
|
+
const after = last ? last.nextSibling : null;
|
|
1159
|
+
if (spacer)
|
|
1160
|
+
container.insertBefore(spacer, after);
|
|
1161
|
+
container.insertBefore(this.placeholder, after);
|
|
1162
|
+
}
|
|
1163
|
+
clearDisplacement() {
|
|
1164
|
+
for (const e of this.entries) {
|
|
1165
|
+
e.el.style.transition = 'none';
|
|
1166
|
+
e.el.style.transform = e.savedTransform;
|
|
1167
|
+
}
|
|
1168
|
+
this.applied.clear();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function initialTransform(el) {
|
|
1172
|
+
const t = getComputedStyle(el).transform;
|
|
1173
|
+
return t && t !== 'none' ? ` ${t}` : '';
|
|
1174
|
+
}
|
|
1175
|
+
|
|
318
1176
|
/** Base (LTR) physical direction for every logical direction. */
|
|
319
1177
|
const LTR_BASE = {
|
|
320
1178
|
start: 'w',
|
|
@@ -369,248 +1227,16 @@ function resolveLogicalDirection(dir, rtl) {
|
|
|
369
1227
|
}
|
|
370
1228
|
|
|
371
1229
|
/**
|
|
372
|
-
*
|
|
1230
|
+
* A drop list.
|
|
373
1231
|
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* -
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
container;
|
|
383
|
-
dragging = null;
|
|
384
|
-
index = -1;
|
|
385
|
-
flow = 'free';
|
|
386
|
-
rtl = false;
|
|
387
|
-
withElementContainer(el) {
|
|
388
|
-
this.container = el;
|
|
389
|
-
return this;
|
|
390
|
-
}
|
|
391
|
-
start(_items) {
|
|
392
|
-
this.flow = detectFlow(this.container);
|
|
393
|
-
this.rtl = isRtl(this.container);
|
|
394
|
-
this.index = -1;
|
|
395
|
-
}
|
|
396
|
-
enter(drag, x, y) {
|
|
397
|
-
this.dragging = drag;
|
|
398
|
-
this.index = this.findInsertionIndex(drag, x, y);
|
|
399
|
-
this.movePlaceholder(drag, this.index);
|
|
400
|
-
}
|
|
401
|
-
sort(drag, x, y) {
|
|
402
|
-
this.dragging = drag;
|
|
403
|
-
const next = this.findInsertionIndex(drag, x, y);
|
|
404
|
-
const previous = this.getCurrentIndex();
|
|
405
|
-
if (next === previous)
|
|
406
|
-
return null;
|
|
407
|
-
this.movePlaceholder(drag, next);
|
|
408
|
-
return {
|
|
409
|
-
previousIndex: previous,
|
|
410
|
-
currentIndex: next,
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
getCurrentIndex() {
|
|
414
|
-
if (!this.container)
|
|
415
|
-
return Math.max(0, this.index);
|
|
416
|
-
const placeholder = this.dragging?.getPlaceholderElement();
|
|
417
|
-
if (placeholder?.parentElement === this.container) {
|
|
418
|
-
const refs = new Set(this.container.__ngxDragItems ?? []);
|
|
419
|
-
let position = 0;
|
|
420
|
-
const itemElements = new Set(Array.from(refs, (item) => item.el));
|
|
421
|
-
for (const node of Array.from(this.container.children)) {
|
|
422
|
-
if (node === placeholder)
|
|
423
|
-
return position;
|
|
424
|
-
if (node instanceof HTMLElement && itemElements.has(node) && node !== this.dragging?.el) {
|
|
425
|
-
position++;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return Math.max(0, this.index);
|
|
430
|
-
}
|
|
431
|
-
getItemIndex(item) {
|
|
432
|
-
let index = 0;
|
|
433
|
-
const refs = new Set(this.container.__ngxDragItems ?? []);
|
|
434
|
-
const itemElements = new Set(Array.from(refs, (ref) => ref.el));
|
|
435
|
-
for (const node of Array.from(this.container.children)) {
|
|
436
|
-
if (node === item.el)
|
|
437
|
-
return index;
|
|
438
|
-
if (node instanceof HTMLElement &&
|
|
439
|
-
!node.classList.contains('ngx-drag-placeholder') &&
|
|
440
|
-
!node.classList.contains('ngx-drag-in-body') &&
|
|
441
|
-
(!refs.size || itemElements.has(node))) {
|
|
442
|
-
index++;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
return -1;
|
|
446
|
-
}
|
|
447
|
-
reset() {
|
|
448
|
-
this.dragging = null;
|
|
449
|
-
this.index = -1;
|
|
450
|
-
}
|
|
451
|
-
movePlaceholder(drag, index) {
|
|
452
|
-
const ph = drag.getPlaceholderElement();
|
|
453
|
-
if (!ph || !this.container)
|
|
454
|
-
return;
|
|
455
|
-
const items = this.readItems(drag);
|
|
456
|
-
const reference = items[index]?.drag.el ?? null;
|
|
457
|
-
if (reference) {
|
|
458
|
-
if (ph !== reference.previousSibling) {
|
|
459
|
-
this.container.insertBefore(ph, reference);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
else if (ph.parentElement !== this.container || ph !== this.container.lastElementChild) {
|
|
463
|
-
this.container.appendChild(ph);
|
|
464
|
-
}
|
|
465
|
-
this.index = Math.max(0, Math.min(index, items.length));
|
|
466
|
-
}
|
|
467
|
-
findInsertionIndex(drag, x, y) {
|
|
468
|
-
const items = this.readItems(drag);
|
|
469
|
-
if (!items.length)
|
|
470
|
-
return 0;
|
|
471
|
-
switch (this.flow) {
|
|
472
|
-
case 'flex-row':
|
|
473
|
-
return this.findFlexRow(items, x);
|
|
474
|
-
case 'flex-column':
|
|
475
|
-
return this.findFlexColumn(items, y);
|
|
476
|
-
case 'grid':
|
|
477
|
-
case 'free':
|
|
478
|
-
default:
|
|
479
|
-
return this.findGrid(items, x, y);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
readItems(drag) {
|
|
483
|
-
const itemSet = new Set(
|
|
484
|
-
// DropListRef maintains the authoritative set; DOM order is authoritative
|
|
485
|
-
// for placement, so we only inspect direct children here.
|
|
486
|
-
this.container.__ngxDragItems ?? []);
|
|
487
|
-
const byElement = new Map(Array.from(itemSet, (item) => [item.el, item]));
|
|
488
|
-
const result = [];
|
|
489
|
-
Array.from(this.container.children).forEach((element, domIndex) => {
|
|
490
|
-
if (!(element instanceof HTMLElement) ||
|
|
491
|
-
element === drag.el ||
|
|
492
|
-
element.classList.contains('ngx-drag-placeholder') ||
|
|
493
|
-
element.classList.contains('ngx-drag-in-body')) {
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
const ref = byElement.get(element);
|
|
497
|
-
if (!ref)
|
|
498
|
-
return;
|
|
499
|
-
result.push({
|
|
500
|
-
drag: ref,
|
|
501
|
-
rect: element.getBoundingClientRect(),
|
|
502
|
-
domIndex,
|
|
503
|
-
});
|
|
504
|
-
});
|
|
505
|
-
// Fallback for environments where the private bridge is not available.
|
|
506
|
-
if (!result.length) {
|
|
507
|
-
return [];
|
|
508
|
-
}
|
|
509
|
-
return result;
|
|
510
|
-
}
|
|
511
|
-
findFlexRow(items, x) {
|
|
512
|
-
for (let i = 0; i < items.length; i++) {
|
|
513
|
-
const r = items[i].rect;
|
|
514
|
-
const mid = r.left + r.width / 2;
|
|
515
|
-
const before = this.rtl ? x > mid : x < mid;
|
|
516
|
-
if (before)
|
|
517
|
-
return i;
|
|
518
|
-
}
|
|
519
|
-
return items.length;
|
|
520
|
-
}
|
|
521
|
-
findFlexColumn(items, y) {
|
|
522
|
-
for (let i = 0; i < items.length; i++) {
|
|
523
|
-
const r = items[i].rect;
|
|
524
|
-
if (y < r.top + r.height / 2)
|
|
525
|
-
return i;
|
|
526
|
-
}
|
|
527
|
-
return items.length;
|
|
528
|
-
}
|
|
529
|
-
findGrid(items, x, y) {
|
|
530
|
-
let best = items[0];
|
|
531
|
-
let bestScore = Number.POSITIVE_INFINITY;
|
|
532
|
-
for (const item of items) {
|
|
533
|
-
const cx = item.rect.left + item.rect.width / 2;
|
|
534
|
-
const cy = item.rect.top + item.rect.height / 2;
|
|
535
|
-
const dx = Math.abs(x - cx) / Math.max(item.rect.width, 1);
|
|
536
|
-
const dy = Math.abs(y - cy) / Math.max(item.rect.height, 1);
|
|
537
|
-
const score = dx + dy * 1.2;
|
|
538
|
-
if (score < bestScore) {
|
|
539
|
-
bestScore = score;
|
|
540
|
-
best = item;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
const target = best.rect;
|
|
544
|
-
const sameRow = y >= target.top - target.height * 0.35 && y <= target.bottom + target.height * 0.35;
|
|
545
|
-
const before = sameRow
|
|
546
|
-
? this.rtl
|
|
547
|
-
? x > target.left + target.width / 2
|
|
548
|
-
: x < target.left + target.width / 2
|
|
549
|
-
: y < target.top + target.height / 2;
|
|
550
|
-
return Math.max(0, Math.min(items.length, items.findIndex((x) => x === best) + (before ? 0 : 1)));
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
function detectFlow(el) {
|
|
554
|
-
const s = getComputedStyle(el);
|
|
555
|
-
if (s.display.includes('grid'))
|
|
556
|
-
return 'grid';
|
|
557
|
-
if (s.display.includes('flex')) {
|
|
558
|
-
if (s.flexWrap !== 'nowrap')
|
|
559
|
-
return 'grid';
|
|
560
|
-
return s.flexDirection.startsWith('column') ? 'flex-column' : 'flex-row';
|
|
561
|
-
}
|
|
562
|
-
return 'free';
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
class PlaceHolderRef {
|
|
566
|
-
tpl;
|
|
567
|
-
dropList;
|
|
568
|
-
element;
|
|
569
|
-
_visible = false;
|
|
570
|
-
attach(container, source, reference) {
|
|
571
|
-
if (!this.element) {
|
|
572
|
-
// Keep the same tag/attributes as the dragged item so selectors such as
|
|
573
|
-
// ".list > article" and grid/flex item rules continue to apply.
|
|
574
|
-
this.element = source.cloneNode(false);
|
|
575
|
-
this.element.removeAttribute('id');
|
|
576
|
-
this.element.classList.add('ngx-drag-placeholder');
|
|
577
|
-
this.element.setAttribute('aria-hidden', 'true');
|
|
578
|
-
this.element.setAttribute('inert', '');
|
|
579
|
-
this.element.removeAttribute('tabindex');
|
|
580
|
-
}
|
|
581
|
-
// The placeholder must never inherit a live drag transform/animation.
|
|
582
|
-
this.element.style.setProperty('transform', 'none', 'important');
|
|
583
|
-
this.element.style.setProperty('transition', 'none', 'important');
|
|
584
|
-
this.element.style.setProperty('animation', 'none', 'important');
|
|
585
|
-
this.element.style.setProperty('pointer-events', 'none', 'important');
|
|
586
|
-
this.element.style.setProperty('visibility', 'visible', 'important');
|
|
587
|
-
this.element.style.setProperty('opacity', '0.16', 'important');
|
|
588
|
-
this.element.style.setProperty('outline', '2px dashed currentColor', 'important');
|
|
589
|
-
this.element.style.setProperty('outline-offset', '-2px', 'important');
|
|
590
|
-
this._visible = true;
|
|
591
|
-
if (reference && reference.parentNode === container) {
|
|
592
|
-
if (this.element !== reference)
|
|
593
|
-
container.insertBefore(this.element, reference);
|
|
594
|
-
}
|
|
595
|
-
else if (this.element.parentNode !== container) {
|
|
596
|
-
container.appendChild(this.element);
|
|
597
|
-
}
|
|
598
|
-
return this.element;
|
|
599
|
-
}
|
|
600
|
-
detach() {
|
|
601
|
-
this.element?.remove();
|
|
602
|
-
this._visible = false;
|
|
603
|
-
}
|
|
604
|
-
get visible() {
|
|
605
|
-
return this._visible && !!this.element?.isConnected;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* DropListRef is intentionally generic, while the positional sorting strategy
|
|
611
|
-
* works on DragRef<any>. The public DropList/DragRef relationship is invariant
|
|
612
|
-
* because IDropEvent<T> contains T in a callback position. Keep the strongly
|
|
613
|
-
* typed Set here, and narrow only at the DOM-local bridge / strategy boundary.
|
|
1232
|
+
* Membership (`_draggables`) only ever reflects which draggables *belong* to this list in the
|
|
1233
|
+
* template. It is never changed by dragging. Everything that is transient during a drag lives
|
|
1234
|
+
* in a single `SortSession` (see sorting/sort-session.ts) that is created when a drag enters
|
|
1235
|
+
* the list and disposed when it leaves or the drag ends:
|
|
1236
|
+
*
|
|
1237
|
+
* - the list the drag started in keeps its session for the whole drag (its placeholder holds
|
|
1238
|
+
* the item's original slot, so leaving and re-entering is stable);
|
|
1239
|
+
* - any other list gets a fresh session on every entry and drops it on every exit.
|
|
614
1240
|
*/
|
|
615
1241
|
class DropListRef {
|
|
616
1242
|
data;
|
|
@@ -618,19 +1244,25 @@ class DropListRef {
|
|
|
618
1244
|
disableSort = false;
|
|
619
1245
|
connectedTo = [];
|
|
620
1246
|
dropListGroup;
|
|
1247
|
+
/** Duration (ms) of the sibling slide animation. `0` disables animation. */
|
|
1248
|
+
sortAnimationDuration = 200;
|
|
621
1249
|
_draggables = new Set();
|
|
622
1250
|
onDrop = new EventEmitter();
|
|
623
|
-
|
|
624
|
-
active = false;
|
|
625
|
-
previousIndex = -1;
|
|
1251
|
+
session = null;
|
|
626
1252
|
placeholder;
|
|
1253
|
+
customPlaceholder;
|
|
1254
|
+
activeDrag = null;
|
|
1255
|
+
isOrigin = false;
|
|
1256
|
+
hovered = false;
|
|
1257
|
+
hit = null;
|
|
1258
|
+
/** Pre-insertion geometry of a foreign list, restored when the drag leaves it. */
|
|
1259
|
+
naturalHit = null;
|
|
1260
|
+
hitCached = false;
|
|
627
1261
|
addItem(item) {
|
|
628
1262
|
this._draggables.add(item);
|
|
629
|
-
this.publishDragItems();
|
|
630
1263
|
}
|
|
631
1264
|
removeItem(item) {
|
|
632
1265
|
this._draggables.delete(item);
|
|
633
|
-
this.publishDragItems();
|
|
634
1266
|
}
|
|
635
1267
|
isConnectedTo(other) {
|
|
636
1268
|
if (other === this)
|
|
@@ -639,76 +1271,255 @@ class DropListRef {
|
|
|
639
1271
|
return true;
|
|
640
1272
|
return !!this.dropListGroup && this.dropListGroup.has(other);
|
|
641
1273
|
}
|
|
1274
|
+
// ---------------------------------------------------------------------------------------
|
|
1275
|
+
// hit testing (cached for the duration of a drag)
|
|
1276
|
+
// ---------------------------------------------------------------------------------------
|
|
1277
|
+
/** Measure the list and its clipping ancestors once, at drag start. */
|
|
1278
|
+
_cacheGeometry() {
|
|
1279
|
+
this.hit = measureHit(this.el);
|
|
1280
|
+
this.hitCached = true;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Is the point over the *visible* part of this list? Scroll-aware without re-measuring.
|
|
1284
|
+
*
|
|
1285
|
+
* The list box and every clipping ancestor are tracked separately because they react to scroll
|
|
1286
|
+
* differently: scrolling a container moves the list but not the container's own clip window.
|
|
1287
|
+
*/
|
|
1288
|
+
_containsPoint(x, y) {
|
|
1289
|
+
const hit = this.hitCached ? this.hit : measureHit(this.el);
|
|
1290
|
+
if (!hit)
|
|
1291
|
+
return false;
|
|
1292
|
+
let d = hit.frame.delta();
|
|
1293
|
+
if (!boxContains(hit.box, x + d.x, y + d.y))
|
|
1294
|
+
return false;
|
|
1295
|
+
for (const clip of hit.clips) {
|
|
1296
|
+
d = clip.frame.delta();
|
|
1297
|
+
if (!boxContains(clip.box, x + d.x, y + d.y))
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
return true;
|
|
1301
|
+
}
|
|
1302
|
+
/** Approximate area, used to break ties between overlapping, unrelated lists. */
|
|
1303
|
+
_area() {
|
|
1304
|
+
const b = (this.hit ?? measureHit(this.el))?.box;
|
|
1305
|
+
return b ? (b.right - b.left) * (b.bottom - b.top) : 0;
|
|
1306
|
+
}
|
|
1307
|
+
_onScroll() {
|
|
1308
|
+
this.hit?.frame.invalidate();
|
|
1309
|
+
this.hit?.clips.forEach((c) => c.frame.invalidate());
|
|
1310
|
+
this.session?.onScroll();
|
|
1311
|
+
}
|
|
1312
|
+
/** Re-measure after a viewport resize. */
|
|
1313
|
+
_refresh() {
|
|
1314
|
+
if (this.hitCached)
|
|
1315
|
+
this._cacheGeometry();
|
|
1316
|
+
this.session?.refresh();
|
|
1317
|
+
}
|
|
1318
|
+
// ---------------------------------------------------------------------------------------
|
|
1319
|
+
// drag lifecycle
|
|
1320
|
+
// ---------------------------------------------------------------------------------------
|
|
1321
|
+
/** Backwards-compatible entry point: makes this list the drag's target. */
|
|
642
1322
|
createPlaceholder(drag) {
|
|
643
|
-
// There is exactly one placeholder for the active drag.
|
|
644
|
-
this.placeholder?.detach();
|
|
645
|
-
const ref = new PlaceHolderRef();
|
|
646
|
-
ref.dropList = this;
|
|
647
|
-
const source = drag.el;
|
|
648
|
-
const rect = source.getBoundingClientRect();
|
|
649
|
-
ref.attach(this.el, source);
|
|
650
|
-
this.copyPlaceholderGeometry(source, ref.element, rect);
|
|
651
|
-
this.placeholder = ref;
|
|
652
|
-
drag.placeholder = ref;
|
|
653
1323
|
this.enter(drag, drag.pointer.x, drag.pointer.y);
|
|
654
|
-
return
|
|
1324
|
+
return drag.placeholder;
|
|
655
1325
|
}
|
|
1326
|
+
/** The drag pointer entered this list (or the drag started inside it). */
|
|
656
1327
|
enter(drag, x = 0, y = 0) {
|
|
657
|
-
if (
|
|
658
|
-
|
|
659
|
-
this.
|
|
660
|
-
// Sorting strategy reads the live DOM. Expose the authoritative refs
|
|
661
|
-
// through a DOM-local bridge with the intentionally wider any type.
|
|
662
|
-
this.publishDragItems();
|
|
663
|
-
this.strategy.withElementContainer(this.el).start(this.getStrategyItems());
|
|
1328
|
+
if (this.session && this.activeDrag === drag) {
|
|
1329
|
+
// Re-entering the list the drag started in: its session (and slot) are still alive.
|
|
1330
|
+
this.hovered = true;
|
|
664
1331
|
this.el.classList.add('ngx-drop-list--active');
|
|
1332
|
+
drag.placeholder = this.placeholder;
|
|
1333
|
+
this.sortItem(drag, { x, y });
|
|
1334
|
+
return;
|
|
665
1335
|
}
|
|
666
|
-
this.
|
|
1336
|
+
this.releaseSession(); // never keep stale state from a previous drag (geometry cache stays)
|
|
1337
|
+
this.begin(drag, { x, y });
|
|
1338
|
+
this.hovered = true;
|
|
1339
|
+
this.el.classList.add('ngx-drop-list--active');
|
|
667
1340
|
}
|
|
1341
|
+
/** Re-evaluate the insertion index for the pointer. Returns the new index, or null if unchanged. */
|
|
668
1342
|
sortItem(drag, position) {
|
|
669
|
-
|
|
1343
|
+
const s = this.session;
|
|
1344
|
+
if (!s || this.activeDrag !== drag || !this.hovered || this.disableSort)
|
|
670
1345
|
return null;
|
|
671
|
-
|
|
1346
|
+
const next = s.resolve(position);
|
|
1347
|
+
if (next === s.target)
|
|
1348
|
+
return null;
|
|
1349
|
+
s.apply(next);
|
|
1350
|
+
return s.modelIndex;
|
|
672
1351
|
}
|
|
673
|
-
|
|
674
|
-
|
|
1352
|
+
/** The pointer left this list. */
|
|
1353
|
+
exit(drag) {
|
|
1354
|
+
if (!this.session || this.activeDrag !== drag)
|
|
675
1355
|
return;
|
|
676
|
-
|
|
677
|
-
this.
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1356
|
+
this.hovered = false;
|
|
1357
|
+
this.el.classList.remove('ngx-drop-list--active');
|
|
1358
|
+
if (this.isOrigin) {
|
|
1359
|
+
// Keep the original slot (the item logically still lives here) but stop displacing siblings.
|
|
1360
|
+
this.session.reset();
|
|
1361
|
+
}
|
|
1362
|
+
else {
|
|
1363
|
+
// A foreign list forgets the drag on exit; its geometry stays cached until the drag ends.
|
|
1364
|
+
this.releaseSession(drag);
|
|
1365
|
+
}
|
|
685
1366
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Ends the drag for this list. Returns the drop event when the pointer was released over this
|
|
1369
|
+
* list, `null` otherwise (nothing to emit). All transient state is disposed either way.
|
|
1370
|
+
*/
|
|
1371
|
+
_finish(drag, commit) {
|
|
1372
|
+
const s = this.session;
|
|
1373
|
+
if (!s || this.activeDrag !== drag)
|
|
1374
|
+
return null;
|
|
1375
|
+
const event = commit && this.hovered
|
|
1376
|
+
? {
|
|
1377
|
+
previousIndex: Math.max(0, drag.originIndex),
|
|
1378
|
+
currentIndex: s.modelIndex,
|
|
1379
|
+
item: drag,
|
|
1380
|
+
container: this,
|
|
1381
|
+
previousContainer: drag.originDropList ?? this,
|
|
1382
|
+
isPointerOverContainer: true,
|
|
1383
|
+
}
|
|
1384
|
+
: null;
|
|
1385
|
+
this.releaseSession(drag);
|
|
1386
|
+
return event;
|
|
1387
|
+
}
|
|
1388
|
+
/** Compatibility wrapper: finish + emit. */
|
|
1389
|
+
finishDrag(drag) {
|
|
1390
|
+
const event = this._finish(drag, true);
|
|
1391
|
+
if (event)
|
|
1392
|
+
this.onDrop.emit(event);
|
|
691
1393
|
}
|
|
692
1394
|
resetSortTransforms() {
|
|
693
|
-
this.reset();
|
|
1395
|
+
this.session?.reset();
|
|
694
1396
|
}
|
|
695
1397
|
getFinalIndex() {
|
|
696
|
-
return this.
|
|
1398
|
+
return this.session ? this.session.modelIndex : 0;
|
|
697
1399
|
}
|
|
698
1400
|
getItemIndex(item) {
|
|
699
1401
|
return this.indexOf(item);
|
|
700
1402
|
}
|
|
1403
|
+
/** End of the drag: dispose EVERYTHING this list holds, including cached geometry. Idempotent. */
|
|
1404
|
+
_release(drag) {
|
|
1405
|
+
if (drag && this.activeDrag && this.activeDrag !== drag)
|
|
1406
|
+
return;
|
|
1407
|
+
this.releaseSession(drag);
|
|
1408
|
+
this.hit = null;
|
|
1409
|
+
this.hitCached = false;
|
|
1410
|
+
}
|
|
1411
|
+
/** Dispose the sort session, placeholder and drag ownership. */
|
|
1412
|
+
releaseSession(drag) {
|
|
1413
|
+
if (drag && this.activeDrag && this.activeDrag !== drag)
|
|
1414
|
+
return;
|
|
1415
|
+
this.session?.dispose();
|
|
1416
|
+
this.session = null;
|
|
1417
|
+
this.placeholder?.detach();
|
|
1418
|
+
if (this.activeDrag && this.activeDrag.placeholder === this.placeholder) {
|
|
1419
|
+
this.activeDrag.placeholder = undefined;
|
|
1420
|
+
}
|
|
1421
|
+
this.placeholder = undefined;
|
|
1422
|
+
if (this.naturalHit) {
|
|
1423
|
+
this.hit = this.naturalHit;
|
|
1424
|
+
this.naturalHit = null;
|
|
1425
|
+
}
|
|
1426
|
+
this.activeDrag = null;
|
|
1427
|
+
this.isOrigin = false;
|
|
1428
|
+
this.hovered = false;
|
|
1429
|
+
this.el?.classList.remove('ngx-drop-list--active');
|
|
1430
|
+
}
|
|
1431
|
+
// ---------------------------------------------------------------------------------------
|
|
1432
|
+
// internals
|
|
1433
|
+
// ---------------------------------------------------------------------------------------
|
|
1434
|
+
begin(drag, pointer) {
|
|
1435
|
+
const origin = drag.originDropList === this;
|
|
1436
|
+
const others = this.otherSlots(drag);
|
|
1437
|
+
const rtl = isRtl(this.el);
|
|
1438
|
+
const axis = fallbackAxis(this.el);
|
|
1439
|
+
const ph = this.customPlaceholder ?? new PlaceHolderRef();
|
|
1440
|
+
ph.dropList = this;
|
|
1441
|
+
let spacer;
|
|
1442
|
+
if (origin) {
|
|
1443
|
+
// The placeholder takes over the exact slot of the (now hidden) source element.
|
|
1444
|
+
ph.attach(this.el, drag.el, drag.el);
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
ph.detach();
|
|
1448
|
+
const reference = this.foreignInsertionReference(others, pointer, axis, rtl);
|
|
1449
|
+
ph.attach(this.el, drag.el, reference);
|
|
1450
|
+
spacer = makeSpacer(this.el);
|
|
1451
|
+
if (spacer)
|
|
1452
|
+
this.el.insertBefore(spacer, reference ?? ph.element);
|
|
1453
|
+
}
|
|
1454
|
+
this.lockPlaceholderGeometry(drag, ph.element, drag.start);
|
|
1455
|
+
this.placeholder = ph;
|
|
1456
|
+
this.activeDrag = drag;
|
|
1457
|
+
this.isOrigin = origin;
|
|
1458
|
+
drag.placeholder = ph;
|
|
1459
|
+
this.session = new SortSession({
|
|
1460
|
+
container: this.el,
|
|
1461
|
+
placeholder: ph.element,
|
|
1462
|
+
itemElements: others,
|
|
1463
|
+
fallbackAxis: axis,
|
|
1464
|
+
fallbackRtl: rtl,
|
|
1465
|
+
animationMs: this.sortAnimationDuration,
|
|
1466
|
+
spacer,
|
|
1467
|
+
});
|
|
1468
|
+
if (!origin && this.hitCached) {
|
|
1469
|
+
// Inserting the placeholder made this list bigger; the grown box is what the user sees
|
|
1470
|
+
// (and must be able to reach, e.g. to append below the last item). The natural box comes
|
|
1471
|
+
// back on exit, so entering/leaving at the old edge cannot flicker.
|
|
1472
|
+
this.naturalHit = this.hit;
|
|
1473
|
+
this.hit = measureHit(this.el);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* First placement into a list that does not contain the item yet: the node the placeholder
|
|
1478
|
+
* must be inserted before (null = append). Same pure resolver as sorting, no hysteresis.
|
|
1479
|
+
*/
|
|
1480
|
+
foreignInsertionReference(others, pointer, axis, rtl) {
|
|
1481
|
+
const els = [];
|
|
1482
|
+
const boxes = [];
|
|
1483
|
+
for (const node of Array.from(this.el.children)) {
|
|
1484
|
+
if (!(node instanceof HTMLElement) || !others.has(node))
|
|
1485
|
+
continue;
|
|
1486
|
+
const r = node.getBoundingClientRect();
|
|
1487
|
+
if (r.width === 0 && r.height === 0)
|
|
1488
|
+
continue;
|
|
1489
|
+
els.push(node);
|
|
1490
|
+
boxes.push(toBox(r));
|
|
1491
|
+
}
|
|
1492
|
+
if (!els.length)
|
|
1493
|
+
return null;
|
|
1494
|
+
const index = this.disableSort
|
|
1495
|
+
? els.length
|
|
1496
|
+
: resolveInsertionIndex(analyzeLayout(boxes, axis, rtl), boxes, -1, pointer, null);
|
|
1497
|
+
return index < els.length ? els[index] : els[els.length - 1].nextSibling;
|
|
1498
|
+
}
|
|
1499
|
+
otherSlots(drag) {
|
|
1500
|
+
const set = new Set();
|
|
1501
|
+
for (const d of this._draggables) {
|
|
1502
|
+
if (d !== drag && d.el?.parentElement === this.el)
|
|
1503
|
+
set.add(d.el);
|
|
1504
|
+
}
|
|
1505
|
+
return set;
|
|
1506
|
+
}
|
|
701
1507
|
indexOf(item) {
|
|
702
|
-
const
|
|
1508
|
+
const byElement = new Map();
|
|
1509
|
+
for (const d of this._draggables)
|
|
1510
|
+
if (d.el)
|
|
1511
|
+
byElement.set(d.el, d);
|
|
703
1512
|
let index = 0;
|
|
704
1513
|
for (const node of Array.from(this.el.children)) {
|
|
705
1514
|
if (!(node instanceof HTMLElement))
|
|
706
1515
|
continue;
|
|
707
1516
|
if (node === this.placeholder?.element)
|
|
708
1517
|
continue;
|
|
709
|
-
if (node.classList.contains('ngx-drag-
|
|
1518
|
+
if (node.classList.contains('ngx-drag-placeholder'))
|
|
1519
|
+
continue;
|
|
1520
|
+
if (node.classList.contains('ngx-drag-preview'))
|
|
710
1521
|
continue;
|
|
711
|
-
const ref =
|
|
1522
|
+
const ref = byElement.get(node);
|
|
712
1523
|
if (!ref)
|
|
713
1524
|
continue;
|
|
714
1525
|
if (ref === item)
|
|
@@ -717,44 +1528,63 @@ class DropListRef {
|
|
|
717
1528
|
}
|
|
718
1529
|
return -1;
|
|
719
1530
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
placeholder.style.setProperty('opacity', '0.16', 'important');
|
|
738
|
-
placeholder.style.setProperty('pointer-events', 'none', 'important');
|
|
739
|
-
placeholder.style.setProperty('transform', 'none', 'important');
|
|
740
|
-
placeholder.style.setProperty('transition', 'none', 'important');
|
|
741
|
-
placeholder.style.setProperty('animation', 'none', 'important');
|
|
742
|
-
placeholder.style.setProperty('box-sizing', 'border-box', 'important');
|
|
1531
|
+
lockPlaceholderGeometry(drag, placeholder, rect) {
|
|
1532
|
+
const set = (k, v) => placeholder.style.setProperty(k, v);
|
|
1533
|
+
// The clone was taken from the source, which may already carry drag-time inline styles.
|
|
1534
|
+
placeholder.style.display = drag.originalDisplay;
|
|
1535
|
+
placeholder.style.zIndex = '';
|
|
1536
|
+
placeholder.style.removeProperty('will-change');
|
|
1537
|
+
set('width', `${rect.width}px`);
|
|
1538
|
+
set('height', `${rect.height}px`);
|
|
1539
|
+
// set('min-width', `${rect.width}px`);
|
|
1540
|
+
// set('min-height', `${rect.height}px`);
|
|
1541
|
+
// set('max-width', `${rect.width}px`);
|
|
1542
|
+
// set('max-height', `${rect.height}px`);
|
|
1543
|
+
set('visibility', 'visible');
|
|
1544
|
+
set('opacity', '0.16');
|
|
1545
|
+
set('pointer-events', 'none');
|
|
1546
|
+
set('animation', 'none');
|
|
1547
|
+
set('box-sizing', 'border-box');
|
|
743
1548
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1549
|
+
}
|
|
1550
|
+
/** Orientation to assume while a list has fewer than two items to measure. */
|
|
1551
|
+
function fallbackAxis(container) {
|
|
1552
|
+
const s = getComputedStyle(container);
|
|
1553
|
+
if (s.display.includes('flex'))
|
|
1554
|
+
return s.flexDirection.startsWith('column') ? 'y' : 'x';
|
|
1555
|
+
if (s.display.includes('grid'))
|
|
1556
|
+
return 'y';
|
|
1557
|
+
const child = container.firstElementChild;
|
|
1558
|
+
if (child && getComputedStyle(child).display.startsWith('inline'))
|
|
1559
|
+
return 'x';
|
|
1560
|
+
return 'y';
|
|
1561
|
+
}
|
|
1562
|
+
/** Rect of the list plus one clip window per clipping (overflow != visible) ancestor. */
|
|
1563
|
+
function measureHit(el) {
|
|
1564
|
+
const r = el.getBoundingClientRect();
|
|
1565
|
+
if (r.right <= r.left || r.bottom <= r.top)
|
|
1566
|
+
return null;
|
|
1567
|
+
const clips = [];
|
|
1568
|
+
const root = el.ownerDocument.documentElement;
|
|
1569
|
+
for (let node = el.parentElement; node && node !== root; node = node.parentElement) {
|
|
1570
|
+
const s = getComputedStyle(node);
|
|
1571
|
+
const cx = s.overflowX !== 'visible';
|
|
1572
|
+
const cy = s.overflowY !== 'visible';
|
|
1573
|
+
if (!cx && !cy)
|
|
1574
|
+
continue;
|
|
1575
|
+
const c = node.getBoundingClientRect();
|
|
1576
|
+
clips.push({
|
|
1577
|
+
box: {
|
|
1578
|
+
left: cx ? c.left : -Infinity,
|
|
1579
|
+
right: cx ? c.right : Infinity,
|
|
1580
|
+
top: cy ? c.top : -Infinity,
|
|
1581
|
+
bottom: cy ? c.bottom : Infinity,
|
|
1582
|
+
},
|
|
1583
|
+
// A clip window only moves when something ABOVE the clipping element scrolls.
|
|
1584
|
+
frame: new ScrollFrame(node, false),
|
|
1585
|
+
});
|
|
757
1586
|
}
|
|
1587
|
+
return { box: toBox(r), frame: new ScrollFrame(el, false), clips };
|
|
758
1588
|
}
|
|
759
1589
|
|
|
760
1590
|
/**
|
|
@@ -763,6 +1593,7 @@ class DropListRef {
|
|
|
763
1593
|
* Lists register themselves automatically when `NgxDropListGroup` wraps them.
|
|
764
1594
|
*/
|
|
765
1595
|
class DropListGroupRef {
|
|
1596
|
+
el;
|
|
766
1597
|
lists = new Set();
|
|
767
1598
|
add(list) {
|
|
768
1599
|
this.lists.add(list);
|
|
@@ -810,23 +1641,41 @@ class DragDropService {
|
|
|
810
1641
|
ref.cancelDrag();
|
|
811
1642
|
this.activeDrag.set(null);
|
|
812
1643
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1644
|
+
/**
|
|
1645
|
+
* Which drop list is under `point`?
|
|
1646
|
+
*
|
|
1647
|
+
* - Only lists connected to the drag's ORIGIN list qualify (connectivity is not transitive, so
|
|
1648
|
+
* A→B→C never lets an item hop A→C). Without an origin (a free-drag element) nothing
|
|
1649
|
+
* qualifies: free dragging is never captured by a list.
|
|
1650
|
+
* - Only the visible (clip-aware) part of a list counts.
|
|
1651
|
+
* - A list inside the dragged element itself is never a target.
|
|
1652
|
+
* - Nested lists: the innermost qualifying list wins. Among unrelated overlapping lists the
|
|
1653
|
+
* current one is kept (no flicker), otherwise the smallest.
|
|
1654
|
+
*/
|
|
1655
|
+
findDropList(point, current, origin, dragEl) {
|
|
1656
|
+
const source = origin ?? null;
|
|
1657
|
+
if (!source)
|
|
1658
|
+
return null;
|
|
1659
|
+
let best = null;
|
|
1660
|
+
for (const list of this.dropLists()) {
|
|
1661
|
+
if (!list.el || !source.isConnectedTo(list))
|
|
1662
|
+
continue;
|
|
1663
|
+
if (dragEl && dragEl !== list.el && dragEl.contains(list.el))
|
|
1664
|
+
continue;
|
|
1665
|
+
if (!list._containsPoint(point.x, point.y))
|
|
1666
|
+
continue;
|
|
1667
|
+
if (!best)
|
|
1668
|
+
best = list;
|
|
1669
|
+
else if (best.el.contains(list.el))
|
|
1670
|
+
best = list; // deeper wins
|
|
1671
|
+
else if (list.el.contains(best.el))
|
|
1672
|
+
continue;
|
|
1673
|
+
else if (list === current)
|
|
1674
|
+
best = list;
|
|
1675
|
+
else if (best !== current && list._area() < best._area())
|
|
1676
|
+
best = list;
|
|
1677
|
+
}
|
|
1678
|
+
return best;
|
|
830
1679
|
}
|
|
831
1680
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: DragDropService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
832
1681
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: DragDropService, providedIn: 'root' });
|
|
@@ -840,19 +1689,46 @@ const NGX_DROPLIST_GROUP = new InjectionToken('ngx-drop-list-group');
|
|
|
840
1689
|
/** Wrap several `ngxDropList`s to let items move freely between them. */
|
|
841
1690
|
class NgxDropListGroup {
|
|
842
1691
|
_ref = new DropListGroupRef();
|
|
1692
|
+
el = inject((ElementRef));
|
|
1693
|
+
injector = inject(Injector);
|
|
1694
|
+
constructor() {
|
|
1695
|
+
this._ref.el = this.el.nativeElement;
|
|
1696
|
+
}
|
|
843
1697
|
ngOnDestroy() {
|
|
844
1698
|
this._ref.lists.clear();
|
|
845
1699
|
}
|
|
846
1700
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropListGroup, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
847
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.4", type: NgxDropListGroup, isStandalone: true, selector: "[NgxDropListGroup],[ngxDropListGroup]", providers: [{ provide: NGX_DROPLIST_GROUP, useExisting: NgxDropListGroup }], ngImport: i0 });
|
|
1701
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.4", type: NgxDropListGroup, isStandalone: true, selector: "[NgxDropListGroup],[ngxDropListGroup]", providers: [{ provide: NGX_DROPLIST_GROUP, useExisting: NgxDropListGroup }], exportAs: ["ngxDropListGroup"], ngImport: i0 });
|
|
848
1702
|
}
|
|
849
1703
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropListGroup, decorators: [{
|
|
850
1704
|
type: Directive,
|
|
851
1705
|
args: [{
|
|
852
1706
|
selector: '[NgxDropListGroup],[ngxDropListGroup]',
|
|
853
1707
|
providers: [{ provide: NGX_DROPLIST_GROUP, useExisting: NgxDropListGroup }],
|
|
1708
|
+
exportAs: 'ngxDropListGroup',
|
|
854
1709
|
}]
|
|
855
|
-
}] });
|
|
1710
|
+
}], ctorParameters: () => [] });
|
|
1711
|
+
|
|
1712
|
+
const NGX_PLACEHOLDER = new InjectionToken('ngx-place-holder');
|
|
1713
|
+
class NgxPlaceholder {
|
|
1714
|
+
tpl;
|
|
1715
|
+
_ref = new PlaceHolderRef();
|
|
1716
|
+
list = inject(NGX_DROPLIST, { optional: true, skipSelf: true });
|
|
1717
|
+
constructor(tpl) {
|
|
1718
|
+
this.tpl = tpl;
|
|
1719
|
+
this._ref.tpl = tpl;
|
|
1720
|
+
this._ref.dropList = this.list?._ref;
|
|
1721
|
+
}
|
|
1722
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxPlaceholder, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
|
|
1723
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.4", type: NgxPlaceholder, isStandalone: true, selector: "[NgxPlaceholder],[ngxPlaceholder]", providers: [{ provide: NGX_PLACEHOLDER, useExisting: NgxPlaceholder }], ngImport: i0 });
|
|
1724
|
+
}
|
|
1725
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxPlaceholder, decorators: [{
|
|
1726
|
+
type: Directive,
|
|
1727
|
+
args: [{
|
|
1728
|
+
selector: '[NgxPlaceholder],[ngxPlaceholder]',
|
|
1729
|
+
providers: [{ provide: NGX_PLACEHOLDER, useExisting: NgxPlaceholder }],
|
|
1730
|
+
}]
|
|
1731
|
+
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
|
|
856
1732
|
|
|
857
1733
|
const NGX_DROPLIST = new InjectionToken('ngx-drop-list');
|
|
858
1734
|
class NgxDropList {
|
|
@@ -868,6 +1744,8 @@ class NgxDropList {
|
|
|
868
1744
|
this._ref.disableSort = value === true;
|
|
869
1745
|
}
|
|
870
1746
|
drop = new EventEmitter();
|
|
1747
|
+
customPlaceholder;
|
|
1748
|
+
sub;
|
|
871
1749
|
service = inject(DragDropService);
|
|
872
1750
|
group = inject(NGX_DROPLIST_GROUP, { optional: true, skipSelf: true });
|
|
873
1751
|
constructor(el) {
|
|
@@ -876,17 +1754,25 @@ class NgxDropList {
|
|
|
876
1754
|
ngOnInit() {
|
|
877
1755
|
this._ref.el = this.el.nativeElement;
|
|
878
1756
|
this._ref.dropListGroup = this.group?._ref ?? null;
|
|
1757
|
+
// console.log('list:', this._ref.el, 'group:', this._ref.dropListGroup?.el);
|
|
879
1758
|
// Previously the group directive never actually tracked its lists — fixed here.
|
|
880
1759
|
this.group?._ref.add(this._ref);
|
|
881
|
-
this._ref.onDrop.subscribe((e) => this.drop.emit(e));
|
|
1760
|
+
this.sub = this._ref.onDrop.subscribe((e) => this.drop.emit(e));
|
|
882
1761
|
this.service.registerDropList(this._ref);
|
|
883
1762
|
}
|
|
1763
|
+
ngAfterContentInit() {
|
|
1764
|
+
if (this.customPlaceholder) {
|
|
1765
|
+
this._ref.customPlaceholder = this.customPlaceholder._ref;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
884
1768
|
ngOnDestroy() {
|
|
1769
|
+
this.sub?.unsubscribe();
|
|
1770
|
+
this._ref._release();
|
|
885
1771
|
this.group?._ref.remove(this._ref);
|
|
886
1772
|
this.service.removeDropList(this._ref);
|
|
887
1773
|
}
|
|
888
1774
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropList, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive });
|
|
889
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.4", type: NgxDropList, isStandalone: true, selector: "[NgxDropList],[ngxDropList]", inputs: { data: "data", connectedTo: "connectedTo", disableSort: "disableSort" }, outputs: { drop: "drop" }, host: { classAttribute: "ngx-drop-list" }, providers: [{ provide: NGX_DROPLIST, useExisting: NgxDropList }], ngImport: i0 });
|
|
1775
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.4", type: NgxDropList, isStandalone: true, selector: "[NgxDropList],[ngxDropList]", inputs: { data: "data", connectedTo: "connectedTo", disableSort: "disableSort" }, outputs: { drop: "drop" }, host: { classAttribute: "ngx-drop-list" }, providers: [{ provide: NGX_DROPLIST, useExisting: NgxDropList }], queries: [{ propertyName: "customPlaceholder", first: true, predicate: NgxPlaceholder, descendants: true }], ngImport: i0 });
|
|
890
1776
|
}
|
|
891
1777
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropList, decorators: [{
|
|
892
1778
|
type: Directive,
|
|
@@ -904,6 +1790,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImpor
|
|
|
904
1790
|
type: Input
|
|
905
1791
|
}], drop: [{
|
|
906
1792
|
type: Output
|
|
1793
|
+
}], customPlaceholder: [{
|
|
1794
|
+
type: ContentChild,
|
|
1795
|
+
args: [NgxPlaceholder, {
|
|
1796
|
+
descendants: true,
|
|
1797
|
+
}]
|
|
907
1798
|
}] } });
|
|
908
1799
|
|
|
909
1800
|
/**
|
|
@@ -997,6 +1888,8 @@ function getScrollPosition(container) {
|
|
|
997
1888
|
return { left: container.scrollLeft, top: container.scrollTop };
|
|
998
1889
|
}
|
|
999
1890
|
|
|
1891
|
+
/** Pointer events already claimed by an (inner) draggable. */
|
|
1892
|
+
const claimedPointerEvents = new WeakSet();
|
|
1000
1893
|
const NGX_DRAGGABLE = new InjectionToken('ngx-draggable');
|
|
1001
1894
|
const DEFAULT_KEYBOARD_STEP = 8;
|
|
1002
1895
|
class NgxDraggable {
|
|
@@ -1047,6 +1940,8 @@ class NgxDraggable {
|
|
|
1047
1940
|
removeUp;
|
|
1048
1941
|
removeKeydown;
|
|
1049
1942
|
removeEscape;
|
|
1943
|
+
removeCancel;
|
|
1944
|
+
removeLostCapture;
|
|
1050
1945
|
scrollTarget = window;
|
|
1051
1946
|
lastScroll = { left: 0, top: 0 };
|
|
1052
1947
|
lastWindowScroll = { left: 0, top: 0 };
|
|
@@ -1068,6 +1963,7 @@ class NgxDraggable {
|
|
|
1068
1963
|
this._ref.boundary = this.boundary();
|
|
1069
1964
|
this._ref.lockAxis = this.lockAxis();
|
|
1070
1965
|
this._ref.dropListGroup = this.group?._ref;
|
|
1966
|
+
this._ref.service = this.service;
|
|
1071
1967
|
this._ref.init();
|
|
1072
1968
|
this._ref.withDropList(this.list?._ref ?? null);
|
|
1073
1969
|
this.service.registerDragItem(this._ref);
|
|
@@ -1080,7 +1976,12 @@ class NgxDraggable {
|
|
|
1080
1976
|
this.removeUp?.();
|
|
1081
1977
|
this.removeKeydown?.();
|
|
1082
1978
|
this.removeEscape?.();
|
|
1979
|
+
this.removeCancel?.();
|
|
1980
|
+
this.removeLostCapture?.();
|
|
1083
1981
|
this.scroller.stop();
|
|
1982
|
+
this.stopScrollTracking();
|
|
1983
|
+
// Destroyed mid-drag (e.g. the model changed underneath us): never leave a body clone behind.
|
|
1984
|
+
this._ref.dispose();
|
|
1084
1985
|
this.service.removeDragItem(this._ref);
|
|
1085
1986
|
this._ref.dropList?.removeItem(this._ref);
|
|
1086
1987
|
}
|
|
@@ -1090,6 +1991,11 @@ class NgxDraggable {
|
|
|
1090
1991
|
// this.isInteractive(e.target) ||
|
|
1091
1992
|
!this.isOnHandle(e.target))
|
|
1092
1993
|
return;
|
|
1994
|
+
// Nested draggables: pointerdown bubbles, so without this an ancestor draggable would start
|
|
1995
|
+
// its own drag from the very same gesture. The innermost enabled draggable claims it.
|
|
1996
|
+
if (claimedPointerEvents.has(e))
|
|
1997
|
+
return;
|
|
1998
|
+
claimedPointerEvents.add(e);
|
|
1093
1999
|
e.preventDefault();
|
|
1094
2000
|
this.down = true;
|
|
1095
2001
|
this.pointerId = e.pointerId;
|
|
@@ -1103,24 +2009,20 @@ class NgxDraggable {
|
|
|
1103
2009
|
if (ev.key === 'Escape' && this.dragging())
|
|
1104
2010
|
this.cancel();
|
|
1105
2011
|
});
|
|
2012
|
+
this.removeCancel = this.renderer.listen(this.doc, 'pointercancel', (ev) => this.pointerCancel(ev));
|
|
2013
|
+
this.removeLostCapture = this.renderer.listen(this.doc, 'lostpointercapture', (ev) => this.pointerCancel(ev));
|
|
1106
2014
|
}
|
|
1107
2015
|
applyDragUpdate(p) {
|
|
2016
|
+
const before = this._ref.activeDropList;
|
|
2017
|
+
// DragRef decides everything (preview, target list, placeholder). A free drag never
|
|
2018
|
+
// interacts with drop lists.
|
|
1108
2019
|
this._ref.dragMove(p);
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
this._ref.withDropList(target);
|
|
1115
|
-
target.createPlaceholder(this._ref);
|
|
1116
|
-
if (this.autoScroll())
|
|
1117
|
-
this.scroller.retarget(target.el);
|
|
1118
|
-
}
|
|
1119
|
-
else if (this.autoScroll()) {
|
|
1120
|
-
this.scroller.stop();
|
|
1121
|
-
}
|
|
2020
|
+
if (this._ref.isListDrag && this.autoScroll()) {
|
|
2021
|
+
const after = this._ref.activeDropList;
|
|
2022
|
+
// Follow the hovered list; outside every list, scroll the page.
|
|
2023
|
+
if (after !== before)
|
|
2024
|
+
this.scroller.retarget(after?.el ?? this.doc.body);
|
|
1122
2025
|
}
|
|
1123
|
-
this._ref.dropList?.sortItem(this._ref, p);
|
|
1124
2026
|
this.dragMove.emit(p);
|
|
1125
2027
|
}
|
|
1126
2028
|
/** فراخوانی میشود دقیقاً وقتی drag واقعاً شروع میشود (چه با موس، چه با کیبورد). */
|
|
@@ -1181,7 +2083,13 @@ class NgxDraggable {
|
|
|
1181
2083
|
if (!this.down || e.pointerId !== this.pointerId)
|
|
1182
2084
|
return;
|
|
1183
2085
|
if (this.dragging()) {
|
|
1184
|
-
|
|
2086
|
+
// Also when released OUTSIDE every list: endDrag() rolls the drag back and cleans up.
|
|
2087
|
+
if (this._ref.isListDrag) {
|
|
2088
|
+
// The release position is authoritative for the drop target (a fast flick may end with
|
|
2089
|
+
// a pointerup that has no preceding pointermove at that position).
|
|
2090
|
+
const p = { x: e.clientX, y: e.clientY };
|
|
2091
|
+
if (p.x !== this.lastPointer.x || p.y !== this.lastPointer.y)
|
|
2092
|
+
this._ref.dragMove(p);
|
|
1185
2093
|
this._ref.endDrag();
|
|
1186
2094
|
}
|
|
1187
2095
|
this.service.end(this._ref);
|
|
@@ -1189,6 +2097,16 @@ class NgxDraggable {
|
|
|
1189
2097
|
this.finish();
|
|
1190
2098
|
this.dragEnd.emit(this._ref.pointer);
|
|
1191
2099
|
}
|
|
2100
|
+
/** Browser cancelled the pointer (touch takeover, lost capture...). */
|
|
2101
|
+
pointerCancel(e) {
|
|
2102
|
+
if (!this.down || e.pointerId !== this.pointerId)
|
|
2103
|
+
return;
|
|
2104
|
+
if (this.dragging() && this._ref.isListDrag) {
|
|
2105
|
+
this.cancel();
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
this.pointerUp(e);
|
|
2109
|
+
}
|
|
1192
2110
|
cancel() {
|
|
1193
2111
|
this._ref.cancelDrag();
|
|
1194
2112
|
this.service.end(this._ref);
|
|
@@ -1202,7 +2120,10 @@ class NgxDraggable {
|
|
|
1202
2120
|
this.removeMove?.();
|
|
1203
2121
|
this.removeUp?.();
|
|
1204
2122
|
this.removeEscape?.();
|
|
2123
|
+
this.removeCancel?.();
|
|
2124
|
+
this.removeLostCapture?.();
|
|
1205
2125
|
this.removeMove = this.removeUp = this.removeEscape = undefined;
|
|
2126
|
+
this.removeCancel = this.removeLostCapture = undefined;
|
|
1206
2127
|
}
|
|
1207
2128
|
/** Arrow-key movement for keyboard/assistive-tech users. Shift multiplies the step by 4. */
|
|
1208
2129
|
keyDown(e) {
|
|
@@ -1219,14 +2140,19 @@ class NgxDraggable {
|
|
|
1219
2140
|
if (delta) {
|
|
1220
2141
|
e.preventDefault();
|
|
1221
2142
|
if (!this.dragging()) {
|
|
1222
|
-
|
|
2143
|
+
// Free drag keeps its (0,0) pseudo pointer. A list item starts from its own centre so
|
|
2144
|
+
// that keyboard sorting resolves against real geometry.
|
|
2145
|
+
const inList = !!this._ref.dropList && this._ref.dropList.el === this._ref.el.parentElement;
|
|
2146
|
+
const r = this._ref.el.getBoundingClientRect();
|
|
2147
|
+
const origin = inList ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: 0, y: 0 };
|
|
2148
|
+
this._ref.pointerDown(origin);
|
|
1223
2149
|
this._ref.boundary = this.boundary();
|
|
1224
2150
|
this._ref.lockAxis = this.lockAxis();
|
|
1225
|
-
this._ref.startDrag(
|
|
2151
|
+
this._ref.startDrag(origin);
|
|
1226
2152
|
this.dragging.set(true);
|
|
1227
2153
|
this.service.begin(this._ref);
|
|
1228
2154
|
this.startScrollTracking();
|
|
1229
|
-
this.dragStart.emit(
|
|
2155
|
+
this.dragStart.emit(origin);
|
|
1230
2156
|
}
|
|
1231
2157
|
this._ref.nudge(delta.x, delta.y);
|
|
1232
2158
|
this.dragMove.emit(this._ref.pointer);
|
|
@@ -1652,27 +2578,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImpor
|
|
|
1652
2578
|
args: ['keydown', ['$event']]
|
|
1653
2579
|
}] } });
|
|
1654
2580
|
|
|
1655
|
-
const NGX_PLACEHOLDER = new InjectionToken('ngx-place-holder');
|
|
1656
|
-
class NgxPlaceholder {
|
|
1657
|
-
tpl;
|
|
1658
|
-
_ref = new PlaceHolderRef();
|
|
1659
|
-
list = inject(NGX_DROPLIST, { optional: true, skipSelf: true });
|
|
1660
|
-
constructor(tpl) {
|
|
1661
|
-
this.tpl = tpl;
|
|
1662
|
-
this._ref.tpl = tpl;
|
|
1663
|
-
this._ref.dropList = this.list?._ref;
|
|
1664
|
-
}
|
|
1665
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxPlaceholder, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive });
|
|
1666
|
-
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.4", type: NgxPlaceholder, isStandalone: true, selector: "[NgxPlaceholder],[ngxPlaceholder]", providers: [{ provide: NGX_PLACEHOLDER, useExisting: NgxPlaceholder }], ngImport: i0 });
|
|
1667
|
-
}
|
|
1668
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxPlaceholder, decorators: [{
|
|
1669
|
-
type: Directive,
|
|
1670
|
-
args: [{
|
|
1671
|
-
selector: '[NgxPlaceholder],[ngxPlaceholder]',
|
|
1672
|
-
providers: [{ provide: NGX_PLACEHOLDER, useExisting: NgxPlaceholder }],
|
|
1673
|
-
}]
|
|
1674
|
-
}], ctorParameters: () => [{ type: i0.TemplateRef }] });
|
|
1675
|
-
|
|
1676
2581
|
function moveItemInArray(array, from, to) {
|
|
1677
2582
|
const f = clamp(from, array.length - 1);
|
|
1678
2583
|
const t = clamp(to, array.length - 1);
|