@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
package/README.md
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
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
|
+
> [!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/
|
|
7
7
|
|
|
8
8
|
## 🚀 Features
|
|
9
9
|
|
|
10
10
|
- **Svelte 5 Native**: Built specifically for Svelte 5 with full support for runes and modern Svelte patterns
|
|
11
|
-
- **High Performance**:
|
|
12
|
-
- **Drag & Drop**: Built-in drag and drop
|
|
11
|
+
- **High Performance**: Flat rendering mode with progressive loading for 5000+ nodes
|
|
12
|
+
- **Drag & Drop**: Built-in drag and drop with position control (above/below/child), touch support, and async validation
|
|
13
|
+
- **Tree Editing**: Built-in methods for add, move, remove operations with automatic path management
|
|
13
14
|
- **Search & Filter**: Integrated FlexSearch for fast, full-text search capabilities
|
|
14
15
|
- **Flexible Data Sources**: Works with any hierarchical data structure
|
|
15
16
|
- **Context Menus**: Dynamic right-click menus with callback-based generation, icons, disabled states
|
|
@@ -82,6 +83,40 @@ let treeData = $state.raw<TreeNode[]>([])
|
|
|
82
83
|
|
|
83
84
|
The array itself remains reactive - only individual items lose deep reactivity (which Tree doesn't need).
|
|
84
85
|
|
|
86
|
+
## 📢 New in v4.6: Progressive Flat Rendering
|
|
87
|
+
|
|
88
|
+
> [!NOTE]
|
|
89
|
+
> **The tree now uses progressive flat rendering by default for significantly improved performance.**
|
|
90
|
+
|
|
91
|
+
**What this means:**
|
|
92
|
+
- The tree renders immediately with the first batch of nodes (~200 by default)
|
|
93
|
+
- Remaining nodes are rendered progressively in subsequent frames
|
|
94
|
+
- For large trees (5000+ nodes), you'll see nodes appear over ~100-500ms instead of a single long freeze
|
|
95
|
+
- The UI remains responsive during rendering
|
|
96
|
+
|
|
97
|
+
**Configuration options:**
|
|
98
|
+
```svelte
|
|
99
|
+
<Tree
|
|
100
|
+
{data}
|
|
101
|
+
useFlatRendering={true} <!-- Default: true (flat mode) -->
|
|
102
|
+
progressiveRender={true} <!-- Default: true (batched rendering) -->
|
|
103
|
+
initialBatchSize={20} <!-- First batch size (default: 20) -->
|
|
104
|
+
maxBatchSize={500} <!-- Maximum batch size cap (default: 500) -->
|
|
105
|
+
/>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Exponential batching:** The first batch renders 20 nodes instantly, then doubles each frame (20 → 40 → 80 → 160 → 320 → 500...) for optimal perceived performance.
|
|
109
|
+
|
|
110
|
+
**To use the legacy recursive rendering:**
|
|
111
|
+
```svelte
|
|
112
|
+
<Tree
|
|
113
|
+
{data}
|
|
114
|
+
useFlatRendering={false} <!-- Uses recursive Node components -->
|
|
115
|
+
/>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Recursive mode may be preferred for very small trees or when you need the `{#key changeTracker}` behavior that recreates all nodes on any change.
|
|
119
|
+
|
|
85
120
|
## 🎯 Quick Start
|
|
86
121
|
|
|
87
122
|
```svelte
|
|
@@ -237,49 +272,124 @@ For complete FlexSearch documentation, visit: [FlexSearch Options](https://githu
|
|
|
237
272
|
```svelte
|
|
238
273
|
<script lang="ts">
|
|
239
274
|
import { Tree } from '@keenmate/svelte-treeview';
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
{ path: 'zone1', name: 'Drop Zone 1' },
|
|
248
|
-
{ path: 'zone2', name: 'Drop Zone 2' }
|
|
275
|
+
|
|
276
|
+
let treeRef: Tree<MyNode>;
|
|
277
|
+
|
|
278
|
+
const data = [
|
|
279
|
+
{ path: '1', name: 'Folder 1', isDraggable: true },
|
|
280
|
+
{ path: '1.1', name: 'Item 1', isDraggable: true },
|
|
281
|
+
{ path: '2', name: 'Folder 2', isDraggable: true }
|
|
249
282
|
];
|
|
250
|
-
|
|
283
|
+
|
|
251
284
|
function onDragStart(node, event) {
|
|
252
285
|
console.log('Dragging:', node.data.name);
|
|
253
286
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
287
|
+
|
|
288
|
+
// Same-tree moves are auto-handled - this callback is for notification/custom logic
|
|
289
|
+
function onDrop(dropNode, draggedNode, position, event, operation) {
|
|
290
|
+
console.log(`Dropped ${draggedNode.data.name} ${position} ${dropNode?.data.name}`);
|
|
291
|
+
// position is 'above', 'below', or 'child'
|
|
292
|
+
// operation is 'move' or 'copy' (Ctrl+drag)
|
|
258
293
|
}
|
|
259
294
|
</script>
|
|
260
295
|
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
<div class="col-6">
|
|
272
|
-
<Tree
|
|
273
|
-
data={targetData}
|
|
274
|
-
idMember="path"
|
|
275
|
-
pathMember="path"
|
|
276
|
-
dragOverNodeClass="ltree-dragover-highlight"
|
|
277
|
-
onNodeDrop={onDrop}
|
|
278
|
-
/>
|
|
279
|
-
</div>
|
|
280
|
-
</div>
|
|
296
|
+
<Tree
|
|
297
|
+
bind:this={treeRef}
|
|
298
|
+
{data}
|
|
299
|
+
idMember="path"
|
|
300
|
+
pathMember="path"
|
|
301
|
+
orderMember="sortOrder"
|
|
302
|
+
dragOverNodeClass="ltree-dragover-highlight"
|
|
303
|
+
onNodeDragStart={onDragStart}
|
|
304
|
+
onNodeDrop={onDrop}
|
|
305
|
+
/>
|
|
281
306
|
```
|
|
282
307
|
|
|
308
|
+
#### Drop Position Control
|
|
309
|
+
|
|
310
|
+
When using `dropZoneMode="floating"` (default), users can choose where to drop:
|
|
311
|
+
- **Above**: Insert as sibling before the target node
|
|
312
|
+
- **Below**: Insert as sibling after the target node
|
|
313
|
+
- **Child**: Insert as child of the target node
|
|
314
|
+
|
|
315
|
+
#### Async Drop Validation
|
|
316
|
+
|
|
317
|
+
Use `beforeDropCallback` to validate or modify drops, including async operations like confirmation dialogs:
|
|
318
|
+
|
|
319
|
+
```svelte
|
|
320
|
+
<script lang="ts">
|
|
321
|
+
async function beforeDrop(dropNode, draggedNode, position, event, operation) {
|
|
322
|
+
// Cancel specific drops
|
|
323
|
+
if (draggedNode.data.locked) {
|
|
324
|
+
return false; // Cancel the drop
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Show confirmation dialog (async)
|
|
328
|
+
if (position === 'child' && !dropNode.data.isFolder) {
|
|
329
|
+
const confirmed = await showConfirmDialog('Drop as sibling instead?');
|
|
330
|
+
if (!confirmed) return false;
|
|
331
|
+
return { position: 'below' }; // Override position
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Proceed normally
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
</script>
|
|
338
|
+
|
|
339
|
+
<Tree
|
|
340
|
+
{data}
|
|
341
|
+
beforeDropCallback={beforeDrop}
|
|
342
|
+
onNodeDrop={onDrop}
|
|
343
|
+
/>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Tree Editing
|
|
347
|
+
|
|
348
|
+
The tree provides built-in methods for programmatic editing:
|
|
349
|
+
|
|
350
|
+
```svelte
|
|
351
|
+
<script lang="ts">
|
|
352
|
+
import { Tree } from '@keenmate/svelte-treeview';
|
|
353
|
+
|
|
354
|
+
let treeRef: Tree<MyNode>;
|
|
355
|
+
|
|
356
|
+
// Add a new node
|
|
357
|
+
function addChild() {
|
|
358
|
+
const result = treeRef.addNode(
|
|
359
|
+
selectedNode?.path || '', // parent path (empty = root)
|
|
360
|
+
{ id: Date.now(), path: '', name: 'New Item', sortOrder: 100 }
|
|
361
|
+
);
|
|
362
|
+
if (result.success) {
|
|
363
|
+
console.log('Added:', result.node);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Move a node
|
|
368
|
+
function moveUp() {
|
|
369
|
+
const siblings = treeRef.getSiblings(selectedNode.path);
|
|
370
|
+
const index = siblings.findIndex(s => s.path === selectedNode.path);
|
|
371
|
+
if (index > 0) {
|
|
372
|
+
treeRef.moveNode(selectedNode.path, siblings[index - 1].path, 'above');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Remove a node
|
|
377
|
+
function remove() {
|
|
378
|
+
treeRef.removeNode(selectedNode.path);
|
|
379
|
+
}
|
|
380
|
+
</script>
|
|
381
|
+
|
|
382
|
+
<Tree
|
|
383
|
+
bind:this={treeRef}
|
|
384
|
+
{data}
|
|
385
|
+
idMember="id"
|
|
386
|
+
pathMember="path"
|
|
387
|
+
orderMember="sortOrder"
|
|
388
|
+
/>
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Note**: When using `orderMember`, the tree automatically calculates sort order values when moving nodes with 'above' or 'below' positions.
|
|
392
|
+
|
|
283
393
|
### With Context Menus
|
|
284
394
|
|
|
285
395
|
The tree supports context menus with two approaches: callback-based (recommended) and snippet-based.
|
|
@@ -531,6 +641,10 @@ Without both requirements, no search indexing will occur.
|
|
|
531
641
|
|------|------|---------|-------------|
|
|
532
642
|
| `expandLevel` | `number \| null` | `2` | Automatically expand nodes up to this level |
|
|
533
643
|
| `shouldToggleOnNodeClick` | `boolean` | `true` | Toggle expansion on node click |
|
|
644
|
+
| `useFlatRendering` | `boolean` | `true` | Use flat rendering mode (faster for large trees) |
|
|
645
|
+
| `progressiveRender` | `boolean` | `true` | Progressively render nodes in batches |
|
|
646
|
+
| `renderBatchSize` | `number` | `50` | Number of nodes to render per batch |
|
|
647
|
+
| `orderMember` | `string \| null` | `null` | Property name for sort order (enables above/below positioning in drag-drop) |
|
|
534
648
|
| `indexerBatchSize` | `number \| null` | `25` | Number of nodes to process per batch during search indexing |
|
|
535
649
|
| `indexerTimeout` | `number \| null` | `50` | Maximum time (ms) to wait for idle callback before forcing indexing |
|
|
536
650
|
| `shouldDisplayDebugInformation` | `boolean` | `false` | Show debug information panel with tree statistics and enable console debug logging for tree operations and async search indexing |
|
|
@@ -542,7 +656,8 @@ Without both requirements, no search indexing will occur.
|
|
|
542
656
|
| `onNodeClicked` | `(node) => void` | `undefined` | Node click event handler |
|
|
543
657
|
| `onNodeDragStart` | `(node, event) => void` | `undefined` | Drag start event handler |
|
|
544
658
|
| `onNodeDragOver` | `(node, event) => void` | `undefined` | Drag over event handler |
|
|
545
|
-
| `
|
|
659
|
+
| `beforeDropCallback` | `(dropNode, draggedNode, position, event, operation) => boolean \| { position?, operation? } \| Promise<...>` | `undefined` | Async-capable callback to validate/modify drops before they happen |
|
|
660
|
+
| `onNodeDrop` | `(dropNode, draggedNode, position, event, operation) => void` | `undefined` | Drop event handler. Position is 'above', 'below', or 'child'. Operation is 'move' or 'copy' |
|
|
546
661
|
|
|
547
662
|
#### Visual Styling Properties
|
|
548
663
|
| Prop | Type | Default | Description |
|
|
@@ -577,6 +692,12 @@ Without both requirements, no search indexing will occur.
|
|
|
577
692
|
| `searchNodes` | `searchText: string \| null \| undefined, searchOptions?: SearchOptions` | Search nodes using internal search index and return matching nodes with optional FlexSearch options |
|
|
578
693
|
| `scrollToPath` | `path: string, options?: ScrollToPathOptions` | Scroll to and highlight a specific node |
|
|
579
694
|
| `update` | `updates: Partial<Props>` | Programmatically update component props from external JavaScript |
|
|
695
|
+
| `addNode` | `parentPath: string, data: T, pathSegment?: string` | Add a new node under the specified parent |
|
|
696
|
+
| `moveNode` | `sourcePath: string, targetPath: string, position: 'above' \| 'below' \| 'child'` | Move a node to a new location |
|
|
697
|
+
| `removeNode` | `path: string, includeDescendants?: boolean` | Remove a node (and optionally its descendants) |
|
|
698
|
+
| `getNodeByPath` | `path: string` | Get a node by its path |
|
|
699
|
+
| `getChildren` | `parentPath: string` | Get direct children of a node |
|
|
700
|
+
| `getSiblings` | `path: string` | Get siblings of a node (including itself) |
|
|
580
701
|
|
|
581
702
|
#### ScrollToPath Options
|
|
582
703
|
|
|
@@ -742,8 +863,17 @@ Triggered when drag operation starts.
|
|
|
742
863
|
#### onNodeDragOver(node, event)
|
|
743
864
|
Triggered when dragging over a potential drop target.
|
|
744
865
|
|
|
745
|
-
####
|
|
746
|
-
|
|
866
|
+
#### beforeDropCallback(dropNode, draggedNode, position, event, operation)
|
|
867
|
+
Called before a drop is processed. Can be async for showing dialogs.
|
|
868
|
+
- Return `false` to cancel the drop
|
|
869
|
+
- Return `{ position: 'above'|'below'|'child' }` to override position
|
|
870
|
+
- Return `{ operation: 'move'|'copy' }` to override operation
|
|
871
|
+
- Return `true` or `undefined` to proceed normally
|
|
872
|
+
|
|
873
|
+
#### onNodeDrop(dropNode, draggedNode, position, event, operation)
|
|
874
|
+
Triggered when a node is dropped. For same-tree moves, the tree auto-handles the move and this callback is for notification.
|
|
875
|
+
- `position`: 'above', 'below', or 'child'
|
|
876
|
+
- `operation`: 'move' or 'copy' (Ctrl+drag)
|
|
747
877
|
|
|
748
878
|
### Slots
|
|
749
879
|
|
|
@@ -917,15 +1047,34 @@ interface InsertArrayResult<T> {
|
|
|
917
1047
|
|
|
918
1048
|
The component is optimized for large datasets:
|
|
919
1049
|
|
|
1050
|
+
- **Flat Rendering Mode**: Single `{#each}` loop instead of recursive components (default, ~12x faster initial render)
|
|
1051
|
+
- **Progressive Rendering**: Batched rendering prevents UI freeze during initial load
|
|
1052
|
+
- **Context-Based Callbacks**: Stable function references eliminate unnecessary re-renders
|
|
920
1053
|
- **LTree**: Efficient hierarchical data structure
|
|
921
1054
|
- **Async Search Indexing**: Uses `requestIdleCallback` for non-blocking search index building
|
|
922
|
-
- **Accurate Search Results**: Search index only includes successfully inserted nodes
|
|
923
|
-
- **Consistent Visual Hierarchy**: Optimized CSS-based indentation prevents exponential spacing growth
|
|
1055
|
+
- **Accurate Search Results**: Search index only includes successfully inserted nodes
|
|
924
1056
|
- **Search Indexing**: Uses FlexSearch for fast search operations
|
|
925
1057
|
|
|
926
|
-
###
|
|
1058
|
+
### Performance Benchmarks (5500 nodes)
|
|
1059
|
+
|
|
1060
|
+
| Operation | Time |
|
|
1061
|
+
|-----------|------|
|
|
1062
|
+
| Initial render | ~25ms |
|
|
1063
|
+
| Expand/collapse | ~100-150ms |
|
|
1064
|
+
| Search filtering | <50ms |
|
|
1065
|
+
|
|
1066
|
+
### v4.5+ Performance Improvements
|
|
1067
|
+
|
|
1068
|
+
**Flat Rendering Mode** (default) - Renders all visible nodes in a single loop:
|
|
1069
|
+
```svelte
|
|
1070
|
+
<Tree
|
|
1071
|
+
{data}
|
|
1072
|
+
useFlatRendering={true}
|
|
1073
|
+
progressiveRender={true}
|
|
1074
|
+
/>
|
|
1075
|
+
```
|
|
927
1076
|
|
|
928
|
-
**Optimized `insertArray` algorithm** - Fixed O(n²) bottleneck
|
|
1077
|
+
**Optimized `insertArray` algorithm** - Fixed O(n²) bottleneck. Now loads 17,000+ nodes in under 100ms.
|
|
929
1078
|
|
|
930
1079
|
**Performance Logging** - Built-in performance measurement for debugging:
|
|
931
1080
|
```typescript
|
|
@@ -4,102 +4,73 @@
|
|
|
4
4
|
import {getContext, onDestroy, type Snippet} from "svelte"
|
|
5
5
|
import type {Ltree, DropPosition, DropOperation} from "../ltree/types.js"
|
|
6
6
|
import type {RenderCoordinator} from "./RenderCoordinator.svelte.js"
|
|
7
|
+
import type {NodeCallbacks, NodeConfig} from "./Tree.svelte"
|
|
7
8
|
import { uiLogger } from "../logger.js"
|
|
8
9
|
|
|
9
10
|
// Define component props interface
|
|
11
|
+
// Callbacks and config come from context, drag state comes as props
|
|
10
12
|
interface Props {
|
|
11
13
|
node: LTreeNode<T>;
|
|
12
14
|
children?: Snippet<[T]>; // Keep the general children slot for backward compatibility
|
|
13
|
-
onNodeClicked?: (node: LTreeNode<T>) => void;
|
|
14
|
-
onNodeRightClicked?: (node: LTreeNode<T>, event: MouseEvent) => void;
|
|
15
|
-
onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
16
|
-
onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
17
|
-
onNodeDragLeave?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
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;
|
|
25
|
-
|
|
26
|
-
// BEHAVIOUR
|
|
27
|
-
shouldToggleOnNodeClick?: boolean | null | undefined;
|
|
28
15
|
|
|
29
16
|
// Progressive rendering
|
|
30
17
|
progressiveRender?: boolean;
|
|
31
18
|
renderBatchSize?: number;
|
|
32
19
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
collapseIconClass?: string | null | undefined;
|
|
36
|
-
leafIconClass?: string | null | undefined;
|
|
37
|
-
selectedNodeClass?: string | null | undefined;
|
|
38
|
-
dragOverNodeClass?: string | null | undefined;
|
|
39
|
-
isDraggedNode?: boolean | null | undefined;
|
|
40
|
-
|
|
41
|
-
// Drag position indicators
|
|
20
|
+
// Drag state (passed as props for efficient Svelte diffing)
|
|
21
|
+
isDraggedNode?: boolean;
|
|
42
22
|
isDragInProgress?: boolean;
|
|
43
|
-
hoveredNodeForDropPath?: string | null;
|
|
23
|
+
hoveredNodeForDropPath?: string | null;
|
|
44
24
|
activeDropPosition?: DropPosition | null;
|
|
25
|
+
dropOperation?: DropOperation;
|
|
45
26
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
// Flat rendering mode
|
|
28
|
+
flatMode?: boolean; // When true, don't render children (Tree handles flat rendering)
|
|
29
|
+
flatIndentSize?: string; // CSS value for per-level indentation in flat mode
|
|
53
30
|
}
|
|
54
31
|
|
|
55
32
|
// Destructure props using Svelte 5 syntax
|
|
56
33
|
let {
|
|
57
34
|
node,
|
|
58
35
|
children = undefined,
|
|
59
|
-
onNodeClicked,
|
|
60
|
-
onNodeRightClicked,
|
|
61
|
-
onNodeDragStart,
|
|
62
|
-
onNodeDragOver,
|
|
63
|
-
onNodeDragLeave,
|
|
64
|
-
onNodeDrop,
|
|
65
|
-
onZoneDrop,
|
|
66
|
-
|
|
67
|
-
// Touch drag handlers for mobile support
|
|
68
|
-
onTouchDragStart,
|
|
69
|
-
onTouchDragMove,
|
|
70
|
-
onTouchDragEnd,
|
|
71
|
-
|
|
72
|
-
// BEHAVIOUR
|
|
73
|
-
shouldToggleOnNodeClick = true,
|
|
74
36
|
|
|
75
37
|
// Progressive rendering
|
|
76
38
|
progressiveRender = false,
|
|
77
39
|
renderBatchSize = 50,
|
|
78
40
|
|
|
79
|
-
//
|
|
80
|
-
expandIconClass = "ltree-icon-expand",
|
|
81
|
-
collapseIconClass = "ltree-icon-collapse",
|
|
82
|
-
leafIconClass = "ltree-icon-leaf",
|
|
83
|
-
selectedNodeClass,
|
|
84
|
-
dragOverNodeClass,
|
|
41
|
+
// Drag state
|
|
85
42
|
isDraggedNode = false,
|
|
86
|
-
|
|
87
|
-
// Drag position indicators
|
|
88
43
|
isDragInProgress = false,
|
|
89
44
|
hoveredNodeForDropPath = null,
|
|
90
45
|
activeDropPosition = null,
|
|
91
|
-
|
|
92
|
-
// Drop zone configuration
|
|
93
|
-
dropZoneMode = 'glow',
|
|
94
|
-
dropZoneLayout = 'around',
|
|
95
|
-
dropZoneStart = 33,
|
|
96
|
-
dropZoneMaxWidth = 120,
|
|
97
46
|
dropOperation = 'move',
|
|
98
|
-
|
|
47
|
+
|
|
48
|
+
// Flat rendering mode
|
|
49
|
+
flatMode = false,
|
|
50
|
+
flatIndentSize = '1.5rem',
|
|
99
51
|
}: Props = $props()
|
|
100
52
|
|
|
53
|
+
// Get stable references from context (avoids prop drilling and re-renders from inline functions)
|
|
54
|
+
const callbacks = getContext<NodeCallbacks<T>>('NodeCallbacks');
|
|
55
|
+
const config = getContext<NodeConfig>('NodeConfig');
|
|
56
|
+
|
|
57
|
+
// Destructure config for convenience (these are stable references)
|
|
58
|
+
const {
|
|
59
|
+
shouldToggleOnNodeClick,
|
|
60
|
+
expandIconClass,
|
|
61
|
+
collapseIconClass,
|
|
62
|
+
leafIconClass,
|
|
63
|
+
selectedNodeClass,
|
|
64
|
+
dragOverNodeClass,
|
|
65
|
+
dropZoneMode,
|
|
66
|
+
dropZoneLayout,
|
|
67
|
+
dropZoneStart,
|
|
68
|
+
dropZoneMaxWidth,
|
|
69
|
+
allowCopy,
|
|
70
|
+
} = config;
|
|
71
|
+
|
|
101
72
|
// Compute if THIS node is the one being hovered for drop
|
|
102
|
-
const isHoveredForDrop = $derived(hoveredNodeForDropPath === node.path)
|
|
73
|
+
const isHoveredForDrop = $derived(hoveredNodeForDropPath === node.path);
|
|
103
74
|
|
|
104
75
|
// Format dropZoneStart - number = percentage, string = as-is
|
|
105
76
|
const formattedDropZoneStart = $derived(
|
|
@@ -139,32 +110,36 @@
|
|
|
139
110
|
}
|
|
140
111
|
|
|
141
112
|
// Convert reactive statements to derived values
|
|
142
|
-
|
|
143
|
-
const
|
|
113
|
+
// In flat mode, children rendering is handled by Tree.svelte, so we skip these computations
|
|
114
|
+
const childrenArray = $derived(!flatMode ? Object.values(node?.children || []) : [])
|
|
144
115
|
const hasChildren = $derived(node?.hasChildren || false)
|
|
145
116
|
const indentStyle = $derived(
|
|
146
|
-
|
|
117
|
+
flatMode
|
|
118
|
+
? `margin-left: calc(${(node?.level || 1) - 1} * ${flatIndentSize})`
|
|
119
|
+
: `margin-left: var(--tree-node-indent-per-level, 0.5rem)`,
|
|
147
120
|
)
|
|
148
121
|
|
|
149
|
-
// Progressive rendering state
|
|
122
|
+
// Progressive rendering state - only used in recursive mode
|
|
150
123
|
let renderedCount = $state(0);
|
|
151
124
|
let unregisterFromCoordinator: (() => void) | null = null;
|
|
152
125
|
let lastExpandedState = false;
|
|
153
126
|
let lastChildrenLength = 0;
|
|
154
127
|
|
|
155
|
-
// Get the children to render (all or progressive slice)
|
|
128
|
+
// Get the children to render (all or progressive slice) - only used in recursive mode
|
|
156
129
|
const childrenToRender = $derived(
|
|
157
|
-
progressiveRender && renderCoordinator
|
|
130
|
+
!flatMode && progressiveRender && renderCoordinator
|
|
158
131
|
? childrenArray.slice(0, renderedCount)
|
|
159
132
|
: childrenArray
|
|
160
133
|
);
|
|
161
134
|
const hasMoreToRender = $derived(
|
|
162
|
-
progressiveRender && renderCoordinator && renderedCount < childrenArray.length
|
|
135
|
+
!flatMode && progressiveRender && renderCoordinator && renderedCount < childrenArray.length
|
|
163
136
|
);
|
|
164
137
|
|
|
165
138
|
// Handle expansion state changes - use coordinator for progressive rendering
|
|
166
|
-
//
|
|
139
|
+
// Skip entirely in flat mode since Tree.svelte handles children rendering
|
|
167
140
|
$effect(() => {
|
|
141
|
+
if (flatMode) return; // Skip in flat mode - children handled by Tree
|
|
142
|
+
|
|
168
143
|
const isExpanded = node?.isExpanded ?? false;
|
|
169
144
|
const childCount = childrenArray.length;
|
|
170
145
|
const shouldRenderProgressively = progressiveRender && renderCoordinator && childCount > 0;
|
|
@@ -232,7 +207,7 @@
|
|
|
232
207
|
|
|
233
208
|
function _onNodeClicked() {
|
|
234
209
|
uiLogger.debug(`Node clicked: ${node.path}`, { id: node.id, hasChildren: node.hasChildren })
|
|
235
|
-
onNodeClicked
|
|
210
|
+
callbacks.onNodeClicked(node)
|
|
236
211
|
if (shouldToggleOnNodeClick) {
|
|
237
212
|
toggleExpanded()
|
|
238
213
|
}
|
|
@@ -280,7 +255,7 @@
|
|
|
280
255
|
}}
|
|
281
256
|
oncontextmenu={(e) => {
|
|
282
257
|
e.stopPropagation();
|
|
283
|
-
onNodeRightClicked
|
|
258
|
+
callbacks.onNodeRightClicked(node, e);
|
|
284
259
|
}}
|
|
285
260
|
ondragstart={(e) => {
|
|
286
261
|
if (node?.isDraggable && e.dataTransfer) {
|
|
@@ -289,7 +264,7 @@
|
|
|
289
264
|
"application/svelte-treeview",
|
|
290
265
|
JSON.stringify(node),
|
|
291
266
|
);
|
|
292
|
-
onNodeDragStart
|
|
267
|
+
callbacks.onNodeDragStart(node, e);
|
|
293
268
|
}
|
|
294
269
|
}}
|
|
295
270
|
ondragover={(e) => {
|
|
@@ -305,7 +280,7 @@
|
|
|
305
280
|
glowPosition = calculateGlowPosition(e, e.currentTarget as HTMLElement);
|
|
306
281
|
}
|
|
307
282
|
}
|
|
308
|
-
onNodeDragOver
|
|
283
|
+
callbacks.onNodeDragOver(node, e);
|
|
309
284
|
}}
|
|
310
285
|
ondragleave={(e) => {
|
|
311
286
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
@@ -315,7 +290,7 @@
|
|
|
315
290
|
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
|
|
316
291
|
isDraggedOver = false;
|
|
317
292
|
glowPosition = null;
|
|
318
|
-
onNodeDragLeave
|
|
293
|
+
callbacks.onNodeDragLeave(node, e);
|
|
319
294
|
}
|
|
320
295
|
}}
|
|
321
296
|
ondrop={(e) => {
|
|
@@ -327,15 +302,15 @@
|
|
|
327
302
|
isDraggedOver = false;
|
|
328
303
|
// In glow mode, use the calculated glowPosition for the drop
|
|
329
304
|
if (dropZoneMode === 'glow' && glowPosition) {
|
|
330
|
-
onZoneDrop
|
|
305
|
+
callbacks.onZoneDrop(node, glowPosition, e);
|
|
331
306
|
} else {
|
|
332
|
-
onNodeDrop
|
|
307
|
+
callbacks.onNodeDrop(node, e);
|
|
333
308
|
}
|
|
334
309
|
glowPosition = null;
|
|
335
310
|
}}
|
|
336
|
-
ontouchstart={(e) => onTouchDragStart
|
|
337
|
-
ontouchmove={(e) => onTouchDragMove
|
|
338
|
-
ontouchend={(e) => onTouchDragEnd
|
|
311
|
+
ontouchstart={(e) => callbacks.onTouchDragStart(node, e)}
|
|
312
|
+
ontouchmove={(e) => callbacks.onTouchDragMove(node, e)}
|
|
313
|
+
ontouchend={(e) => callbacks.onTouchDragEnd(node, e)}
|
|
339
314
|
>
|
|
340
315
|
{#if children}
|
|
341
316
|
{@render children(node)}
|
|
@@ -354,62 +329,42 @@
|
|
|
354
329
|
<div
|
|
355
330
|
class="ltree-drop-zone ltree-drop-above"
|
|
356
331
|
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
|
|
332
|
+
ondragover={(e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = 'above'; callbacks.onNodeDragOver(node, e); }}
|
|
358
333
|
ondragleave={() => { hoveredZone = null; }}
|
|
359
|
-
ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; onZoneDrop
|
|
334
|
+
ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; callbacks.onZoneDrop(node, 'above', e); }}
|
|
360
335
|
>↑ Above</div>
|
|
361
336
|
<div
|
|
362
337
|
class="ltree-drop-zone ltree-drop-below"
|
|
363
338
|
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
|
|
339
|
+
ondragover={(e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = 'below'; callbacks.onNodeDragOver(node, e); }}
|
|
365
340
|
ondragleave={() => { hoveredZone = null; }}
|
|
366
|
-
ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; onZoneDrop
|
|
341
|
+
ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; callbacks.onZoneDrop(node, 'below', e); }}
|
|
367
342
|
>↓ Below</div>
|
|
368
343
|
<div
|
|
369
344
|
class="ltree-drop-zone ltree-drop-child"
|
|
370
345
|
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
|
|
346
|
+
ondragover={(e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = 'child'; callbacks.onNodeDragOver(node, e); }}
|
|
372
347
|
ondragleave={() => { hoveredZone = null; }}
|
|
373
|
-
ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; onZoneDrop
|
|
348
|
+
ondrop={(e) => { e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = (allowCopy && e.ctrlKey) ? 'copy' : 'move'; hoveredZone = null; callbacks.onZoneDrop(node, 'child', e); }}
|
|
374
349
|
>→ Child</div>
|
|
375
350
|
</div>
|
|
376
351
|
{/if}
|
|
377
352
|
</div>
|
|
378
353
|
|
|
379
|
-
|
|
354
|
+
<!-- In flat mode, children are rendered by Tree.svelte, not recursively here -->
|
|
355
|
+
{#if !flatMode && node?.isExpanded && node?.hasChildren}
|
|
380
356
|
<div class="ltree-children">
|
|
381
357
|
{#each childrenToRender as item (item.id)}
|
|
382
358
|
<Node
|
|
383
359
|
node={item}
|
|
384
360
|
{children}
|
|
385
|
-
{shouldToggleOnNodeClick}
|
|
386
|
-
{onNodeClicked}
|
|
387
|
-
{onNodeRightClicked}
|
|
388
|
-
{onNodeDragStart}
|
|
389
|
-
{onNodeDragOver}
|
|
390
|
-
{onNodeDragLeave}
|
|
391
|
-
{onNodeDrop}
|
|
392
|
-
{onZoneDrop}
|
|
393
|
-
{onTouchDragStart}
|
|
394
|
-
{onTouchDragMove}
|
|
395
|
-
{onTouchDragEnd}
|
|
396
361
|
{progressiveRender}
|
|
397
362
|
{renderBatchSize}
|
|
398
|
-
{expandIconClass}
|
|
399
|
-
{collapseIconClass}
|
|
400
|
-
{leafIconClass}
|
|
401
|
-
{selectedNodeClass}
|
|
402
|
-
{dragOverNodeClass}
|
|
403
363
|
{isDraggedNode}
|
|
404
364
|
{isDragInProgress}
|
|
405
365
|
{hoveredNodeForDropPath}
|
|
406
366
|
{activeDropPosition}
|
|
407
|
-
{dropZoneMode}
|
|
408
|
-
{dropZoneLayout}
|
|
409
|
-
{dropZoneStart}
|
|
410
|
-
{dropZoneMaxWidth}
|
|
411
367
|
{dropOperation}
|
|
412
|
-
{allowCopy}
|
|
413
368
|
/>
|
|
414
369
|
{/each}
|
|
415
370
|
{#if hasMoreToRender}
|
|
@@ -6,34 +6,15 @@ declare function $$render<T>(): {
|
|
|
6
6
|
props: {
|
|
7
7
|
node: LTreeNode<T>;
|
|
8
8
|
children?: Snippet<[T]>;
|
|
9
|
-
onNodeClicked?: (node: LTreeNode<T>) => void;
|
|
10
|
-
onNodeRightClicked?: (node: LTreeNode<T>, event: MouseEvent) => void;
|
|
11
|
-
onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
12
|
-
onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
13
|
-
onNodeDragLeave?: (node: LTreeNode<T>, event: DragEvent) => void;
|
|
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;
|
|
19
|
-
shouldToggleOnNodeClick?: boolean | null | undefined;
|
|
20
9
|
progressiveRender?: boolean;
|
|
21
10
|
renderBatchSize?: number;
|
|
22
|
-
|
|
23
|
-
collapseIconClass?: string | null | undefined;
|
|
24
|
-
leafIconClass?: string | null | undefined;
|
|
25
|
-
selectedNodeClass?: string | null | undefined;
|
|
26
|
-
dragOverNodeClass?: string | null | undefined;
|
|
27
|
-
isDraggedNode?: boolean | null | undefined;
|
|
11
|
+
isDraggedNode?: boolean;
|
|
28
12
|
isDragInProgress?: boolean;
|
|
29
13
|
hoveredNodeForDropPath?: string | null;
|
|
30
14
|
activeDropPosition?: DropPosition | null;
|
|
31
|
-
dropZoneMode?: "floating" | "glow";
|
|
32
|
-
dropZoneLayout?: "around" | "above" | "below" | "wave" | "wave2";
|
|
33
|
-
dropZoneStart?: number | string;
|
|
34
|
-
dropZoneMaxWidth?: number;
|
|
35
15
|
dropOperation?: DropOperation;
|
|
36
|
-
|
|
16
|
+
flatMode?: boolean;
|
|
17
|
+
flatIndentSize?: string;
|
|
37
18
|
};
|
|
38
19
|
exports: {};
|
|
39
20
|
bindings: "";
|