@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.
- package/README.md +148 -205
- package/dist/components/Node.svelte +39 -67
- package/dist/components/Node.svelte.d.ts +1 -1
- package/dist/components/Tree.svelte +416 -171
- package/dist/components/Tree.svelte.d.ts +31 -13
- package/dist/constants.generated.d.ts +1 -1
- package/dist/constants.generated.js +1 -1
- package/dist/ltree/indexer.js +3 -1
- package/dist/ltree/ltree-node.svelte.d.ts +1 -0
- package/dist/ltree/ltree-node.svelte.js +1 -0
- package/dist/ltree/ltree.svelte.js +104 -72
- package/dist/ltree/types.d.ts +4 -0
- package/dist/styles/main.scss +53 -6
- package/dist/styles.css +43 -6
- package/dist/styles.css.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
160
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
713
|
-
//
|
|
714
|
-
|
|
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
|
-
//
|
|
815
|
+
// Mutate the shared config proxy when props change
|
|
730
816
|
$effect(() => {
|
|
731
|
-
nodeConfig =
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
892
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
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
|
-
{/
|
|
1650
|
-
|
|
1651
|
-
{@render noDataFound?.()}
|
|
1652
|
-
{/if}
|
|
1819
|
+
{/each}
|
|
1820
|
+
</div>
|
|
1653
1821
|
</div>
|
|
1654
|
-
|
|
1655
|
-
|
|
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;">
|