@keenmate/svelte-treeview 4.4.0 → 4.5.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.
Files changed (37) hide show
  1. package/README.md +39 -4
  2. package/dist/components/Node.svelte +249 -12
  3. package/dist/components/Node.svelte.d.ts +17 -0
  4. package/dist/components/RenderCoordinator.svelte.d.ts +29 -0
  5. package/dist/components/RenderCoordinator.svelte.js +115 -0
  6. package/dist/components/Tree.svelte +855 -38
  7. package/dist/components/Tree.svelte.d.ts +160 -8
  8. package/dist/constants.generated.d.ts +6 -0
  9. package/dist/constants.generated.js +8 -0
  10. package/dist/global-api.d.ts +35 -0
  11. package/dist/global-api.js +36 -0
  12. package/dist/index.d.ts +6 -1
  13. package/dist/index.js +5 -0
  14. package/dist/logger.d.ts +56 -0
  15. package/dist/logger.js +159 -0
  16. package/dist/ltree/indexer.d.ts +0 -1
  17. package/dist/ltree/indexer.js +23 -19
  18. package/dist/ltree/ltree.svelte.d.ts +1 -1
  19. package/dist/ltree/ltree.svelte.js +593 -30
  20. package/dist/ltree/types.d.ts +62 -0
  21. package/dist/perf-logger.d.ts +70 -0
  22. package/dist/perf-logger.js +196 -0
  23. package/dist/styles/main.scss +437 -4
  24. package/dist/styles.css +329 -3
  25. package/dist/styles.css.map +1 -1
  26. package/dist/vendor/loglevel/index.d.ts +2 -0
  27. package/dist/vendor/loglevel/index.js +9 -0
  28. package/dist/vendor/loglevel/loglevel-esm.d.ts +2 -0
  29. package/dist/vendor/loglevel/loglevel-esm.js +349 -0
  30. package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.d.ts +7 -0
  31. package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.js +132 -0
  32. package/dist/vendor/loglevel/loglevel-plugin-prefix.d.ts +2 -0
  33. package/dist/vendor/loglevel/loglevel-plugin-prefix.js +149 -0
  34. package/dist/vendor/loglevel/loglevel.js +357 -0
  35. package/dist/vendor/loglevel/prefix.d.ts +2 -0
  36. package/dist/vendor/loglevel/prefix.js +9 -0
  37. package/package.json +3 -2
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  A high-performance, feature-rich hierarchical tree view component for Svelte 5 with drag & drop support, search functionality, and flexible data structures using LTree.
4
4
 
5
- > [!IMPORTANT]
6
- > **Looking for a framework-agnostic solution?** There's also a web component version that can be used standalone or in other frameworks at https://github.com/KeenMate/web-treeview/
5
+ > [!TIP]
6
+ > **v4.5.0 Performance Boost** - Optimized tree building algorithm now loads 17,000+ nodes in under 100ms (previously 85+ seconds). See [Performance](#-performance) for details.
7
7
 
8
8
  ## šŸš€ Features
9
9
 
@@ -60,6 +60,28 @@ If using Vite, Webpack, or similar, you can import the SCSS:
60
60
  import '@keenmate/svelte-treeview/styles.scss';
61
61
  ```
62
62
 
63
+ ## āš ļø Performance Warning: Use `$state.raw()` for Large Datasets
64
+
65
+ > [!WARNING]
66
+ > **When passing large arrays (1000+ items) to the Tree component, use `$state.raw()` instead of `$state()` to avoid severe performance issues.**
67
+
68
+ Svelte 5's `$state()` creates deep proxies for all nested objects. With thousands of items, this causes massive overhead during tree operations.
69
+
70
+ ```typescript
71
+ // āŒ SLOW - Each item becomes a Proxy (5000x slower with large datasets)
72
+ let treeData = $state<TreeNode[]>([])
73
+
74
+ // āœ… FAST - Items remain plain objects
75
+ let treeData = $state.raw<TreeNode[]>([])
76
+ ```
77
+
78
+ **Symptoms of this issue:**
79
+ - Tree takes 15-90+ seconds to render with thousands of items
80
+ - Console shows `[Violation] 'message' handler took XXXXms`
81
+ - Same data loads instantly in isolated test
82
+
83
+ The array itself remains reactive - only individual items lose deep reactivity (which Tree doesn't need).
84
+
63
85
  ## šŸŽÆ Quick Start
64
86
 
65
87
  ```svelte
@@ -899,10 +921,23 @@ The component is optimized for large datasets:
899
921
  - **Async Search Indexing**: Uses `requestIdleCallback` for non-blocking search index building
900
922
  - **Accurate Search Results**: Search index only includes successfully inserted nodes, ensuring results match visible tree structure
901
923
  - **Consistent Visual Hierarchy**: Optimized CSS-based indentation prevents exponential spacing growth
902
- - **Virtual Scrolling**: (Coming soon)
903
- - **Lazy Loading**: (Coming soon)
904
924
  - **Search Indexing**: Uses FlexSearch for fast search operations
905
925
 
926
+ ### v4.5 Performance Improvements
927
+
928
+ **Optimized `insertArray` algorithm** - Fixed O(n²) bottleneck that caused 85+ second load times with large datasets. Now loads 17,000+ nodes in under 100ms.
929
+
930
+ **Performance Logging** - Built-in performance measurement for debugging:
931
+ ```typescript
932
+ import { enablePerfLogging } from '@keenmate/svelte-treeview';
933
+ enablePerfLogging();
934
+
935
+ // Or from browser console:
936
+ window.components['svelte-treeview'].perf.enable()
937
+ ```
938
+
939
+ **Important**: See the [$state.raw() warning](#%EF%B8%8F-performance-warning-use-stateraw-for-large-datasets) above - using `$state()` instead of `$state.raw()` for tree data can cause 5,000x slowdown!
940
+
906
941
  ## šŸ¤ Contributing
907
942
 
908
943
  We welcome contributions! Please see our contributing guidelines for details.
@@ -1,8 +1,10 @@
1
1
  <script lang="ts" generics="T">
2
2
  import {type LTreeNode} from "../ltree/ltree-node.svelte.js"
3
3
  import Node from "./Node.svelte"
4
- import {getContext, type Snippet} from "svelte"
5
- import type {Ltree} from "../ltree/types.js"
4
+ import {getContext, onDestroy, type Snippet} from "svelte"
5
+ import type {Ltree, DropPosition, DropOperation} from "../ltree/types.js"
6
+ import type {RenderCoordinator} from "./RenderCoordinator.svelte.js"
7
+ import { uiLogger } from "../logger.js"
6
8
 
7
9
  // Define component props interface
8
10
  interface Props {
@@ -12,11 +14,22 @@
12
14
  onNodeRightClicked?: (node: LTreeNode<T>, event: MouseEvent) => void;
13
15
  onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
14
16
  onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
17
+ onNodeDragLeave?: (node: LTreeNode<T>, event: DragEvent) => void;
15
18
  onNodeDrop?: (node: LTreeNode<T>, event: DragEvent) => void;
19
+ onZoneDrop?: (node: LTreeNode<T>, position: DropPosition, event: DragEvent) => void;
20
+
21
+ // Touch drag handlers for mobile support
22
+ onTouchDragStart?: (node: LTreeNode<T>, event: TouchEvent) => void;
23
+ onTouchDragMove?: (node: LTreeNode<T>, event: TouchEvent) => void;
24
+ onTouchDragEnd?: (node: LTreeNode<T>, event: TouchEvent) => void;
16
25
 
17
26
  // BEHAVIOUR
18
27
  shouldToggleOnNodeClick?: boolean | null | undefined;
19
28
 
29
+ // Progressive rendering
30
+ progressiveRender?: boolean;
31
+ renderBatchSize?: number;
32
+
20
33
  // VISUALS
21
34
  expandIconClass?: string | null | undefined;
22
35
  collapseIconClass?: string | null | undefined;
@@ -24,6 +37,19 @@
24
37
  selectedNodeClass?: string | null | undefined;
25
38
  dragOverNodeClass?: string | null | undefined;
26
39
  isDraggedNode?: boolean | null | undefined;
40
+
41
+ // Drag position indicators
42
+ isDragInProgress?: boolean;
43
+ hoveredNodeForDropPath?: string | null; // Path of node being hovered for drop
44
+ activeDropPosition?: DropPosition | null;
45
+
46
+ // Drop zone configuration
47
+ dropZoneMode?: 'floating' | 'glow'; // 'floating' = original floating zones, 'glow' = border glow indicators
48
+ dropZoneLayout?: 'around' | 'above' | 'below' | 'wave' | 'wave2';
49
+ dropZoneStart?: number | string; // number = percentage (0-100), string = any CSS value ("33%", "50px", "3rem")
50
+ dropZoneMaxWidth?: number; // max width in pixels for wave layouts
51
+ dropOperation?: DropOperation; // Current drag operation ('move' or 'copy')
52
+ allowCopy?: boolean; // Whether copy operation is allowed (Ctrl+drag)
27
53
  }
28
54
 
29
55
  // Destructure props using Svelte 5 syntax
@@ -34,11 +60,22 @@
34
60
  onNodeRightClicked,
35
61
  onNodeDragStart,
36
62
  onNodeDragOver,
63
+ onNodeDragLeave,
37
64
  onNodeDrop,
65
+ onZoneDrop,
66
+
67
+ // Touch drag handlers for mobile support
68
+ onTouchDragStart,
69
+ onTouchDragMove,
70
+ onTouchDragEnd,
38
71
 
39
72
  // BEHAVIOUR
40
73
  shouldToggleOnNodeClick = true,
41
74
 
75
+ // Progressive rendering
76
+ progressiveRender = false,
77
+ renderBatchSize = 50,
78
+
42
79
  // VISUALS
43
80
  expandIconClass = "ltree-icon-expand",
44
81
  collapseIconClass = "ltree-icon-collapse",
@@ -46,28 +83,155 @@
46
83
  selectedNodeClass,
47
84
  dragOverNodeClass,
48
85
  isDraggedNode = false,
86
+
87
+ // Drag position indicators
88
+ isDragInProgress = false,
89
+ hoveredNodeForDropPath = null,
90
+ activeDropPosition = null,
91
+
92
+ // Drop zone configuration
93
+ dropZoneMode = 'glow',
94
+ dropZoneLayout = 'around',
95
+ dropZoneStart = 33,
96
+ dropZoneMaxWidth = 120,
97
+ dropOperation = 'move',
98
+ allowCopy = false,
49
99
  }: Props = $props()
50
100
 
101
+ // Compute if THIS node is the one being hovered for drop
102
+ const isHoveredForDrop = $derived(hoveredNodeForDropPath === node.path)
103
+
104
+ // Format dropZoneStart - number = percentage, string = as-is
105
+ const formattedDropZoneStart = $derived(
106
+ typeof dropZoneStart === 'number' ? `${dropZoneStart}%` : dropZoneStart
107
+ )
108
+
51
109
  const tree = getContext<Ltree<T>>("Ltree")
110
+ const renderCoordinator = getContext<RenderCoordinator | null>("RenderCoordinator")
52
111
 
53
112
  // Drag over state
54
113
  let isDraggedOver = $state(false);
55
114
 
115
+ // Track which drop zone is being hovered during drag (for floating mode)
116
+ let hoveredZone = $state<'above' | 'below' | 'child' | null>(null);
117
+
118
+ // Track glow position for glow mode
119
+ let glowPosition = $state<'above' | 'below' | 'child' | null>(null);
120
+
121
+ // Calculate glow position based on mouse position in the node row
122
+ function calculateGlowPosition(event: DragEvent, element: HTMLElement): 'above' | 'below' | 'child' {
123
+ const rect = element.getBoundingClientRect();
124
+ const x = event.clientX - rect.left;
125
+ const y = event.clientY - rect.top;
126
+ const width = rect.width;
127
+ const height = rect.height;
128
+
129
+ // Right half = child
130
+ if (x > width / 2) {
131
+ return 'child';
132
+ }
133
+ // Left half, top 50% = above
134
+ if (y < height / 2) {
135
+ return 'above';
136
+ }
137
+ // Left half, bottom 50% = below
138
+ return 'below';
139
+ }
140
+
56
141
  // Convert reactive statements to derived values
142
+ const childrenArray = $derived(Object.values(node?.children || []))
57
143
  const childrenWithData = $derived(Object.values(node?.children || []))
58
144
  const hasChildren = $derived(node?.hasChildren || false)
59
145
  const indentStyle = $derived(
60
146
  `margin-left: var(--tree-node-indent-per-level, 0.5rem)`,
61
147
  )
62
148
 
149
+ // Progressive rendering state
150
+ let renderedCount = $state(0);
151
+ let unregisterFromCoordinator: (() => void) | null = null;
152
+ let lastExpandedState = false;
153
+ let lastChildrenLength = 0;
154
+
155
+ // Get the children to render (all or progressive slice)
156
+ const childrenToRender = $derived(
157
+ progressiveRender && renderCoordinator
158
+ ? childrenArray.slice(0, renderedCount)
159
+ : childrenArray
160
+ );
161
+ const hasMoreToRender = $derived(
162
+ progressiveRender && renderCoordinator && renderedCount < childrenArray.length
163
+ );
164
+
165
+ // Handle expansion state changes - use coordinator for progressive rendering
166
+ // Only react to isExpanded changes, not renderedCount changes
167
+ $effect(() => {
168
+ const isExpanded = node?.isExpanded ?? false;
169
+ const childCount = childrenArray.length;
170
+ const shouldRenderProgressively = progressiveRender && renderCoordinator && childCount > 0;
171
+
172
+ // Only act on actual state changes
173
+ if (isExpanded !== lastExpandedState || childCount !== lastChildrenLength) {
174
+ lastExpandedState = isExpanded;
175
+ lastChildrenLength = childCount;
176
+
177
+ if (isExpanded && shouldRenderProgressively) {
178
+ // Clean up any existing registration first
179
+ if (unregisterFromCoordinator) {
180
+ unregisterFromCoordinator();
181
+ unregisterFromCoordinator = null;
182
+ }
183
+
184
+ // If this node was already fully rendered (component recreated after changeTracker update),
185
+ // render all children immediately instead of progressive rendering
186
+ if (renderCoordinator.isCompleted(node.path)) {
187
+ renderedCount = childCount;
188
+ return;
189
+ }
190
+
191
+ // Start with first batch immediately
192
+ renderedCount = Math.min(renderBatchSize, childCount);
193
+
194
+ // Register with coordinator if there are more children to render
195
+ if (renderedCount < childCount) {
196
+ unregisterFromCoordinator = renderCoordinator.register(node.path, () => {
197
+ // Render a batch of children per callback invocation
198
+ if (renderedCount < childCount) {
199
+ renderedCount = Math.min(renderedCount + renderBatchSize, childCount);
200
+ return renderedCount < childCount; // Return true if more work needed
201
+ }
202
+ return false;
203
+ });
204
+ }
205
+ } else if (!isExpanded) {
206
+ // Clean up when collapsed
207
+ if (unregisterFromCoordinator) {
208
+ unregisterFromCoordinator();
209
+ unregisterFromCoordinator = null;
210
+ }
211
+ renderedCount = 0;
212
+ }
213
+ }
214
+ });
215
+
216
+ // Clean up on component destroy
217
+ onDestroy(() => {
218
+ if (unregisterFromCoordinator) {
219
+ unregisterFromCoordinator();
220
+ unregisterFromCoordinator = null;
221
+ }
222
+ });
223
+
63
224
  function toggleExpanded() {
64
225
  if (node.hasChildren) {
65
- node.isExpanded = !node.isExpanded
226
+ const newState = !node.isExpanded
227
+ uiLogger.debug(`${newState ? 'Expanding' : 'Collapsing'} node: ${node.path}`)
228
+ node.isExpanded = newState
66
229
  tree.refresh()
67
230
  }
68
231
  }
69
232
 
70
233
  function _onNodeClicked() {
234
+ uiLogger.debug(`Node clicked: ${node.path}`, { id: node.id, hasChildren: node.hasChildren })
71
235
  onNodeClicked?.(node)
72
236
  if (shouldToggleOnNodeClick) {
73
237
  toggleExpanded()
@@ -105,6 +269,10 @@
105
269
  class:ltree-clickable={node.isSelectable}
106
270
  class:ltree-dragged={isDraggedNode}
107
271
  class:ltree-draggable={node?.isDraggable}
272
+ class:ltree-glow-above={dropZoneMode === 'glow' && isDragInProgress && isHoveredForDrop && glowPosition === 'above'}
273
+ class:ltree-glow-below={dropZoneMode === 'glow' && isDragInProgress && isHoveredForDrop && glowPosition === 'below'}
274
+ class:ltree-glow-child={dropZoneMode === 'glow' && isDragInProgress && isHoveredForDrop && glowPosition === 'child'}
275
+ class:ltree-drop-copy={isDragInProgress && isHoveredForDrop && dropOperation === 'copy'}
108
276
  draggable={node?.isDraggable}
109
277
  onclick={(e) => {
110
278
  e.stopPropagation();
@@ -116,41 +284,58 @@
116
284
  }}
117
285
  ondragstart={(e) => {
118
286
  if (node?.isDraggable && e.dataTransfer) {
119
- // e.stopPropagation();
120
- e.dataTransfer.effectAllowed = "move";
287
+ e.dataTransfer.effectAllowed = allowCopy ? "copyMove" : "move";
121
288
  e.dataTransfer.setData(
122
289
  "application/svelte-treeview",
123
290
  JSON.stringify(node),
124
291
  );
125
- console.log(
126
- "dataTransfer types",
127
- JSON.stringify(e.dataTransfer.types),
128
- );
129
292
  onNodeDragStart?.(node, e);
130
293
  }
131
294
  }}
132
295
  ondragover={(e) => {
133
296
  if (e.dataTransfer?.types.includes("application/svelte-treeview")) {
134
297
  e.preventDefault();
298
+ // Set dropEffect directly from event to avoid timing issues with prop updates
299
+ if (e.dataTransfer) {
300
+ e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move';
301
+ }
135
302
  isDraggedOver = true;
303
+ // In glow mode, calculate and update the glow position
304
+ if (dropZoneMode === 'glow') {
305
+ glowPosition = calculateGlowPosition(e, e.currentTarget as HTMLElement);
306
+ }
136
307
  }
137
308
  onNodeDragOver?.(node, e);
138
309
  }}
139
310
  ondragleave={(e) => {
140
- // Only reset if we're actually leaving the node (not entering a child)
141
311
  const rect = e.currentTarget.getBoundingClientRect();
142
312
  const x = e.clientX;
143
313
  const y = e.clientY;
144
314
 
145
315
  if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
146
316
  isDraggedOver = false;
317
+ glowPosition = null;
318
+ onNodeDragLeave?.(node, e);
147
319
  }
148
320
  }}
149
321
  ondrop={(e) => {
150
322
  e.stopPropagation();
323
+ // Confirm dropEffect for spec compliance
324
+ if (e.dataTransfer) {
325
+ e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move';
326
+ }
151
327
  isDraggedOver = false;
152
- onNodeDrop?.(node, e);
328
+ // In glow mode, use the calculated glowPosition for the drop
329
+ if (dropZoneMode === 'glow' && glowPosition) {
330
+ onZoneDrop?.(node, glowPosition, e);
331
+ } else {
332
+ onNodeDrop?.(node, e);
333
+ }
334
+ glowPosition = null;
153
335
  }}
336
+ ontouchstart={(e) => onTouchDragStart?.(node, e)}
337
+ ontouchmove={(e) => onTouchDragMove?.(node, e)}
338
+ ontouchend={(e) => onTouchDragEnd?.(node, e)}
154
339
  >
155
340
  {#if children}
156
341
  {@render children(node)}
@@ -158,11 +343,42 @@
158
343
  {tree.getNodeDisplayValue(node)}
159
344
  {/if}
160
345
  </div>
346
+
347
+ <!-- Drop zones: positioned relative to .ltree-node-row (outside content to avoid padding issues) -->
348
+ <!-- Only render floating drop zones when in 'floating' mode -->
349
+ {#if dropZoneMode === 'floating' && isDragInProgress && isHoveredForDrop}
350
+ <div
351
+ class="ltree-drop-zones ltree-drop-zones-{dropZoneLayout}"
352
+ style="--drop-zone-start: {formattedDropZoneStart}; --drop-zone-max-width: {dropZoneMaxWidth}px;"
353
+ >
354
+ <div
355
+ class="ltree-drop-zone ltree-drop-above"
356
+ class:ltree-drop-zone-active={hoveredZone === 'above'}
357
+ ondragover={(e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = 'above'; onNodeDragOver?.(node, e); }}
358
+ ondragleave={() => { hoveredZone = null; }}
359
+ ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; onZoneDrop?.(node, 'above', e); }}
360
+ >↑ Above</div>
361
+ <div
362
+ class="ltree-drop-zone ltree-drop-below"
363
+ class:ltree-drop-zone-active={hoveredZone === 'below'}
364
+ ondragover={(e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = 'below'; onNodeDragOver?.(node, e); }}
365
+ ondragleave={() => { hoveredZone = null; }}
366
+ ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; onZoneDrop?.(node, 'below', e); }}
367
+ >↓ Below</div>
368
+ <div
369
+ class="ltree-drop-zone ltree-drop-child"
370
+ class:ltree-drop-zone-active={hoveredZone === 'child'}
371
+ ondragover={(e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = 'child'; onNodeDragOver?.(node, e); }}
372
+ ondragleave={() => { hoveredZone = null; }}
373
+ ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; onZoneDrop?.(node, 'child', e); }}
374
+ >→ Child</div>
375
+ </div>
376
+ {/if}
161
377
  </div>
162
378
 
163
379
  {#if node?.isExpanded && node?.hasChildren}
164
380
  <div class="ltree-children">
165
- {#each Object.values(node?.children) as item (item.id)}
381
+ {#each childrenToRender as item (item.id)}
166
382
  <Node
167
383
  node={item}
168
384
  {children}
@@ -171,15 +387,36 @@
171
387
  {onNodeRightClicked}
172
388
  {onNodeDragStart}
173
389
  {onNodeDragOver}
390
+ {onNodeDragLeave}
174
391
  {onNodeDrop}
392
+ {onZoneDrop}
393
+ {onTouchDragStart}
394
+ {onTouchDragMove}
395
+ {onTouchDragEnd}
396
+ {progressiveRender}
397
+ {renderBatchSize}
175
398
  {expandIconClass}
176
399
  {collapseIconClass}
177
400
  {leafIconClass}
178
401
  {selectedNodeClass}
179
402
  {dragOverNodeClass}
180
403
  {isDraggedNode}
404
+ {isDragInProgress}
405
+ {hoveredNodeForDropPath}
406
+ {activeDropPosition}
407
+ {dropZoneMode}
408
+ {dropZoneLayout}
409
+ {dropZoneStart}
410
+ {dropZoneMaxWidth}
411
+ {dropOperation}
412
+ {allowCopy}
181
413
  />
182
414
  {/each}
415
+ {#if hasMoreToRender}
416
+ <div class="ltree-loading-more">
417
+ Loading... ({renderedCount}/{childrenArray.length})
418
+ </div>
419
+ {/if}
183
420
  </div>
184
421
  {/if}
185
422
  </div>
@@ -1,6 +1,7 @@
1
1
  import { type LTreeNode } from "../ltree/ltree-node.svelte.js";
2
2
  import Node from "./Node.svelte";
3
3
  import { type Snippet } from "svelte";
4
+ import type { DropPosition, DropOperation } from "../ltree/types.js";
4
5
  declare function $$render<T>(): {
5
6
  props: {
6
7
  node: LTreeNode<T>;
@@ -9,14 +10,30 @@ declare function $$render<T>(): {
9
10
  onNodeRightClicked?: (node: LTreeNode<T>, event: MouseEvent) => void;
10
11
  onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
11
12
  onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
13
+ onNodeDragLeave?: (node: LTreeNode<T>, event: DragEvent) => void;
12
14
  onNodeDrop?: (node: LTreeNode<T>, event: DragEvent) => void;
15
+ onZoneDrop?: (node: LTreeNode<T>, position: DropPosition, event: DragEvent) => void;
16
+ onTouchDragStart?: (node: LTreeNode<T>, event: TouchEvent) => void;
17
+ onTouchDragMove?: (node: LTreeNode<T>, event: TouchEvent) => void;
18
+ onTouchDragEnd?: (node: LTreeNode<T>, event: TouchEvent) => void;
13
19
  shouldToggleOnNodeClick?: boolean | null | undefined;
20
+ progressiveRender?: boolean;
21
+ renderBatchSize?: number;
14
22
  expandIconClass?: string | null | undefined;
15
23
  collapseIconClass?: string | null | undefined;
16
24
  leafIconClass?: string | null | undefined;
17
25
  selectedNodeClass?: string | null | undefined;
18
26
  dragOverNodeClass?: string | null | undefined;
19
27
  isDraggedNode?: boolean | null | undefined;
28
+ isDragInProgress?: boolean;
29
+ hoveredNodeForDropPath?: string | null;
30
+ activeDropPosition?: DropPosition | null;
31
+ dropZoneMode?: "floating" | "glow";
32
+ dropZoneLayout?: "around" | "above" | "below" | "wave" | "wave2";
33
+ dropZoneStart?: number | string;
34
+ dropZoneMaxWidth?: number;
35
+ dropOperation?: DropOperation;
36
+ allowCopy?: boolean;
20
37
  };
21
38
  exports: {};
22
39
  bindings: "";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Global render coordinator for progressive rendering.
3
+ * Manages a single render loop that processes all pending node renders
4
+ * instead of each node having its own RAF loop.
5
+ */
6
+ type RenderCallback = () => boolean;
7
+ export interface RenderStats {
8
+ pending: number;
9
+ processed: number;
10
+ }
11
+ export interface RenderCoordinatorCallbacks {
12
+ onStart?: () => void;
13
+ onProgress?: (stats: RenderStats) => void;
14
+ onComplete?: (stats: RenderStats) => void;
15
+ }
16
+ export interface RenderCoordinator {
17
+ /** Register a node's render callback. Returns an unregister function. */
18
+ register(id: string, callback: RenderCallback): () => void;
19
+ /** Check if coordinator is actively rendering */
20
+ isActive(): boolean;
21
+ /** Get stats for debugging */
22
+ getStats(): RenderStats;
23
+ /** Check if a node path was already fully rendered */
24
+ isCompleted(id: string): boolean;
25
+ /** Reset completed state (e.g., when tree data changes) */
26
+ reset(): void;
27
+ }
28
+ export declare function createRenderCoordinator(batchSize?: number, callbacks?: RenderCoordinatorCallbacks): RenderCoordinator;
29
+ export {};
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Global render coordinator for progressive rendering.
3
+ * Manages a single render loop that processes all pending node renders
4
+ * instead of each node having its own RAF loop.
5
+ */
6
+ import { renderLogger } from '../logger.js';
7
+ export function createRenderCoordinator(batchSize = 100, callbacks) {
8
+ const pendingNodes = new Map();
9
+ const completedNodes = new Set(); // Track nodes that finished rendering
10
+ let rafId = null;
11
+ let isProcessing = false;
12
+ let processedCount = 0;
13
+ let wasActive = false;
14
+ function scheduleProcess() {
15
+ if (rafId !== null || pendingNodes.size === 0)
16
+ return;
17
+ rafId = requestAnimationFrame(() => {
18
+ rafId = null;
19
+ processFrame();
20
+ });
21
+ }
22
+ function processFrame() {
23
+ const frameStart = performance.now();
24
+ if (pendingNodes.size === 0) {
25
+ if (wasActive) {
26
+ isProcessing = false;
27
+ wasActive = false;
28
+ callbacks?.onComplete?.({ pending: 0, processed: processedCount });
29
+ }
30
+ return;
31
+ }
32
+ // Notify start on first frame
33
+ if (!wasActive) {
34
+ wasActive = true;
35
+ processedCount = 0;
36
+ callbacks?.onStart?.();
37
+ }
38
+ isProcessing = true;
39
+ let budget = batchSize;
40
+ const toRemove = [];
41
+ renderLogger.trace(`Frame start: pending=${pendingNodes.size}, budget=${budget}`);
42
+ // Process nodes until budget is exhausted
43
+ for (const [id, callback] of pendingNodes) {
44
+ if (budget <= 0)
45
+ break;
46
+ const callbackStart = performance.now();
47
+ const needsMore = callback();
48
+ const callbackTime = performance.now() - callbackStart;
49
+ if (callbackTime > 10) {
50
+ renderLogger.warn(`Slow callback: ${id} took ${callbackTime.toFixed(2)}ms`);
51
+ }
52
+ budget--;
53
+ processedCount++;
54
+ if (!needsMore) {
55
+ toRemove.push(id);
56
+ }
57
+ }
58
+ // Remove completed nodes and mark them as done
59
+ for (const id of toRemove) {
60
+ pendingNodes.delete(id);
61
+ completedNodes.add(id);
62
+ }
63
+ const frameTime = performance.now() - frameStart;
64
+ renderLogger.trace(`Frame end: processed ${batchSize - budget} nodes in ${frameTime.toFixed(2)}ms`);
65
+ // Notify progress
66
+ callbacks?.onProgress?.({ pending: pendingNodes.size, processed: processedCount });
67
+ // Schedule next frame if there's more work
68
+ if (pendingNodes.size > 0) {
69
+ scheduleProcess();
70
+ }
71
+ else {
72
+ isProcessing = false;
73
+ wasActive = false;
74
+ callbacks?.onComplete?.({ pending: 0, processed: processedCount });
75
+ }
76
+ }
77
+ return {
78
+ register(id, callback) {
79
+ // Skip if this node was already fully rendered
80
+ if (completedNodes.has(id)) {
81
+ return () => { }; // No-op unregister
82
+ }
83
+ pendingNodes.set(id, callback);
84
+ scheduleProcess();
85
+ // Return unregister function
86
+ return () => {
87
+ pendingNodes.delete(id);
88
+ };
89
+ },
90
+ isActive() {
91
+ return isProcessing || pendingNodes.size > 0;
92
+ },
93
+ getStats() {
94
+ return {
95
+ pending: pendingNodes.size,
96
+ processed: processedCount
97
+ };
98
+ },
99
+ isCompleted(id) {
100
+ return completedNodes.has(id);
101
+ },
102
+ reset() {
103
+ // Clear completed tracking when tree data changes
104
+ completedNodes.clear();
105
+ pendingNodes.clear();
106
+ if (rafId !== null) {
107
+ cancelAnimationFrame(rafId);
108
+ rafId = null;
109
+ }
110
+ isProcessing = false;
111
+ wasActive = false;
112
+ processedCount = 0;
113
+ }
114
+ };
115
+ }