@keenmate/svelte-treeview 4.5.0 → 4.6.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 +194 -45
- package/dist/components/Node.svelte +64 -109
- package/dist/components/Node.svelte.d.ts +3 -22
- package/dist/components/Tree.svelte +335 -41
- package/dist/components/Tree.svelte.d.ts +58 -7
- package/dist/constants.generated.d.ts +1 -1
- package/dist/constants.generated.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/ltree/ltree.svelte.js +71 -5
- package/dist/ltree/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -7,6 +7,36 @@
|
|
|
7
7
|
import { setContext, tick } from 'svelte';
|
|
8
8
|
import { createRenderCoordinator, type RenderCoordinator, type RenderStats } from './RenderCoordinator.svelte.js';
|
|
9
9
|
import { uiLogger, dragLogger } from '../logger.js';
|
|
10
|
+
import { perfStart, perfEnd } from '../perf-logger.js';
|
|
11
|
+
|
|
12
|
+
// Context types for stable function references (prevents re-renders from inline arrow functions)
|
|
13
|
+
export interface NodeCallbacks<T> {
|
|
14
|
+
onNodeClicked: (node: LTreeNode<T>) => void;
|
|
15
|
+
onNodeRightClicked: (node: LTreeNode<T>, event: MouseEvent) => void;
|
|
16
|
+
onNodeDragStart: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
17
|
+
onNodeDragOver: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
18
|
+
onNodeDragLeave: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
19
|
+
onNodeDrop: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
20
|
+
onZoneDrop: (node: LTreeNode<T>, position: DropPosition, event: DragEvent) => void;
|
|
21
|
+
onTouchDragStart: (node: LTreeNode<T>, event: TouchEvent) => void;
|
|
22
|
+
onTouchDragMove: (node: LTreeNode<T>, event: TouchEvent) => void;
|
|
23
|
+
onTouchDragEnd: (node: LTreeNode<T>, event: TouchEvent) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NodeConfig {
|
|
27
|
+
shouldToggleOnNodeClick: boolean;
|
|
28
|
+
expandIconClass: string;
|
|
29
|
+
collapseIconClass: string;
|
|
30
|
+
leafIconClass: string;
|
|
31
|
+
selectedNodeClass: string | null | undefined;
|
|
32
|
+
dragOverNodeClass: string | null | undefined;
|
|
33
|
+
dropZoneMode: 'floating' | 'glow';
|
|
34
|
+
dropZoneLayout: 'around' | 'above' | 'below' | 'wave' | 'wave2';
|
|
35
|
+
dropZoneStart: number | string;
|
|
36
|
+
dropZoneMaxWidth: number;
|
|
37
|
+
allowCopy: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
|
|
11
41
|
// Register global API for runtime logging control
|
|
12
42
|
import '../global-api.js';
|
|
@@ -17,6 +47,9 @@
|
|
|
17
47
|
let contextMenuY = $state(0);
|
|
18
48
|
let contextMenuNode: LTreeNode<T> | null = $state(null);
|
|
19
49
|
|
|
50
|
+
// Scroll highlight state - track current highlight to clear on next scroll
|
|
51
|
+
let currentHighlight: { element: HTMLElement; timeoutId: ReturnType<typeof setTimeout> } | null = null;
|
|
52
|
+
|
|
20
53
|
// Drag and drop state
|
|
21
54
|
let draggedNode: LTreeNode<any> | null = $state.raw(null);
|
|
22
55
|
|
|
@@ -32,6 +65,13 @@
|
|
|
32
65
|
|
|
33
66
|
let touchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
34
67
|
|
|
68
|
+
// Progressive rendering for flat mode
|
|
69
|
+
// 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[]>([]);
|
|
72
|
+
let flatRenderAnimationFrame: number | null = null;
|
|
73
|
+
let currentBatchSize: number = 0; // Exponential: doubles each batch up to maxBatchSize
|
|
74
|
+
|
|
35
75
|
// Drop placeholder state for empty trees
|
|
36
76
|
let isDropPlaceholderActive = $state(false);
|
|
37
77
|
|
|
@@ -99,12 +139,24 @@
|
|
|
99
139
|
|
|
100
140
|
// Progressive rendering - render children in batches to avoid UI freeze
|
|
101
141
|
progressiveRender?: boolean;
|
|
102
|
-
|
|
142
|
+
initialBatchSize?: number;
|
|
143
|
+
maxBatchSize?: number;
|
|
103
144
|
isRendering?: boolean; // Bindable: true while progressive rendering is active
|
|
104
145
|
onRenderStart?: () => void;
|
|
105
146
|
onRenderProgress?: (stats: RenderStats) => void;
|
|
106
147
|
onRenderComplete?: (stats: RenderStats) => void;
|
|
107
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Use flat/centralized rendering instead of recursive node rendering.
|
|
151
|
+
* This significantly improves performance for large trees by:
|
|
152
|
+
* - Removing the {#key changeTracker} block that destroys all nodes on any change
|
|
153
|
+
* - Using a single flat loop instead of recursive component instantiation
|
|
154
|
+
* - Allowing Svelte's keyed {#each} to efficiently diff only changed nodes
|
|
155
|
+
*/
|
|
156
|
+
useFlatRendering?: boolean;
|
|
157
|
+
/** Indentation per level in flat rendering mode (CSS value, default: '1.5rem') */
|
|
158
|
+
flatIndentSize?: string;
|
|
159
|
+
|
|
108
160
|
// DRAG AND DROP
|
|
109
161
|
dragDropMode?: DragDropMode;
|
|
110
162
|
dropZoneMode?: 'floating' | 'glow'; // 'floating' = original floating zones, 'glow' = border glow indicators
|
|
@@ -122,8 +174,9 @@
|
|
|
122
174
|
* Called before a drop is processed. Return false to cancel the drop.
|
|
123
175
|
* Return { position, operation } to override the drop position or operation.
|
|
124
176
|
* Return true or undefined to proceed normally.
|
|
177
|
+
* Can be async - return a Promise to show dialogs or perform async validation.
|
|
125
178
|
*/
|
|
126
|
-
beforeDropCallback?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => boolean | { position?: DropPosition; operation?: DropOperation } | void;
|
|
179
|
+
beforeDropCallback?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => boolean | { position?: DropPosition; operation?: DropOperation } | void | Promise<boolean | { position?: DropPosition; operation?: DropOperation } | void>;
|
|
127
180
|
onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
|
|
128
181
|
contextMenuCallback?: (node: LTreeNode<T>, closeMenuCallback: () => void) => ContextMenuItem[];
|
|
129
182
|
|
|
@@ -191,14 +244,19 @@
|
|
|
191
244
|
shouldDisplayContextMenuInDebugMode = false,
|
|
192
245
|
isLoading = false,
|
|
193
246
|
|
|
194
|
-
// Progressive rendering
|
|
195
|
-
progressiveRender =
|
|
196
|
-
|
|
247
|
+
// Progressive rendering (exponential batching: 20 → 40 → 80 → 160...)
|
|
248
|
+
progressiveRender = true,
|
|
249
|
+
initialBatchSize = 20,
|
|
250
|
+
maxBatchSize = 500,
|
|
197
251
|
isRendering = $bindable(false),
|
|
198
252
|
onRenderStart,
|
|
199
253
|
onRenderProgress,
|
|
200
254
|
onRenderComplete,
|
|
201
255
|
|
|
256
|
+
// Flat rendering mode
|
|
257
|
+
useFlatRendering = true,
|
|
258
|
+
flatIndentSize = '1.5rem',
|
|
259
|
+
|
|
202
260
|
// DRAG AND DROP
|
|
203
261
|
dragDropMode = 'both',
|
|
204
262
|
dropZoneMode = 'glow',
|
|
@@ -351,15 +409,20 @@
|
|
|
351
409
|
export async function scrollToPath(
|
|
352
410
|
path: string,
|
|
353
411
|
options?: {
|
|
412
|
+
/** Expand ancestors to make the node visible (default: true) */
|
|
354
413
|
expand?: boolean;
|
|
414
|
+
/** Also expand the target node itself to show its children (default: false for performance) */
|
|
415
|
+
expandTarget?: boolean;
|
|
355
416
|
highlight?: boolean;
|
|
356
417
|
scrollOptions?: ScrollIntoViewOptions;
|
|
357
418
|
/** Scroll only within the nearest scrollable container (prevents page scroll) */
|
|
358
419
|
containerScroll?: boolean;
|
|
359
420
|
}
|
|
360
421
|
): Promise<boolean> {
|
|
422
|
+
perfStart(`[${treeId}] scrollToPath`);
|
|
361
423
|
const {
|
|
362
424
|
expand = true,
|
|
425
|
+
expandTarget = false,
|
|
363
426
|
highlight = true,
|
|
364
427
|
scrollOptions = { behavior: 'smooth', block: 'center' },
|
|
365
428
|
containerScroll = false
|
|
@@ -369,13 +432,22 @@
|
|
|
369
432
|
const node = tree.getNodeByPath(path);
|
|
370
433
|
if (!node || !node.id) {
|
|
371
434
|
console.warn(`[Tree ${treeId}] Node not found for path: ${path}`);
|
|
435
|
+
perfEnd(`[${treeId}] scrollToPath`);
|
|
372
436
|
return false;
|
|
373
437
|
}
|
|
374
438
|
|
|
375
|
-
// Expand the
|
|
376
|
-
|
|
439
|
+
// Expand ancestors to make the node visible
|
|
440
|
+
// The target node's visibility depends on its parent being expanded, not on its own isExpanded state
|
|
441
|
+
if (expand && node.parentPath) {
|
|
442
|
+
tree.expandNodes(node.parentPath);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Optionally expand the target node itself (shows its children, but triggers re-render if not already expanded)
|
|
446
|
+
if (expandTarget) {
|
|
377
447
|
tree.expandNodes(path);
|
|
378
|
-
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (expand || expandTarget) {
|
|
379
451
|
await tick();
|
|
380
452
|
}
|
|
381
453
|
|
|
@@ -386,6 +458,7 @@
|
|
|
386
458
|
|
|
387
459
|
if (!contentDiv) {
|
|
388
460
|
console.warn(`[Tree ${treeId}] DOM element not found for node ID: ${elementId}`);
|
|
461
|
+
perfEnd(`[${treeId}] scrollToPath`);
|
|
389
462
|
return false;
|
|
390
463
|
}
|
|
391
464
|
|
|
@@ -408,12 +481,23 @@
|
|
|
408
481
|
|
|
409
482
|
// Highlight the node temporarily if requested
|
|
410
483
|
if (highlight && scrollHighlightClass) {
|
|
484
|
+
// Clear previous highlight immediately (for rapid next/prev navigation)
|
|
485
|
+
if (currentHighlight) {
|
|
486
|
+
currentHighlight.element.classList.remove(scrollHighlightClass);
|
|
487
|
+
clearTimeout(currentHighlight.timeoutId);
|
|
488
|
+
currentHighlight = null;
|
|
489
|
+
}
|
|
490
|
+
|
|
411
491
|
contentDiv.classList.add(scrollHighlightClass);
|
|
412
|
-
setTimeout(() => {
|
|
492
|
+
const timeoutId = setTimeout(() => {
|
|
413
493
|
contentDiv.classList.remove(scrollHighlightClass);
|
|
494
|
+
currentHighlight = null;
|
|
414
495
|
}, scrollHighlightTimeout);
|
|
496
|
+
|
|
497
|
+
currentHighlight = { element: contentDiv, timeoutId };
|
|
415
498
|
}
|
|
416
499
|
|
|
500
|
+
perfEnd(`[${treeId}] scrollToPath`);
|
|
417
501
|
return true;
|
|
418
502
|
}
|
|
419
503
|
|
|
@@ -581,8 +665,26 @@
|
|
|
581
665
|
|
|
582
666
|
setContext('Ltree', tree);
|
|
583
667
|
|
|
584
|
-
// Create
|
|
585
|
-
//
|
|
668
|
+
// Create stable callback references to avoid inline arrow functions causing re-renders
|
|
669
|
+
// These are defined as a plain object with function references, not new functions each render
|
|
670
|
+
const nodeCallbacks: NodeCallbacks<T> = {
|
|
671
|
+
onNodeClicked: _onNodeClicked,
|
|
672
|
+
onNodeRightClicked: _onNodeRightClicked,
|
|
673
|
+
onNodeDragStart: _onNodeDragStart,
|
|
674
|
+
onNodeDragOver: _onNodeDragOver,
|
|
675
|
+
onNodeDragLeave: _onNodeDragLeave,
|
|
676
|
+
onNodeDrop: _onNodeDrop,
|
|
677
|
+
onZoneDrop: _onZoneDrop,
|
|
678
|
+
onTouchDragStart: _onTouchStart,
|
|
679
|
+
onTouchDragMove: _onTouchMove,
|
|
680
|
+
onTouchDragEnd: _onTouchEnd,
|
|
681
|
+
};
|
|
682
|
+
setContext('NodeCallbacks', nodeCallbacks);
|
|
683
|
+
|
|
684
|
+
// Note: NodeConfig is set via $effect below since it depends on reactive props
|
|
685
|
+
|
|
686
|
+
// Create and provide render coordinator for progressive rendering (recursive mode)
|
|
687
|
+
// Process only 2 nodes per frame - each node renders initialBatchSize children
|
|
586
688
|
// This prevents too many reactive updates per frame
|
|
587
689
|
const renderCoordinator = progressiveRender ? createRenderCoordinator(2, {
|
|
588
690
|
onStart: () => {
|
|
@@ -601,10 +703,63 @@
|
|
|
601
703
|
setContext('RenderCoordinator', renderCoordinator);
|
|
602
704
|
}
|
|
603
705
|
|
|
706
|
+
// Create stable config object - updated when props change
|
|
707
|
+
// Using $state.raw to avoid deep reactivity on the config object itself
|
|
708
|
+
let nodeConfig = $state.raw<NodeConfig>({
|
|
709
|
+
shouldToggleOnNodeClick: shouldToggleOnNodeClick ?? true,
|
|
710
|
+
expandIconClass: expandIconClass ?? 'ltree-icon-expand',
|
|
711
|
+
collapseIconClass: collapseIconClass ?? 'ltree-icon-collapse',
|
|
712
|
+
leafIconClass: leafIconClass ?? 'ltree-icon-leaf',
|
|
713
|
+
selectedNodeClass,
|
|
714
|
+
dragOverNodeClass,
|
|
715
|
+
dropZoneMode: dropZoneMode ?? 'glow',
|
|
716
|
+
dropZoneLayout: dropZoneLayout ?? 'around',
|
|
717
|
+
dropZoneStart: dropZoneStart ?? 33,
|
|
718
|
+
dropZoneMaxWidth: dropZoneMaxWidth ?? 120,
|
|
719
|
+
allowCopy: allowCopy ?? false,
|
|
720
|
+
});
|
|
721
|
+
setContext('NodeConfig', nodeConfig);
|
|
722
|
+
|
|
723
|
+
// Update config when props change (rarely happens, but supports dynamic updates)
|
|
724
|
+
$effect(() => {
|
|
725
|
+
nodeConfig = {
|
|
726
|
+
shouldToggleOnNodeClick: shouldToggleOnNodeClick ?? true,
|
|
727
|
+
expandIconClass: expandIconClass ?? 'ltree-icon-expand',
|
|
728
|
+
collapseIconClass: collapseIconClass ?? 'ltree-icon-collapse',
|
|
729
|
+
leafIconClass: leafIconClass ?? 'ltree-icon-leaf',
|
|
730
|
+
selectedNodeClass,
|
|
731
|
+
dragOverNodeClass,
|
|
732
|
+
dropZoneMode: dropZoneMode ?? 'glow',
|
|
733
|
+
dropZoneLayout: dropZoneLayout ?? 'around',
|
|
734
|
+
dropZoneStart: dropZoneStart ?? 33,
|
|
735
|
+
dropZoneMaxWidth: dropZoneMaxWidth ?? 120,
|
|
736
|
+
allowCopy: allowCopy ?? false,
|
|
737
|
+
};
|
|
738
|
+
});
|
|
739
|
+
|
|
604
740
|
$effect(() => {
|
|
605
741
|
tree.filterNodes(searchText);
|
|
606
742
|
});
|
|
607
743
|
|
|
744
|
+
// Performance instrumentation for flat mode
|
|
745
|
+
let flatRenderStart: number | null = null;
|
|
746
|
+
$effect(() => {
|
|
747
|
+
if (useFlatRendering && tree?.changeTracker) {
|
|
748
|
+
// changeTracker changed - flat render is about to happen
|
|
749
|
+
flatRenderStart = performance.now();
|
|
750
|
+
console.log('[Flat Mode] changeTracker changed, render starting...');
|
|
751
|
+
|
|
752
|
+
// Measure after Svelte processes the DOM update (single rAF = next paint)
|
|
753
|
+
requestAnimationFrame(() => {
|
|
754
|
+
if (flatRenderStart) {
|
|
755
|
+
const elapsed = performance.now() - flatRenderStart;
|
|
756
|
+
console.log(`[Flat Mode] DOM update complete: ${elapsed.toFixed(2)}ms`);
|
|
757
|
+
flatRenderStart = null;
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
608
763
|
$effect(() => {
|
|
609
764
|
if (tree && data) {
|
|
610
765
|
if (_skipInsertArray) {
|
|
@@ -614,11 +769,126 @@
|
|
|
614
769
|
}
|
|
615
770
|
// Reset progressive render coordinator when data changes
|
|
616
771
|
renderCoordinator?.reset();
|
|
772
|
+
// Reset flat progressive render state when data changes
|
|
773
|
+
flatRenderedIds = new Set();
|
|
774
|
+
flatRenderQueue = [];
|
|
775
|
+
currentBatchSize = 0; // Reset exponential batch size
|
|
617
776
|
console.log('[Tree] Running insertArray with', data.length, 'items');
|
|
618
777
|
insertResult = tree.insertArray(data);
|
|
619
778
|
}
|
|
620
779
|
});
|
|
621
780
|
|
|
781
|
+
// Progressive rendering for flat mode - detect new nodes and queue them
|
|
782
|
+
// Use a separate tracker to avoid reactive loops (effect reads AND writes flatRenderedIds)
|
|
783
|
+
let lastFlatNodesTracker: Symbol | null = null;
|
|
784
|
+
|
|
785
|
+
$effect(() => {
|
|
786
|
+
if (!useFlatRendering || !progressiveRender || !tree?.visibleFlatNodes) return;
|
|
787
|
+
|
|
788
|
+
// Only react to changeTracker changes, not to our own state updates
|
|
789
|
+
const tracker = tree.changeTracker;
|
|
790
|
+
if (tracker === lastFlatNodesTracker) return;
|
|
791
|
+
lastFlatNodesTracker = tracker;
|
|
792
|
+
|
|
793
|
+
const allNodes = tree.visibleFlatNodes;
|
|
794
|
+
const currentIds = new Set(allNodes.map(n => n.id));
|
|
795
|
+
|
|
796
|
+
// Snapshot current state to avoid reactive reads during computation
|
|
797
|
+
const renderedSnapshot = new Set(flatRenderedIds);
|
|
798
|
+
const queueSnapshot = new Set(flatRenderQueue);
|
|
799
|
+
|
|
800
|
+
// Find new nodes (in current but not yet rendered AND not already queued)
|
|
801
|
+
const newIds: string[] = [];
|
|
802
|
+
for (const node of allNodes) {
|
|
803
|
+
if (!renderedSnapshot.has(node.id) && !queueSnapshot.has(node.id)) {
|
|
804
|
+
newIds.push(node.id);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Find removed nodes (rendered but no longer in current)
|
|
809
|
+
const removedIds: string[] = [];
|
|
810
|
+
for (const id of renderedSnapshot) {
|
|
811
|
+
if (!currentIds.has(id)) {
|
|
812
|
+
removedIds.push(id);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Remove nodes that are no longer visible
|
|
817
|
+
if (removedIds.length > 0) {
|
|
818
|
+
const newRendered = new Set(renderedSnapshot);
|
|
819
|
+
for (const id of removedIds) {
|
|
820
|
+
newRendered.delete(id);
|
|
821
|
+
}
|
|
822
|
+
flatRenderedIds = newRendered;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Queue new nodes for progressive rendering
|
|
826
|
+
if (newIds.length > 0) {
|
|
827
|
+
// If we already have many rendered nodes and adding few new ones,
|
|
828
|
+
// skip progressive batching to minimize Svelte diffs (expand/collapse case)
|
|
829
|
+
const alreadyHasManyNodes = renderedSnapshot.size > 1000;
|
|
830
|
+
const addingFewNodes = newIds.length < 200;
|
|
831
|
+
|
|
832
|
+
if (alreadyHasManyNodes && addingFewNodes) {
|
|
833
|
+
// Add all at once - one diff is faster than multiple diffs on large arrays
|
|
834
|
+
console.log(`[Flat Progressive] Adding ${newIds.length} nodes immediately (large tree optimization)`);
|
|
835
|
+
flatRenderedIds = new Set([...flatRenderedIds, ...newIds]);
|
|
836
|
+
} else {
|
|
837
|
+
// Progressive batching for initial load (exponential: 20 → 40 → 80 → 160...)
|
|
838
|
+
currentBatchSize = initialBatchSize; // Start with initial batch size
|
|
839
|
+
console.log(`[Flat Progressive] Queueing ${newIds.length} new nodes for progressive render (exponential batching)`);
|
|
840
|
+
const immediateBatch = newIds.slice(0, currentBatchSize);
|
|
841
|
+
const remaining = newIds.slice(currentBatchSize);
|
|
842
|
+
|
|
843
|
+
if (immediateBatch.length > 0) {
|
|
844
|
+
flatRenderedIds = new Set([...flatRenderedIds, ...immediateBatch]);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Double batch size for next iteration (capped at maxBatchSize)
|
|
848
|
+
currentBatchSize = Math.min(currentBatchSize * 2, maxBatchSize);
|
|
849
|
+
|
|
850
|
+
if (remaining.length > 0) {
|
|
851
|
+
flatRenderQueue = [...remaining]; // Replace queue, don't append
|
|
852
|
+
scheduleFlatRenderBatch();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// Process flat render queue in batches (exponential sizing)
|
|
859
|
+
function scheduleFlatRenderBatch() {
|
|
860
|
+
if (flatRenderAnimationFrame) return; // Already scheduled
|
|
861
|
+
|
|
862
|
+
flatRenderAnimationFrame = requestAnimationFrame(() => {
|
|
863
|
+
flatRenderAnimationFrame = null;
|
|
864
|
+
|
|
865
|
+
if (flatRenderQueue.length === 0) return;
|
|
866
|
+
|
|
867
|
+
const batchSize = currentBatchSize || initialBatchSize;
|
|
868
|
+
const batch = flatRenderQueue.slice(0, batchSize);
|
|
869
|
+
const remaining = flatRenderQueue.slice(batchSize);
|
|
870
|
+
|
|
871
|
+
flatRenderedIds = new Set([...flatRenderedIds, ...batch]);
|
|
872
|
+
flatRenderQueue = remaining;
|
|
873
|
+
|
|
874
|
+
// Double batch size for next iteration (capped at maxBatchSize)
|
|
875
|
+
currentBatchSize = Math.min(batchSize * 2, maxBatchSize);
|
|
876
|
+
|
|
877
|
+
// console.log(`[Flat Progressive] Rendered batch of ${batch.length}, next batch: ${currentBatchSize}, ${remaining.length} remaining`);
|
|
878
|
+
|
|
879
|
+
if (remaining.length > 0) {
|
|
880
|
+
scheduleFlatRenderBatch();
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Derived: nodes to render in flat mode (filtered by progressive state)
|
|
886
|
+
const flatNodesToRender = $derived(
|
|
887
|
+
useFlatRendering && progressiveRender
|
|
888
|
+
? tree?.visibleFlatNodes?.filter(n => flatRenderedIds.has(n.id)) ?? []
|
|
889
|
+
: tree?.visibleFlatNodes ?? []
|
|
890
|
+
);
|
|
891
|
+
|
|
622
892
|
// $inspect("tree change tracker", tree?.changeTracker?.toString());
|
|
623
893
|
|
|
624
894
|
function generateTreeId(): string {
|
|
@@ -724,7 +994,7 @@
|
|
|
724
994
|
* Same-tree moves are auto-handled by default - the library calls moveNode() internally.
|
|
725
995
|
* onNodeDrop is still called for notification/logging purposes.
|
|
726
996
|
*/
|
|
727
|
-
function _handleDrop(dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent): boolean {
|
|
997
|
+
async function _handleDrop(dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent): Promise<boolean> {
|
|
728
998
|
// Determine operation based on Ctrl key and allowCopy setting
|
|
729
999
|
// Touch events always use 'move' (no Ctrl key on mobile)
|
|
730
1000
|
let operation: DropOperation = 'move';
|
|
@@ -741,9 +1011,9 @@
|
|
|
741
1011
|
isCrossTree: draggedNode.treeId !== treeId
|
|
742
1012
|
});
|
|
743
1013
|
|
|
744
|
-
// Call beforeDropCallback if provided
|
|
1014
|
+
// Call beforeDropCallback if provided (supports async for dialogs)
|
|
745
1015
|
if (beforeDropCallback) {
|
|
746
|
-
const result = beforeDropCallback(dropNode, draggedNode, position, event, operation);
|
|
1016
|
+
const result = await beforeDropCallback(dropNode, draggedNode, position, event, operation);
|
|
747
1017
|
if (result === false) {
|
|
748
1018
|
// Drop cancelled
|
|
749
1019
|
return false;
|
|
@@ -1335,40 +1605,21 @@
|
|
|
1335
1605
|
|
|
1336
1606
|
<div class:bodyClass>
|
|
1337
1607
|
{#if tree?.root}
|
|
1338
|
-
{#key
|
|
1339
|
-
|
|
1340
|
-
|
|
1608
|
+
<!-- Flat rendering mode: no {#key} block, uses visibleFlatNodes for efficient updates -->
|
|
1609
|
+
{#if useFlatRendering}
|
|
1610
|
+
<div class="ltree-tree ltree-flat-mode">
|
|
1611
|
+
{#each flatNodesToRender as node (node.id + '|' + node.path + '|' + node.hasChildren)}
|
|
1341
1612
|
<Node
|
|
1342
1613
|
{node}
|
|
1343
1614
|
children={nodeTemplate}
|
|
1344
|
-
{
|
|
1345
|
-
{progressiveRender}
|
|
1346
|
-
{renderBatchSize}
|
|
1347
|
-
onNodeClicked={(node) => _onNodeClicked(node)}
|
|
1348
|
-
onNodeRightClicked={(node, event) => _onNodeRightClicked(node, event)}
|
|
1349
|
-
onNodeDragStart={(node, event) => _onNodeDragStart(node, event)}
|
|
1350
|
-
onNodeDragOver={(node, event) => _onNodeDragOver(node, event)}
|
|
1351
|
-
onNodeDragLeave={(node, event) => _onNodeDragLeave(node, event)}
|
|
1352
|
-
onNodeDrop={(node, event) => _onNodeDrop(node, event)}
|
|
1353
|
-
onZoneDrop={(node, position, event) => _onZoneDrop(node, position, event)}
|
|
1354
|
-
onTouchDragStart={(node, event) => _onTouchStart(node, event)}
|
|
1355
|
-
onTouchDragMove={(node, event) => _onTouchMove(node, event)}
|
|
1356
|
-
onTouchDragEnd={(node, event) => _onTouchEnd(node, event)}
|
|
1357
|
-
{expandIconClass}
|
|
1358
|
-
{collapseIconClass}
|
|
1359
|
-
{leafIconClass}
|
|
1360
|
-
{selectedNodeClass}
|
|
1361
|
-
{dragOverNodeClass}
|
|
1615
|
+
progressiveRender={false}
|
|
1362
1616
|
isDraggedNode={draggedNode?.path === node.path}
|
|
1363
1617
|
{isDragInProgress}
|
|
1364
1618
|
hoveredNodeForDropPath={hoveredNodeForDrop?.path}
|
|
1365
1619
|
{activeDropPosition}
|
|
1366
|
-
{dropZoneMode}
|
|
1367
|
-
{dropZoneLayout}
|
|
1368
|
-
{dropZoneStart}
|
|
1369
|
-
{dropZoneMaxWidth}
|
|
1370
1620
|
dropOperation={currentDropOperation}
|
|
1371
|
-
{
|
|
1621
|
+
flatMode={true}
|
|
1622
|
+
{flatIndentSize}
|
|
1372
1623
|
/>
|
|
1373
1624
|
{:else}
|
|
1374
1625
|
<!-- Empty state when tree has no items -->
|
|
@@ -1396,7 +1647,50 @@
|
|
|
1396
1647
|
</div>
|
|
1397
1648
|
{/each}
|
|
1398
1649
|
</div>
|
|
1399
|
-
{
|
|
1650
|
+
{:else}
|
|
1651
|
+
<!-- Recursive rendering mode: uses {#key} block for forced re-renders -->
|
|
1652
|
+
{#key tree.changeTracker}
|
|
1653
|
+
<div class="ltree-tree">
|
|
1654
|
+
{#each tree.tree as node (node.id)}
|
|
1655
|
+
<Node
|
|
1656
|
+
{node}
|
|
1657
|
+
children={nodeTemplate}
|
|
1658
|
+
{progressiveRender}
|
|
1659
|
+
renderBatchSize={initialBatchSize}
|
|
1660
|
+
isDraggedNode={draggedNode?.path === node.path}
|
|
1661
|
+
{isDragInProgress}
|
|
1662
|
+
hoveredNodeForDropPath={hoveredNodeForDrop?.path}
|
|
1663
|
+
{activeDropPosition}
|
|
1664
|
+
dropOperation={currentDropOperation}
|
|
1665
|
+
/>
|
|
1666
|
+
{:else}
|
|
1667
|
+
<!-- Empty state when tree has no items -->
|
|
1668
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1669
|
+
<div
|
|
1670
|
+
class="ltree-empty-state"
|
|
1671
|
+
class:ltree-drop-placeholder={isDropPlaceholderActive}
|
|
1672
|
+
ondragenter={handleEmptyTreeDragOver}
|
|
1673
|
+
ondragover={handleEmptyTreeDragOver}
|
|
1674
|
+
ondragleave={handleEmptyTreeDragLeave}
|
|
1675
|
+
ondrop={handleEmptyTreeDrop}
|
|
1676
|
+
ontouchend={handleEmptyTreeTouchEnd}
|
|
1677
|
+
>
|
|
1678
|
+
{#if isDropPlaceholderActive}
|
|
1679
|
+
{#if dropPlaceholder}
|
|
1680
|
+
{@render dropPlaceholder()}
|
|
1681
|
+
{:else}
|
|
1682
|
+
<div class="ltree-drop-placeholder-content">
|
|
1683
|
+
Drop here to add
|
|
1684
|
+
</div>
|
|
1685
|
+
{/if}
|
|
1686
|
+
{:else}
|
|
1687
|
+
{@render noDataFound?.()}
|
|
1688
|
+
{/if}
|
|
1689
|
+
</div>
|
|
1690
|
+
{/each}
|
|
1691
|
+
</div>
|
|
1692
|
+
{/key}
|
|
1693
|
+
{/if}
|
|
1400
1694
|
{:else}
|
|
1401
1695
|
<!-- Empty tree drop zone -->
|
|
1402
1696
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|