ngx-virtual-dnd 1.1.1 → 1.1.2

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,6 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, signal, computed, effect, Injectable, inject, NgZone, ElementRef, Injector, viewChild, input, output, afterNextRender, ChangeDetectionStrategy, Component, Directive, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
2
+ import { InjectionToken, signal, computed, effect, Injectable, inject, NgZone, ElementRef, Injector, DestroyRef, viewChild, input, output, afterNextRender, ChangeDetectionStrategy, Component, Directive, untracked, EnvironmentInjector, TemplateRef, ViewContainerRef } from '@angular/core';
3
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3
4
  import { NgTemplateOutlet } from '@angular/common';
5
+ import { fromEvent } from 'rxjs';
4
6
 
5
7
  /**
6
8
  * Initial state for the drag state service.
@@ -1057,6 +1059,7 @@ class VirtualScrollContainerComponent {
1057
1059
  #autoScrollService = inject(AutoScrollService);
1058
1060
  #ngZone = inject(NgZone);
1059
1061
  #injector = inject(Injector);
1062
+ #destroyRef = inject(DestroyRef);
1060
1063
  /** ResizeObserver for automatic height detection */
1061
1064
  #resizeObserver = null;
1062
1065
  /** Measured height from ResizeObserver (used when containerHeight is not provided) */
@@ -1196,25 +1199,30 @@ class VirtualScrollContainerComponent {
1196
1199
  draggedItemId = computed(() => {
1197
1200
  return this.#dragState.draggedItem()?.draggableId ?? null;
1198
1201
  }, ...(ngDevMode ? [{ debugName: "draggedItemId" }] : []));
1202
+ /** Map of item IDs to their indices - rebuilt only when items() changes (O(n) once, then O(1) lookups) */
1203
+ #itemIndexMap = computed(() => {
1204
+ const items = this.items();
1205
+ const idFn = this.itemIdFn();
1206
+ const map = new Map();
1207
+ for (let i = 0; i < items.length; i++) {
1208
+ map.set(idFn(items[i]), i);
1209
+ }
1210
+ return map;
1211
+ }, ...(ngDevMode ? [{ debugName: "#itemIndexMap" }] : []));
1199
1212
  /** The index of the currently dragged item in the items array (-1 if not found or not dragging) */
1200
1213
  #draggedItemIndex = computed(() => {
1201
1214
  const draggedId = this.draggedItemId();
1202
1215
  if (!draggedId)
1203
1216
  return -1;
1204
- const items = this.items();
1205
- const idFn = this.itemIdFn();
1206
- for (let i = 0; i < items.length; i++) {
1207
- if (idFn(items[i]) === draggedId) {
1208
- return i;
1209
- }
1210
- }
1211
- return -1;
1217
+ return this.#itemIndexMap().get(draggedId) ?? -1;
1212
1218
  }, ...(ngDevMode ? [{ debugName: "#draggedItemIndex" }] : []));
1219
+ /** Memoized Set of sticky IDs - rebuilt only when effectiveStickyIds() changes */
1220
+ #stickyIdsSet = computed(() => new Set(this.effectiveStickyIds()), ...(ngDevMode ? [{ debugName: "#stickyIdsSet" }] : []));
1213
1221
  /** Items to render, including sticky items and auto-placeholder */
1214
1222
  renderedItems = computed(() => {
1215
1223
  const items = this.items();
1216
1224
  const { start, end } = this.#renderRange();
1217
- const stickyIds = new Set(this.effectiveStickyIds());
1225
+ const stickyIds = this.#stickyIdsSet();
1218
1226
  const idFn = this.itemIdFn();
1219
1227
  const draggedId = this.draggedItemId();
1220
1228
  // Check if we should insert a placeholder
@@ -1389,6 +1397,20 @@ class VirtualScrollContainerComponent {
1389
1397
  }
1390
1398
  });
1391
1399
  this.#resizeObserver.observe(this.#elementRef.nativeElement);
1400
+ // Set up scroll listener outside Angular zone using RxJS fromEvent
1401
+ // This avoids template event binding which would mark the component dirty 60x/sec
1402
+ fromEvent(this.#elementRef.nativeElement, 'scroll', { passive: true })
1403
+ .pipe(takeUntilDestroyed(this.#destroyRef))
1404
+ .subscribe(() => {
1405
+ const newScrollTop = this.#elementRef.nativeElement.scrollTop;
1406
+ // Only update if the scroll position has changed significantly
1407
+ // (at least 10% of an item height, to reduce updates)
1408
+ const threshold = Math.max(5, this.itemHeight() * 0.1);
1409
+ if (Math.abs(newScrollTop - this.#scrollTop()) >= threshold) {
1410
+ this.#scrollTop.set(newScrollTop);
1411
+ this.scrollPositionChange.emit(newScrollTop);
1412
+ }
1413
+ });
1392
1414
  });
1393
1415
  }
1394
1416
  ngOnDestroy() {
@@ -1398,20 +1420,6 @@ class VirtualScrollContainerComponent {
1398
1420
  const id = this.scrollContainerId() ?? this.#generatedScrollId;
1399
1421
  this.#autoScrollService.unregisterContainer(id);
1400
1422
  }
1401
- /**
1402
- * Handle scroll events.
1403
- */
1404
- onScroll(event) {
1405
- const target = event.target;
1406
- const newScrollTop = target.scrollTop;
1407
- // Only update if the scroll position has changed significantly
1408
- // (at least 10% of an item height, to reduce updates)
1409
- const threshold = Math.max(5, this.itemHeight() * 0.1);
1410
- if (Math.abs(newScrollTop - this.#scrollTop()) >= threshold) {
1411
- this.#scrollTop.set(newScrollTop);
1412
- this.scrollPositionChange.emit(newScrollTop);
1413
- }
1414
- }
1415
1423
  /**
1416
1424
  * Scroll to a specific position.
1417
1425
  */
@@ -1446,7 +1454,7 @@ class VirtualScrollContainerComponent {
1446
1454
  this.scrollTo(newPosition);
1447
1455
  }
1448
1456
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: VirtualScrollContainerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1449
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, autoPlaceholder: { classPropertyName: "autoPlaceholder", publicName: "autoPlaceholder", isSignal: true, isRequired: false, transformFunction: null }, placeholderTemplate: { classPropertyName: "placeholderTemplate", publicName: "placeholderTemplate", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { visibleRangeChange: "visibleRangeChange", scrollPositionChange: "scrollPositionChange" }, host: { listeners: { "scroll": "onScroll($event)" }, properties: { "style.height.px": "containerHeight() ?? null", "style.overflow": "\"auto\"", "style.position": "\"relative\"", "attr.data-item-height": "itemHeight()" }, classAttribute: "vdnd-virtual-scroll" }, viewQueries: [{ propertyName: "contentContainer", first: true, predicate: ["contentContainer"], descendants: true, isSignal: true }], ngImport: i0, template: `
1457
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: VirtualScrollContainerComponent, isStandalone: true, selector: "vdnd-virtual-scroll", inputs: { itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, scrollContainerId: { classPropertyName: "scrollContainerId", publicName: "scrollContainerId", isSignal: true, isRequired: false, transformFunction: null }, autoScrollEnabled: { classPropertyName: "autoScrollEnabled", publicName: "autoScrollEnabled", isSignal: true, isRequired: false, transformFunction: null }, autoScrollConfig: { classPropertyName: "autoScrollConfig", publicName: "autoScrollConfig", isSignal: true, isRequired: false, transformFunction: null }, items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: true, transformFunction: null }, itemHeight: { classPropertyName: "itemHeight", publicName: "itemHeight", isSignal: true, isRequired: true, transformFunction: null }, containerHeight: { classPropertyName: "containerHeight", publicName: "containerHeight", isSignal: true, isRequired: false, transformFunction: null }, overscan: { classPropertyName: "overscan", publicName: "overscan", isSignal: true, isRequired: false, transformFunction: null }, stickyItemIds: { classPropertyName: "stickyItemIds", publicName: "stickyItemIds", isSignal: true, isRequired: false, transformFunction: null }, itemIdFn: { classPropertyName: "itemIdFn", publicName: "itemIdFn", isSignal: true, isRequired: true, transformFunction: null }, trackByFn: { classPropertyName: "trackByFn", publicName: "trackByFn", isSignal: true, isRequired: false, transformFunction: null }, droppableId: { classPropertyName: "droppableId", publicName: "droppableId", isSignal: true, isRequired: false, transformFunction: null }, autoPlaceholder: { classPropertyName: "autoPlaceholder", publicName: "autoPlaceholder", isSignal: true, isRequired: false, transformFunction: null }, placeholderTemplate: { classPropertyName: "placeholderTemplate", publicName: "placeholderTemplate", isSignal: true, isRequired: false, transformFunction: null }, autoStickyDraggedItem: { classPropertyName: "autoStickyDraggedItem", publicName: "autoStickyDraggedItem", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { visibleRangeChange: "visibleRangeChange", scrollPositionChange: "scrollPositionChange" }, host: { properties: { "style.height.px": "containerHeight() ?? null", "style.overflow": "\"auto\"", "style.position": "\"relative\"", "attr.data-item-height": "itemHeight()" }, classAttribute: "vdnd-virtual-scroll" }, viewQueries: [{ propertyName: "contentContainer", first: true, predicate: ["contentContainer"], descendants: true, isSignal: true }], ngImport: i0, template: `
1450
1458
  <div class="vdnd-virtual-scroll-content" #contentContainer>
1451
1459
  <!-- Single spacer maintains scroll height -->
1452
1460
  <div class="vdnd-virtual-scroll-spacer" [style.height.px]="totalHeight()"></div>
@@ -1482,7 +1490,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
1482
1490
  '[style.overflow]': '"auto"',
1483
1491
  '[style.position]': '"relative"',
1484
1492
  '[attr.data-item-height]': 'itemHeight()',
1485
- '(scroll)': 'onScroll($event)',
1486
1493
  }, template: `
1487
1494
  <div class="vdnd-virtual-scroll-content" #contentContainer>
1488
1495
  <!-- Single spacer maintains scroll height -->
@@ -1878,8 +1885,8 @@ class DroppableDirective {
1878
1885
  const active = this.isActive();
1879
1886
  const placeholder = this.placeholderId();
1880
1887
  const draggedItem = this.#dragState.draggedItem();
1881
- const cursorPosition = this.#dragState.cursorPosition();
1882
1888
  const isDragging = this.#dragState.isDragging();
1889
+ // NOTE: cursorPosition is read with untracked() below to avoid effect running 60x/sec
1883
1890
  // Cache state while active for use during drop handling
1884
1891
  if (active && isDragging && draggedItem) {
1885
1892
  this.#cachedDragState = this.#dragState.getStateSnapshot();
@@ -1918,13 +1925,18 @@ class DroppableDirective {
1918
1925
  }
1919
1926
  // Handle over (placeholder changed)
1920
1927
  if (active && placeholder !== this.#previousPlaceholder) {
1921
- if (draggedItem && cursorPosition) {
1922
- this.dragOver.emit({
1923
- droppableId: this.vdndDroppable(),
1924
- draggedItem,
1925
- placeholderId: placeholder,
1926
- position: cursorPosition,
1927
- });
1928
+ if (draggedItem) {
1929
+ // Use untracked() to read cursorPosition without tracking it as a dependency
1930
+ // This prevents the effect from running on every cursor move (60Hz during autoscroll)
1931
+ const cursorPosition = untracked(() => this.#dragState.cursorPosition());
1932
+ if (cursorPosition) {
1933
+ this.dragOver.emit({
1934
+ droppableId: this.vdndDroppable(),
1935
+ draggedItem,
1936
+ placeholderId: placeholder,
1937
+ position: cursorPosition,
1938
+ });
1939
+ }
1928
1940
  }
1929
1941
  }
1930
1942
  this.#wasActive = active;
@@ -3395,106 +3407,128 @@ class VirtualForDirective {
3395
3407
  });
3396
3408
  }
3397
3409
  /**
3398
- * Update the rendered views.
3410
+ * Update the rendered views with true view recycling.
3411
+ * Views are kept in the DOM and have their context updated in place when possible.
3399
3412
  */
3400
3413
  #updateViews() {
3401
3414
  const items = this.vdndVirtualForOf();
3402
3415
  const { start, end } = this.#renderRange();
3403
3416
  const trackByFn = this.vdndVirtualForTrackBy();
3404
3417
  const placeholderIndex = this.#placeholderIndex();
3405
- // Clear view container but keep views in pool
3406
- this.#activeViews.forEach((view) => {
3407
- this.#viewPool.push(view);
3408
- });
3409
- this.#activeViews.clear();
3410
- this.#viewContainer.clear();
3411
- // Clear wrapper content
3412
- if (this.#wrapper) {
3413
- this.#wrapper.innerHTML = '';
3414
- }
3415
- const viewsToRender = [];
3416
- // Render items in range with placeholder
3418
+ // 1. Calculate which keys we need and build the ordered list of items to render
3419
+ const itemsToRender = [];
3417
3420
  for (let i = start; i <= end && i < items.length; i++) {
3418
3421
  // Insert placeholder before this item if needed
3419
3422
  if (placeholderIndex !== null && placeholderIndex === i) {
3420
- const placeholderContext = {
3423
+ itemsToRender.push({
3424
+ key: '__placeholder__',
3425
+ context: {
3426
+ $implicit: { __vdndPlaceholder: true },
3427
+ index: -1,
3428
+ first: false,
3429
+ last: false,
3430
+ count: items.length,
3431
+ isPlaceholder: true,
3432
+ },
3433
+ });
3434
+ }
3435
+ const item = items[i];
3436
+ itemsToRender.push({
3437
+ key: trackByFn(i, item),
3438
+ context: {
3439
+ $implicit: item,
3440
+ index: i,
3441
+ first: i === start,
3442
+ last: i === end || i === items.length - 1,
3443
+ count: items.length,
3444
+ isPlaceholder: false,
3445
+ },
3446
+ });
3447
+ }
3448
+ // Insert placeholder at end if needed
3449
+ if (placeholderIndex !== null && placeholderIndex >= items.length) {
3450
+ itemsToRender.push({
3451
+ key: '__placeholder__',
3452
+ context: {
3421
3453
  $implicit: { __vdndPlaceholder: true },
3422
3454
  index: -1,
3423
3455
  first: false,
3424
- last: false,
3456
+ last: true,
3425
3457
  count: items.length,
3426
3458
  isPlaceholder: true,
3427
- };
3428
- const placeholderView = this.#getOrCreateView('__placeholder__', placeholderContext);
3429
- viewsToRender.push(placeholderView);
3430
- }
3431
- const item = items[i];
3432
- const key = trackByFn(i, item);
3433
- const context = {
3434
- $implicit: item,
3435
- index: i,
3436
- first: i === start,
3437
- last: i === end || i === items.length - 1,
3438
- count: items.length,
3439
- isPlaceholder: false,
3440
- };
3441
- const view = this.#getOrCreateView(key, context);
3442
- viewsToRender.push(view);
3459
+ },
3460
+ });
3443
3461
  }
3444
- // Insert placeholder at end if needed
3445
- if (placeholderIndex !== null && placeholderIndex >= items.length) {
3446
- const placeholderContext = {
3447
- $implicit: { __vdndPlaceholder: true },
3448
- index: -1,
3449
- first: false,
3450
- last: true,
3451
- count: items.length,
3452
- isPlaceholder: true,
3453
- };
3454
- const placeholderView = this.#getOrCreateView('__placeholder__', placeholderContext);
3455
- viewsToRender.push(placeholderView);
3462
+ const neededKeys = new Set(itemsToRender.map((item) => item.key));
3463
+ // 2. Remove views we no longer need (move to pool)
3464
+ for (const [key, view] of this.#activeViews) {
3465
+ if (!neededKeys.has(key)) {
3466
+ const index = this.#viewContainer.indexOf(view);
3467
+ if (index >= 0) {
3468
+ this.#viewContainer.detach(index);
3469
+ }
3470
+ this.#viewPool.push(view);
3471
+ this.#activeViews.delete(key);
3472
+ }
3456
3473
  }
3457
- // Insert views into ViewContainerRef (for change detection) and move root nodes to wrapper
3458
- viewsToRender.forEach((view, index) => {
3459
- this.#viewContainer.insert(view, index);
3460
- // Move view's root nodes into the wrapper for transform-based positioning
3474
+ // 3. For each needed item, update existing view context or get/create from pool
3475
+ const viewsInOrder = [];
3476
+ for (const { key, context } of itemsToRender) {
3477
+ let view = this.#activeViews.get(key);
3478
+ if (view) {
3479
+ // View exists - update context in place (no DOM manipulation needed)
3480
+ Object.assign(view.context, context);
3481
+ view.markForCheck();
3482
+ }
3483
+ else {
3484
+ // Need a new view - try pool first, then create
3485
+ view = this.#viewPool.pop();
3486
+ if (view) {
3487
+ Object.assign(view.context, context);
3488
+ view.markForCheck();
3489
+ }
3490
+ else {
3491
+ view = this.#templateRef.createEmbeddedView(context);
3492
+ }
3493
+ this.#activeViews.set(key, view);
3494
+ }
3495
+ viewsInOrder.push(view);
3496
+ }
3497
+ // 4. Ensure views are in correct order in ViewContainerRef and wrapper
3498
+ for (let i = 0; i < viewsInOrder.length; i++) {
3499
+ const view = viewsInOrder[i];
3500
+ const currentIndex = this.#viewContainer.indexOf(view);
3501
+ if (currentIndex !== i) {
3502
+ // View needs to be inserted or moved
3503
+ if (currentIndex >= 0) {
3504
+ this.#viewContainer.move(view, i);
3505
+ }
3506
+ else {
3507
+ this.#viewContainer.insert(view, i);
3508
+ }
3509
+ }
3510
+ // Ensure view's root nodes are in the wrapper (for newly inserted views)
3461
3511
  if (this.#wrapper) {
3462
- view.rootNodes.forEach((node) => {
3463
- this.#wrapper.appendChild(node);
3464
- });
3512
+ const expectedChild = this.#wrapper.children[i];
3513
+ for (const node of view.rootNodes) {
3514
+ if (node instanceof HTMLElement && node !== expectedChild) {
3515
+ // Node is not at expected position, insert it
3516
+ if (expectedChild) {
3517
+ this.#wrapper.insertBefore(node, expectedChild);
3518
+ }
3519
+ else {
3520
+ this.#wrapper.appendChild(node);
3521
+ }
3522
+ }
3523
+ }
3465
3524
  }
3466
- });
3467
- // Destroy unused views in pool (keep some for reuse)
3525
+ }
3526
+ // 5. Destroy unused views in pool (keep some for reuse)
3468
3527
  while (this.#viewPool.length > 10) {
3469
3528
  const view = this.#viewPool.pop();
3470
3529
  view?.destroy();
3471
3530
  }
3472
3531
  }
3473
- /**
3474
- * Get an existing view from pool or create a new one.
3475
- */
3476
- #getOrCreateView(key, context) {
3477
- // Check if we have this view active already
3478
- let view = this.#activeViews.get(key);
3479
- if (view) {
3480
- // Update context
3481
- Object.assign(view.context, context);
3482
- view.markForCheck();
3483
- return view;
3484
- }
3485
- // Try to reuse from pool
3486
- view = this.#viewPool.pop();
3487
- if (view) {
3488
- Object.assign(view.context, context);
3489
- view.markForCheck();
3490
- }
3491
- else {
3492
- // Create new view
3493
- view = this.#templateRef.createEmbeddedView(context);
3494
- }
3495
- this.#activeViews.set(key, view);
3496
- return view;
3497
- }
3498
3532
  /**
3499
3533
  * Static method for Angular's structural directive microsyntax.
3500
3534
  */