ngx-kit 7.4.0 → 8.0.1

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, EventEmitter, Injectable, InjectionToken, Directive, inject, Output, Input, model, input, output, effect, HostListener } from '@angular/core';
2
+ import { signal, EventEmitter, Injectable, InjectionToken, model, input, output, inject, Renderer2, effect, Input, Directive, HostListener, ApplicationRef, ContentChild, Output, ElementRef } 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
- document.body.appendChild(preview);
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,21 @@ 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
+ /**
222
+ * Candidate lists for hit-testing, filtered (connected to the origin; not inside the dragged
223
+ * element) and sorted deepest-first — both computed ONCE, not on every pointer move, since
224
+ * neither ever changes mid-drag. Kept in sync with the live registry (see `dropListsSnapshot`)
225
+ * so a list that appears or disappears mid-drag (e.g. behind an `@if`) is still picked up.
226
+ */
227
+ dropCandidates = [];
228
+ dropListsSnapshot = null;
229
+ lastEffective = { x: 0, y: 0 };
230
+ scrollListener;
231
+ resizeListener;
134
232
  init() {
135
233
  this.previousTransform = getComputedStyle(this.el).getPropertyValue('transform');
136
234
  }
@@ -157,14 +255,18 @@ class DragRef {
157
255
  this.moveDx = 0;
158
256
  this.moveDy = 0;
159
257
  this.scrollCompensation = { x: 0, y: 0 };
160
- this.originDropList = this.dropList;
161
- this.originIndex = this.dropList?.getItemIndex(this) ?? -1;
258
+ // Only draggables that are direct children of their drop list take part in sorting.
259
+ // Everything else is a plain free drag.
260
+ const list = this.dropList && this.dropList.el === this.el.parentElement ? this.dropList : null;
261
+ this.originDropList = list;
262
+ this.originIndex = list?.getItemIndex(this) ?? -1;
162
263
  this.previousTransform = getComputedStyle(this.el).getPropertyValue('transform');
163
264
  this.isDragging.set(true);
164
265
  this.previousZIndex = this.el.style.zIndex;
165
266
  this.sourceVisibility = this.el.style.visibility;
166
267
  this.sourceDisplay = this.el.style.display;
167
268
  this.sourcePointerEvents = this.el.style.pointerEvents;
269
+ this.sourceTransition = this.el.style.transition;
168
270
  this.el.classList.add('ngx-draggable--dragging');
169
271
  this.el.style.willChange = 'transform';
170
272
  this.el.style.transition = 'none';
@@ -179,8 +281,10 @@ class DragRef {
179
281
  * create a body-level preview because the original element can be
180
282
  * inside an overflow:auto/hidden container such as a Kanban column.
181
283
  */
182
- if (this.dropList) {
183
- this.placeholder = this.dropList.createPlaceholder(this);
284
+ if (list) {
285
+ this.listDrag = true;
286
+ // Order matters: clone while the source is still visible and fully styled, hide it,
287
+ // and only THEN measure (the placeholder takes over the source's slot).
184
288
  const preview = cloneDragElementInBody(this.el, this.startRect, pointer.x, pointer.y);
185
289
  this.preview = preview.element;
186
290
  this.previewOffsetX = preview.offsetX;
@@ -188,6 +292,7 @@ class DragRef {
188
292
  this.el.style.visibility = 'hidden';
189
293
  this.el.style.display = 'none';
190
294
  this.el.style.pointerEvents = 'none';
295
+ this.beginListDrag(list, pointer);
191
296
  this.updatePreview();
192
297
  }
193
298
  else {
@@ -215,8 +320,11 @@ class DragRef {
215
320
  x: dx,
216
321
  y: dy,
217
322
  });
218
- if (this.dropList) {
323
+ if (this.listDrag) {
219
324
  this.updatePreview();
325
+ // Sorting follows the (axis/boundary constrained) pointer, not the raw one.
326
+ this.lastEffective = { x: this.startPointer.x + dx, y: this.startPointer.y + dy };
327
+ this.updateDropTarget(this.lastEffective);
220
328
  }
221
329
  else {
222
330
  this.applyTransform();
@@ -233,16 +341,15 @@ class DragRef {
233
341
  endDrag() {
234
342
  if (!this.isDragging())
235
343
  return;
236
- const dropList = this.dropList;
344
+ if (this.listDrag) {
345
+ this.isDragging.set(false);
346
+ this.finishListDrag(true);
347
+ return;
348
+ }
349
+ // ---- free drag: unchanged ----
237
350
  this.isDragging.set(false);
238
- // Normal Drag MUST behave exactly like before.
239
351
  this.placeholder?.detach();
240
352
  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
353
  this.preview?.remove();
247
354
  this.preview = undefined;
248
355
  this.el.classList.remove('ngx-draggable--dragging');
@@ -258,14 +365,34 @@ class DragRef {
258
365
  this.moveDx = 0;
259
366
  this.moveDy = 0;
260
367
  this.position.set({ x: 0, y: 0 });
261
- this.dropList?.exit(this);
262
- this.clearDropList();
368
+ if (this.listDrag) {
369
+ // Rollback: nothing was ever committed to the DOM or the model, so restoring is just cleanup.
370
+ this.isDragging.set(false);
371
+ this.finishListDrag(false);
372
+ return;
373
+ }
374
+ // ---- free drag: unchanged ----
263
375
  this.el.style.transform = this.previousTransform;
264
376
  this.el.style.visibility = this.sourceVisibility;
265
377
  this.el.style.pointerEvents = this.sourcePointerEvents;
266
378
  this.scrollCompensation = { x: 0, y: 0 };
267
379
  this.endDrag();
268
380
  }
381
+ /** Abort without emitting anything (the directive was destroyed mid-drag). Idempotent. */
382
+ dispose() {
383
+ if (!this.listDrag)
384
+ return;
385
+ this.isDragging.set(false);
386
+ this.finishListDrag(false);
387
+ }
388
+ /** True while a drop-list drag is running. */
389
+ get isListDrag() {
390
+ return this.listDrag;
391
+ }
392
+ /** Inline `display` the source element had before it was hidden for the drag. */
393
+ get originalDisplay() {
394
+ return this.sourceDisplay;
395
+ }
269
396
  get x() {
270
397
  return this.moveDx;
271
398
  }
@@ -289,16 +416,15 @@ class DragRef {
289
416
  notifyScroll(dx, dy) {
290
417
  if (!this.isDragging())
291
418
  return;
419
+ // Drop-list drags are position:fixed in <body> and follow the pointer in viewport space, so
420
+ // scrolling needs no compensation; sorting listens to scroll on its own (see beginListDrag).
421
+ if (this.listDrag)
422
+ return;
292
423
  this.scrollCompensation = {
293
424
  x: this.scrollCompensation.x + dx,
294
425
  y: this.scrollCompensation.y + dy,
295
426
  };
296
- if (this.dropList) {
297
- this.updatePreview();
298
- }
299
- else {
300
- this.applyTransform();
301
- }
427
+ this.applyTransform();
302
428
  }
303
429
  applyTransform() {
304
430
  const base = this.previousTransform && this.previousTransform !== 'none'
@@ -308,12 +434,848 @@ class DragRef {
308
434
  const ty = this.moveDy + this.scrollCompensation.y;
309
435
  this.el.style.transform = `${base}translate3d(${tx}px, ${ty}px, 0)`;
310
436
  }
437
+ // -------------------------------------------------------------------------------------
438
+ // drop-list drag
439
+ // -------------------------------------------------------------------------------------
440
+ beginListDrag(origin, pointer) {
441
+ const doc = this.el.ownerDocument;
442
+ const win = doc.defaultView ?? window;
443
+ this.dropListsSnapshot = null; // force refreshDropCandidates() to do a full pass below
444
+ this.refreshDropCandidates(origin);
445
+ this.lastEffective = { ...pointer };
446
+ this.activeDropList = origin;
447
+ origin.enter(this, pointer.x, pointer.y);
448
+ // The origin is (re-)measured AFTER its placeholder took over the source's slot, so its box
449
+ // still covers the whole list (the hidden source no longer occupies space).
450
+ origin._cacheGeometry();
451
+ this.scrollListener = () => {
452
+ for (const l of this.cachedLists)
453
+ l._onScroll();
454
+ this.updateDropTarget(this.lastEffective);
455
+ };
456
+ this.resizeListener = () => {
457
+ for (const l of this.cachedLists)
458
+ l._refresh();
459
+ this.updateDropTarget(this.lastEffective);
460
+ };
461
+ // Capture: `scroll` does not bubble, and any ancestor (or the page) may be the one scrolling.
462
+ doc.addEventListener('scroll', this.scrollListener, { capture: true, passive: true });
463
+ win.addEventListener('resize', this.resizeListener, { passive: true });
464
+ }
465
+ /**
466
+ * Recomputes which registered lists are valid targets for this drag — connected to the
467
+ * origin, and not nested inside the element being dragged — plus each one's nesting depth.
468
+ *
469
+ * `DragDropService.dropLists()` is an Angular signal: its array is a NEW reference only when a
470
+ * list actually registers or unregisters, so the check below is a single reference comparison
471
+ * on every pointer move and the (heavier) connectivity/containment/depth work below it only
472
+ * runs on the rare event a list appears or disappears mid-drag — never on every pointer move.
473
+ */
474
+ refreshDropCandidates(origin) {
475
+ const all = this.service?.dropLists() ?? [];
476
+ if (all === this.dropListsSnapshot)
477
+ return;
478
+ this.dropListsSnapshot = all;
479
+ const already = new Set(this.dropCandidates.map((c) => c.list));
480
+ const next = [{ list: origin, depth: origin.depth }];
481
+ for (const l of all) {
482
+ if (l === origin || !l.el || !origin.isConnectedTo(l))
483
+ continue;
484
+ if (this.el !== l.el && this.el.contains(l.el))
485
+ continue;
486
+ if (!already.has(l))
487
+ l._cacheGeometry(); // newly eligible mid-drag: measure it now
488
+ next.push({ list: l, depth: l.depth });
489
+ }
490
+ this.dropCandidates = next;
491
+ this.cachedLists = next.map((c) => c.list);
492
+ }
493
+ /**
494
+ * Decide which list is under the pointer, switch if needed, then re-sort in the active one.
495
+ *
496
+ * The origin list is the one exception to "exiting a list resets/releases it": while the
497
+ * pointer dives into one of the origin's OWN nested lists (at any depth), the origin's
498
+ * placeholder and sibling displacement are left exactly as they are — frozen, not reset —
499
+ * so returning to the origin's own level doesn't snap items back and then forward again.
500
+ * Every OTHER (foreign) list still fully releases the moment the pointer leaves it, exactly
501
+ * as before; nesting multiple foreign sessions alive at once would let their own internal
502
+ * sibling displacement compound in ways a single flat `ancestorOffset` cannot represent.
503
+ */
504
+ updateDropTarget(pointer) {
505
+ const origin = this.originDropList;
506
+ if (!origin)
507
+ return;
508
+ this.refreshDropCandidates(origin);
509
+ const next = this.resolveDropList(pointer);
510
+ if (next === this.activeDropList) {
511
+ next?.sortItem(this, pointer);
512
+ return;
513
+ }
514
+ const prev = this.activeDropList;
515
+ if (prev && prev !== origin)
516
+ prev.exit(this);
517
+ const nextWithinOrigin = !!next && (next === origin || isDescendantOfList(next, origin));
518
+ if (nextWithinOrigin) {
519
+ if (prev === origin)
520
+ origin._freeze(); // no longer the innermost, but stays engaged
521
+ }
522
+ else {
523
+ origin.exit(this); // truly outside the origin's territory now: reset it (idempotent)
524
+ }
525
+ this.activeDropList = next;
526
+ next?.enter(this, pointer.x, pointer.y); // fresh entry, or resumes a still-alive (frozen) session
527
+ }
528
+ /**
529
+ * Innermost connected list under the pointer, from the precomputed candidates: deeper always
530
+ * beats shallower; among lists at the same depth (unrelated, visually overlapping lists) the
531
+ * currently active one is kept to avoid flicker, otherwise the smaller one wins. No DOM
532
+ * reads and no `.contains()` calls happen here — everything needed was precomputed above.
533
+ */
534
+ resolveDropList(pointer) {
535
+ let best = null;
536
+ let bestDepth = -1;
537
+ for (const { list, depth } of this.dropCandidates) {
538
+ if (!list._containsPoint(pointer.x, pointer.y))
539
+ continue;
540
+ if (!best || depth > bestDepth) {
541
+ best = list;
542
+ bestDepth = depth;
543
+ }
544
+ else if (depth === bestDepth && best !== this.activeDropList) {
545
+ if (list === this.activeDropList || list._area() < best._area())
546
+ best = list;
547
+ }
548
+ }
549
+ return best;
550
+ }
551
+ /**
552
+ * Single teardown path for every way a drop-list drag can end (drop on a list, drop outside,
553
+ * Escape, pointercancel, destroy). Everything transient is removed here, so nothing can leak
554
+ * regardless of how the drag ended. Only then is the drop event emitted.
555
+ */
556
+ finishListDrag(commit) {
557
+ const doc = this.el.ownerDocument;
558
+ if (this.scrollListener)
559
+ doc.removeEventListener('scroll', this.scrollListener, { capture: true });
560
+ if (this.resizeListener)
561
+ (doc.defaultView ?? window).removeEventListener('resize', this.resizeListener);
562
+ this.scrollListener = this.resizeListener = undefined;
563
+ const target = commit ? this.activeDropList : null;
564
+ const event = target?._finish(this, true) ?? null;
565
+ for (const l of this.cachedLists)
566
+ l._release(this);
567
+ this.preview?.remove();
568
+ this.preview = undefined;
569
+ this.placeholder = undefined;
570
+ this.activeDropList = null;
571
+ this.cachedLists = [];
572
+ this.dropCandidates = [];
573
+ this.dropListsSnapshot = null;
574
+ this.listDrag = false;
575
+ // The source element is put back exactly as it was (its transform was never touched).
576
+ this.el.classList.remove('ngx-draggable--dragging');
577
+ this.el.style.visibility = this.sourceVisibility;
578
+ this.el.style.display = this.sourceDisplay;
579
+ this.el.style.pointerEvents = this.sourcePointerEvents;
580
+ this.el.style.transition = this.sourceTransition;
581
+ this.el.style.willChange = '';
582
+ this.el.style.zIndex = this.previousZIndex;
583
+ if (event && target)
584
+ target.onDrop.emit(event);
585
+ }
311
586
  updatePreview() {
312
587
  if (!this.preview)
313
588
  return;
314
589
  this.preview.style.transform = `translate3d(${this.moveDx}px, ${this.moveDy}px, 0)`;
315
590
  }
316
591
  }
592
+ /** Is `list` nested (at any depth) inside `ancestor`, per the parent/child list tree? */
593
+ function isDescendantOfList(list, ancestor) {
594
+ for (let p = list.parentList; p; p = p.parentList) {
595
+ if (p === ancestor)
596
+ return true;
597
+ }
598
+ return false;
599
+ }
600
+
601
+ class PlaceHolderRef {
602
+ /**
603
+ * Custom placeholder content, from `*ngxPlaceholder` inside the `ngxDropList`. When unset,
604
+ * `attach()` falls back to a shallow clone of the dragged element (previous behaviour).
605
+ *
606
+ * Example:
607
+ *
608
+ * <div ngxDropList>
609
+ * <span class="my-placeholder" *ngxPlaceholder></span>
610
+ * ...
611
+ * </div>
612
+ */
613
+ tpl;
614
+ /** Set by `NgxPlaceholder` so the embedded view can join Angular's change-detection tree. */
615
+ appRef;
616
+ dropList;
617
+ element;
618
+ _visible = false;
619
+ _view;
620
+ /** True once `this.element` was built from `tpl` (as opposed to the fallback clone). */
621
+ isCustom = false;
622
+ attach(container, source, reference) {
623
+ if (!this.element) {
624
+ if (this.tpl) {
625
+ this._view = this.tpl.createEmbeddedView({});
626
+ this.appRef?.attachView(this._view);
627
+ // Render bindings inside the embedded view before we move its DOM nodes.
628
+ this._view.detectChanges();
629
+ const element = this._view.rootNodes.find((node) => node instanceof HTMLElement);
630
+ if (!element) {
631
+ this.appRef?.detachView(this._view);
632
+ this._view.destroy();
633
+ this._view = undefined;
634
+ throw new Error('ngxPlaceholder template must contain exactly one HTMLElement root node ' +
635
+ '(other root nodes, if any, are ignored).');
636
+ }
637
+ this.element = element;
638
+ this.isCustom = true;
639
+ }
640
+ else {
641
+ // Fallback: keep the same tag/attributes as the dragged item so selectors such as
642
+ // ".list > article" and grid/flex item rules continue to apply.
643
+ this.element = source.cloneNode(false);
644
+ this.element.removeAttribute('id');
645
+ this.element.classList.add('ngx-drag-placeholder');
646
+ this.element.setAttribute('aria-hidden', 'true');
647
+ this.element.setAttribute('inert', '');
648
+ this.element.removeAttribute('tabindex');
649
+ this.isCustom = false;
650
+ }
651
+ }
652
+ // Safety overrides that apply regardless of who authored the element: it must never
653
+ // inherit a live drag transform/animation (transform/transition are left free for the
654
+ // sort session to drive, translate3d-ing the placeholder to the current insertion slot),
655
+ // never intercept pointer events, and never be display:none.
656
+ this.element.style.removeProperty('transform');
657
+ this.element.style.removeProperty('transition');
658
+ this.element.style.removeProperty('will-change');
659
+ this.element.style.setProperty('animation', 'none', 'important');
660
+ this.element.style.setProperty('pointer-events', 'none', 'important');
661
+ this.element.style.setProperty('visibility', 'visible', 'important');
662
+ if (!this.isCustom) {
663
+ // Only the auto-generated clone gets the default "phantom box" look. A custom
664
+ // *ngxPlaceholder template is styled entirely by the caller.
665
+ this.element.style.setProperty('opacity', '0.16', 'important');
666
+ this.element.style.setProperty('outline', '2px dashed currentColor', 'important');
667
+ this.element.style.setProperty('outline-offset', '-2px', 'important');
668
+ }
669
+ this._visible = true;
670
+ if (reference && reference.parentNode === container) {
671
+ if (this.element !== reference)
672
+ container.insertBefore(this.element, reference);
673
+ }
674
+ else if (this.element.parentNode !== container) {
675
+ container.appendChild(this.element);
676
+ }
677
+ return this.element;
678
+ }
679
+ /** True while the currently attached element came from a custom `*ngxPlaceholder` template. */
680
+ get custom() {
681
+ return this.isCustom;
682
+ }
683
+ detach() {
684
+ this.element?.remove();
685
+ if (this._view) {
686
+ this.appRef?.detachView(this._view);
687
+ this._view.destroy();
688
+ this._view = undefined;
689
+ }
690
+ this.element = undefined;
691
+ this._visible = false;
692
+ }
693
+ get visible() {
694
+ return this._visible && !!this.element?.isConnected;
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Pure geometry used by the drop-list sorting engine.
700
+ *
701
+ * Nothing in this file touches the DOM. Everything is a deterministic function of
702
+ * `(rectangles, pointer)`, which is what makes sorting reversible: the same pointer over the
703
+ * same snapshot always yields the same insertion index, regardless of what the previous
704
+ * pointer positions were.
705
+ *
706
+ * Layout direction is *derived from the rectangles* (row clustering + the order in which DOM
707
+ * siblings actually appear on screen) rather than from `flex-direction` / `direction`. That is
708
+ * what lets one algorithm handle block flow, inline flow, flex (row/column/wrap/reverse),
709
+ * grid, RTL and `order:`-reshuffled layouts. Computed style is only a fallback for lists that
710
+ * are too small (0 or 1 item) to reveal their own orientation.
711
+ */
712
+ /**
713
+ * Hysteresis is the only "magic" number in the algorithm and it is deliberate: without it a
714
+ * pointer resting exactly on a sibling's mid-line flips the placeholder on every sub-pixel
715
+ * jitter (and on every rounding difference between two layouts). It is proportional to the
716
+ * sibling's size and capped, so it can never make a small item feel sticky.
717
+ */
718
+ const HYSTERESIS_RATIO = 0.1;
719
+ const HYSTERESIS_MAX_PX = 6;
720
+ function hysteresis(size) {
721
+ return Math.min(HYSTERESIS_MAX_PX, Math.max(0, size) * HYSTERESIS_RATIO);
722
+ }
723
+ const cx = (b) => (b.left + b.right) / 2;
724
+ const cy = (b) => (b.top + b.bottom) / 2;
725
+ const width = (b) => b.right - b.left;
726
+ const height = (b) => b.bottom - b.top;
727
+ const sign = (n) => (n < 0 ? -1 : 1);
728
+ /**
729
+ * Builds a layout model from DOM-ordered boxes.
730
+ *
731
+ * @param fallbackAxis orientation to assume when there are fewer than two boxes.
732
+ * @param fallbackRtl inline direction to assume when no row contains two items.
733
+ */
734
+ function analyzeLayout(boxes, fallbackAxis = 'y', fallbackRtl = false) {
735
+ const rows = [];
736
+ const rowOf = [];
737
+ boxes.forEach((b, i) => {
738
+ const row = rows[rows.length - 1];
739
+ if (row) {
740
+ const overlap = Math.min(b.bottom, row.bottom) - Math.max(b.top, row.top);
741
+ const smaller = Math.min(height(b), row.bottom - row.top);
742
+ // Same row when the boxes share at least half of the smaller one's height. Touching or
743
+ // sub-pixel-overlapping boxes (fractional heights) therefore start a new row.
744
+ if (overlap > 0 && overlap >= smaller * 0.5) {
745
+ row.top = Math.min(row.top, b.top);
746
+ row.bottom = Math.max(row.bottom, b.bottom);
747
+ row.last = i;
748
+ row.count++;
749
+ rowOf.push(rows.length - 1);
750
+ return;
751
+ }
752
+ }
753
+ rows.push({ top: b.top, bottom: b.bottom, first: i, last: i, count: 1 });
754
+ rowOf.push(rows.length - 1);
755
+ });
756
+ const rowCount = rows.length;
757
+ // Vertical direction: which way do successive rows travel?
758
+ const dirY = rowCount > 1
759
+ ? sign((rows[rowCount - 1].top + rows[rowCount - 1].bottom) / 2 - (rows[0].top + rows[0].bottom) / 2)
760
+ : 1;
761
+ // Horizontal direction per row (from the first/last item of that row) + a global vote.
762
+ const rowDirX = [];
763
+ let votes = 0;
764
+ for (const r of rows) {
765
+ if (r.count > 1) {
766
+ const d = sign(cx(boxes[r.last]) - cx(boxes[r.first]));
767
+ rowDirX.push(d);
768
+ votes += d;
769
+ }
770
+ else {
771
+ rowDirX.push(0); // resolved below once the global vote is known
772
+ }
773
+ }
774
+ const dirX = votes !== 0 ? sign(votes) : fallbackRtl ? -1 : 1;
775
+ for (let i = 0; i < rowDirX.length; i++)
776
+ if (!rowDirX[i])
777
+ rowDirX[i] = dirX;
778
+ const rowBounds = [];
779
+ for (let r = 0; r < rowCount - 1; r++) {
780
+ rowBounds.push(dirY === 1 ? (rows[r].bottom + rows[r + 1].top) / 2 : (rows[r].top + rows[r + 1].bottom) / 2);
781
+ }
782
+ const first = rows[0];
783
+ const lastRow = rows[rowCount - 1];
784
+ const yStart = first ? (dirY === 1 ? first.top : first.bottom) : 0;
785
+ const yEnd = lastRow ? (dirY === 1 ? lastRow.bottom : lastRow.top) : 0;
786
+ let axis;
787
+ if (boxes.length < 2)
788
+ axis = fallbackAxis;
789
+ else if (rowCount === 1)
790
+ axis = 'x';
791
+ else if (rows.every((r) => r.count === 1))
792
+ axis = 'y';
793
+ else
794
+ axis = 'grid';
795
+ return { axis, dirX, dirY, rowOf, rowDirX, rowBounds, yStart, yEnd, rowCount };
796
+ }
797
+ function rowAt(model, y) {
798
+ let r = 0;
799
+ for (const bound of model.rowBounds) {
800
+ if (model.dirY === 1 ? y > bound : y < bound)
801
+ r++;
802
+ else
803
+ break;
804
+ }
805
+ return r;
806
+ }
807
+ /**
808
+ * Where should the dragged item be inserted, given the pointer?
809
+ *
810
+ * The result is the number of *other* items that end up before the dragged one, i.e. the
811
+ * dragged item's final index. `slot` is the index (in `boxes`) of the dragged item's own slot
812
+ * (its placeholder), or -1 when the list does not contain one yet.
813
+ *
814
+ * `current` is the index the slot currently has in "others space"; it only decides on which
815
+ * side of a sibling's mid-line the small hysteresis band applies. Pass `null` for a first
816
+ * placement (no history, no hysteresis).
817
+ *
818
+ * Pure: depends only on its arguments.
819
+ */
820
+ function resolveInsertionIndex(model, boxes, slot, pointer, current) {
821
+ let before = 0;
822
+ let others = 0;
823
+ for (let i = 0; i < boxes.length; i++) {
824
+ if (i === slot)
825
+ continue;
826
+ const o = others++;
827
+ const b = boxes[i];
828
+ // Sign of the hysteresis band: +1 favours "stays before the pointer", -1 favours "stays after".
829
+ const bias = current === null ? 0 : o < current ? 1 : -1;
830
+ let isBefore;
831
+ if (model.axis === 'x') {
832
+ isBefore = model.dirX * (pointer.x - cx(b)) + bias * hysteresis(width(b)) > 0;
833
+ }
834
+ else if (model.axis === 'y') {
835
+ isBefore = model.dirY * (pointer.y - cy(b)) + bias * hysteresis(height(b)) > 0;
836
+ }
837
+ else {
838
+ const row = model.rowOf[i];
839
+ const py = pointer.y + model.dirY * bias * hysteresis(height(b));
840
+ const pointerRow = rowAt(model, py);
841
+ if (model.dirY * (py - model.yStart) < 0) {
842
+ isBefore = false; // above the first row: nothing precedes the pointer
843
+ }
844
+ else if (model.dirY * (py - model.yEnd) > 0) {
845
+ isBefore = true; // below the last row: everything does (append)
846
+ }
847
+ else if (pointerRow !== row) {
848
+ isBefore = pointerRow > row;
849
+ }
850
+ else {
851
+ isBefore = model.rowDirX[row] * (pointer.x - cx(b)) + bias * hysteresis(width(b)) > 0;
852
+ }
853
+ }
854
+ if (isBefore)
855
+ before++;
856
+ }
857
+ return before;
858
+ }
859
+ /**
860
+ * Along-axis displacement of every box when the slot moves to `target` (others-space index).
861
+ *
862
+ * The result is where each box has to be translated so that the boxes, re-ordered, occupy the
863
+ * same run of space they occupied before, using each box's own size and the *measured* gaps
864
+ * between the original slots (so `gap`, margins and inline whitespace are handled by
865
+ * construction, not by assumption). Only meaningful for `x` / `y` axes; `grid` returns zeros.
866
+ */
867
+ function computeAxisOffsets(model, boxes, slot, target) {
868
+ const n = boxes.length;
869
+ const offsets = new Array(n).fill(0);
870
+ if (model.axis === 'grid' || n < 2 || slot < 0)
871
+ return offsets;
872
+ const horizontal = model.axis === 'x';
873
+ const dir = horizontal ? model.dirX : model.dirY;
874
+ // Mirror into a frame where DOM order is ascending, so one code path serves LTR and RTL.
875
+ const lo = boxes.map((b) => (horizontal ? (dir === 1 ? b.left : -b.right) : dir === 1 ? b.top : -b.bottom));
876
+ const hi = boxes.map((b) => (horizontal ? (dir === 1 ? b.right : -b.left) : dir === 1 ? b.bottom : -b.top));
877
+ const order = [];
878
+ for (let i = 0; i < n; i++)
879
+ if (i !== slot)
880
+ order.push(i);
881
+ order.splice(Math.max(0, Math.min(target, n - 1)), 0, slot);
882
+ let cursor = lo[0];
883
+ for (let p = 0; p < n; p++) {
884
+ const item = order[p];
885
+ const delta = cursor - lo[item];
886
+ offsets[item] = Math.abs(delta) < 1e-6 ? 0 : dir * delta;
887
+ cursor += hi[item] - lo[item] + (p < n - 1 ? lo[p + 1] - hi[p] : 0);
888
+ }
889
+ return offsets;
890
+ }
891
+ /** Intersection of two boxes, or `null` when they do not overlap. */
892
+ function intersectBoxes(a, b) {
893
+ const left = Math.max(a.left, b.left);
894
+ const top = Math.max(a.top, b.top);
895
+ const right = Math.min(a.right, b.right);
896
+ const bottom = Math.min(a.bottom, b.bottom);
897
+ return right > left && bottom > top ? { left, top, right, bottom } : null;
898
+ }
899
+ function boxContains(b, x, y) {
900
+ return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
901
+ }
902
+ function toBox(r) {
903
+ return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
904
+ }
905
+
906
+ /**
907
+ * Tracks how far the scrollable ancestors of an element (and the window) have scrolled since
908
+ * the frame was created.
909
+ *
910
+ * Geometry is measured once, when a drag enters a list. Scrolling afterwards moves the
911
+ * content under a stationary pointer, which is equivalent to moving the pointer by the scroll
912
+ * delta in the frame the geometry was measured in. Shifting the *pointer* (one point) instead
913
+ * of re-measuring every item keeps cached rectangles valid across page scroll, parent scroll
914
+ * and nested scroll without any layout reads on the hot path.
915
+ *
916
+ * `delta()` only reads `scrollLeft/Top` after `invalidate()` (called from a scroll event), so
917
+ * ordinary pointer moves perform zero scroll reads.
918
+ */
919
+ class ScrollFrame {
920
+ win;
921
+ targets = [];
922
+ origin = { x: 0, y: 0 };
923
+ cached = { x: 0, y: 0 };
924
+ dirty = false;
925
+ /**
926
+ * @param start first element to inspect (walks up through its ancestors)
927
+ * @param includeStart whether `start` itself counts (true for a list whose own scroll moves its items)
928
+ */
929
+ constructor(start, includeStart = true, win = window) {
930
+ this.win = win;
931
+ let node = includeStart ? start : (start?.parentElement ?? null);
932
+ const root = start?.ownerDocument.documentElement;
933
+ while (node) {
934
+ // <html> scrolling is reported through `window`; counting both would double the delta.
935
+ if (node !== root && scrolls(node))
936
+ this.targets.push(node);
937
+ node = node.parentElement;
938
+ }
939
+ this.targets.push(win);
940
+ this.origin = this.read();
941
+ }
942
+ /** Call when any scroll event fires. */
943
+ invalidate() {
944
+ this.dirty = true;
945
+ }
946
+ /** Total scroll distance (px) of all tracked containers since construction. */
947
+ delta() {
948
+ if (this.dirty) {
949
+ const now = this.read();
950
+ this.cached = { x: now.x - this.origin.x, y: now.y - this.origin.y };
951
+ this.dirty = false;
952
+ }
953
+ return this.cached;
954
+ }
955
+ read() {
956
+ let x = 0;
957
+ let y = 0;
958
+ for (const t of this.targets) {
959
+ if (t === this.win) {
960
+ x += this.win.scrollX;
961
+ y += this.win.scrollY;
962
+ }
963
+ else {
964
+ x += t.scrollLeft;
965
+ y += t.scrollTop;
966
+ }
967
+ }
968
+ return { x, y };
969
+ }
970
+ }
971
+ function scrolls(el) {
972
+ const s = getComputedStyle(el);
973
+ // `hidden` containers still scroll programmatically; `clip` never does.
974
+ const scrollish = (v) => v === 'auto' || v === 'scroll' || v === 'overlay' || v === 'hidden';
975
+ return scrollish(s.overflowX) || scrollish(s.overflowY);
976
+ }
977
+
978
+ /**
979
+ * Inline / inline-block layouts get their inter-item gap from whitespace TEXT nodes. A
980
+ * placeholder that is moved (or freshly inserted) without one would collapse that gap and shift
981
+ * everything after it by a space width, so it carries a separator of its own. Returns `null`
982
+ * for layouts that have no whitespace nodes (flex, grid, Angular-trimmed templates).
983
+ */
984
+ function makeSpacer(container) {
985
+ for (const n of Array.from(container.childNodes)) {
986
+ if (n.nodeType === Node.TEXT_NODE && n.nodeValue && /^\s+$/.test(n.nodeValue)) {
987
+ return container.ownerDocument.createTextNode(n.nodeValue);
988
+ }
989
+ }
990
+ return null;
991
+ }
992
+ const EASING = 'cubic-bezier(0, 0, 0.2, 1)';
993
+ /**
994
+ * One drag's view of one drop list.
995
+ *
996
+ * Lifecycle
997
+ * ---------
998
+ * snapshot ──► resolve(pointer) ──► apply(index) ──► ... ──► dispose()
999
+ *
1000
+ * The snapshot (`boxes`) is taken once, with the dragged item's slot already in place, and is
1001
+ * never re-read while the drag is over the list. `resolve` is a pure function of the pointer
1002
+ * and that snapshot, so moving the pointer back always restores the previous state.
1003
+ *
1004
+ * Two ways to *show* the result, chosen from the measured layout:
1005
+ *
1006
+ * - `transform` (one row or one column; the common case): the DOM is never touched. Siblings
1007
+ * and the placeholder are displaced with `translate3d`, computed from measured sizes and
1008
+ * gaps, exactly like Angular CDK's single-axis strategy.
1009
+ * - `flow` (grid / wrapping layouts): an exact translate cannot be derived without
1010
+ * re-implementing the browser's layout engine, so the single placeholder node is moved and
1011
+ * the browser lays the list out. Siblings are then animated from their old to new position
1012
+ * with FLIP `translate3d` so the motion is still transform-driven and smooth. Decisions
1013
+ * keep using the immutable snapshot, so the reflow can never feed back into them.
1014
+ */
1015
+ class SortSession {
1016
+ mode;
1017
+ container;
1018
+ placeholder;
1019
+ itemElements;
1020
+ animationMs;
1021
+ fallbackAxis;
1022
+ fallbackRtl;
1023
+ entries = [];
1024
+ boxes = [];
1025
+ model;
1026
+ /** Index of the slot (placeholder) inside `entries`/`boxes` at snapshot time. */
1027
+ slotIdx = -1;
1028
+ /** For each visible "other" item: its index among ALL other items (hidden ones included). */
1029
+ otherModelIndex = [];
1030
+ frame;
1031
+ applied = new Map();
1032
+ targetIdx = 0;
1033
+ disposed = false;
1034
+ /** undefined = not created yet, null = this layout has no whitespace separators. */
1035
+ spacer;
1036
+ onOffsetChange;
1037
+ constructor(init) {
1038
+ this.spacer = init.spacer;
1039
+ this.onOffsetChange = init.onOffsetChange;
1040
+ this.container = init.container;
1041
+ this.placeholder = init.placeholder;
1042
+ this.itemElements = init.itemElements;
1043
+ this.animationMs = init.animationMs;
1044
+ this.fallbackAxis = init.fallbackAxis;
1045
+ this.fallbackRtl = init.fallbackRtl;
1046
+ this.snapshot();
1047
+ this.frame = new ScrollFrame(this.container, true);
1048
+ this.mode = this.model.axis === 'grid' ? 'flow' : 'transform';
1049
+ this.targetIdx = this.slotIdx;
1050
+ const transition = this.animationMs > 0 ? `transform ${this.animationMs}ms ${EASING}` : '';
1051
+ for (const e of this.entries) {
1052
+ e.el.style.willChange = 'transform';
1053
+ if (transition)
1054
+ e.el.style.transition = transition;
1055
+ }
1056
+ }
1057
+ /** Detected orientation (useful for tests/diagnostics). */
1058
+ get axis() {
1059
+ return this.model.axis;
1060
+ }
1061
+ /** Current insertion index among the *visible other* items. */
1062
+ get target() {
1063
+ return this.targetIdx;
1064
+ }
1065
+ /** Index at which the session started (the slot's original position). */
1066
+ get origin() {
1067
+ return this.slotIdx;
1068
+ }
1069
+ /** Insertion index among ALL other items of the list (what consumers see as `currentIndex`). */
1070
+ get modelIndex() {
1071
+ const idx = this.otherModelIndex;
1072
+ if (this.targetIdx < idx.length)
1073
+ return idx[this.targetIdx];
1074
+ return idx.length ? idx[idx.length - 1] + 1 : 0;
1075
+ }
1076
+ /** Where would the dragged item be inserted if released at `pointer`? Pure; no side effects. */
1077
+ resolve(pointer) {
1078
+ const d = this.frame.delta();
1079
+ return resolveInsertionIndex(this.model, this.boxes, this.slotIdx, { x: pointer.x + d.x, y: pointer.y + d.y }, this.targetIdx);
1080
+ }
1081
+ /** Visually place the slot at `index`. Idempotent. */
1082
+ apply(index) {
1083
+ if (this.disposed || index === this.targetIdx)
1084
+ return;
1085
+ this.targetIdx = index;
1086
+ if (this.mode === 'transform')
1087
+ this.applyTransforms(index);
1088
+ else
1089
+ this.applyFlow(index);
1090
+ }
1091
+ /** Put everything back where it started (slot at its original index). */
1092
+ reset() {
1093
+ this.apply(this.slotIdx);
1094
+ }
1095
+ /** A scroll event happened somewhere: cached scroll deltas must be re-read once. */
1096
+ onScroll() {
1097
+ this.frame.invalidate();
1098
+ }
1099
+ /**
1100
+ * Re-baseline from the live layout (e.g. the window was resized mid-drag). Not called during
1101
+ * normal sorting, because re-measuring while items are displaced is exactly what would
1102
+ * re-introduce feedback into the decision.
1103
+ */
1104
+ refresh() {
1105
+ if (this.disposed)
1106
+ return;
1107
+ const target = this.targetIdx;
1108
+ this.clearDisplacement();
1109
+ this.snapshot();
1110
+ this.frame = new ScrollFrame(this.container, true);
1111
+ if (this.mode === 'transform') {
1112
+ this.targetIdx = this.slotIdx;
1113
+ this.apply(Math.min(target, this.boxes.length - 1));
1114
+ }
1115
+ else {
1116
+ this.targetIdx = this.slotIdx;
1117
+ }
1118
+ }
1119
+ /** Restore every inline style this session touched. Safe to call more than once. */
1120
+ dispose() {
1121
+ if (this.disposed)
1122
+ return;
1123
+ this.disposed = true;
1124
+ for (const e of this.entries) {
1125
+ e.el.style.transform = e.savedTransform;
1126
+ e.el.style.transition = e.savedTransition;
1127
+ e.el.style.willChange = e.savedWillChange;
1128
+ if (this.applied.has(e.el))
1129
+ this.onOffsetChange?.(e.el, { x: 0, y: 0 });
1130
+ }
1131
+ this.applied.clear();
1132
+ this.spacer?.remove();
1133
+ }
1134
+ // ---------------------------------------------------------------------------------------
1135
+ // snapshot
1136
+ // ---------------------------------------------------------------------------------------
1137
+ snapshot() {
1138
+ const previous = new Map(this.entries.map((e) => [e.el, e]));
1139
+ const entries = [];
1140
+ const boxes = [];
1141
+ const otherModelIndex = [];
1142
+ let slot = -1;
1143
+ let allOthers = 0;
1144
+ for (const node of Array.from(this.container.children)) {
1145
+ if (!(node instanceof HTMLElement))
1146
+ continue;
1147
+ const isSlot = node === this.placeholder;
1148
+ if (!isSlot && !this.itemElements.has(node))
1149
+ continue;
1150
+ const modelIdx = isSlot ? -1 : allOthers++;
1151
+ const rect = node.getBoundingClientRect();
1152
+ const off = this.applied.get(node);
1153
+ const box = toBox(rect);
1154
+ if (off) {
1155
+ // Strip our own displacement so the snapshot always describes the *undisplaced* layout.
1156
+ box.left -= off.x;
1157
+ box.right -= off.x;
1158
+ box.top -= off.y;
1159
+ box.bottom -= off.y;
1160
+ }
1161
+ const hidden = !isSlot && rect.width === 0 && rect.height === 0;
1162
+ if (hidden)
1163
+ continue;
1164
+ if (isSlot)
1165
+ slot = entries.length;
1166
+ else
1167
+ otherModelIndex.push(modelIdx);
1168
+ const known = previous.get(node);
1169
+ entries.push(known ?? {
1170
+ el: node,
1171
+ isSlot,
1172
+ base: initialTransform(node),
1173
+ savedTransform: node.style.transform,
1174
+ savedTransition: node.style.transition,
1175
+ savedWillChange: node.style.willChange,
1176
+ });
1177
+ boxes.push(box);
1178
+ }
1179
+ this.entries = entries;
1180
+ this.boxes = boxes;
1181
+ this.slotIdx = slot;
1182
+ this.otherModelIndex = otherModelIndex;
1183
+ this.model = analyzeLayout(boxes, this.fallbackAxis, this.fallbackRtl);
1184
+ }
1185
+ // ---------------------------------------------------------------------------------------
1186
+ // transform strategy (DOM stays untouched)
1187
+ // ---------------------------------------------------------------------------------------
1188
+ applyTransforms(target) {
1189
+ const offsets = computeAxisOffsets(this.model, this.boxes, this.slotIdx, target);
1190
+ const horizontal = this.model.axis === 'x';
1191
+ this.entries.forEach((e, i) => {
1192
+ const o = offsets[i];
1193
+ this.setOffset(e, horizontal ? { x: o, y: 0 } : { x: 0, y: o });
1194
+ });
1195
+ }
1196
+ setOffset(e, off) {
1197
+ const prev = this.applied.get(e.el);
1198
+ if ((prev?.x ?? 0) === off.x && (prev?.y ?? 0) === off.y)
1199
+ return;
1200
+ if (off.x === 0 && off.y === 0) {
1201
+ this.applied.delete(e.el);
1202
+ e.el.style.transform = e.savedTransform;
1203
+ }
1204
+ else {
1205
+ this.applied.set(e.el, off);
1206
+ e.el.style.transform = `translate3d(${off.x}px, ${off.y}px, 0)${e.base}`;
1207
+ }
1208
+ this.onOffsetChange?.(e.el, off);
1209
+ }
1210
+ // ---------------------------------------------------------------------------------------
1211
+ // flow strategy (single placeholder move + FLIP transforms)
1212
+ // ---------------------------------------------------------------------------------------
1213
+ applyFlow(target) {
1214
+ const entries = this.entries;
1215
+ // 1. READ: where everything visually is right now (including in-flight animations).
1216
+ const first = entries.map((e) => e.el.getBoundingClientRect());
1217
+ // 2. WRITE: move the one placeholder node; drop any FLIP transforms.
1218
+ this.placeAt(target);
1219
+ for (const e of entries) {
1220
+ e.el.style.transition = 'none';
1221
+ e.el.style.transform = e.savedTransform;
1222
+ }
1223
+ // 3. READ: where everything is after the browser re-flowed the list.
1224
+ const last = entries.map((e) => e.el.getBoundingClientRect());
1225
+ // 4. WRITE: invert (put each item back where it was), then play (animate to its new home).
1226
+ const moved = [];
1227
+ entries.forEach((e, i) => {
1228
+ const dx = first[i].left - last[i].left;
1229
+ const dy = first[i].top - last[i].top;
1230
+ if (this.animationMs > 0 && (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)) {
1231
+ e.el.style.transform = `translate3d(${dx}px, ${dy}px, 0)${e.base}`;
1232
+ moved.push(e);
1233
+ }
1234
+ });
1235
+ const transition = this.animationMs > 0 ? `transform ${this.animationMs}ms ${EASING}` : '';
1236
+ if (moved.length)
1237
+ void this.container.offsetWidth; // commit the inverted frame
1238
+ for (const e of entries)
1239
+ e.el.style.transition = transition || e.savedTransition;
1240
+ for (const e of moved)
1241
+ e.el.style.transform = e.savedTransform;
1242
+ }
1243
+ placeAt(target) {
1244
+ const container = this.container;
1245
+ if (this.spacer === undefined)
1246
+ this.spacer = makeSpacer(container);
1247
+ const spacer = this.spacer;
1248
+ spacer?.remove();
1249
+ const others = this.entries.filter((e) => !e.isSlot);
1250
+ const ref = others[target]?.el;
1251
+ if (ref) {
1252
+ // ... [placeholder][separator][ref]: the separator that used to sit before `ref` still
1253
+ // separates it from its previous sibling, the new one separates it from the placeholder.
1254
+ container.insertBefore(this.placeholder, ref);
1255
+ if (spacer)
1256
+ container.insertBefore(spacer, ref);
1257
+ return;
1258
+ }
1259
+ const last = others[others.length - 1]?.el;
1260
+ const after = last ? last.nextSibling : null;
1261
+ if (spacer)
1262
+ container.insertBefore(spacer, after);
1263
+ container.insertBefore(this.placeholder, after);
1264
+ }
1265
+ clearDisplacement() {
1266
+ for (const e of this.entries) {
1267
+ e.el.style.transition = 'none';
1268
+ e.el.style.transform = e.savedTransform;
1269
+ if (this.applied.has(e.el))
1270
+ this.onOffsetChange?.(e.el, { x: 0, y: 0 });
1271
+ }
1272
+ this.applied.clear();
1273
+ }
1274
+ }
1275
+ function initialTransform(el) {
1276
+ const t = getComputedStyle(el).transform;
1277
+ return t && t !== 'none' ? ` ${t}` : '';
1278
+ }
317
1279
 
318
1280
  /** Base (LTR) physical direction for every logical direction. */
319
1281
  const LTR_BASE = {
@@ -355,262 +1317,30 @@ function isRtl(element) {
355
1317
  * Resolves a logical direction ('start' | 'end' | 'n-start' | 'n-end' | 's-start' | 's-end')
356
1318
  * to a physical, viewport-space direction ('n' | 'e' | 's' | 'w' | 'ne' | 'nw' | 'se' | 'sw')
357
1319
  * based on `rtl`.
358
- *
359
- * Physical directions pass through untouched, so it's always safe to call this
360
- * even when `dir` might already be physical (e.g. resolving a direction that was
361
- * already resolved once before).
362
- */
363
- function resolveLogicalDirection(dir, rtl) {
364
- if (!isLogicalDirection(dir)) {
365
- return dir;
366
- }
367
- const base = LTR_BASE[dir];
368
- return rtl ? MIRROR[base] : base;
369
- }
370
-
371
- /**
372
- * Live geometry sorting.
373
- *
374
- * Unlike the old snapshot-only implementation, every sort pass reads the
375
- * current DOM order and current client rects. This matters when:
376
- * - the placeholder changes layout,
377
- * - a Kanban column is scrolled while dragging,
378
- * - the dragged item enters another list,
379
- * - CSS grid/flex reflows occur.
380
- */
381
- class PositionalSortStrategy {
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;
1320
+ *
1321
+ * Physical directions pass through untouched, so it's always safe to call this
1322
+ * even when `dir` might already be physical (e.g. resolving a direction that was
1323
+ * already resolved once before).
1324
+ */
1325
+ function resolveLogicalDirection(dir, rtl) {
1326
+ if (!isLogicalDirection(dir)) {
1327
+ return dir;
606
1328
  }
1329
+ const base = LTR_BASE[dir];
1330
+ return rtl ? MIRROR[base] : base;
607
1331
  }
608
1332
 
609
1333
  /**
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.
1334
+ * A drop list.
1335
+ *
1336
+ * Membership (`_draggables`) only ever reflects which draggables *belong* to this list in the
1337
+ * template. It is never changed by dragging. Everything that is transient during a drag lives
1338
+ * in a single `SortSession` (see sorting/sort-session.ts) that is created when a drag enters
1339
+ * the list and disposed when it leaves or the drag ends:
1340
+ *
1341
+ * - the list the drag started in keeps its session for the whole drag (its placeholder holds
1342
+ * the item's original slot, so leaving and re-entering is stable);
1343
+ * - any other list gets a fresh session on every entry and drops it on every exit.
614
1344
  */
615
1345
  class DropListRef {
616
1346
  data;
@@ -618,19 +1348,125 @@ class DropListRef {
618
1348
  disableSort = false;
619
1349
  connectedTo = [];
620
1350
  dropListGroup;
1351
+ /** Duration (ms) of the sibling slide animation. `0` disables animation. */
1352
+ sortAnimationDuration = 200;
621
1353
  _draggables = new Set();
622
1354
  onDrop = new EventEmitter();
623
- strategy = new PositionalSortStrategy();
624
- active = false;
625
- previousIndex = -1;
1355
+ /**
1356
+ * Nesting: the nearest ENCLOSING drop list (null for a top-level list), and this list's own
1357
+ * direct nested lists — i.e. lists whose nearest enclosing list is this one, not a list at any
1358
+ * depth. Set by `NgxDropList` at init from a `skipSelf` DI lookup. Used for two things: (1)
1359
+ * `depth` for "innermost wins" in `DragRef`'s target resolution, without any DOM `.contains()`
1360
+ * calls on the hot path, and (2) cascading `_setAncestorOffset` (below) to every list nested
1361
+ * inside an item that gets displaced by sorting, at any depth.
1362
+ */
1363
+ parentList = null;
1364
+ childLists = new Set();
1365
+ session = null;
626
1366
  placeholder;
1367
+ customPlaceholder;
1368
+ activeDrag = null;
1369
+ isOrigin = false;
1370
+ hovered = false;
1371
+ hit = null;
1372
+ /** Pre-insertion geometry of a foreign list, restored when the drag leaves it. */
1373
+ naturalHit = null;
1374
+ hitCached = false;
1375
+ /**
1376
+ * Screen-space offset contributed by displaced ANCESTOR item(s) (see `parentList` above).
1377
+ * `{0,0}` outside of that (the overwhelmingly common case). Kept as a SUM of independent
1378
+ * per-source contributions, not a single overwritten value: while the pointer hovers a
1379
+ * FOREIGN (non-origin) list nested inside the origin's own displaced item, up to two ancestor
1380
+ * sessions can be displacing this list at once — the origin's (frozen, still active further
1381
+ * up) and that foreign list's own — and one clearing its contribution on release must not
1382
+ * wipe out the other's, still-active, contribution.
1383
+ */
1384
+ ancestorContributions = new Map();
1385
+ ancestorOffset = { x: 0, y: 0 };
1386
+ /** Entries this list itself pushed via a one-time placeholder-insertion reflow (see below). */
1387
+ reflowContributions = new Map();
1388
+ /** How many enclosing drop lists this one is nested inside. Root lists are depth 0. */
1389
+ get depth() {
1390
+ let d = 0;
1391
+ for (let p = this.parentList; p; p = p.parentList)
1392
+ d++;
1393
+ return d;
1394
+ }
1395
+ _registerChild(child) {
1396
+ child.parentList = this;
1397
+ this.childLists.add(child);
1398
+ }
1399
+ _unregisterChild(child) {
1400
+ if (child.parentList === this)
1401
+ child.parentList = null;
1402
+ this.childLists.delete(child);
1403
+ }
1404
+ /**
1405
+ * One ancestor SESSION's contribution to this list's total displacement just changed (or
1406
+ * cleared, `{0,0}`). `source` identifies WHICH session — the list whose own item is doing the
1407
+ * displacing — so that this list's own recomputed total is a SUM across every currently-active
1408
+ * source, and cascades the SAME (source, offset) pair on to every nested list at any depth
1409
+ * unchanged, so a list several levels down stays hit-testable at its true on-screen position
1410
+ * regardless of which ancestor(s) are currently sorting.
1411
+ */
1412
+ _setAncestorOffset(source, offset) {
1413
+ const prev = this.ancestorContributions.get(source);
1414
+ if (prev && prev.x === offset.x && prev.y === offset.y)
1415
+ return;
1416
+ if (offset.x === 0 && offset.y === 0)
1417
+ this.ancestorContributions.delete(source);
1418
+ else
1419
+ this.ancestorContributions.set(source, offset);
1420
+ let x = 0;
1421
+ let y = 0;
1422
+ for (const o of this.ancestorContributions.values()) {
1423
+ x += o.x;
1424
+ y += o.y;
1425
+ }
1426
+ if (x === this.ancestorOffset.x && y === this.ancestorOffset.y)
1427
+ return; // no net change
1428
+ this.ancestorOffset = { x, y };
1429
+ for (const child of this.childLists)
1430
+ child._setAncestorOffset(source, offset);
1431
+ }
1432
+ /** Which of this list's DIRECT nested lists live inside `el`, notified of its new offset. */
1433
+ propagateOffset(el, offset) {
1434
+ for (const child of this.childLists) {
1435
+ if (el.contains(child.el))
1436
+ child._setAncestorOffset(this, offset);
1437
+ }
1438
+ }
1439
+ /** Rects, right now, of every `other` entry that contains one of this list's nested lists. */
1440
+ trackedEntryRects(others) {
1441
+ const map = new Map();
1442
+ for (const child of this.childLists) {
1443
+ for (const el of others) {
1444
+ if (el.contains(child.el)) {
1445
+ if (!map.has(el))
1446
+ map.set(el, el.getBoundingClientRect());
1447
+ break;
1448
+ }
1449
+ }
1450
+ }
1451
+ return map;
1452
+ }
1453
+ /** Turns the real reflow a fresh placeholder insertion just caused into ordinary offsets. */
1454
+ propagateReflow(before) {
1455
+ for (const [el, prev] of before) {
1456
+ const now = el.getBoundingClientRect();
1457
+ const dx = now.left - prev.left;
1458
+ const dy = now.top - prev.top;
1459
+ if (dx || dy) {
1460
+ this.propagateOffset(el, { x: dx, y: dy });
1461
+ this.reflowContributions.set(el, { x: dx, y: dy });
1462
+ }
1463
+ }
1464
+ }
627
1465
  addItem(item) {
628
1466
  this._draggables.add(item);
629
- this.publishDragItems();
630
1467
  }
631
1468
  removeItem(item) {
632
1469
  this._draggables.delete(item);
633
- this.publishDragItems();
634
1470
  }
635
1471
  isConnectedTo(other) {
636
1472
  if (other === this)
@@ -639,76 +1475,322 @@ class DropListRef {
639
1475
  return true;
640
1476
  return !!this.dropListGroup && this.dropListGroup.has(other);
641
1477
  }
1478
+ // ---------------------------------------------------------------------------------------
1479
+ // hit testing (cached for the duration of a drag)
1480
+ // ---------------------------------------------------------------------------------------
1481
+ /**
1482
+ * Measure the list and its clipping ancestors once, at drag start (or the moment a nested
1483
+ * list becomes a candidate mid-drag). `measureHit` reads live `getBoundingClientRect()`s,
1484
+ * which already include any `translate3d` an ancestor item's displacement has applied — so if
1485
+ * this list is currently offset (nested inside a sibling that sorting is displacing right
1486
+ * now), the measurement is normalized back to the zero-offset frame `_containsPoint` expects,
1487
+ * by subtracting that offset straight back out. Without this, entering a nested list while
1488
+ * its ancestor is mid-displacement would double-count the offset on every check afterwards.
1489
+ */
1490
+ _cacheGeometry() {
1491
+ const hit = measureHit(this.el);
1492
+ if (hit && (this.ancestorOffset.x || this.ancestorOffset.y)) {
1493
+ hit.box.left -= this.ancestorOffset.x;
1494
+ hit.box.right -= this.ancestorOffset.x;
1495
+ hit.box.top -= this.ancestorOffset.y;
1496
+ hit.box.bottom -= this.ancestorOffset.y;
1497
+ }
1498
+ this.hit = hit;
1499
+ this.hitCached = true;
1500
+ }
1501
+ /**
1502
+ * Is the point over the *visible* part of this list? Scroll-aware without re-measuring.
1503
+ *
1504
+ * The list box and every clipping ancestor are tracked separately because they react to scroll
1505
+ * differently: scrolling a container moves the list but not the container's own clip window.
1506
+ */
1507
+ _containsPoint(x, y) {
1508
+ const hit = this.hitCached ? this.hit : measureHit(this.el);
1509
+ if (!hit)
1510
+ return false;
1511
+ // The list's own box shifts with any ancestor displacement; a clip window (a scroll/overflow
1512
+ // ancestor) normally does not, since it is not itself an item being sorted, so it is tested
1513
+ // against the raw pointer.
1514
+ const d0 = hit.frame.delta();
1515
+ if (!boxContains(hit.box, x - this.ancestorOffset.x + d0.x, y - this.ancestorOffset.y + d0.y))
1516
+ return false;
1517
+ for (const clip of hit.clips) {
1518
+ const d = clip.frame.delta();
1519
+ if (!boxContains(clip.box, x + d.x, y + d.y))
1520
+ return false;
1521
+ }
1522
+ return true;
1523
+ }
1524
+ /** Approximate area, used to break ties between overlapping, unrelated lists. */
1525
+ _area() {
1526
+ const b = (this.hit ?? measureHit(this.el))?.box;
1527
+ return b ? (b.right - b.left) * (b.bottom - b.top) : 0;
1528
+ }
1529
+ _onScroll() {
1530
+ this.hit?.frame.invalidate();
1531
+ this.hit?.clips.forEach((c) => c.frame.invalidate());
1532
+ this.session?.onScroll();
1533
+ }
1534
+ /** Re-measure after a viewport resize. */
1535
+ _refresh() {
1536
+ if (this.hitCached)
1537
+ this._cacheGeometry();
1538
+ this.session?.refresh();
1539
+ }
1540
+ // ---------------------------------------------------------------------------------------
1541
+ // drag lifecycle
1542
+ // ---------------------------------------------------------------------------------------
1543
+ /** Backwards-compatible entry point: makes this list the drag's target. */
642
1544
  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
1545
  this.enter(drag, drag.pointer.x, drag.pointer.y);
654
- return ref;
1546
+ return drag.placeholder;
655
1547
  }
1548
+ /** The drag pointer entered this list (or the drag started inside it). */
656
1549
  enter(drag, x = 0, y = 0) {
657
- if (!this.active) {
658
- this.active = true;
659
- this.previousIndex = this.indexOf(drag);
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());
1550
+ if (this.session && this.activeDrag === drag) {
1551
+ // Resuming a session that's still alive: either this is the origin list (its slot is
1552
+ // always kept), or the pointer dove into one of its own nested lists and came back up
1553
+ // (frozen rather than released in `exit()`, see there).
1554
+ this.hovered = true;
664
1555
  this.el.classList.add('ngx-drop-list--active');
1556
+ drag.placeholder = this.placeholder;
1557
+ this.sortItem(drag, { x, y });
1558
+ return;
665
1559
  }
666
- this.strategy.enter(this.asStrategyDrag(drag), x, y);
1560
+ this.releaseSession(); // never keep stale state from a previous drag (geometry cache stays)
1561
+ this.begin(drag, { x, y });
1562
+ this.hovered = true;
1563
+ this.el.classList.add('ngx-drop-list--active');
667
1564
  }
1565
+ /** Re-evaluate the insertion index for the pointer. Returns the new index, or null if unchanged. */
668
1566
  sortItem(drag, position) {
669
- if (!this.active || this.disableSort)
1567
+ const s = this.session;
1568
+ if (!s || this.activeDrag !== drag || !this.hovered || this.disableSort)
670
1569
  return null;
671
- return (this.strategy.sort(this.asStrategyDrag(drag), position.x, position.y)?.currentIndex ?? null);
1570
+ const next = s.resolve(position);
1571
+ if (next === s.target)
1572
+ return null;
1573
+ s.apply(next);
1574
+ return s.modelIndex;
672
1575
  }
673
- finishDrag(drag) {
674
- if (!this.active)
1576
+ /** The pointer left this list. */
1577
+ /**
1578
+ * The pointer truly left this list (as opposed to moving deeper into one of its own nested
1579
+ * lists — see `DragRef`'s chain tracking, which never calls `exit()` for a list that remains
1580
+ * an ancestor of the new target, so this always means "left this list's territory").
1581
+ */
1582
+ exit(drag) {
1583
+ if (!this.session || this.activeDrag !== drag)
675
1584
  return;
676
- const currentIndex = this.strategy.getCurrentIndex();
677
- this.onDrop.emit({
678
- previousIndex: Math.max(0, drag.originIndex >= 0 ? drag.originIndex : this.previousIndex),
679
- currentIndex,
680
- item: drag,
681
- container: this,
682
- previousContainer: drag.originDropList ?? this,
683
- });
684
- this.reset();
1585
+ this.hovered = false;
1586
+ this.el.classList.remove('ngx-drop-list--active');
1587
+ if (this.isOrigin) {
1588
+ // Keep the original slot (the item logically still lives here) but stop displacing siblings.
1589
+ this.session.reset();
1590
+ }
1591
+ else {
1592
+ // A foreign list forgets the drag on exit; its geometry stays cached until the drag ends.
1593
+ this.releaseSession(drag);
1594
+ }
685
1595
  }
686
- exit(_drag) {
687
- if (!this.active)
688
- return;
689
- this.placeholder?.detach();
690
- this.reset();
1596
+ /**
1597
+ * The pointer dove into one of this list's own nested lists: this list is no longer the
1598
+ * innermost target, but it isn't being left either — its placeholder and sibling displacement
1599
+ * stay exactly as they are (avoids a snap-back-then-forward flicker), only the "currently
1600
+ * hovered" bookkeeping (and its visual highlight) is cleared. `DragRef`'s chain tracking calls
1601
+ * this instead of `exit()` for every list that remains an ancestor of the new target.
1602
+ */
1603
+ _freeze() {
1604
+ this.hovered = false;
1605
+ this.el.classList.remove('ngx-drop-list--active');
1606
+ }
1607
+ /**
1608
+ * Ends the drag for this list. Returns the drop event when the pointer was released over this
1609
+ * list, `null` otherwise (nothing to emit). All transient state is disposed either way.
1610
+ */
1611
+ _finish(drag, commit) {
1612
+ const s = this.session;
1613
+ if (!s || this.activeDrag !== drag)
1614
+ return null;
1615
+ const event = commit && this.hovered
1616
+ ? {
1617
+ previousIndex: Math.max(0, drag.originIndex),
1618
+ currentIndex: s.modelIndex,
1619
+ item: drag,
1620
+ container: this,
1621
+ previousContainer: drag.originDropList ?? this,
1622
+ isPointerOverContainer: true,
1623
+ }
1624
+ : null;
1625
+ this.releaseSession(drag);
1626
+ return event;
1627
+ }
1628
+ /** Compatibility wrapper: finish + emit. */
1629
+ finishDrag(drag) {
1630
+ const event = this._finish(drag, true);
1631
+ if (event)
1632
+ this.onDrop.emit(event);
691
1633
  }
692
1634
  resetSortTransforms() {
693
- this.reset();
1635
+ this.session?.reset();
694
1636
  }
695
1637
  getFinalIndex() {
696
- return this.strategy.getCurrentIndex();
1638
+ return this.session ? this.session.modelIndex : 0;
697
1639
  }
698
1640
  getItemIndex(item) {
699
1641
  return this.indexOf(item);
700
1642
  }
1643
+ /** End of the drag: dispose EVERYTHING this list holds, including cached geometry. Idempotent. */
1644
+ _release(drag) {
1645
+ if (drag && this.activeDrag && this.activeDrag !== drag)
1646
+ return;
1647
+ this.releaseSession(drag);
1648
+ this.hit = null;
1649
+ this.hitCached = false;
1650
+ // `ancestorOffset` is deliberately NOT reset here: `enter()` calls this unconditionally as a
1651
+ // safety-clear on every (re-)entry, including while a currently-active ancestor transform
1652
+ // still legitimately applies to this list. It self-corrects when that transform actually
1653
+ // reverts to zero, via the `onOffsetChange` cascade (see `_setAncestorOffset`), which also
1654
+ // covers the true end of a drag (the displaced list's own session disposes and reports
1655
+ // every entry back to `{0,0}`).
1656
+ }
1657
+ /** Dispose the sort session, placeholder and drag ownership. */
1658
+ releaseSession(drag) {
1659
+ if (drag && this.activeDrag && this.activeDrag !== drag)
1660
+ return;
1661
+ this.session?.dispose();
1662
+ this.session = null;
1663
+ if (this.reflowContributions.size) {
1664
+ // The one-time reflow from this list's own placeholder insertion (see `propagateReflow`)
1665
+ // never went through the session's own `applied` map, so its `dispose()` above cannot
1666
+ // have cleared it — do that here instead, or it would linger as a stale contribution.
1667
+ for (const [el] of this.reflowContributions)
1668
+ this.propagateOffset(el, { x: 0, y: 0 });
1669
+ this.reflowContributions.clear();
1670
+ }
1671
+ this.placeholder?.detach();
1672
+ if (this.activeDrag && this.activeDrag.placeholder === this.placeholder) {
1673
+ this.activeDrag.placeholder = undefined;
1674
+ }
1675
+ this.placeholder = undefined;
1676
+ if (this.naturalHit) {
1677
+ this.hit = this.naturalHit;
1678
+ this.naturalHit = null;
1679
+ }
1680
+ this.activeDrag = null;
1681
+ this.isOrigin = false;
1682
+ this.hovered = false;
1683
+ this.el?.classList.remove('ngx-drop-list--active');
1684
+ }
1685
+ // ---------------------------------------------------------------------------------------
1686
+ // internals
1687
+ // ---------------------------------------------------------------------------------------
1688
+ begin(drag, pointer) {
1689
+ const origin = drag.originDropList === this;
1690
+ const others = this.otherSlots(drag);
1691
+ const rtl = isRtl(this.el);
1692
+ const axis = fallbackAxis(this.el);
1693
+ const ph = this.customPlaceholder ?? new PlaceHolderRef();
1694
+ ph.dropList = this;
1695
+ let spacer;
1696
+ if (origin) {
1697
+ // The placeholder takes over the exact slot of the (now hidden) source element.
1698
+ ph.attach(this.el, drag.el, drag.el);
1699
+ this.lockPlaceholderGeometry(drag, ph, drag.start);
1700
+ }
1701
+ else {
1702
+ ph.detach();
1703
+ // Only the "other" entries that contain a nested list are worth measuring (usually none,
1704
+ // or very few, even in a large tree): inserting the placeholder is a REAL DOM node, so it
1705
+ // shifts later siblings via ordinary CSS reflow, not `translate3d` — a one-time event this
1706
+ // list's own SortSession never reports through `onOffsetChange`, since (being sized and
1707
+ // positioned by `foreignInsertionReference` to already match where it belongs) it never
1708
+ // needs to actually transform anything afterwards. Measuring the small tracked set just
1709
+ // before and after the insertion turns that one-time reflow into an ordinary ancestor
1710
+ // contribution, exactly like any other displaced entry.
1711
+ const tracked = this.childLists.size ? this.trackedEntryRects(others) : null;
1712
+ const reference = this.foreignInsertionReference(others, pointer, axis, rtl);
1713
+ ph.attach(this.el, drag.el, reference);
1714
+ // Sized to its final footprint BEFORE measuring the reflow it causes: an unsized clone
1715
+ // (still near-zero height) wouldn't have pushed `others` at all yet.
1716
+ this.lockPlaceholderGeometry(drag, ph, drag.start);
1717
+ if (tracked)
1718
+ this.propagateReflow(tracked);
1719
+ spacer = makeSpacer(this.el);
1720
+ if (spacer)
1721
+ this.el.insertBefore(spacer, reference ?? ph.element);
1722
+ }
1723
+ this.placeholder = ph;
1724
+ this.activeDrag = drag;
1725
+ this.isOrigin = origin;
1726
+ drag.placeholder = ph;
1727
+ this.session = new SortSession({
1728
+ container: this.el,
1729
+ placeholder: ph.element,
1730
+ itemElements: others,
1731
+ fallbackAxis: axis,
1732
+ fallbackRtl: rtl,
1733
+ animationMs: this.sortAnimationDuration,
1734
+ spacer,
1735
+ onOffsetChange: this.childLists.size
1736
+ ? (el, offset) => this.propagateOffset(el, offset)
1737
+ : undefined,
1738
+ });
1739
+ if (!origin && this.hitCached) {
1740
+ // Inserting the placeholder made this list bigger; the grown box is what the user sees
1741
+ // (and must be able to reach, e.g. to append below the last item). The natural box comes
1742
+ // back on exit, so entering/leaving at the old edge cannot flicker.
1743
+ this.naturalHit = this.hit;
1744
+ this._cacheGeometry();
1745
+ }
1746
+ }
1747
+ /**
1748
+ * First placement into a list that does not contain the item yet: the node the placeholder
1749
+ * must be inserted before (null = append). Same pure resolver as sorting, no hysteresis.
1750
+ */
1751
+ foreignInsertionReference(others, pointer, axis, rtl) {
1752
+ const els = [];
1753
+ const boxes = [];
1754
+ for (const node of Array.from(this.el.children)) {
1755
+ if (!(node instanceof HTMLElement) || !others.has(node))
1756
+ continue;
1757
+ const r = node.getBoundingClientRect();
1758
+ if (r.width === 0 && r.height === 0)
1759
+ continue;
1760
+ els.push(node);
1761
+ boxes.push(toBox(r));
1762
+ }
1763
+ if (!els.length)
1764
+ return null;
1765
+ const index = this.disableSort
1766
+ ? els.length
1767
+ : resolveInsertionIndex(analyzeLayout(boxes, axis, rtl), boxes, -1, pointer, null);
1768
+ return index < els.length ? els[index] : els[els.length - 1].nextSibling;
1769
+ }
1770
+ otherSlots(drag) {
1771
+ const set = new Set();
1772
+ for (const d of this._draggables) {
1773
+ if (d !== drag && d.el?.parentElement === this.el)
1774
+ set.add(d.el);
1775
+ }
1776
+ return set;
1777
+ }
701
1778
  indexOf(item) {
702
- const elements = new Set(this._draggables);
1779
+ const byElement = new Map();
1780
+ for (const d of this._draggables)
1781
+ if (d.el)
1782
+ byElement.set(d.el, d);
703
1783
  let index = 0;
704
1784
  for (const node of Array.from(this.el.children)) {
705
1785
  if (!(node instanceof HTMLElement))
706
1786
  continue;
707
1787
  if (node === this.placeholder?.element)
708
1788
  continue;
709
- if (node.classList.contains('ngx-drag-in-body'))
1789
+ if (node.classList.contains('ngx-drag-placeholder'))
710
1790
  continue;
711
- const ref = Array.from(elements).find((candidate) => candidate.el === node);
1791
+ if (node.classList.contains('ngx-drag-preview'))
1792
+ continue;
1793
+ const ref = byElement.get(node);
712
1794
  if (!ref)
713
1795
  continue;
714
1796
  if (ref === item)
@@ -717,44 +1799,67 @@ class DropListRef {
717
1799
  }
718
1800
  return -1;
719
1801
  }
720
- reset() {
721
- this.active = false;
722
- this.strategy.reset();
723
- this.placeholder?.detach();
724
- this.placeholder = undefined;
725
- this.publishDragItems();
726
- this.el.classList.remove('ngx-drop-list--active');
727
- }
728
- copyPlaceholderGeometry(source, placeholder, rect) {
729
- // copyComputedStyleTree(source, placeholder);
730
- placeholder.style.setProperty('width', `${rect.width}px`, 'important');
731
- placeholder.style.setProperty('height', `${rect.height}px`, 'important');
732
- placeholder.style.setProperty('min-width', `${rect.width}px`, 'important');
733
- placeholder.style.setProperty('min-height', `${rect.height}px`, 'important');
734
- placeholder.style.setProperty('max-width', `${rect.width}px`, 'important');
735
- placeholder.style.setProperty('max-height', `${rect.height}px`, 'important');
736
- placeholder.style.setProperty('visibility', 'visible', 'important');
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');
743
- }
744
- /**
745
- * The sorting strategy only needs a read-only collection of draggable refs.
746
- * Using DragRef<any> at this internal boundary avoids the invariant generic
747
- * relationship between DragRef<T> and DragRef<unknown>.
748
- */
749
- getStrategyItems() {
750
- return Array.from(this._draggables);
751
- }
752
- asStrategyDrag(drag) {
753
- return drag;
1802
+ lockPlaceholderGeometry(drag, ph, rect) {
1803
+ const placeholder = ph.element;
1804
+ const set = (k, v) => placeholder.style.setProperty(k, v, 'important');
1805
+ placeholder.style.zIndex = '';
1806
+ placeholder.style.removeProperty('will-change');
1807
+ if (ph.custom)
1808
+ return;
1809
+ placeholder.style.display = drag.originalDisplay;
1810
+ // Occupies exactly the dragged item's footprint, so the gap reserved in the list — and the
1811
+ // sibling-displacement math derived from it — matches the item's real size.
1812
+ set('width', `${rect.width}px`);
1813
+ set('height', `${rect.height}px`);
1814
+ // set('min-width', `${rect.width}px`);
1815
+ // set('min-height', `${rect.height}px`);
1816
+ // set('max-width', `${rect.width}px`);
1817
+ // set('max-height', `${rect.height}px`);
1818
+ set('visibility', 'visible');
1819
+ set('opacity', '0.16');
1820
+ set('pointer-events', 'none');
1821
+ set('animation', 'none');
1822
+ set('box-sizing', 'border-box');
754
1823
  }
755
- publishDragItems() {
756
- this.el.__ngxDragItems = this._draggables;
1824
+ }
1825
+ /** Orientation to assume while a list has fewer than two items to measure. */
1826
+ function fallbackAxis(container) {
1827
+ const s = getComputedStyle(container);
1828
+ if (s.display.includes('flex'))
1829
+ return s.flexDirection.startsWith('column') ? 'y' : 'x';
1830
+ if (s.display.includes('grid'))
1831
+ return 'y';
1832
+ const child = container.firstElementChild;
1833
+ if (child && getComputedStyle(child).display.startsWith('inline'))
1834
+ return 'x';
1835
+ return 'y';
1836
+ }
1837
+ /** Rect of the list plus one clip window per clipping (overflow != visible) ancestor. */
1838
+ function measureHit(el) {
1839
+ const r = el.getBoundingClientRect();
1840
+ if (r.right <= r.left || r.bottom <= r.top)
1841
+ return null;
1842
+ const clips = [];
1843
+ const root = el.ownerDocument.documentElement;
1844
+ for (let node = el.parentElement; node && node !== root; node = node.parentElement) {
1845
+ const s = getComputedStyle(node);
1846
+ const cx = s.overflowX !== 'visible';
1847
+ const cy = s.overflowY !== 'visible';
1848
+ if (!cx && !cy)
1849
+ continue;
1850
+ const c = node.getBoundingClientRect();
1851
+ clips.push({
1852
+ box: {
1853
+ left: cx ? c.left : -Infinity,
1854
+ right: cx ? c.right : Infinity,
1855
+ top: cy ? c.top : -Infinity,
1856
+ bottom: cy ? c.bottom : Infinity,
1857
+ },
1858
+ // A clip window only moves when something ABOVE the clipping element scrolls.
1859
+ frame: new ScrollFrame(node, false),
1860
+ });
757
1861
  }
1862
+ return { box: toBox(r), frame: new ScrollFrame(el, false), clips };
758
1863
  }
759
1864
 
760
1865
  /**
@@ -782,6 +1887,21 @@ class DragDropService {
782
1887
  ...(ngDevMode ? [{ debugName: "dropLists" }] : /* istanbul ignore next */ []));
783
1888
  activeDrag = signal(null, /* @ts-ignore */
784
1889
  ...(ngDevMode ? [{ debugName: "activeDrag" }] : /* istanbul ignore next */ []));
1890
+ /**
1891
+ * DOM-position registries, used to find the nearest enclosing list/group by walking real
1892
+ * ancestor elements instead of Angular's element-injector tree.
1893
+ *
1894
+ * This matters for `ngTemplateOutlet`-based recursive trees (a list of items whose own
1895
+ * children are rendered by re-invoking the SAME `<ng-template>`, as in a tree/outliner UI):
1896
+ * each invocation gets a FRESH embedded view whose injector context is wherever the outlet
1897
+ * was written in the template, not wherever its content ends up in the DOM. So `inject(TOKEN,
1898
+ * {skipSelf})` from one recursion level can never see a provider from another level — the
1899
+ * only fix within Angular's own DI is to manually thread `ngTemplateOutletInjector` through
1900
+ * every recursive call, which isn't reasonable to require of every consumer of this library.
1901
+ * A plain DOM walk has no such limitation: it only cares where elements actually ended up.
1902
+ */
1903
+ listsByEl = new Map();
1904
+ groupsByEl = new Map();
785
1905
  registerDragItem(ref) {
786
1906
  this.drags.update((items) => (items.includes(ref) ? items : [...items, ref]));
787
1907
  }
@@ -792,9 +1912,37 @@ class DragDropService {
792
1912
  }
793
1913
  registerDropList(ref) {
794
1914
  this.dropLists.update((items) => (items.includes(ref) ? items : [...items, ref]));
1915
+ if (ref.el)
1916
+ this.listsByEl.set(ref.el, ref);
795
1917
  }
796
1918
  removeDropList(ref) {
797
1919
  this.dropLists.update((items) => items.filter((x) => x !== ref));
1920
+ if (ref.el && this.listsByEl.get(ref.el) === ref)
1921
+ this.listsByEl.delete(ref.el);
1922
+ }
1923
+ registerGroup(el, ref) {
1924
+ this.groupsByEl.set(el, ref);
1925
+ }
1926
+ removeGroup(el) {
1927
+ this.groupsByEl.delete(el);
1928
+ }
1929
+ /** Nearest registered drop list starting at (and including) `start`, walking up the DOM. */
1930
+ findAncestorDropList(start) {
1931
+ for (let n = start; n; n = n.parentElement) {
1932
+ const found = this.listsByEl.get(n);
1933
+ if (found)
1934
+ return found;
1935
+ }
1936
+ return null;
1937
+ }
1938
+ /** Nearest registered drop-list group starting at (and including) `start`, walking up the DOM. */
1939
+ findAncestorGroup(start) {
1940
+ for (let n = start; n; n = n.parentElement) {
1941
+ const found = this.groupsByEl.get(n);
1942
+ if (found)
1943
+ return found;
1944
+ }
1945
+ return null;
798
1946
  }
799
1947
  begin(ref) {
800
1948
  this.activeDrag.set(ref);
@@ -810,23 +1958,41 @@ class DragDropService {
810
1958
  ref.cancelDrag();
811
1959
  this.activeDrag.set(null);
812
1960
  }
813
- findDropList(point, current) {
814
- const candidates = this.dropLists().filter((list) => {
815
- const r = list.el?.getBoundingClientRect();
816
- return (!!r && point.x >= r.left && point.x <= r.right && point.y >= r.top && point.y <= r.bottom);
817
- });
818
- // Staying over the current list should always win.
819
- if (current && candidates.includes(current))
820
- return current;
821
- // Never jump into an unrelated list just because it overlaps the pointer.
822
- // Prefer the smallest matching connected container (useful for nested lists).
823
- return (candidates
824
- .filter((list) => !current || current.isConnectedTo(list))
825
- .sort((a, b) => {
826
- const ra = a.el.getBoundingClientRect();
827
- const rb = b.el.getBoundingClientRect();
828
- return ra.width * ra.height - rb.width * rb.height;
829
- })[0] ?? null);
1961
+ /**
1962
+ * Which drop list is under `point`?
1963
+ *
1964
+ * - Only lists connected to the drag's ORIGIN list qualify (connectivity is not transitive, so
1965
+ * A→B→C never lets an item hop A→C). Without an origin (a free-drag element) nothing
1966
+ * qualifies: free dragging is never captured by a list.
1967
+ * - Only the visible (clip-aware) part of a list counts.
1968
+ * - A list inside the dragged element itself is never a target.
1969
+ * - Nested lists: the innermost qualifying list wins. Among unrelated overlapping lists the
1970
+ * current one is kept (no flicker), otherwise the smallest.
1971
+ */
1972
+ findDropList(point, current, origin, dragEl) {
1973
+ const source = origin ?? null;
1974
+ if (!source)
1975
+ return null;
1976
+ let best = null;
1977
+ for (const list of this.dropLists()) {
1978
+ if (!list.el || !source.isConnectedTo(list))
1979
+ continue;
1980
+ if (dragEl && dragEl !== list.el && dragEl.contains(list.el))
1981
+ continue;
1982
+ if (!list._containsPoint(point.x, point.y))
1983
+ continue;
1984
+ if (!best)
1985
+ best = list;
1986
+ else if (best.el.contains(list.el))
1987
+ best = list; // deeper wins
1988
+ else if (list.el.contains(best.el))
1989
+ continue;
1990
+ else if (list === current)
1991
+ best = list;
1992
+ else if (best !== current && list._area() < best._area())
1993
+ best = list;
1994
+ }
1995
+ return best;
830
1996
  }
831
1997
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: DragDropService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
832
1998
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: DragDropService, providedIn: 'root' });
@@ -836,76 +2002,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImpor
836
2002
  args: [{ providedIn: 'root' }]
837
2003
  }] });
838
2004
 
839
- const NGX_DROPLIST_GROUP = new InjectionToken('ngx-drop-list-group');
840
- /** Wrap several `ngxDropList`s to let items move freely between them. */
841
- class NgxDropListGroup {
842
- _ref = new DropListGroupRef();
843
- ngOnDestroy() {
844
- this._ref.lists.clear();
845
- }
846
- 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 });
848
- }
849
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropListGroup, decorators: [{
850
- type: Directive,
851
- args: [{
852
- selector: '[NgxDropListGroup],[ngxDropListGroup]',
853
- providers: [{ provide: NGX_DROPLIST_GROUP, useExisting: NgxDropListGroup }],
854
- }]
855
- }] });
856
-
857
- const NGX_DROPLIST = new InjectionToken('ngx-drop-list');
858
- class NgxDropList {
859
- el;
860
- _ref = new DropListRef();
861
- set data(value) {
862
- this._ref.data = value;
863
- }
864
- set connectedTo(value) {
865
- this._ref.connectedTo = value ?? [];
866
- }
867
- set disableSort(value) {
868
- this._ref.disableSort = value === true;
869
- }
870
- drop = new EventEmitter();
871
- service = inject(DragDropService);
872
- group = inject(NGX_DROPLIST_GROUP, { optional: true, skipSelf: true });
873
- constructor(el) {
874
- this.el = el;
875
- }
876
- ngOnInit() {
877
- this._ref.el = this.el.nativeElement;
878
- this._ref.dropListGroup = this.group?._ref ?? null;
879
- // Previously the group directive never actually tracked its lists — fixed here.
880
- this.group?._ref.add(this._ref);
881
- this._ref.onDrop.subscribe((e) => this.drop.emit(e));
882
- this.service.registerDropList(this._ref);
883
- }
884
- ngOnDestroy() {
885
- this.group?._ref.remove(this._ref);
886
- this.service.removeDropList(this._ref);
887
- }
888
- 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 });
890
- }
891
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropList, decorators: [{
892
- type: Directive,
893
- args: [{
894
- selector: '[NgxDropList],[ngxDropList]',
895
- providers: [{ provide: NGX_DROPLIST, useExisting: NgxDropList }],
896
- host: { class: 'ngx-drop-list' },
897
- }]
898
- }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { data: [{
899
- type: Input,
900
- args: ['data']
901
- }], connectedTo: [{
902
- type: Input
903
- }], disableSort: [{
904
- type: Input
905
- }], drop: [{
906
- type: Output
907
- }] } });
908
-
909
2005
  /**
910
2006
  * Auto-scrolls the nearest scrollable ancestor (or window) while the pointer
911
2007
  * is dragged near the edge of the scrollable viewport. Direction-agnostic —
@@ -997,11 +2093,12 @@ function getScrollPosition(container) {
997
2093
  return { left: container.scrollLeft, top: container.scrollTop };
998
2094
  }
999
2095
 
2096
+ /** Pointer events already claimed by an (inner) draggable. */
2097
+ const claimedPointerEvents = new WeakSet();
1000
2098
  const NGX_DRAGGABLE = new InjectionToken('ngx-draggable');
1001
2099
  const DEFAULT_KEYBOARD_STEP = 8;
1002
2100
  class NgxDraggable {
1003
2101
  host;
1004
- renderer;
1005
2102
  disabled = model(false, /* @ts-ignore */
1006
2103
  ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
1007
2104
  /** Element whose rect constrains the drag. Now actually enforced. */
@@ -1035,8 +2132,6 @@ class NgxDraggable {
1035
2132
  ...(ngDevMode ? [{ debugName: "dragging" }] : /* istanbul ignore next */ []));
1036
2133
  doc = inject(DOCUMENT);
1037
2134
  service = inject(DragDropService);
1038
- list = inject(NGX_DROPLIST, { optional: true, skipSelf: true });
1039
- group = inject(NGX_DROPLIST_GROUP, { optional: true, skipSelf: true });
1040
2135
  scroller = new AutoScroller();
1041
2136
  down = false;
1042
2137
  start = { x: 0, y: 0 };
@@ -1047,14 +2142,16 @@ class NgxDraggable {
1047
2142
  removeUp;
1048
2143
  removeKeydown;
1049
2144
  removeEscape;
2145
+ removeCancel;
2146
+ removeLostCapture;
1050
2147
  scrollTarget = window;
1051
2148
  lastScroll = { left: 0, top: 0 };
1052
2149
  lastWindowScroll = { left: 0, top: 0 };
1053
2150
  removeContainerScroll;
1054
2151
  removeWindowScroll;
1055
- constructor(host, renderer) {
2152
+ renderer = inject(Renderer2);
2153
+ constructor(host) {
1056
2154
  this.host = host;
1057
- this.renderer = renderer;
1058
2155
  effect(() => {
1059
2156
  this._ref.el = this.dragRootElement()
1060
2157
  ? (this.host.nativeElement.closest(this.dragRootElement()) ?? this.host.nativeElement)
@@ -1067,9 +2164,15 @@ class NgxDraggable {
1067
2164
  : this.host.nativeElement;
1068
2165
  this._ref.boundary = this.boundary();
1069
2166
  this._ref.lockAxis = this.lockAxis();
1070
- this._ref.dropListGroup = this.group?._ref;
2167
+ // Found by walking the real DOM, not Angular's element-injector tree — see the matching
2168
+ // comment in `ngx-drop-list.directive.ts` for why: a draggable rendered by a recursively
2169
+ // invoked `ngTemplateOutlet` needs this just as much as a nested list does.
2170
+ const parentEl = this.host.nativeElement.parentElement;
2171
+ const list = this.service.findAncestorDropList(parentEl);
2172
+ this._ref.dropListGroup = list?.dropListGroup ?? this.service.findAncestorGroup(parentEl);
2173
+ this._ref.service = this.service;
1071
2174
  this._ref.init();
1072
- this._ref.withDropList(this.list?._ref ?? null);
2175
+ this._ref.withDropList(list ?? null);
1073
2176
  this.service.registerDragItem(this._ref);
1074
2177
  this.removeDown = this.renderer.listen(this.host.nativeElement, 'pointerdown', (e) => this.pointerDown(e));
1075
2178
  this.removeKeydown = this.renderer.listen(this.host.nativeElement, 'keydown', (e) => this.keyDown(e));
@@ -1080,7 +2183,12 @@ class NgxDraggable {
1080
2183
  this.removeUp?.();
1081
2184
  this.removeKeydown?.();
1082
2185
  this.removeEscape?.();
2186
+ this.removeCancel?.();
2187
+ this.removeLostCapture?.();
1083
2188
  this.scroller.stop();
2189
+ this.stopScrollTracking();
2190
+ // Destroyed mid-drag (e.g. the model changed underneath us): never leave a body clone behind.
2191
+ this._ref.dispose();
1084
2192
  this.service.removeDragItem(this._ref);
1085
2193
  this._ref.dropList?.removeItem(this._ref);
1086
2194
  }
@@ -1090,6 +2198,11 @@ class NgxDraggable {
1090
2198
  // this.isInteractive(e.target) ||
1091
2199
  !this.isOnHandle(e.target))
1092
2200
  return;
2201
+ // Nested draggables: pointerdown bubbles, so without this an ancestor draggable would start
2202
+ // its own drag from the very same gesture. The innermost enabled draggable claims it.
2203
+ if (claimedPointerEvents.has(e))
2204
+ return;
2205
+ claimedPointerEvents.add(e);
1093
2206
  e.preventDefault();
1094
2207
  this.down = true;
1095
2208
  this.pointerId = e.pointerId;
@@ -1103,24 +2216,20 @@ class NgxDraggable {
1103
2216
  if (ev.key === 'Escape' && this.dragging())
1104
2217
  this.cancel();
1105
2218
  });
2219
+ this.removeCancel = this.renderer.listen(this.doc, 'pointercancel', (ev) => this.pointerCancel(ev));
2220
+ this.removeLostCapture = this.renderer.listen(this.doc, 'lostpointercapture', (ev) => this.pointerCancel(ev));
1106
2221
  }
1107
2222
  applyDragUpdate(p) {
2223
+ const before = this._ref.activeDropList;
2224
+ // DragRef decides everything (preview, target list, placeholder). A free drag never
2225
+ // interacts with drop lists.
1108
2226
  this._ref.dragMove(p);
1109
- const target = this.service.findDropList(p, this._ref.dropList);
1110
- if (target !== this._ref.dropList) {
1111
- this._ref.dropList?.exit(this._ref);
1112
- this._ref.clearDropList();
1113
- if (target) {
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
- }
2227
+ if (this._ref.isListDrag && this.autoScroll()) {
2228
+ const after = this._ref.activeDropList;
2229
+ // Follow the hovered list; outside every list, scroll the page.
2230
+ if (after !== before)
2231
+ this.scroller.retarget(after?.el ?? this.doc.body);
1122
2232
  }
1123
- this._ref.dropList?.sortItem(this._ref, p);
1124
2233
  this.dragMove.emit(p);
1125
2234
  }
1126
2235
  /** فراخوانی می‌شود دقیقاً وقتی drag واقعاً شروع می‌شود (چه با موس، چه با کیبورد). */
@@ -1181,7 +2290,13 @@ class NgxDraggable {
1181
2290
  if (!this.down || e.pointerId !== this.pointerId)
1182
2291
  return;
1183
2292
  if (this.dragging()) {
1184
- if (this._ref.dropList) {
2293
+ // Also when released OUTSIDE every list: endDrag() rolls the drag back and cleans up.
2294
+ if (this._ref.isListDrag) {
2295
+ // The release position is authoritative for the drop target (a fast flick may end with
2296
+ // a pointerup that has no preceding pointermove at that position).
2297
+ const p = { x: e.clientX, y: e.clientY };
2298
+ if (p.x !== this.lastPointer.x || p.y !== this.lastPointer.y)
2299
+ this._ref.dragMove(p);
1185
2300
  this._ref.endDrag();
1186
2301
  }
1187
2302
  this.service.end(this._ref);
@@ -1189,6 +2304,16 @@ class NgxDraggable {
1189
2304
  this.finish();
1190
2305
  this.dragEnd.emit(this._ref.pointer);
1191
2306
  }
2307
+ /** Browser cancelled the pointer (touch takeover, lost capture...). */
2308
+ pointerCancel(e) {
2309
+ if (!this.down || e.pointerId !== this.pointerId)
2310
+ return;
2311
+ if (this.dragging() && this._ref.isListDrag) {
2312
+ this.cancel();
2313
+ return;
2314
+ }
2315
+ this.pointerUp(e);
2316
+ }
1192
2317
  cancel() {
1193
2318
  this._ref.cancelDrag();
1194
2319
  this.service.end(this._ref);
@@ -1202,7 +2327,10 @@ class NgxDraggable {
1202
2327
  this.removeMove?.();
1203
2328
  this.removeUp?.();
1204
2329
  this.removeEscape?.();
2330
+ this.removeCancel?.();
2331
+ this.removeLostCapture?.();
1205
2332
  this.removeMove = this.removeUp = this.removeEscape = undefined;
2333
+ this.removeCancel = this.removeLostCapture = undefined;
1206
2334
  }
1207
2335
  /** Arrow-key movement for keyboard/assistive-tech users. Shift multiplies the step by 4. */
1208
2336
  keyDown(e) {
@@ -1219,14 +2347,21 @@ class NgxDraggable {
1219
2347
  if (delta) {
1220
2348
  e.preventDefault();
1221
2349
  if (!this.dragging()) {
1222
- this._ref.pointerDown({ x: 0, y: 0 });
2350
+ // Free drag keeps its (0,0) pseudo pointer. A list item starts from its own centre so
2351
+ // that keyboard sorting resolves against real geometry.
2352
+ const inList = !!this._ref.dropList && this._ref.dropList.el === this._ref.el.parentElement;
2353
+ const r = this._ref.el.getBoundingClientRect();
2354
+ const origin = inList
2355
+ ? { x: r.left + r.width / 2, y: r.top + r.height / 2 }
2356
+ : { x: 0, y: 0 };
2357
+ this._ref.pointerDown(origin);
1223
2358
  this._ref.boundary = this.boundary();
1224
2359
  this._ref.lockAxis = this.lockAxis();
1225
- this._ref.startDrag({ x: 0, y: 0 });
2360
+ this._ref.startDrag(origin);
1226
2361
  this.dragging.set(true);
1227
2362
  this.service.begin(this._ref);
1228
2363
  this.startScrollTracking();
1229
- this.dragStart.emit({ x: 0, y: 0 });
2364
+ this.dragStart.emit(origin);
1230
2365
  }
1231
2366
  this._ref.nudge(delta.x, delta.y);
1232
2367
  this.dragMove.emit(this._ref.pointer);
@@ -1256,7 +2391,7 @@ class NgxDraggable {
1256
2391
  return (target instanceof HTMLElement &&
1257
2392
  !!target.closest('button,a,input,textarea,select,[data-no-drag]'));
1258
2393
  }
1259
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDraggable, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Directive });
2394
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDraggable, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive });
1260
2395
  static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.4", type: NgxDraggable, isStandalone: true, selector: "[NgxDraggable],[ngxDraggable]", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, boundary: { classPropertyName: "boundary", publicName: "boundary", isSignal: true, isRequired: false, transformFunction: null }, dragRootElement: { classPropertyName: "dragRootElement", publicName: "dragRootElement", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, lockAxis: { classPropertyName: "lockAxis", publicName: "lockAxis", isSignal: true, isRequired: false, transformFunction: null }, dragThreshold: { classPropertyName: "dragThreshold", publicName: "dragThreshold", isSignal: true, isRequired: false, transformFunction: null }, keyboardStep: { classPropertyName: "keyboardStep", publicName: "keyboardStep", isSignal: true, isRequired: false, transformFunction: null }, autoScroll: { classPropertyName: "autoScroll", publicName: "autoScroll", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: false, isRequired: false, transformFunction: null } }, outputs: { disabled: "disabledChange", dragRootElement: "dragRootElementChange", dragHandle: "dragHandleChange", dragStart: "dragStart", dragMove: "dragMove", dragEnd: "dragEnd" }, host: { properties: { "style.touch-action": "\"none\"", "attr.tabindex": "disabled() ? null : 0", "attr.aria-grabbed": "dragging()", "class.disable-drag": "disabled()" }, classAttribute: "ngx-draggable" }, providers: [{ provide: NGX_DRAGGABLE, useExisting: NgxDraggable }], ngImport: i0 });
1261
2396
  }
1262
2397
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDraggable, decorators: [{
@@ -1272,7 +2407,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImpor
1272
2407
  '[class.disable-drag]': 'disabled()',
1273
2408
  },
1274
2409
  }]
1275
- }], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.Renderer2 }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], boundary: [{ type: i0.Input, args: [{ isSignal: true, alias: "boundary", required: false }] }], dragRootElement: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragRootElement", required: false }] }, { type: i0.Output, args: ["dragRootElementChange"] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }, { type: i0.Output, args: ["dragHandleChange"] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], keyboardStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "keyboardStep", required: false }] }], autoScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScroll", required: false }] }], data: [{
2410
+ }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }, { type: i0.Output, args: ["disabledChange"] }], boundary: [{ type: i0.Input, args: [{ isSignal: true, alias: "boundary", required: false }] }], dragRootElement: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragRootElement", required: false }] }, { type: i0.Output, args: ["dragRootElementChange"] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }, { type: i0.Output, args: ["dragHandleChange"] }], lockAxis: [{ type: i0.Input, args: [{ isSignal: true, alias: "lockAxis", required: false }] }], dragThreshold: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragThreshold", required: false }] }], keyboardStep: [{ type: i0.Input, args: [{ isSignal: true, alias: "keyboardStep", required: false }] }], autoScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoScroll", required: false }] }], data: [{
1276
2411
  type: Input,
1277
2412
  args: ['data']
1278
2413
  }], dragStart: [{ type: i0.Output, args: ["dragStart"] }], dragMove: [{ type: i0.Output, args: ["dragMove"] }], dragEnd: [{ type: i0.Output, args: ["dragEnd"] }] } });
@@ -1661,6 +2796,9 @@ class NgxPlaceholder {
1661
2796
  this.tpl = tpl;
1662
2797
  this._ref.tpl = tpl;
1663
2798
  this._ref.dropList = this.list?._ref;
2799
+ // Lets the embedded view join Angular's change-detection tree (matters in zoneless apps,
2800
+ // and for app-stability tracking) instead of being rendered once and left dangling.
2801
+ this._ref.appRef = inject(ApplicationRef);
1664
2802
  }
1665
2803
  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
2804
  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 });
@@ -1673,6 +2811,107 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImpor
1673
2811
  }]
1674
2812
  }], ctorParameters: () => [{ type: i0.TemplateRef }] });
1675
2813
 
2814
+ const NGX_DROPLIST = new InjectionToken('ngx-drop-list');
2815
+ class NgxDropList {
2816
+ el;
2817
+ _ref = new DropListRef();
2818
+ set data(value) {
2819
+ this._ref.data = value;
2820
+ }
2821
+ set connectedTo(value) {
2822
+ this._ref.connectedTo = value ?? [];
2823
+ }
2824
+ set disableSort(value) {
2825
+ this._ref.disableSort = value === true;
2826
+ }
2827
+ drop = new EventEmitter();
2828
+ customPlaceholder;
2829
+ sub;
2830
+ service = inject(DragDropService);
2831
+ constructor(el) {
2832
+ this.el = el;
2833
+ }
2834
+ ngOnInit() {
2835
+ this._ref.el = this.el.nativeElement;
2836
+ // Found by walking the real DOM (not Angular's element-injector tree): a list rendered by a
2837
+ // recursively-invoked `ngTemplateOutlet` (a tree/outliner UI, where every level re-uses the
2838
+ // SAME `<ng-template>`) gets a fresh injector context per invocation, so `inject(TOKEN,
2839
+ // {skipSelf})` can never see a provider from another recursion level — the DOM position is
2840
+ // what actually determines nesting/grouping for this library's purposes anyway.
2841
+ const parentEl = this.el.nativeElement.parentElement;
2842
+ const group = this.service.findAncestorGroup(parentEl);
2843
+ const parent = this.service.findAncestorDropList(parentEl);
2844
+ this._ref.dropListGroup = group ?? null;
2845
+ group?.add(this._ref);
2846
+ parent?._registerChild(this._ref);
2847
+ this.sub = this._ref.onDrop.subscribe((e) => this.drop.emit(e));
2848
+ this.service.registerDropList(this._ref);
2849
+ }
2850
+ ngAfterContentInit() {
2851
+ if (this.customPlaceholder) {
2852
+ this._ref.customPlaceholder = this.customPlaceholder._ref;
2853
+ }
2854
+ }
2855
+ ngOnDestroy() {
2856
+ this.sub?.unsubscribe();
2857
+ this._ref._release();
2858
+ this._ref.parentList?._unregisterChild(this._ref);
2859
+ this._ref.dropListGroup?.remove(this._ref);
2860
+ this.service.removeDropList(this._ref);
2861
+ }
2862
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropList, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive });
2863
+ 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 });
2864
+ }
2865
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropList, decorators: [{
2866
+ type: Directive,
2867
+ args: [{
2868
+ selector: '[NgxDropList],[ngxDropList]',
2869
+ providers: [{ provide: NGX_DROPLIST, useExisting: NgxDropList }],
2870
+ host: { class: 'ngx-drop-list' },
2871
+ }]
2872
+ }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { data: [{
2873
+ type: Input,
2874
+ args: ['data']
2875
+ }], connectedTo: [{
2876
+ type: Input
2877
+ }], disableSort: [{
2878
+ type: Input
2879
+ }], drop: [{
2880
+ type: Output
2881
+ }], customPlaceholder: [{
2882
+ type: ContentChild,
2883
+ args: [NgxPlaceholder, {
2884
+ descendants: true,
2885
+ }]
2886
+ }] } });
2887
+
2888
+ const NGX_DROPLIST_GROUP = new InjectionToken('ngx-drop-list-group');
2889
+ /** Wrap several `ngxDropList`s to let items move freely between them. */
2890
+ class NgxDropListGroup {
2891
+ _ref = new DropListGroupRef();
2892
+ el = inject((ElementRef));
2893
+ service = inject(DragDropService);
2894
+ ngOnInit() {
2895
+ // Registered by DOM element (not just provided via DI) so a list nested inside a recursive
2896
+ // `ngTemplateOutlet` tree can still find this group by walking its real ancestors — see the
2897
+ // comment on `DragDropService`'s registries for why DI alone can't do this.
2898
+ this.service.registerGroup(this.el.nativeElement, this._ref);
2899
+ }
2900
+ ngOnDestroy() {
2901
+ this._ref.lists.clear();
2902
+ this.service.removeGroup(this.el.nativeElement);
2903
+ }
2904
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropListGroup, deps: [], target: i0.ɵɵFactoryTarget.Directive });
2905
+ 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 });
2906
+ }
2907
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxDropListGroup, decorators: [{
2908
+ type: Directive,
2909
+ args: [{
2910
+ selector: '[NgxDropListGroup],[ngxDropListGroup]',
2911
+ providers: [{ provide: NGX_DROPLIST_GROUP, useExisting: NgxDropListGroup }],
2912
+ }]
2913
+ }] });
2914
+
1676
2915
  function moveItemInArray(array, from, to) {
1677
2916
  const f = clamp(from, array.length - 1);
1678
2917
  const t = clamp(to, array.length - 1);