ngx-kit 7.3.3 → 8.0.0

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