@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.
- package/README.md +39 -4
- package/dist/components/Node.svelte +249 -12
- package/dist/components/Node.svelte.d.ts +17 -0
- package/dist/components/RenderCoordinator.svelte.d.ts +29 -0
- package/dist/components/RenderCoordinator.svelte.js +115 -0
- package/dist/components/Tree.svelte +855 -38
- package/dist/components/Tree.svelte.d.ts +160 -8
- package/dist/constants.generated.d.ts +6 -0
- package/dist/constants.generated.js +8 -0
- package/dist/global-api.d.ts +35 -0
- package/dist/global-api.js +36 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- package/dist/logger.d.ts +56 -0
- package/dist/logger.js +159 -0
- package/dist/ltree/indexer.d.ts +0 -1
- package/dist/ltree/indexer.js +23 -19
- package/dist/ltree/ltree.svelte.d.ts +1 -1
- package/dist/ltree/ltree.svelte.js +593 -30
- package/dist/ltree/types.d.ts +62 -0
- package/dist/perf-logger.d.ts +70 -0
- package/dist/perf-logger.js +196 -0
- package/dist/styles/main.scss +437 -4
- package/dist/styles.css +329 -3
- package/dist/styles.css.map +1 -1
- package/dist/vendor/loglevel/index.d.ts +2 -0
- package/dist/vendor/loglevel/index.js +9 -0
- package/dist/vendor/loglevel/loglevel-esm.d.ts +2 -0
- package/dist/vendor/loglevel/loglevel-esm.js +349 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.d.ts +7 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.js +132 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix.d.ts +2 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix.js +149 -0
- package/dist/vendor/loglevel/loglevel.js +357 -0
- package/dist/vendor/loglevel/prefix.d.ts +2 -0
- package/dist/vendor/loglevel/prefix.js +9 -0
- 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
|
-
> [!
|
|
6
|
-
> **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|