@keenmate/svelte-treeview 4.6.0 → 4.8.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.
@@ -5,7 +5,7 @@ import { getLevel, getParentPath, getPathSegments, getRelativePath } from '../he
5
5
  import { createSearchIndex } from './flex.js';
6
6
  import { Indexer } from './indexer.js';
7
7
  import { perfStart, perfEnd, perfSummary } from '../perf-logger.js';
8
- export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMember, _hasChildrenMember, _isExpandedMember, _isSelectableMember, _isDraggableMember, _isDropAllowedMember, _displayValueMember, _getDisplayValueCallback, _searchValueMember, _getSearchValueCallback, _orderMember, _treeId, _treePathSeparator, _expandLevel, _shouldUseInternalSearchIndex, _initializeIndexCallback, _indexerBatchSize, _indexerTimeout, opts) {
8
+ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMember, _hasChildrenMember, _isExpandedMember, _isSelectableMember, _isDraggableMember, _isDropAllowedMember, _allowedDropPositionsMember, _displayValueMember, _getDisplayValueCallback, _searchValueMember, _getSearchValueCallback, _getAllowedDropPositionsCallback, _orderMember, _treeId, _treePathSeparator, _expandLevel, _shouldUseInternalSearchIndex, _initializeIndexCallback, _indexerBatchSize, _indexerTimeout, opts) {
9
9
  let shouldCalculateParentPath = isEmptyString(_parentPathMember);
10
10
  let shouldCalculateLevel = isEmptyString(_levelMember);
11
11
  let shouldCalculateHasChildren = isEmptyString(_hasChildrenMember);
@@ -13,6 +13,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
13
13
  let shouldCalculateIsSelectable = isEmptyString(_isSelectableMember);
14
14
  let shouldCalculateIsDraggable = isEmptyString(_isDraggableMember);
15
15
  let shouldCalculateIsDropAllowed = isEmptyString(_isDropAllowedMember);
16
+ let shouldCalculateAllowedDropPositions = isEmptyString(_allowedDropPositionsMember);
16
17
  let shouldCalculateDisplayValue = isEmptyString(_displayValueMember);
17
18
  let shouldCalculateSearchValue = isEmptyString(_searchValueMember);
18
19
  // this is absolutely crucial to keep order of sorted items. Segments are just numbers and numbers as properties are always sorted
@@ -42,6 +43,27 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
42
43
  _indexerTimeout || 50, // timeout with fallback
43
44
  opts.shouldDisplayDebugInformation);
44
45
  }
46
+ // Per-node reactive signals for O(1) fine-grained updates.
47
+ // Each NodeSignal is an independent $state — bumping one only notifies
48
+ // the single Node component that reads it. Stored in a plain Map
49
+ // (not $state) to avoid proxy overhead.
50
+ class NodeSignal {
51
+ value = $state(0);
52
+ bump() { this.value++; }
53
+ }
54
+ let nodeSignals = new Map();
55
+ function _bumpRev(node) {
56
+ node._rev = (node._rev || 0) + 1;
57
+ }
58
+ function _bumpNodeRev(node) {
59
+ // Signal only — do NOT bump _rev. _rev is part of the {#each} key,
60
+ // so bumping it would cause {#each} to detect a key change and
61
+ // destroy/recreate the component (O(n) diffing). We only want the
62
+ // per-node signal to fire, which is O(1).
63
+ const signal = nodeSignals.get(String(node.id));
64
+ if (signal)
65
+ signal.bump();
66
+ }
45
67
  return {
46
68
  // Properties
47
69
  treePathSeparator: _treePathSeparator || '.',
@@ -57,13 +79,15 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
57
79
  isSelectableMember: _isSelectableMember,
58
80
  isDraggableMember: _isDraggableMember,
59
81
  isDropAllowedMember: _isDropAllowedMember,
82
+ allowedDropPositionsMember: _allowedDropPositionsMember,
60
83
  hasChildrenMember: _hasChildrenMember,
61
84
  displayValueMember: _displayValueMember,
62
85
  getDisplayValueCallback: _getDisplayValueCallback,
63
86
  searchValueMember: _searchValueMember,
64
87
  getSearchValueCallback: _getSearchValueCallback,
88
+ getAllowedDropPositionsCallback: _getAllowedDropPositionsCallback,
65
89
  orderMember: _orderMember,
66
- isSorted: false,
90
+ isSorted: opts?.isSorted ?? false,
67
91
  // Properties for filtering
68
92
  filteredTree,
69
93
  isFiltered,
@@ -104,11 +128,8 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
104
128
  const result = [];
105
129
  const self = this;
106
130
  function traverse(node) {
107
- // Get children and optionally sort them
108
- let children = Object.values(node.children);
109
- if (self.isSorted && children.length > 0) {
110
- children = self.sortCallback(children);
111
- }
131
+ // Get children in natural tree key order (same as recursive mode)
132
+ const children = Object.values(node.children);
112
133
  for (const child of children) {
113
134
  result.push(child);
114
135
  // Only traverse into children if this node is expanded
@@ -118,8 +139,6 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
118
139
  }
119
140
  }
120
141
  traverse(startRoot);
121
- const computeTime = performance.now() - computeStart;
122
- console.log(`[visibleFlatNodes] Computed ${result.length} nodes in ${computeTime.toFixed(2)}ms`);
123
142
  // Cache the result
124
143
  cachedVisibleFlatNodes = result;
125
144
  cachedVisibleFlatNodesTracker = _tracker;
@@ -138,6 +157,12 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
138
157
  },
139
158
  insertArray: function (data, noEmitChanges = false) {
140
159
  data = data || [];
160
+ // Create per-node signals for O(1) reactive updates
161
+ nodeSignals = new Map();
162
+ for (let i = 0; i < data.length; i++) {
163
+ const id = _idMember ? data[i][_idMember] : i;
164
+ nodeSignals.set(String(id), new NodeSignal());
165
+ }
141
166
  // Clear any pending indexing from previous calls
142
167
  indexer?.clearQueue();
143
168
  flatTreeNodes = [];
@@ -171,14 +196,14 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
171
196
  node.isDraggable = row[_isDraggableMember];
172
197
  if (!shouldCalculateIsDropAllowed)
173
198
  node.isDropAllowed = row[_isDropAllowedMember];
199
+ if (!shouldCalculateAllowedDropPositions)
200
+ node.allowedDropPositions = row[_allowedDropPositionsMember];
174
201
  if (!shouldCalculateHasChildren)
175
202
  node.hasChildren = row[_hasChildrenMember];
176
203
  node.data = row;
177
204
  return node;
178
205
  });
179
206
  const conversionTime = perfEnd(`[${_treeId}] insertArray:conversion`, data.length);
180
- if (this.shouldDisplayDebugInformation)
181
- console.log(`[Tree ${_treeId}] Mapped data before sort`, mappedData);
182
207
  perfStart(`[${_treeId}] insertArray:sort`);
183
208
  if (!this.isSorted) {
184
209
  if (this.sortCallback)
@@ -186,8 +211,6 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
186
211
  else
187
212
  mappedData = this._defaultSort(this, mappedData);
188
213
  }
189
- if (this.shouldDisplayDebugInformation)
190
- console.log(`[Tree ${_treeId}] Mapped data after sort`, mappedData);
191
214
  const sortTime = perfEnd(`[${_treeId}] insertArray:sort`, data.length);
192
215
  perfStart(`[${_treeId}] insertArray:insert`);
193
216
  const failedNodes = [];
@@ -228,9 +251,6 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
228
251
  // We've processed all nodes up to expandLevel - render now!
229
252
  hasRenderedExpandLevel = true;
230
253
  this._emitTreeChanged();
231
- if (this.shouldDisplayDebugInformation) {
232
- console.log(`[Tree ${_treeId}] Progressive render: Displayed levels 1-${_expandLevel} (${successfulCount} nodes processed so far)`);
233
- }
234
254
  }
235
255
  }
236
256
  });
@@ -252,20 +272,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
252
272
  }
253
273
  // Final render (only if we haven't already rendered progressively)
254
274
  if (!noEmitChanges) {
255
- if (hasRenderedExpandLevel) {
256
- // We already rendered expandLevel, now render the complete tree
257
- this._emitTreeChanged();
258
- if (this.shouldDisplayDebugInformation) {
259
- console.log(`[Tree ${_treeId}] Final render: Complete tree with all ${successfulCount} nodes`);
260
- }
261
- }
262
- else {
263
- // No progressive rendering occurred, render everything at once
264
- this._emitTreeChanged();
265
- if (this.shouldDisplayDebugInformation) {
266
- console.log(`[Tree ${_treeId}] Single render: All ${successfulCount} nodes (no expandLevel or progressive render conditions met)`);
267
- }
268
- }
275
+ this._emitTreeChanged();
269
276
  }
270
277
  const insertTime = perfEnd(`[${_treeId}] insertArray:insert`, data.length);
271
278
  // Log performance summary
@@ -304,11 +311,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
304
311
  return null;
305
312
  },
306
313
  filterNodes(_searchText, _searchOptions) {
307
- if (this.shouldDisplayDebugInformation)
308
- console.log(`[Tree ${_treeId}] Filtering nodes by:`, _searchText);
309
314
  if (isEmptyString(_searchText)) {
310
- if (this.shouldDisplayDebugInformation)
311
- console.log(`[Tree ${_treeId}] Search text is empty, cleaning filtered tree and setting isFiltered = false`);
312
315
  // Clear filter when search is empty
313
316
  filteredRoot.children = {};
314
317
  this.isFiltered = false;
@@ -316,33 +319,23 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
316
319
  return;
317
320
  }
318
321
  if (!_shouldUseInternalSearchIndex) {
319
- if (this.shouldDisplayDebugInformation)
320
- console.warn(`[Tree ${_treeId}] Internal search index is disabled`);
321
322
  return;
322
323
  }
323
324
  perfStart(`[${_treeId}] filterNodes:search`);
324
325
  const resultIndices = searchIndex.search(_searchText, _searchOptions);
325
326
  const foundPaths = resultIndices.map((row) => flatTreeNodes[row].path);
326
327
  perfEnd(`[${_treeId}] filterNodes:search`, resultIndices.length);
327
- if (this.shouldDisplayDebugInformation)
328
- console.warn(`[Tree ${_treeId}] Found indices:`, resultIndices, foundPaths);
329
328
  this.createFilteredTree(foundPaths);
330
329
  },
331
330
  searchNodes(_searchText, _searchOptions) {
332
- if (this.shouldDisplayDebugInformation)
333
- console.log(`[Tree ${_treeId}] Searching nodes by:`, _searchText);
334
331
  if (isEmptyString(_searchText)) {
335
332
  return [];
336
333
  }
337
334
  if (!_shouldUseInternalSearchIndex) {
338
- if (this.shouldDisplayDebugInformation)
339
- console.warn(`[Tree ${_treeId}] Internal search index is disabled`);
340
335
  return [];
341
336
  }
342
337
  const resultIndices = searchIndex.search(_searchText, _searchOptions);
343
338
  const foundNodes = resultIndices.map((row) => flatTreeNodes[row]);
344
- if (this.shouldDisplayDebugInformation)
345
- console.warn(`[Tree ${_treeId}] Found indices:`, resultIndices, foundNodes);
346
339
  return foundNodes;
347
340
  },
348
341
  createFilteredTree(targetPaths) {
@@ -358,8 +351,6 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
358
351
  allRequiredPaths.add(segments.slice(0, i).join(this.treePathSeparator));
359
352
  }
360
353
  });
361
- if (this.shouldDisplayDebugInformation)
362
- console.log(`[Tree ${_treeId}] allRequiredPaths`, Array.from(allRequiredPaths));
363
354
  // 2. Build filtered tree with only required paths
364
355
  const pathToNode = new Map();
365
356
  // First pass: create copies of all required nodes
@@ -411,8 +402,6 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
411
402
  this.isFiltered = true;
412
403
  this._emitTreeChanged();
413
404
  perfEnd(`[${_treeId}] createFilteredTree`, rootNodes.length);
414
- if (this.shouldDisplayDebugInformation)
415
- console.log(`[Tree ${_treeId}] Created filtered tree with`, rootNodes.length, 'root nodes');
416
405
  },
417
406
  clearFilter() {
418
407
  filteredRoot.children = {};
@@ -424,7 +413,10 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
424
413
  perfStart(`[${_treeId}] expandAll`);
425
414
  if (isEmptyString(nodePath))
426
415
  flatTreeNodes.forEach((row) => {
427
- row.isExpanded = true;
416
+ if (!row.isExpanded) {
417
+ row.isExpanded = true;
418
+ _bumpRev(row);
419
+ }
428
420
  });
429
421
  this._emitTreeChanged();
430
422
  perfEnd(`[${_treeId}] expandAll`, flatTreeNodes.length);
@@ -433,7 +425,10 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
433
425
  perfStart(`[${_treeId}] collapseAll`);
434
426
  if (isEmptyString(nodePath))
435
427
  flatTreeNodes.forEach((row) => {
436
- row.isExpanded = false;
428
+ if (row.isExpanded) {
429
+ row.isExpanded = false;
430
+ _bumpRev(row);
431
+ }
437
432
  });
438
433
  this._emitTreeChanged();
439
434
  perfEnd(`[${_treeId}] collapseAll`, flatTreeNodes.length);
@@ -470,13 +465,13 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
470
465
  // Only mark as changed if actually changing from collapsed to expanded
471
466
  if (!node.isExpanded) {
472
467
  node.isExpanded = true;
468
+ _bumpRev(node);
473
469
  hasChanges = true;
474
470
  }
475
471
  }
476
472
  }
477
473
  // Only emit changes if something actually changed
478
474
  if (!noEmitChanges && hasChanges) {
479
- console.log(`[Tree ${_treeId}] expandNodes triggering re-render for path: ${path}`);
480
475
  this._emitTreeChanged();
481
476
  }
482
477
  perfEnd(`[${_treeId}] expandNodes`);
@@ -493,6 +488,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
493
488
  // Only mark as changed if actually changing from expanded to collapsed
494
489
  if (node.isExpanded) {
495
490
  node.isExpanded = false;
491
+ _bumpRev(node);
496
492
  hasChanges = true;
497
493
  }
498
494
  }
@@ -532,9 +528,25 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
532
528
  return this.getSearchValueCallback(node);
533
529
  return '[N/A]';
534
530
  },
531
+ getNodeAllowedDropPositions(node) {
532
+ // Priority: callback > member > node property
533
+ if (this.getAllowedDropPositionsCallback) {
534
+ return this.getAllowedDropPositionsCallback(node);
535
+ }
536
+ if (!shouldCalculateAllowedDropPositions && node.data) {
537
+ return node.data[_allowedDropPositionsMember];
538
+ }
539
+ return node.allowedDropPositions;
540
+ },
535
541
  refresh() {
536
542
  this._emitTreeChanged();
537
543
  },
544
+ getNodeSignal(id) {
545
+ return nodeSignals.get(id);
546
+ },
547
+ bumpNodeRev(node) {
548
+ _bumpNodeRev(node);
549
+ },
538
550
  /**
539
551
  * Get direct children of a node at the given path
540
552
  * @param parentPath - Path to parent node (empty string for root)
@@ -599,9 +611,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
599
611
  newChildren[segment] = child;
600
612
  });
601
613
  parent.children = newChildren;
602
- if (this.shouldDisplayDebugInformation) {
603
- console.log(`[Tree ${_treeId}] refreshSiblings: Re-sorted ${sorted.length} children under "${parentPath || 'root'}":`, sorted.map(s => ({ path: s.path, sortOrder: s.data?.[this.orderMember] })));
604
- }
614
+ _bumpRev(parent);
605
615
  this._emitTreeChanged();
606
616
  },
607
617
  /**
@@ -610,9 +620,10 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
610
620
  * @param path - Path to the node to refresh
611
621
  */
612
622
  refreshNode(path) {
613
- // For now, just trigger a tree change
614
- // Future optimization: only re-render the specific subtree
615
- this._emitTreeChanged();
623
+ const node = this.getNodeByPath(path);
624
+ if (node) {
625
+ _bumpNodeRev(node);
626
+ }
616
627
  },
617
628
  /**
618
629
  * Move a node to a new location in the tree
@@ -645,6 +656,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
645
656
  // Remove source from current parent
646
657
  const sourceSegment = segmentPrefix + sourceNode.pathSegment;
647
658
  delete sourceParent.children[sourceSegment];
659
+ _bumpRev(sourceParent);
648
660
  // Update source parent's hasChildren
649
661
  if (Object.keys(sourceParent.children).length === 0) {
650
662
  sourceParent.hasChildren = false;
@@ -691,6 +703,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
691
703
  }
692
704
  // Update all descendants' paths recursively
693
705
  this._updateDescendantPaths(sourceNode, oldPath, newPath);
706
+ _bumpRev(sourceNode);
694
707
  // Insert into new parent
695
708
  newParent.children[segmentPrefix + newSegment] = sourceNode;
696
709
  newParent.hasChildren = true;
@@ -706,7 +719,9 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
706
719
  .filter(o => o < targetOrder)
707
720
  .sort((a, b) => b - a);
708
721
  const belowOrder = siblingOrders[0] ?? targetOrder - 20;
709
- sourceNode.data[this.orderMember] = Math.floor((belowOrder + targetOrder) / 2);
722
+ const newOrder = Math.floor((belowOrder + targetOrder) / 2);
723
+ console.log(`[moveNode] position=above, targetOrder=${targetOrder}, belowOrder=${belowOrder}, newOrder=${newOrder}, siblingOrders=`, siblings.map(s => ({ path: s.path, order: s.data?.[this.orderMember] })));
724
+ sourceNode.data[this.orderMember] = newOrder;
710
725
  }
711
726
  else {
712
727
  // Find order value just above target
@@ -716,11 +731,24 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
716
731
  .filter(o => o > targetOrder)
717
732
  .sort((a, b) => a - b);
718
733
  const aboveOrder = siblingOrders[0] ?? targetOrder + 20;
719
- sourceNode.data[this.orderMember] = Math.floor((targetOrder + aboveOrder) / 2);
734
+ const newOrder = Math.floor((targetOrder + aboveOrder) / 2);
735
+ console.log(`[moveNode] position=below, targetOrder=${targetOrder}, aboveOrder=${aboveOrder}, newOrder=${newOrder}, siblingOrders=`, siblings.map(s => ({ path: s.path, order: s.data?.[this.orderMember] })));
736
+ sourceNode.data[this.orderMember] = newOrder;
720
737
  }
721
738
  }
739
+ else if (position !== 'child') {
740
+ console.log(`[moveNode] position=${position}, but orderMember=${this.orderMember}, sourceNode.data=${!!sourceNode.data} — order NOT calculated`);
741
+ }
722
742
  // Re-sort siblings if needed
723
743
  this.refreshSiblings(newParentPath);
744
+ // Log final sibling order after refresh
745
+ {
746
+ const parent = newParentPath ? this.getNodeByPath(newParentPath) : root;
747
+ if (parent) {
748
+ const finalOrder = Object.values(parent.children).map((s) => ({ path: s.path, order: s.data?.[this.orderMember] }));
749
+ console.log(`[moveNode] Final sibling order after refreshSiblings:`, finalOrder);
750
+ }
751
+ }
724
752
  return { success: true };
725
753
  },
726
754
  /**
@@ -764,6 +792,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
764
792
  // Remove from parent
765
793
  const segment = segmentPrefix + node.pathSegment;
766
794
  delete parent.children[segment];
795
+ _bumpRev(parent);
767
796
  // Update parent's hasChildren
768
797
  if (Object.keys(parent.children).length === 0) {
769
798
  parent.hasChildren = false;
@@ -824,6 +853,10 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
824
853
  if (_pathMember && data) {
825
854
  data[_pathMember] = newPath;
826
855
  }
856
+ // Create per-node signal for O(1) reactive updates
857
+ if (newNode.id !== undefined) {
858
+ nodeSignals.set(String(newNode.id), new NodeSignal());
859
+ }
827
860
  // Add to parent
828
861
  const targetParent = parent || root;
829
862
  targetParent.children[segmentPrefix + pathSegment] = newNode;
@@ -835,10 +868,6 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
835
868
  flatTreeNodes.push(newNode);
836
869
  // Re-sort siblings to place new node in correct position
837
870
  this.refreshSiblings(parentPath);
838
- if (this.shouldDisplayDebugInformation) {
839
- const siblings = Object.values(targetParent.children);
840
- console.log(`[Tree ${_treeId}] addNode: Added "${newPath}" to "${parentPath || 'root'}". Siblings after sort:`, siblings.map(s => ({ path: s.path, sortOrder: s.data?.[this.orderMember] })));
841
- }
842
871
  this._emitTreeChanged();
843
872
  return { success: true, node: newNode };
844
873
  },
@@ -858,9 +887,6 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
858
887
  }
859
888
  // Check if orderMember is being updated (will need re-sort)
860
889
  const orderMemberUpdated = this.orderMember && this.orderMember in dataUpdates;
861
- if (this.shouldDisplayDebugInformation) {
862
- console.log(`[Tree ${_treeId}] updateNode: "${path}" updating:`, dataUpdates);
863
- }
864
890
  // Merge updates into existing data
865
891
  node.data = { ...node.data, ...dataUpdates };
866
892
  // Re-index for search if needed
@@ -870,11 +896,14 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
870
896
  indexer.addItem({ node, index: flatIndex });
871
897
  }
872
898
  }
873
- // Re-sort siblings if order was updated
899
+ // Re-sort siblings if order was updated (structural change)
874
900
  if (orderMemberUpdated) {
901
+ _bumpRev(node);
875
902
  this.refreshSiblings(node.parentPath || '');
903
+ return { success: true, node };
876
904
  }
877
- this._emitTreeChanged();
905
+ // Data-only change — signal only, skip _emitTreeChanged for O(1) update
906
+ _bumpNodeRev(node);
878
907
  return { success: true, node };
879
908
  },
880
909
  /**
@@ -986,6 +1015,7 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
986
1015
  // Handle positioning relative to sibling if specified
987
1016
  if (siblingPath && position && rootNode.data) {
988
1017
  const siblingNode = this.getNodeByPath(siblingPath);
1018
+ console.log(`[copyNodeWithDescendants] Positioning: siblingPath=${siblingPath}, position=${position}, siblingFound=${!!siblingNode}, orderMember=${this.orderMember}`);
989
1019
  if (siblingNode && this.orderMember) {
990
1020
  // Get the parent to access siblings
991
1021
  const parent = targetParentPath ? this.getNodeByPath(targetParentPath) : root;
@@ -1000,7 +1030,9 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
1000
1030
  .filter(o => o < siblingOrder)
1001
1031
  .sort((a, b) => b - a);
1002
1032
  const belowOrder = siblingOrders[0] ?? siblingOrder - 20;
1003
- rootNode.data[this.orderMember] = Math.floor((belowOrder + siblingOrder) / 2);
1033
+ const newOrder = Math.floor((belowOrder + siblingOrder) / 2);
1034
+ console.log(`[copyNodeWithDescendants] position=above, siblingOrder=${siblingOrder}, belowOrder=${belowOrder}, newOrder=${newOrder}`);
1035
+ rootNode.data[this.orderMember] = newOrder;
1004
1036
  }
1005
1037
  else {
1006
1038
  // Find order value just above sibling
@@ -1010,15 +1042,26 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
1010
1042
  .filter(o => o > siblingOrder)
1011
1043
  .sort((a, b) => a - b);
1012
1044
  const aboveOrder = siblingOrders[0] ?? siblingOrder + 20;
1013
- rootNode.data[this.orderMember] = Math.floor((siblingOrder + aboveOrder) / 2);
1045
+ const newOrder = Math.floor((siblingOrder + aboveOrder) / 2);
1046
+ console.log(`[copyNodeWithDescendants] position=below, siblingOrder=${siblingOrder}, aboveOrder=${aboveOrder}, newOrder=${newOrder}`);
1047
+ rootNode.data[this.orderMember] = newOrder;
1014
1048
  }
1015
1049
  // Re-sort siblings
1016
1050
  this.refreshSiblings(targetParentPath);
1051
+ // Log final order
1052
+ const finalOrder = Object.values(parent.children).map((s) => ({ path: s.path, order: s.data?.[this.orderMember] }));
1053
+ console.log(`[copyNodeWithDescendants] Final sibling order:`, finalOrder);
1017
1054
  }
1018
1055
  }
1056
+ else if (!siblingNode) {
1057
+ console.warn(`[copyNodeWithDescendants] Sibling not found at path: ${siblingPath}`);
1058
+ }
1059
+ else if (!this.orderMember) {
1060
+ console.warn(`[copyNodeWithDescendants] No orderMember set — cannot position`);
1061
+ }
1019
1062
  }
1020
- if (this.shouldDisplayDebugInformation) {
1021
- console.log(`[Tree ${_treeId}] copyNodeWithDescendants: Copied ${totalCount} nodes to "${targetParentPath}"${siblingPath ? ` ${position} "${siblingPath}"` : ''}`);
1063
+ else {
1064
+ console.log(`[copyNodeWithDescendants] No positioning: siblingPath=${siblingPath}, position=${position}, hasData=${!!rootNode.data}`);
1022
1065
  }
1023
1066
  return { success: true, rootNode, count: totalCount };
1024
1067
  },
@@ -1049,7 +1092,11 @@ export function createLTree(_idMember, _pathMember, _parentPathMember, _levelMem
1049
1092
  const pathSet = new Set(paths);
1050
1093
  const traverse = (node) => {
1051
1094
  if (node.path) {
1052
- node.isExpanded = pathSet.has(node.path);
1095
+ const shouldExpand = pathSet.has(node.path);
1096
+ if (node.isExpanded !== shouldExpand) {
1097
+ node.isExpanded = shouldExpand;
1098
+ _bumpRev(node);
1099
+ }
1053
1100
  }
1054
1101
  for (const child of Object.values(node.children)) {
1055
1102
  traverse(child);
@@ -1,8 +1,7 @@
1
1
  import type { SearchOptions } from 'flexsearch';
2
- import type { LTreeNode } from './ltree-node.svelte';
3
- export type { LTreeNode } from './ltree-node.svelte';
2
+ import type { LTreeNode, DropPosition } from './ltree-node.svelte';
3
+ export type { LTreeNode, DropPosition } from './ltree-node.svelte';
4
4
  export type Tuple<T, U> = [T, U];
5
- export type DropPosition = 'above' | 'below' | 'child';
6
5
  export type DragDropMode = 'none' | 'self' | 'cross' | 'both';
7
6
  export type DropZoneLayout = 'around' | 'above' | 'below' | 'wave' | 'wave2';
8
7
  export type DropOperation = 'move' | 'copy';
@@ -69,7 +68,10 @@ export interface Ltree<T> {
69
68
  isSelectableMember: string | null | undefined;
70
69
  isDraggableMember: string | null | undefined;
71
70
  isDropAllowedMember: string | null | undefined;
71
+ allowedDropPositionsMember: string | null | undefined;
72
+ getAllowedDropPositionsCallback?: (node: LTreeNode<T>) => DropPosition[] | null | undefined;
72
73
  shouldDisplayDebugInformation: boolean | null | undefined;
74
+ getNodeAllowedDropPositions(node: LTreeNode<T>): DropPosition[] | null | undefined;
73
75
  get tree(): LTreeNode<T>[];
74
76
  /** Flat array of all visible nodes in render order (depth-first, respects isExpanded) */
75
77
  get visibleFlatNodes(): LTreeNode<T>[];
@@ -98,6 +100,10 @@ export interface Ltree<T> {
98
100
  getSiblings(path: string): LTreeNode<T>[];
99
101
  refreshSiblings(parentPath: string): void;
100
102
  refreshNode(path: string): void;
103
+ bumpNodeRev(node: LTreeNode<T>): void;
104
+ getNodeSignal(id: string): {
105
+ readonly value: number;
106
+ } | undefined;
101
107
  moveNode(sourcePath: string, targetPath: string, position: 'above' | 'below' | 'child'): {
102
108
  success: boolean;
103
109
  error?: string;
@@ -74,6 +74,36 @@ $light-color: #f8f9fa !default;
74
74
  $border-color: #dee2e6 !default;
75
75
  $body-color: #212529 !default;
76
76
 
77
+ // Mixin: expand horizontal zone layout when zones are hidden (used by "above" and "below" layouts)
78
+ @mixin zone-horizontal-expansion {
79
+ // Two zones: first present zone gets left half
80
+ &:not(:has(.ltree-drop-child)) .ltree-drop-above,
81
+ &:not(:has(.ltree-drop-below)) .ltree-drop-above,
82
+ &:not(:has(.ltree-drop-above)) .ltree-drop-below {
83
+ left: var(--drop-zone-start, 33%);
84
+ right: calc((100% - var(--drop-zone-start, 33%)) / 2);
85
+ border-radius: $drop-zone-border-radius 0 0 $drop-zone-border-radius;
86
+ }
87
+
88
+ // Two zones: second present zone gets right half
89
+ &:not(:has(.ltree-drop-child)) .ltree-drop-below,
90
+ &:not(:has(.ltree-drop-below)) .ltree-drop-child,
91
+ &:not(:has(.ltree-drop-above)) .ltree-drop-child {
92
+ left: calc(var(--drop-zone-start, 33%) + (100% - var(--drop-zone-start, 33%)) / 2);
93
+ right: 0;
94
+ border-radius: 0 $drop-zone-border-radius $drop-zone-border-radius 0;
95
+ }
96
+
97
+ // Single zone: full width
98
+ &:not(:has(.ltree-drop-below)):not(:has(.ltree-drop-child)) .ltree-drop-above,
99
+ &:not(:has(.ltree-drop-above)):not(:has(.ltree-drop-child)) .ltree-drop-below,
100
+ &:not(:has(.ltree-drop-above)):not(:has(.ltree-drop-below)) .ltree-drop-child {
101
+ left: var(--drop-zone-start, 33%);
102
+ right: 0;
103
+ border-radius: $drop-zone-border-radius;
104
+ }
105
+ }
106
+
77
107
  :root {
78
108
  --tree-node-indent-per-level: #{$tree-node-indent-per-level};
79
109
  --ltree-primary: #{$primary-color};
@@ -103,8 +133,15 @@ $body-color: #212529 !default;
103
133
  }
104
134
  }
105
135
 
136
+ // Virtual scroll container
137
+ .ltree-virtual-scroll {
138
+ overflow-y: auto !important;
139
+ overflow-x: visible;
140
+ overscroll-behavior: contain;
141
+ }
142
+
106
143
  // Ensure all tree ancestors allow overflow for drop zones
107
- .ltree-tree,
144
+ .ltree-tree:not(.ltree-virtual-scroll),
108
145
  .ltree-node,
109
146
  .ltree-node-row,
110
147
  .ltree-children {
@@ -579,12 +616,8 @@ $body-color: #212529 !default;
579
616
  }
580
617
 
581
618
  // Drop Zone Container - wraps all zones with CSS variable for start position
619
+ // Position is set inline (fixed) by Tree.svelte for the floating overlay
582
620
  .ltree-drop-zones {
583
- position: absolute;
584
- top: 0;
585
- left: 0;
586
- right: 0;
587
- bottom: 0;
588
621
  pointer-events: none; // Let children handle pointer events
589
622
  overflow: visible;
590
623
  }
@@ -668,6 +701,14 @@ $body-color: #212529 !default;
668
701
  transform: translateY(100%);
669
702
  // border-radius: 0 0 4px 0;
670
703
  }
704
+
705
+ // Zone expansion: when a zone is hidden, remaining zones fill the gap
706
+ &:not(:has(.ltree-drop-child)) .ltree-drop-below {
707
+ right: 0;
708
+ }
709
+ &:not(:has(.ltree-drop-below)) .ltree-drop-child {
710
+ left: var(--drop-zone-start, 33%);
711
+ }
671
712
  }
672
713
 
673
714
  // Layout: "above" - All 3 zones in a row above the node
@@ -696,6 +737,9 @@ $body-color: #212529 !default;
696
737
  right: 0;
697
738
  border-radius: 0 $drop-zone-border-radius $drop-zone-border-radius 0;
698
739
  }
740
+
741
+ // Zone expansion: when a zone is hidden, remaining zones fill the gap
742
+ @include zone-horizontal-expansion;
699
743
  }
700
744
 
701
745
  // Layout: "below" - All 3 zones in a row below the node
@@ -724,6 +768,9 @@ $body-color: #212529 !default;
724
768
  right: 0;
725
769
  border-radius: 0 $drop-zone-border-radius $drop-zone-border-radius 0;
726
770
  }
771
+
772
+ // Zone expansion: when a zone is hidden, remaining zones fill the gap
773
+ @include zone-horizontal-expansion;
727
774
  }
728
775
 
729
776
  // Layout: "wave" - Zones stacked vertically (above/child/below)