@keenmate/svelte-treeview 4.7.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.
@@ -4,7 +4,7 @@
4
4
  import { type LTreeNode } from '../ltree/ltree-node.svelte.js';
5
5
  import { createLTree } from '../ltree/ltree.svelte.js';
6
6
  import { type Ltree, type InsertArrayResult, type ContextMenuItem, type DropPosition, type DragDropMode, type DropOperation } from '../ltree/types.js';
7
- import { setContext, tick } from 'svelte';
7
+ import { setContext, tick, untrack } from 'svelte';
8
8
  import { createRenderCoordinator, type RenderCoordinator, type RenderStats } from './RenderCoordinator.svelte.js';
9
9
  import { uiLogger, dragLogger } from '../logger.js';
10
10
  import { perfStart, perfEnd } from '../perf-logger.js';
@@ -30,6 +30,7 @@
30
30
  leafIconClass: string;
31
31
  selectedNodeClass: string | null | undefined;
32
32
  dragOverNodeClass: string | null | undefined;
33
+ dragDropMode: DragDropMode;
33
34
  dropZoneMode: 'floating' | 'glow';
34
35
  dropZoneLayout: 'around' | 'above' | 'below' | 'wave' | 'wave2';
35
36
  dropZoneStart: number | string;
@@ -67,11 +68,17 @@
67
68
 
68
69
  // Progressive rendering for flat mode
69
70
  // Track which node IDs we've rendered and progressively add new ones
70
- let flatRenderedIds = $state<Set<string>>(new Set());
71
- let flatRenderQueue = $state<string[]>([]);
71
+ let flatRenderedIds = $state.raw<Set<string>>(new Set());
72
+ let flatRenderQueue = $state.raw<string[]>([]);
72
73
  let flatRenderAnimationFrame: number | null = null;
73
74
  let currentBatchSize: number = 0; // Exponential: doubles each batch up to maxBatchSize
74
75
 
76
+ // Virtual scrolling state
77
+ let vsScrollTop = $state(0);
78
+ let vsMeasuredRowHeight = $state<number | null>(null);
79
+ let vsContainerRef = $state<HTMLDivElement | undefined>();
80
+ let vsDetectedHeight = $state<string | null>(null);
81
+
75
82
  // Drop placeholder state for empty trees
76
83
  let isDropPlaceholderActive = $state(false);
77
84
 
@@ -81,6 +88,10 @@
81
88
  let activeDropPosition = $state<DropPosition | null>(null);
82
89
  let currentDropOperation = $state<DropOperation>('move');
83
90
 
91
+ // Floating drop zone overlay state (position: fixed to escape overflow clipping)
92
+ let floatingZoneRect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
93
+ let floatingHoveredZone = $state<'above' | 'below' | 'child' | null>(null);
94
+
84
95
  // Flag to skip insertArray during internal mutations (addNode, moveNode, removeNode)
85
96
  let _skipInsertArray = false;
86
97
 
@@ -151,13 +162,19 @@
151
162
  /**
152
163
  * Use flat/centralized rendering instead of recursive node rendering.
153
164
  * This significantly improves performance for large trees by:
154
- * - Removing the {#key changeTracker} block that destroys all nodes on any change
155
165
  * - Using a single flat loop instead of recursive component instantiation
156
166
  * - Allowing Svelte's keyed {#each} to efficiently diff only changed nodes
167
+ * - Per-node reactive signals for O(1) data-only updates (updateNode, selection)
157
168
  */
158
169
  useFlatRendering?: boolean;
159
- /** Indentation per level in flat rendering mode (CSS value, default: '1.5rem') */
160
- flatIndentSize?: string;
170
+ /** Enable virtual scrolling in flat mode. Only visible nodes + overscan are rendered. */
171
+ virtualScroll?: boolean;
172
+ /** Explicit row height in px. Auto-measured from first row if not set. */
173
+ virtualRowHeight?: number;
174
+ /** Extra rows above/below viewport (default: 5) */
175
+ virtualOverscan?: number;
176
+ /** CSS height for scroll container. Auto-detected from parent if not set, fallback 400px. */
177
+ virtualContainerHeight?: string;
161
178
 
162
179
  // DRAG AND DROP
163
180
  dragDropMode?: DragDropMode;
@@ -259,10 +276,15 @@
259
276
 
260
277
  // Flat rendering mode
261
278
  useFlatRendering = true,
262
- flatIndentSize = '1.5rem',
279
+
280
+ // Virtual scrolling (flat mode only)
281
+ virtualScroll = false,
282
+ virtualRowHeight = undefined,
283
+ virtualOverscan = 5,
284
+ virtualContainerHeight = undefined,
263
285
 
264
286
  // DRAG AND DROP
265
- dragDropMode = 'both',
287
+ dragDropMode = 'none',
266
288
  dropZoneMode = 'glow',
267
289
  dropZoneLayout = 'around',
268
290
  dropZoneStart = 33,
@@ -382,10 +404,12 @@
382
404
  export function copyNodeWithDescendants(
383
405
  sourceNode: LTreeNode<T>,
384
406
  targetParentPath: string,
385
- transformData: (data: T) => T
407
+ transformData: (data: T) => T,
408
+ siblingPath?: string,
409
+ position?: 'above' | 'below'
386
410
  ): { success: boolean; rootNode?: LTreeNode<T>; count: number; error?: string } {
387
411
  _skipInsertArray = true;
388
- const result = tree?.copyNodeWithDescendants(sourceNode, targetParentPath, transformData) || { success: false, count: 0, error: 'Tree not initialized' };
412
+ const result = tree?.copyNodeWithDescendants(sourceNode, targetParentPath, transformData, siblingPath, position) || { success: false, count: 0, error: 'Tree not initialized' };
389
413
  tick().then(() => { _skipInsertArray = false; });
390
414
  return result;
391
415
  }
@@ -455,6 +479,65 @@
455
479
  await tick();
456
480
  }
457
481
 
482
+ // Helper: find DOM element and apply highlight
483
+ const applyHighlight = (elementId: string): boolean => {
484
+ const element = document.getElementById(elementId);
485
+ const contentDiv = element?.querySelector('.ltree-node-content') as HTMLElement | null;
486
+ if (!contentDiv) return false;
487
+
488
+ if (currentHighlight) {
489
+ currentHighlight.element.classList.remove(scrollHighlightClass!);
490
+ clearTimeout(currentHighlight.timeoutId);
491
+ currentHighlight = null;
492
+ }
493
+ contentDiv.classList.add(scrollHighlightClass!);
494
+ const timeoutId = setTimeout(() => {
495
+ contentDiv.classList.remove(scrollHighlightClass!);
496
+ currentHighlight = null;
497
+ }, scrollHighlightTimeout);
498
+ currentHighlight = { element: contentDiv, timeoutId };
499
+ return true;
500
+ };
501
+
502
+ // Virtual scroll: index-based scrolling instead of DOM query
503
+ if (vsActive && vsContainerRef) {
504
+ const nodeIndex = allFlatNodes.findIndex(n => n.path === path);
505
+ if (nodeIndex === -1) {
506
+ console.warn(`[Tree ${treeId}] Node not found in flat nodes for path: ${path}`);
507
+ perfEnd(`[${treeId}] scrollToPath`);
508
+ return false;
509
+ }
510
+
511
+ // Scroll virtual container to center the node
512
+ const targetScroll = nodeIndex * vsRowHeight
513
+ - (vsContainerRef.clientHeight / 2)
514
+ + vsRowHeight / 2;
515
+ vsContainerRef.scrollTo({
516
+ top: Math.max(0, targetScroll),
517
+ behavior: scrollOptions?.behavior || 'smooth'
518
+ });
519
+
520
+ // Wait for scroll + re-render — need multiple frames for
521
+ // rAF-throttled scroll handler → reactive update → DOM render
522
+ await tick();
523
+ await new Promise(r => requestAnimationFrame(r));
524
+ await tick();
525
+ await new Promise(r => requestAnimationFrame(r));
526
+
527
+ if (highlight && scrollHighlightClass) {
528
+ const elementId = `${treeId}-${node.id}`;
529
+ if (!applyHighlight(elementId)) {
530
+ // Element might not be rendered yet — retry after another frame
531
+ await tick();
532
+ await new Promise(r => requestAnimationFrame(r));
533
+ applyHighlight(elementId);
534
+ }
535
+ }
536
+
537
+ perfEnd(`[${treeId}] scrollToPath`);
538
+ return true;
539
+ }
540
+
458
541
  // Find the DOM element using the generated ID
459
542
  const elementId = `${treeId}-${node.id}`;
460
543
  const element = document.getElementById(elementId);
@@ -485,20 +568,7 @@
485
568
 
486
569
  // Highlight the node temporarily if requested
487
570
  if (highlight && scrollHighlightClass) {
488
- // Clear previous highlight immediately (for rapid next/prev navigation)
489
- if (currentHighlight) {
490
- currentHighlight.element.classList.remove(scrollHighlightClass);
491
- clearTimeout(currentHighlight.timeoutId);
492
- currentHighlight = null;
493
- }
494
-
495
- contentDiv.classList.add(scrollHighlightClass);
496
- const timeoutId = setTimeout(() => {
497
- contentDiv.classList.remove(scrollHighlightClass);
498
- currentHighlight = null;
499
- }, scrollHighlightTimeout);
500
-
501
- currentHighlight = { element: contentDiv, timeoutId };
571
+ applyHighlight(elementId);
502
572
  }
503
573
 
504
574
  perfEnd(`[${treeId}] scrollToPath`);
@@ -571,6 +641,10 @@
571
641
  | "scrollHighlightClass"
572
642
  | "contextMenuXOffset"
573
643
  | "contextMenuYOffset"
644
+ | "virtualScroll"
645
+ | "virtualRowHeight"
646
+ | "virtualOverscan"
647
+ | "virtualContainerHeight"
574
648
  >
575
649
  >
576
650
  ) {
@@ -621,12 +695,21 @@
621
695
  if (updates.scrollHighlightClass !== undefined) scrollHighlightClass = updates.scrollHighlightClass;
622
696
  if (updates.contextMenuXOffset !== undefined) contextMenuXOffset = updates.contextMenuXOffset;
623
697
  if (updates.contextMenuYOffset !== undefined) contextMenuYOffset = updates.contextMenuYOffset;
698
+ if (updates.virtualScroll !== undefined) virtualScroll = updates.virtualScroll;
699
+ if (updates.virtualRowHeight !== undefined) virtualRowHeight = updates.virtualRowHeight;
700
+ if (updates.virtualOverscan !== undefined) virtualOverscan = updates.virtualOverscan;
701
+ if (updates.virtualContainerHeight !== undefined) virtualContainerHeight = updates.virtualContainerHeight;
624
702
  }
625
703
 
626
- treeId = treeId || generateTreeId();
704
+ // Stable auto-generated treeId survives parent re-renders that reset prop to undefined
705
+ const _autoTreeId = generateTreeId();
706
+ treeId = treeId || _autoTreeId;
707
+
708
+ // Keep treeId stable when parent re-renders without passing treeId prop
709
+ $effect.pre(() => {
710
+ if (!treeId) treeId = _autoTreeId;
711
+ });
627
712
 
628
- if (shouldDisplayDebugInformation)
629
- console.log("Tree treePathSeparator:", treePathSeparator)
630
713
 
631
714
  // svelte-ignore non_reactive_update
632
715
  const tree: Ltree<T> = createLTree<T>(
@@ -709,15 +792,18 @@
709
792
  setContext('RenderCoordinator', renderCoordinator);
710
793
  }
711
794
 
712
- // Create stable config object - updated when props change
713
- // Using $state.raw to avoid deep reactivity on the config object itself
714
- let nodeConfig = $state.raw<NodeConfig>({
795
+ // Create stable config object shared via context.
796
+ // Must use $state (not $state.raw) so property mutations are tracked reactively.
797
+ // We mutate individual properties in the $effect below so that Node components
798
+ // (which hold the same proxy reference via getContext) see the updates.
799
+ let nodeConfig = $state<NodeConfig>({
715
800
  shouldToggleOnNodeClick: shouldToggleOnNodeClick ?? true,
716
801
  expandIconClass: expandIconClass ?? 'ltree-icon-expand',
717
802
  collapseIconClass: collapseIconClass ?? 'ltree-icon-collapse',
718
803
  leafIconClass: leafIconClass ?? 'ltree-icon-leaf',
719
804
  selectedNodeClass,
720
805
  dragOverNodeClass,
806
+ dragDropMode: dragDropMode ?? 'none',
721
807
  dropZoneMode: dropZoneMode ?? 'glow',
722
808
  dropZoneLayout: dropZoneLayout ?? 'around',
723
809
  dropZoneStart: dropZoneStart ?? 33,
@@ -726,50 +812,34 @@
726
812
  });
727
813
  setContext('NodeConfig', nodeConfig);
728
814
 
729
- // Update config when props change (rarely happens, but supports dynamic updates)
815
+ // Mutate the shared config proxy when props change
730
816
  $effect(() => {
731
- nodeConfig = {
732
- shouldToggleOnNodeClick: shouldToggleOnNodeClick ?? true,
733
- expandIconClass: expandIconClass ?? 'ltree-icon-expand',
734
- collapseIconClass: collapseIconClass ?? 'ltree-icon-collapse',
735
- leafIconClass: leafIconClass ?? 'ltree-icon-leaf',
736
- selectedNodeClass,
737
- dragOverNodeClass,
738
- dropZoneMode: dropZoneMode ?? 'glow',
739
- dropZoneLayout: dropZoneLayout ?? 'around',
740
- dropZoneStart: dropZoneStart ?? 33,
741
- dropZoneMaxWidth: dropZoneMaxWidth ?? 120,
742
- allowCopy: allowCopy ?? false,
743
- };
817
+ nodeConfig.shouldToggleOnNodeClick = shouldToggleOnNodeClick ?? true;
818
+ nodeConfig.expandIconClass = expandIconClass ?? 'ltree-icon-expand';
819
+ nodeConfig.collapseIconClass = collapseIconClass ?? 'ltree-icon-collapse';
820
+ nodeConfig.leafIconClass = leafIconClass ?? 'ltree-icon-leaf';
821
+ nodeConfig.selectedNodeClass = selectedNodeClass;
822
+ nodeConfig.dragOverNodeClass = dragOverNodeClass;
823
+ nodeConfig.dragDropMode = dragDropMode ?? 'none';
824
+ nodeConfig.dropZoneMode = dropZoneMode ?? 'glow';
825
+ nodeConfig.dropZoneLayout = dropZoneLayout ?? 'around';
826
+ nodeConfig.dropZoneStart = dropZoneStart ?? 33;
827
+ nodeConfig.dropZoneMaxWidth = dropZoneMaxWidth ?? 120;
828
+ nodeConfig.allowCopy = allowCopy ?? false;
744
829
  });
745
830
 
746
- $effect(() => {
747
- tree.filterNodes(searchText);
748
- });
831
+ // Format dropZoneStart for CSS variable - number = percentage, string = as-is
832
+ const formattedDropZoneStart = $derived(
833
+ typeof dropZoneStart === 'number' ? `${dropZoneStart}%` : dropZoneStart
834
+ );
749
835
 
750
- // Performance instrumentation for flat mode
751
- let flatRenderStart: number | null = null;
752
836
  $effect(() => {
753
- if (useFlatRendering && tree?.changeTracker) {
754
- // changeTracker changed - flat render is about to happen
755
- flatRenderStart = performance.now();
756
- console.log('[Flat Mode] changeTracker changed, render starting...');
757
-
758
- // Measure after Svelte processes the DOM update (single rAF = next paint)
759
- requestAnimationFrame(() => {
760
- if (flatRenderStart) {
761
- const elapsed = performance.now() - flatRenderStart;
762
- console.log(`[Flat Mode] DOM update complete: ${elapsed.toFixed(2)}ms`);
763
- flatRenderStart = null;
764
- }
765
- });
766
- }
837
+ tree.filterNodes(searchText);
767
838
  });
768
839
 
769
840
  $effect(() => {
770
841
  if (tree && data) {
771
- if (_skipInsertArray) {
772
- console.log('[Tree] Skipping insertArray due to internal mutation');
842
+ if (untrack(() => _skipInsertArray)) {
773
843
  _skipInsertArray = false; // Reset for next time
774
844
  return;
775
845
  }
@@ -779,7 +849,9 @@
779
849
  flatRenderedIds = new Set();
780
850
  flatRenderQueue = [];
781
851
  currentBatchSize = 0; // Reset exponential batch size
782
- console.log('[Tree] Running insertArray with', data.length, 'items');
852
+ // Reset virtual scroll measurements
853
+ vsMeasuredRowHeight = null;
854
+ vsDetectedHeight = null;
783
855
  insertResult = tree.insertArray(data);
784
856
  }
785
857
  });
@@ -837,12 +909,10 @@
837
909
 
838
910
  if (alreadyHasManyNodes && addingFewNodes) {
839
911
  // Add all at once - one diff is faster than multiple diffs on large arrays
840
- console.log(`[Flat Progressive] Adding ${newIds.length} nodes immediately (large tree optimization)`);
841
912
  flatRenderedIds = new Set([...flatRenderedIds, ...newIds]);
842
913
  } else {
843
914
  // Progressive batching for initial load (exponential: 20 → 40 → 80 → 160...)
844
915
  currentBatchSize = initialBatchSize; // Start with initial batch size
845
- console.log(`[Flat Progressive] Queueing ${newIds.length} new nodes for progressive render (exponential batching)`);
846
916
  const immediateBatch = newIds.slice(0, currentBatchSize);
847
917
  const remaining = newIds.slice(currentBatchSize);
848
918
 
@@ -880,21 +950,84 @@
880
950
  // Double batch size for next iteration (capped at maxBatchSize)
881
951
  currentBatchSize = Math.min(batchSize * 2, maxBatchSize);
882
952
 
883
- // console.log(`[Flat Progressive] Rendered batch of ${batch.length}, next batch: ${currentBatchSize}, ${remaining.length} remaining`);
884
-
885
953
  if (remaining.length > 0) {
886
954
  scheduleFlatRenderBatch();
887
955
  }
888
956
  });
889
957
  }
890
958
 
891
- // Derived: nodes to render in flat mode (filtered by progressive state)
892
- const flatNodesToRender = $derived(
959
+ // Derived: all flat nodes (with progressive render filter if active)
960
+ const allFlatNodes = $derived(
893
961
  useFlatRendering && progressiveRender
894
962
  ? tree?.visibleFlatNodes?.filter(n => flatRenderedIds.has(n.id)) ?? []
895
963
  : tree?.visibleFlatNodes ?? []
896
964
  );
897
965
 
966
+ // Virtual scrolling derived computations
967
+ const vsRowHeight = $derived(virtualRowHeight ?? vsMeasuredRowHeight ?? 32);
968
+ const vsActive = $derived(virtualScroll && useFlatRendering);
969
+ const vsContainerStyle = $derived(
970
+ virtualContainerHeight ?? vsDetectedHeight ?? '400px'
971
+ );
972
+ const vsTotalCount = $derived(allFlatNodes.length);
973
+ const vsTotalHeight = $derived(vsTotalCount * vsRowHeight);
974
+ const vsStartIndex = $derived(
975
+ vsActive
976
+ ? Math.max(0, Math.floor(vsScrollTop / vsRowHeight) - virtualOverscan)
977
+ : 0
978
+ );
979
+ const vsEndIndex = $derived(
980
+ vsActive
981
+ ? Math.min(vsTotalCount,
982
+ Math.ceil((vsScrollTop + (vsContainerRef?.clientHeight ?? 0)) / vsRowHeight) + virtualOverscan)
983
+ : vsTotalCount
984
+ );
985
+ const vsOffsetY = $derived(vsStartIndex * vsRowHeight);
986
+
987
+ // Final nodes to render — virtual window or all
988
+ const flatNodesToRender = $derived(
989
+ vsActive ? allFlatNodes.slice(vsStartIndex, vsEndIndex) : allFlatNodes
990
+ );
991
+
992
+ // Virtual scroll: rAF-throttled scroll handler
993
+ let vsRafPending = false;
994
+ function handleVirtualScroll(event: Event) {
995
+ if (vsRafPending) return;
996
+ vsRafPending = true;
997
+ requestAnimationFrame(() => {
998
+ vsScrollTop = (event.target as HTMLElement).scrollTop;
999
+ vsRafPending = false;
1000
+ });
1001
+ }
1002
+
1003
+ // Auto-measure row height from first rendered node
1004
+ $effect(() => {
1005
+ if (!vsActive || virtualRowHeight || vsMeasuredRowHeight) return;
1006
+ if (allFlatNodes.length === 0) return;
1007
+ tick().then(() => {
1008
+ if (vsContainerRef) {
1009
+ const firstNode = vsContainerRef.querySelector('.ltree-node');
1010
+ if (firstNode) {
1011
+ const height = firstNode.getBoundingClientRect().height;
1012
+ if (height > 0) vsMeasuredRowHeight = height;
1013
+ }
1014
+ }
1015
+ });
1016
+ });
1017
+
1018
+ // Auto-detect container height from parent element
1019
+ $effect(() => {
1020
+ if (!vsActive || virtualContainerHeight || vsDetectedHeight) return;
1021
+ tick().then(() => {
1022
+ if (vsContainerRef?.parentElement) {
1023
+ const parentHeight = vsContainerRef.parentElement.clientHeight;
1024
+ if (parentHeight > 100) {
1025
+ vsDetectedHeight = parentHeight + 'px';
1026
+ }
1027
+ }
1028
+ });
1029
+ });
1030
+
898
1031
  // $inspect("tree change tracker", tree?.changeTracker?.toString());
899
1032
 
900
1033
  function generateTreeId(): string {
@@ -912,10 +1045,12 @@
912
1045
  const previousNode = tree.getNodeByPath(selectedNode.path);
913
1046
  if (previousNode) {
914
1047
  previousNode.isSelected = false;
1048
+ tree.bumpNodeRev(previousNode);
915
1049
  } else selectedNode = null;
916
1050
  }
917
1051
 
918
1052
  node.isSelected = true;
1053
+ tree.bumpNodeRev(node);
919
1054
  selectedNode = node;
920
1055
 
921
1056
  uiLogger.debug(`Node selected: ${node.path}`, {
@@ -925,10 +1060,7 @@
925
1060
  });
926
1061
 
927
1062
  onNodeClicked?.(node);
928
-
929
- // if (!node.hasChildren) {
930
- tree.refresh();
931
- // }
1063
+ // NO tree.refresh() — fine-grained signals handle re-rendering
932
1064
  }
933
1065
 
934
1066
  function _onNodeRightClicked(node: LTreeNode<T>, event: MouseEvent) {
@@ -970,6 +1102,10 @@
970
1102
  }
971
1103
 
972
1104
  function _onNodeDragStart(node: LTreeNode<T>, event: DragEvent) {
1105
+ if (dragDropMode === 'none') {
1106
+ event.preventDefault();
1107
+ return;
1108
+ }
973
1109
  dragLogger.debug(`Drag started: ${node.path}`, {
974
1110
  ctrlKey: event.ctrlKey,
975
1111
  allowCopy,
@@ -991,6 +1127,8 @@
991
1127
  activeDropPosition = null;
992
1128
  isDropPlaceholderActive = false;
993
1129
  currentDropOperation = 'move';
1130
+ floatingZoneRect = null;
1131
+ floatingHoveredZone = null;
994
1132
  }
995
1133
 
996
1134
  /**
@@ -1039,9 +1177,6 @@
1039
1177
  const isSameTreeDrag = draggedNode.treeId === treeId;
1040
1178
  if (isSameTreeDrag && operation === 'move' && dropNode) {
1041
1179
  const result = moveNode(draggedNode.path, dropNode.path, position);
1042
- if (shouldDisplayDebugInformation) {
1043
- console.log('[Tree] Auto-moved node:', result);
1044
- }
1045
1180
  // Still call onNodeDrop for notification/logging
1046
1181
  onNodeDrop?.(dropNode, draggedNode, position, event, operation);
1047
1182
  return result.success;
@@ -1066,9 +1201,6 @@
1066
1201
  siblingPath,
1067
1202
  copyPosition
1068
1203
  );
1069
- if (shouldDisplayDebugInformation) {
1070
- console.log('[Tree] Auto-copied node:', result);
1071
- }
1072
1204
  // Still call onNodeDrop for notification/logging
1073
1205
  onNodeDrop?.(dropNode, draggedNode, position, event, operation);
1074
1206
  return result.success;
@@ -1105,9 +1237,6 @@
1105
1237
  : isDropAllowedByMode(effectiveDraggedNode?.treeId);
1106
1238
 
1107
1239
  if (!dropAllowed) {
1108
- if (shouldDisplayDebugInformation) {
1109
- console.log('[Tree] Drop not allowed:', { treeId, dragDropMode, isCrossTreeDrag, effectiveDraggedNodeTreeId: effectiveDraggedNode?.treeId });
1110
- }
1111
1240
  hoveredNodeForDrop = null; // Clear hover to prevent glow on invalid targets
1112
1241
  return;
1113
1242
  }
@@ -1129,12 +1258,17 @@
1129
1258
  activeDropPosition = calculateDropPosition(event, nodeElement);
1130
1259
  }
1131
1260
 
1261
+ // Capture node row rect for floating drop zone overlay
1262
+ if (dropZoneMode === 'floating') {
1263
+ const nodeRow = (event.target as Element).closest('.ltree-node-row');
1264
+ if (nodeRow) {
1265
+ const r = nodeRow.getBoundingClientRect();
1266
+ floatingZoneRect = { top: r.top, left: r.left, width: r.width, height: r.height };
1267
+ }
1268
+ }
1269
+
1132
1270
  // Update current operation based on Ctrl key
1133
- const prevOperation = currentDropOperation;
1134
1271
  currentDropOperation = (allowCopy && event.ctrlKey) ? 'copy' : 'move';
1135
- if (shouldDisplayDebugInformation && prevOperation !== currentDropOperation) {
1136
- console.log('[Tree] _onNodeDragOver - operation changed:', prevOperation, '->', currentDropOperation, 'ctrlKey:', event.ctrlKey, 'allowCopy:', allowCopy);
1137
- }
1138
1272
 
1139
1273
  onNodeDragOver?.(node, event);
1140
1274
 
@@ -1151,12 +1285,6 @@
1151
1285
  }
1152
1286
 
1153
1287
  function _onNodeDrop(node: LTreeNode<T>, event: DragEvent) {
1154
- if (shouldDisplayDebugInformation)
1155
- console.log(
1156
- '🚀 ~ _onNodeDrop ~ _onNodeDrop:',
1157
- _onNodeDrop,
1158
- event.dataTransfer?.getData('application/svelte-treeview')
1159
- );
1160
1288
  event.preventDefault();
1161
1289
 
1162
1290
  let isCrossTreeDrag = false;
@@ -1191,10 +1319,8 @@
1191
1319
 
1192
1320
  // Zone drop handler - receives explicit position from drop zone panels
1193
1321
  function _onZoneDrop(node: LTreeNode<T>, position: DropPosition, event: DragEvent) {
1194
- if (shouldDisplayDebugInformation)
1195
- console.log('🎯 ~ _onZoneDrop ~ position:', position, 'node:', node.path);
1196
-
1197
1322
  event.preventDefault();
1323
+ console.log(`[ZoneDrop] dropNode=${node.path}, position=${position}, treeId=${treeId}`);
1198
1324
 
1199
1325
  let isCrossTreeDrag = false;
1200
1326
  if (!draggedNode) {
@@ -1206,16 +1332,20 @@
1206
1332
  }
1207
1333
 
1208
1334
  if (!draggedNode) {
1335
+ console.warn(`[ZoneDrop] No draggedNode found, aborting`);
1209
1336
  _onNodeDragEnd(event);
1210
1337
  return;
1211
1338
  }
1212
1339
 
1340
+ console.log(`[ZoneDrop] draggedNode=${draggedNode.path} (treeId=${draggedNode.treeId}), isCrossTree=${isCrossTreeDrag}`);
1341
+
1213
1342
  // Check if drop is allowed by mode
1214
1343
  const dropAllowed = isCrossTreeDrag
1215
1344
  ? (dragDropMode === 'both' || dragDropMode === 'cross')
1216
1345
  : isDropAllowedByMode(draggedNode?.treeId);
1217
1346
 
1218
1347
  if (!dropAllowed) {
1348
+ console.warn(`[ZoneDrop] Drop not allowed by mode=${dragDropMode}`);
1219
1349
  _onNodeDragEnd(event);
1220
1350
  return;
1221
1351
  }
@@ -1223,14 +1353,53 @@
1223
1353
  // For cross-tree, always allow; for same-tree, check it's not the same node
1224
1354
  if (isCrossTreeDrag || draggedNode !== node) {
1225
1355
  _handleDrop(node, draggedNode, position, event);
1356
+ } else {
1357
+ console.warn(`[ZoneDrop] Same node — skipped`);
1226
1358
  }
1227
1359
 
1228
1360
  // Reset drag state
1229
1361
  _onNodeDragEnd(event);
1230
1362
  }
1231
1363
 
1364
+ // Floating drop zone overlay helpers
1365
+ function isFloatingPositionAllowed(position: DropPosition): boolean {
1366
+ if (!hoveredNodeForDrop) return false;
1367
+ const allowed = tree.getNodeAllowedDropPositions(hoveredNodeForDrop);
1368
+ if (!allowed || allowed.length === 0) return true;
1369
+ return allowed.includes(position);
1370
+ }
1371
+
1372
+ function handleFloatingZoneDragOver(position: DropPosition, event: DragEvent) {
1373
+ event.preventDefault();
1374
+ if (event.dataTransfer) event.dataTransfer.dropEffect = (allowCopy && event.ctrlKey) ? 'copy' : 'move';
1375
+ floatingHoveredZone = position;
1376
+ // Keep rect fresh (handles scroll while cursor on zone)
1377
+ if (hoveredNodeForDrop && treeContainerRef) {
1378
+ const nodeEl = treeContainerRef.querySelector(`#${treeId}-${hoveredNodeForDrop.id}`);
1379
+ const nodeRow = nodeEl?.querySelector('.ltree-node-row');
1380
+ if (nodeRow) {
1381
+ const r = nodeRow.getBoundingClientRect();
1382
+ floatingZoneRect = { top: r.top, left: r.left, width: r.width, height: r.height };
1383
+ }
1384
+ }
1385
+ if (hoveredNodeForDrop) _onNodeDragOver(hoveredNodeForDrop, event);
1386
+ }
1387
+
1388
+ function handleFloatingZoneDragLeave() {
1389
+ floatingHoveredZone = null;
1390
+ }
1391
+
1392
+ function handleFloatingZoneDrop(position: DropPosition, event: DragEvent) {
1393
+ event.stopPropagation();
1394
+ if (event.dataTransfer) event.dataTransfer.dropEffect = (allowCopy && event.ctrlKey) ? 'copy' : 'move';
1395
+ console.log(`[FloatingZoneDrop] position=${position}, hoveredNode=${hoveredNodeForDrop?.path ?? 'null'}`);
1396
+ floatingHoveredZone = null;
1397
+ if (hoveredNodeForDrop) _onZoneDrop(hoveredNodeForDrop, position, event);
1398
+ }
1399
+
1232
1400
  // Touch drag handlers for mobile support
1233
1401
  function _onTouchStart(node: LTreeNode<any>, event: TouchEvent) {
1402
+ if (dragDropMode === 'none') return;
1234
1403
  if (!node?.isDraggable) return;
1235
1404
 
1236
1405
  const touch = event.touches[0];
@@ -1399,16 +1568,15 @@
1399
1568
 
1400
1569
  // Empty tree drop handlers
1401
1570
  function handleEmptyTreeDragOver(event: DragEvent) {
1402
- console.log('[EmptyTree] dragover/dragenter fired', {
1403
- types: event.dataTransfer?.types,
1404
- hasTreeviewType: event.dataTransfer?.types.includes("application/svelte-treeview"),
1405
- isDropPlaceholderActive,
1406
- treeId
1407
- });
1571
+ if (dragDropMode === 'none') return;
1408
1572
  if (event.dataTransfer?.types.includes("application/svelte-treeview")) {
1573
+ // Check mode: for cross-tree drags, only allow if mode permits
1574
+ const isCrossTree = !draggedNode; // If draggedNode is null, it's from another tree
1575
+ if (isCrossTree && dragDropMode === 'self') return;
1576
+ if (!isCrossTree && dragDropMode === 'cross') return;
1577
+
1409
1578
  event.preventDefault();
1410
1579
  isDropPlaceholderActive = true;
1411
- console.log('[EmptyTree] isDropPlaceholderActive set to true');
1412
1580
  if (event.dataTransfer) {
1413
1581
  event.dataTransfer.dropEffect = 'move';
1414
1582
  }
@@ -1421,46 +1589,31 @@
1421
1589
  const x = event.clientX;
1422
1590
  const y = event.clientY;
1423
1591
 
1424
- console.log('[EmptyTree] dragleave fired', {
1425
- x, y,
1426
- rect: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom },
1427
- isOutside: x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom,
1428
- treeId
1429
- });
1430
-
1431
1592
  if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
1432
1593
  isDropPlaceholderActive = false;
1433
- console.log('[EmptyTree] isDropPlaceholderActive set to false (left element)');
1434
1594
  }
1435
1595
  }
1436
1596
 
1437
1597
  function handleEmptyTreeDrop(event: DragEvent) {
1438
- console.log('[EmptyTree] drop fired', {
1439
- types: event.dataTransfer?.types,
1440
- data: event.dataTransfer?.getData('application/svelte-treeview'),
1441
- treeId
1442
- });
1598
+ if (dragDropMode === 'none') return;
1443
1599
  event.preventDefault();
1444
1600
  isDropPlaceholderActive = false;
1445
1601
 
1446
1602
  const draggedNodeData = event.dataTransfer?.getData('application/svelte-treeview');
1447
1603
  if (draggedNodeData) {
1448
1604
  const droppedNode = JSON.parse(draggedNodeData);
1449
- console.log('[EmptyTree] calling _handleDrop with', { droppedNode });
1605
+ // Respect dragDropMode for empty tree drops too
1606
+ const isCrossTree = droppedNode?.treeId !== treeId;
1607
+ if (isCrossTree && dragDropMode === 'self') return;
1608
+ if (!isCrossTree && dragDropMode === 'cross') return;
1609
+
1450
1610
  // Call onNodeDrop with null as dropNode to indicate "root level drop"
1451
1611
  _handleDrop(null, droppedNode, 'child', event);
1452
- } else {
1453
- console.log('[EmptyTree] no draggedNodeData found!');
1454
1612
  }
1455
1613
  _onNodeDragEnd(event);
1456
1614
  }
1457
1615
 
1458
1616
  function handleEmptyTreeTouchEnd(event: TouchEvent) {
1459
- console.log('[EmptyTree] touchend fired', {
1460
- draggedNode,
1461
- isDropPlaceholderActive,
1462
- treeId
1463
- });
1464
1617
  // Check if touch drag was active and we have a dragged node
1465
1618
  if (draggedNode && isDropPlaceholderActive) {
1466
1619
  _handleDrop(null, draggedNode, 'child', event);
@@ -1476,6 +1629,11 @@
1476
1629
  }
1477
1630
 
1478
1631
  function handleTreeDragLeave(event: DragEvent) {
1632
+ // Don't reset if moving to a child element (including fixed-position floating zones)
1633
+ if (event.relatedTarget instanceof Node && (event.currentTarget as HTMLElement).contains(event.relatedTarget as globalThis.Node)) {
1634
+ return;
1635
+ }
1636
+
1479
1637
  const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
1480
1638
  const x = event.clientX;
1481
1639
  const y = event.clientY;
@@ -1487,6 +1645,8 @@
1487
1645
  isDragInProgress = false;
1488
1646
  hoveredNodeForDrop = null;
1489
1647
  activeDropPosition = null;
1648
+ floatingZoneRect = null;
1649
+ floatingHoveredZone = null;
1490
1650
  }
1491
1651
  }
1492
1652
  }
@@ -1512,9 +1672,6 @@
1512
1672
  };
1513
1673
 
1514
1674
  const handleGlobalScroll = (event?: Event) => {
1515
- if (shouldDisplayDebugInformation) {
1516
- console.log(`[Tree ${treeId}] Scroll/wheel event detected, closing context menu`, event?.type);
1517
- }
1518
1675
  closeContextMenu();
1519
1676
  };
1520
1677
 
@@ -1553,10 +1710,6 @@
1553
1710
  contextMenuY = treeRect.top + 100; // 100px from tree's top edge
1554
1711
  contextMenuVisible = true;
1555
1712
  isDebugMenuActive = true;
1556
-
1557
- if (shouldDisplayDebugInformation) {
1558
- console.log(`[Tree ${treeId}] Debug context menu displayed for node:`, targetNode.data, `at position (${contextMenuX}, ${contextMenuY})`);
1559
- }
1560
1713
  }
1561
1714
  } else if (!shouldDisplayContextMenuInDebugMode && isDebugMenuActive) {
1562
1715
  // Only hide the context menu if it was opened by debug mode
@@ -1609,50 +1762,108 @@
1609
1762
  </div>
1610
1763
  {/if}
1611
1764
 
1612
- <div class:bodyClass>
1765
+ <div class={bodyClass}>
1613
1766
  {#if tree?.root}
1614
1767
  <!-- Flat rendering mode: no {#key} block, uses visibleFlatNodes for efficient updates -->
1615
1768
  {#if useFlatRendering}
1616
- <div class="ltree-tree ltree-flat-mode">
1617
- {#each flatNodesToRender as node (node.id + '|' + node.path + '|' + node.hasChildren)}
1618
- <Node
1619
- {node}
1620
- children={nodeTemplate}
1621
- progressiveRender={false}
1622
- isDraggedNode={draggedNode?.path === node.path}
1623
- {isDragInProgress}
1624
- hoveredNodeForDropPath={hoveredNodeForDrop?.path}
1625
- {activeDropPosition}
1626
- dropOperation={currentDropOperation}
1627
- flatMode={true}
1628
- {flatIndentSize}
1629
- />
1630
- {:else}
1631
- <!-- Empty state when tree has no items -->
1632
- <!-- svelte-ignore a11y_no_static_element_interactions -->
1633
- <div
1634
- class="ltree-empty-state"
1635
- class:ltree-drop-placeholder={isDropPlaceholderActive}
1636
- ondragenter={handleEmptyTreeDragOver}
1637
- ondragover={handleEmptyTreeDragOver}
1638
- ondragleave={handleEmptyTreeDragLeave}
1639
- ondrop={handleEmptyTreeDrop}
1640
- ontouchend={handleEmptyTreeTouchEnd}
1641
- >
1642
- {#if isDropPlaceholderActive}
1643
- {#if dropPlaceholder}
1644
- {@render dropPlaceholder()}
1769
+ {#if vsActive}
1770
+ <!-- Virtual scrolling mode -->
1771
+ <div
1772
+ class="ltree-tree ltree-flat-mode ltree-virtual-scroll"
1773
+ style="height: {vsContainerStyle}; overflow-y: auto;"
1774
+ bind:this={vsContainerRef}
1775
+ onscroll={handleVirtualScroll}
1776
+ >
1777
+ <!-- Spacer for correct scrollbar -->
1778
+ <div style="height: {vsTotalHeight}px; position: relative;">
1779
+ <!-- Rendered window at correct offset -->
1780
+ <div style="transform: translateY({vsOffsetY}px);">
1781
+ {#each flatNodesToRender as node, i (node.id + '|' + node.path + '|' + node.hasChildren + '|' + node._rev)}
1782
+ {@const prevNode = (vsStartIndex + i) > 0 ? allFlatNodes[vsStartIndex + i - 1] : null}
1783
+ <Node
1784
+ {node}
1785
+ children={nodeTemplate}
1786
+ progressiveRender={false}
1787
+ isDraggedNode={draggedNode?.path === node.path}
1788
+ {isDragInProgress}
1789
+ hoveredNodeForDropPath={hoveredNodeForDrop?.path}
1790
+ {activeDropPosition}
1791
+ dropOperation={currentDropOperation}
1792
+ flatMode={true}
1793
+ flatGap={prevNode != null && node.level > prevNode.level}
1794
+ />
1645
1795
  {:else}
1646
- <div class="ltree-drop-placeholder-content">
1647
- Drop here to add
1796
+ <!-- Empty state when tree has no items -->
1797
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1798
+ <div
1799
+ class="ltree-empty-state"
1800
+ class:ltree-drop-placeholder={isDropPlaceholderActive}
1801
+ ondragenter={handleEmptyTreeDragOver}
1802
+ ondragover={handleEmptyTreeDragOver}
1803
+ ondragleave={handleEmptyTreeDragLeave}
1804
+ ondrop={handleEmptyTreeDrop}
1805
+ ontouchend={handleEmptyTreeTouchEnd}
1806
+ >
1807
+ {#if isDropPlaceholderActive}
1808
+ {#if dropPlaceholder}
1809
+ {@render dropPlaceholder()}
1810
+ {:else}
1811
+ <div class="ltree-drop-placeholder-content">
1812
+ Drop here to add
1813
+ </div>
1814
+ {/if}
1815
+ {:else}
1816
+ {@render noDataFound?.()}
1817
+ {/if}
1648
1818
  </div>
1649
- {/if}
1650
- {:else}
1651
- {@render noDataFound?.()}
1652
- {/if}
1819
+ {/each}
1820
+ </div>
1653
1821
  </div>
1654
- {/each}
1655
- </div>
1822
+ </div>
1823
+ {:else}
1824
+ <!-- Non-virtual flat rendering -->
1825
+ <div class="ltree-tree ltree-flat-mode">
1826
+ {#each flatNodesToRender as node, i (node.id + '|' + node.path + '|' + node.hasChildren + '|' + node._rev)}
1827
+ {@const prevNode = i > 0 ? flatNodesToRender[i - 1] : null}
1828
+ <Node
1829
+ {node}
1830
+ children={nodeTemplate}
1831
+ progressiveRender={false}
1832
+ isDraggedNode={draggedNode?.path === node.path}
1833
+ {isDragInProgress}
1834
+ hoveredNodeForDropPath={hoveredNodeForDrop?.path}
1835
+ {activeDropPosition}
1836
+ dropOperation={currentDropOperation}
1837
+ flatMode={true}
1838
+ flatGap={prevNode != null && node.level > prevNode.level}
1839
+ />
1840
+ {:else}
1841
+ <!-- Empty state when tree has no items -->
1842
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1843
+ <div
1844
+ class="ltree-empty-state"
1845
+ class:ltree-drop-placeholder={isDropPlaceholderActive}
1846
+ ondragenter={handleEmptyTreeDragOver}
1847
+ ondragover={handleEmptyTreeDragOver}
1848
+ ondragleave={handleEmptyTreeDragLeave}
1849
+ ondrop={handleEmptyTreeDrop}
1850
+ ontouchend={handleEmptyTreeTouchEnd}
1851
+ >
1852
+ {#if isDropPlaceholderActive}
1853
+ {#if dropPlaceholder}
1854
+ {@render dropPlaceholder()}
1855
+ {:else}
1856
+ <div class="ltree-drop-placeholder-content">
1857
+ Drop here to add
1858
+ </div>
1859
+ {/if}
1860
+ {:else}
1861
+ {@render noDataFound?.()}
1862
+ {/if}
1863
+ </div>
1864
+ {/each}
1865
+ </div>
1866
+ {/if}
1656
1867
  {:else}
1657
1868
  <!-- Recursive rendering mode: uses {#key} block for forced re-renders -->
1658
1869
  {#key tree.changeTracker}
@@ -1726,6 +1937,40 @@
1726
1937
 
1727
1938
  {@render treeFooter?.()}
1728
1939
 
1940
+ <!-- Floating drop zones overlay (position: fixed to escape overflow containers) -->
1941
+ {#if dropZoneMode === 'floating' && isDragInProgress && hoveredNodeForDrop && floatingZoneRect}
1942
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1943
+ <div
1944
+ class="ltree-drop-zones ltree-drop-zones-{dropZoneLayout}"
1945
+ style="position: fixed; top: {floatingZoneRect.top}px; left: {floatingZoneRect.left}px; width: {floatingZoneRect.width}px; height: {floatingZoneRect.height}px; z-index: 10000; --drop-zone-start: {formattedDropZoneStart}; --drop-zone-max-width: {dropZoneMaxWidth}px;"
1946
+ >
1947
+ {#if isFloatingPositionAllowed('above')}
1948
+ <div class="ltree-drop-zone ltree-drop-above"
1949
+ class:ltree-drop-zone-active={floatingHoveredZone === 'above'}
1950
+ ondragover={(e) => handleFloatingZoneDragOver('above', e)}
1951
+ ondragleave={handleFloatingZoneDragLeave}
1952
+ ondrop={(e) => handleFloatingZoneDrop('above', e)}
1953
+ >↑ Above</div>
1954
+ {/if}
1955
+ {#if isFloatingPositionAllowed('below')}
1956
+ <div class="ltree-drop-zone ltree-drop-below"
1957
+ class:ltree-drop-zone-active={floatingHoveredZone === 'below'}
1958
+ ondragover={(e) => handleFloatingZoneDragOver('below', e)}
1959
+ ondragleave={handleFloatingZoneDragLeave}
1960
+ ondrop={(e) => handleFloatingZoneDrop('below', e)}
1961
+ >↓ Below</div>
1962
+ {/if}
1963
+ {#if isFloatingPositionAllowed('child')}
1964
+ <div class="ltree-drop-zone ltree-drop-child"
1965
+ class:ltree-drop-zone-active={floatingHoveredZone === 'child'}
1966
+ ondragover={(e) => handleFloatingZoneDragOver('child', e)}
1967
+ ondragleave={handleFloatingZoneDragLeave}
1968
+ ondrop={(e) => handleFloatingZoneDrop('child', e)}
1969
+ >→ Child</div>
1970
+ {/if}
1971
+ </div>
1972
+ {/if}
1973
+
1729
1974
  <!-- Context Menu -->
1730
1975
  {#if contextMenuVisible && contextMenuNode}
1731
1976
  <div class="ltree-context-menu" style="left: {contextMenuX}px; top: {contextMenuY}px;">