@keenmate/svelte-treeview 4.8.0 → 5.0.0-rc02

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 (51) hide show
  1. package/README.md +106 -117
  2. package/ai/INDEX.txt +310 -0
  3. package/ai/advanced-patterns.txt +506 -0
  4. package/ai/basic-setup.txt +336 -0
  5. package/ai/context-menu.txt +349 -0
  6. package/ai/data-handling.txt +390 -0
  7. package/ai/drag-drop.txt +397 -0
  8. package/ai/events-callbacks.txt +382 -0
  9. package/ai/import-patterns.txt +271 -0
  10. package/ai/performance.txt +349 -0
  11. package/ai/search-features.txt +359 -0
  12. package/ai/styling-theming.txt +354 -0
  13. package/ai/tree-editing.txt +423 -0
  14. package/ai/typescript-types.txt +357 -0
  15. package/dist/components/Node.svelte +47 -40
  16. package/dist/components/Node.svelte.d.ts +1 -1
  17. package/dist/components/Tree.svelte +384 -1479
  18. package/dist/components/Tree.svelte.d.ts +30 -28
  19. package/dist/components/TreeProvider.svelte +28 -0
  20. package/dist/components/TreeProvider.svelte.d.ts +28 -0
  21. package/dist/constants.generated.d.ts +1 -1
  22. package/dist/constants.generated.js +1 -1
  23. package/dist/core/TreeController.svelte.d.ts +353 -0
  24. package/dist/core/TreeController.svelte.js +1503 -0
  25. package/dist/core/createTreeController.d.ts +9 -0
  26. package/dist/core/createTreeController.js +11 -0
  27. package/dist/global-api.d.ts +1 -1
  28. package/dist/global-api.js +5 -5
  29. package/dist/index.d.ts +10 -6
  30. package/dist/index.js +7 -3
  31. package/dist/logger.d.ts +7 -6
  32. package/dist/logger.js +0 -2
  33. package/dist/ltree/indexer.js +2 -4
  34. package/dist/ltree/ltree-node.svelte.d.ts +2 -1
  35. package/dist/ltree/ltree-node.svelte.js +1 -0
  36. package/dist/ltree/ltree.svelte.d.ts +1 -1
  37. package/dist/ltree/ltree.svelte.js +168 -175
  38. package/dist/ltree/types.d.ts +12 -8
  39. package/dist/perf-logger.d.ts +2 -1
  40. package/dist/perf-logger.js +0 -2
  41. package/dist/styles/main.scss +78 -78
  42. package/dist/styles.css +41 -41
  43. package/dist/styles.css.map +1 -1
  44. package/dist/vendor/loglevel/index.d.ts +55 -2
  45. package/dist/vendor/loglevel/prefix.d.ts +23 -2
  46. package/package.json +96 -95
  47. package/dist/ltree/ltree-demo.d.ts +0 -2
  48. package/dist/ltree/ltree-demo.js +0 -90
  49. package/dist/vendor/loglevel/loglevel-esm.d.ts +0 -2
  50. package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.d.ts +0 -7
  51. package/dist/vendor/loglevel/loglevel-plugin-prefix.d.ts +0 -2
@@ -2,98 +2,22 @@
2
2
  import type { Index, SearchOptions } from 'flexsearch';
3
3
  import Node from './Node.svelte';
4
4
  import { type LTreeNode } from '../ltree/ltree-node.svelte.js';
5
- import { createLTree } from '../ltree/ltree.svelte.js';
6
- import { type Ltree, type InsertArrayResult, type ContextMenuItem, type DropPosition, type DragDropMode, type DropOperation } from '../ltree/types.js';
7
- import { setContext, tick, untrack } from 'svelte';
8
- import { createRenderCoordinator, type RenderCoordinator, type RenderStats } from './RenderCoordinator.svelte.js';
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
- dragDropMode: DragDropMode;
34
- dropZoneMode: 'floating' | 'glow';
35
- dropZoneLayout: 'around' | 'above' | 'below' | 'wave' | 'wave2';
36
- dropZoneStart: number | string;
37
- dropZoneMaxWidth: number;
38
- allowCopy: boolean;
39
- }
40
-
41
-
42
- // Register global API for runtime logging control
43
- import '../global-api.js';
44
-
45
- // Context menu state
46
- let contextMenuVisible = $state(false);
47
- let contextMenuX = $state(0);
48
- let contextMenuY = $state(0);
49
- let contextMenuNode: LTreeNode<T> | null = $state(null);
50
-
51
- // Scroll highlight state - track current highlight to clear on next scroll
52
- let currentHighlight: { element: HTMLElement; timeoutId: ReturnType<typeof setTimeout> } | null = null;
53
-
54
- // Drag and drop state
55
- let draggedNode: LTreeNode<any> | null = $state.raw(null);
56
-
57
- // Touch drag state for mobile support
58
- let touchDragState = $state<{
59
- node: LTreeNode<any> | null;
60
- startX: number;
61
- startY: number;
62
- isDragging: boolean;
63
- ghostElement: HTMLElement | null;
64
- currentDropTarget: LTreeNode<any> | null;
65
- }>({ node: null, startX: 0, startY: 0, isDragging: false, ghostElement: null, currentDropTarget: null });
66
-
67
- let touchTimer: ReturnType<typeof setTimeout> | null = null;
68
-
69
- // Progressive rendering for flat mode
70
- // Track which node IDs we've rendered and progressively add new ones
71
- let flatRenderedIds = $state.raw<Set<string>>(new Set());
72
- let flatRenderQueue = $state.raw<string[]>([]);
73
- let flatRenderAnimationFrame: number | null = null;
74
- let currentBatchSize: number = 0; // Exponential: doubles each batch up to maxBatchSize
75
-
76
- // Virtual scrolling state
77
- let vsScrollTop = $state(0);
78
- let vsMeasuredRowHeight = $state<number | null>(null);
79
- let vsContainerRef = $state<HTMLDivElement | undefined>();
80
- let vsDetectedHeight = $state<string | null>(null);
81
-
82
- // Drop placeholder state for empty trees
83
- let isDropPlaceholderActive = $state(false);
84
-
85
- // Advanced drag state for position indicators
86
- let isDragInProgress = $state(false);
87
- let hoveredNodeForDrop = $state<LTreeNode<any> | null>(null);
88
- let activeDropPosition = $state<DropPosition | null>(null);
89
- let currentDropOperation = $state<DropOperation>('move');
90
-
91
- // Floating drop zone overlay state (position: fixed to escape overflow clipping)
92
- let floatingZoneRect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
93
- let floatingHoveredZone = $state<'above' | 'below' | 'child' | null>(null);
94
-
95
- // Flag to skip insertArray during internal mutations (addNode, moveNode, removeNode)
96
- let _skipInsertArray = false;
5
+ import {
6
+ type InsertArrayResult,
7
+ type ContextMenuItem,
8
+ type DropPosition,
9
+ type DragDropMode,
10
+ type DropOperation,
11
+ type TreeChange,
12
+ type ApplyChangesResult
13
+ } from '../ltree/types.js';
14
+ import { setContext, onDestroy } from 'svelte';
15
+ import type { RenderStats } from './RenderCoordinator.svelte.js';
16
+ import { TreeController } from '../core/TreeController.svelte.js';
17
+ import { createTreeController } from '../core/createTreeController.js';
18
+
19
+ // NodeCallbacks and NodeConfig are now defined in ../core/TreeController.svelte.ts
20
+ // and re-exported from index.ts for public consumption.
97
21
 
98
22
  interface Props {
99
23
  // MAPPINGS
@@ -104,9 +28,12 @@
104
28
  isExpandedMember?: string | null | undefined;
105
29
  isSelectedMember?: string | null | undefined;
106
30
  isDraggableMember?: string | null | undefined;
31
+ getIsDraggableCallback?: (node: LTreeNode<T>) => boolean;
107
32
  isDropAllowedMember?: string | null | undefined;
108
33
  allowedDropPositionsMember?: string | null | undefined;
109
34
  getAllowedDropPositionsCallback?: (node: LTreeNode<T>) => DropPosition[] | null | undefined;
35
+ isCollapsibleMember?: string | null | undefined;
36
+ getIsCollapsibleCallback?: (node: LTreeNode<T>) => boolean;
110
37
  hasChildrenMember?: string | null | undefined;
111
38
  isSorted?: boolean | null | undefined;
112
39
 
@@ -116,7 +43,7 @@
116
43
  searchValueMember?: string | null | undefined;
117
44
  getSearchValueCallback?: (node: LTreeNode<T>) => string;
118
45
 
119
- // For sibling ordering in drag-drop (above/below positioning)
46
+ // For sibling ordering in drag-drop (before/after positioning)
120
47
  orderMember?: string | null | undefined;
121
48
 
122
49
  treeId?: string | null | undefined;
@@ -150,7 +77,7 @@
150
77
  shouldDisplayContextMenuInDebugMode?: boolean;
151
78
  isLoading?: boolean;
152
79
 
153
- // Progressive rendering - render children in batches to avoid UI freeze
80
+ // Progressive rendering (exponential batching: 20 40 80 160...)
154
81
  progressiveRender?: boolean;
155
82
  initialBatchSize?: number;
156
83
  maxBatchSize?: number;
@@ -162,11 +89,13 @@
162
89
  /**
163
90
  * Use flat/centralized rendering instead of recursive node rendering.
164
91
  * This significantly improves performance for large trees by:
92
+ * - Removing the {#key changeTracker} block that destroys all nodes on any change
165
93
  * - Using a single flat loop instead of recursive component instantiation
166
94
  * - Allowing Svelte's keyed {#each} to efficiently diff only changed nodes
167
- * - Per-node reactive signals for O(1) data-only updates (updateNode, selection)
168
95
  */
169
96
  useFlatRendering?: boolean;
97
+
98
+ // VIRTUAL SCROLLING (flat mode only)
170
99
  /** Enable virtual scrolling in flat mode. Only visible nodes + overscan are rendered. */
171
100
  virtualScroll?: boolean;
172
101
  /** Explicit row height in px. Auto-measured from first row if not set. */
@@ -226,9 +155,12 @@
226
155
  isExpandedMember,
227
156
  isSelectedMember,
228
157
  isDraggableMember,
158
+ getIsDraggableCallback,
229
159
  isDropAllowedMember,
230
160
  allowedDropPositionsMember,
231
161
  getAllowedDropPositionsCallback,
162
+ isCollapsibleMember,
163
+ getIsCollapsibleCallback,
232
164
 
233
165
  displayValueMember,
234
166
  getDisplayValueCallback,
@@ -313,92 +245,227 @@
313
245
  contextMenuYOffset = 0
314
246
  }: Props = $props();
315
247
 
248
+ // ── Create controller ───────────────────────────────────────────────
249
+ const controller = createTreeController<T>({
250
+ idMember,
251
+ pathMember,
252
+ parentPathMember,
253
+ levelMember,
254
+ hasChildrenMember,
255
+ isExpandedMember,
256
+ isSelectedMember,
257
+ isDraggableMember,
258
+ getIsDraggableCallback,
259
+ isDropAllowedMember,
260
+ allowedDropPositionsMember,
261
+ getAllowedDropPositionsCallback,
262
+ isCollapsibleMember,
263
+ getIsCollapsibleCallback,
264
+ displayValueMember,
265
+ getDisplayValueCallback,
266
+ searchValueMember,
267
+ getSearchValueCallback,
268
+ orderMember,
269
+ isSorted,
270
+ sortCallback,
271
+ treeId,
272
+ treePathSeparator,
273
+ data,
274
+ selectedNode,
275
+ expandLevel,
276
+ shouldToggleOnNodeClick,
277
+ shouldUseInternalSearchIndex,
278
+ initializeIndexCallback,
279
+ searchText,
280
+ indexerBatchSize,
281
+ indexerTimeout,
282
+ shouldDisplayDebugInformation,
283
+ shouldDisplayContextMenuInDebugMode,
284
+ isLoading,
285
+ progressiveRender,
286
+ initialBatchSize,
287
+ maxBatchSize,
288
+ onRenderStart,
289
+ onRenderProgress,
290
+ onRenderComplete,
291
+ useFlatRendering,
292
+ virtualScroll,
293
+ virtualRowHeight,
294
+ virtualOverscan,
295
+ virtualContainerHeight,
296
+ dragDropMode,
297
+ dropZoneMode,
298
+ dropZoneLayout,
299
+ dropZoneStart,
300
+ dropZoneMaxWidth,
301
+ allowCopy,
302
+ autoHandleCopy,
303
+ onNodeClicked,
304
+ onNodeDragStart,
305
+ onNodeDragOver,
306
+ beforeDropCallback,
307
+ onNodeDrop,
308
+ contextMenuCallback,
309
+ hasContextMenuSnippet: !!contextMenu,
310
+ bodyClass,
311
+ selectedNodeClass,
312
+ dragOverNodeClass,
313
+ expandIconClass,
314
+ collapseIconClass,
315
+ leafIconClass,
316
+ scrollHighlightTimeout,
317
+ scrollHighlightClass,
318
+ contextMenuXOffset,
319
+ contextMenuYOffset,
320
+ });
321
+
322
+ // ── Set contexts (must happen synchronously during component init) ──
323
+ setContext('Ltree', controller.tree);
324
+ setContext('NodeCallbacks', controller.nodeCallbacks);
325
+ setContext('NodeConfig', controller.nodeConfig);
326
+ if (controller.renderCoordinator) {
327
+ setContext('RenderCoordinator', controller.renderCoordinator);
328
+ }
329
+
330
+ // ── Cleanup on destroy ──────────────────────────────────────────────
331
+ onDestroy(() => controller.destroy());
332
+
333
+ // ── Bind container element for controller ───────────────────────────
334
+ let treeContainerRef: HTMLDivElement;
335
+ $effect(() => {
336
+ if (treeContainerRef) {
337
+ controller.containerElement = treeContainerRef;
338
+ }
339
+ });
340
+
341
+ // ── Sync props → controller (one-way: parent prop changes flow in) ─
342
+ $effect(() => { controller.data = data; });
343
+ $effect(() => { controller.searchText = searchText; });
344
+ $effect(() => { if (treeId) controller.treeId = treeId; });
345
+ $effect(() => { controller.treePathSeparator = treePathSeparator ?? '.'; });
346
+ $effect(() => { controller.shouldDisplayDebugInformation = shouldDisplayDebugInformation ?? false; });
347
+ $effect(() => { controller.shouldDisplayContextMenuInDebugMode = shouldDisplayContextMenuInDebugMode ?? false; });
348
+ $effect(() => { controller.isLoading = isLoading ?? false; });
349
+ $effect(() => { controller.bodyClass = bodyClass; });
350
+ $effect(() => { controller.useFlatRendering = useFlatRendering ?? true; });
351
+ $effect(() => { controller.virtualScroll = virtualScroll ?? false; });
352
+ $effect(() => { controller.virtualRowHeight = virtualRowHeight; });
353
+ $effect(() => { controller.virtualOverscan = virtualOverscan ?? 5; });
354
+ $effect(() => { controller.virtualContainerHeight = virtualContainerHeight; });
355
+ $effect(() => { controller.progressiveRender = progressiveRender ?? true; });
356
+ $effect(() => { controller.initialBatchSize = initialBatchSize ?? 20; });
357
+ $effect(() => { controller.maxBatchSize = maxBatchSize ?? 500; });
358
+ $effect(() => { controller.dragDropMode = dragDropMode ?? 'none'; });
359
+ $effect(() => { controller.allowCopy = allowCopy ?? false; });
360
+ $effect(() => { controller.autoHandleCopy = autoHandleCopy ?? true; });
361
+ $effect(() => { controller.hasContextMenuSnippet = !!contextMenu; });
362
+
363
+ // Visual config sync (drives nodeConfig update via controller's internal effect)
364
+ $effect(() => { controller.shouldToggleOnNodeClick = shouldToggleOnNodeClick ?? true; });
365
+ $effect(() => { controller.expandIconClass = expandIconClass ?? 'ltree-icon-expand'; });
366
+ $effect(() => { controller.collapseIconClass = collapseIconClass ?? 'ltree-icon-collapse'; });
367
+ $effect(() => { controller.leafIconClass = leafIconClass ?? 'ltree-icon-leaf'; });
368
+ $effect(() => { controller.selectedNodeClass = selectedNodeClass; });
369
+ $effect(() => { controller.dragOverNodeClass = dragOverNodeClass; });
370
+ $effect(() => { controller.dropZoneMode = dropZoneMode ?? 'glow'; });
371
+ $effect(() => { controller.dropZoneLayout = dropZoneLayout ?? 'around'; });
372
+ $effect(() => { controller.dropZoneStart = dropZoneStart ?? 33; });
373
+ $effect(() => { controller.dropZoneMaxWidth = dropZoneMaxWidth ?? 120; });
374
+ $effect(() => { controller.scrollHighlightTimeout = scrollHighlightTimeout ?? 4000; });
375
+ $effect(() => { controller.scrollHighlightClass = scrollHighlightClass ?? 'ltree-scroll-highlight'; });
376
+ $effect(() => { controller.contextMenuXOffset = contextMenuXOffset ?? 8; });
377
+ $effect(() => { controller.contextMenuYOffset = contextMenuYOffset ?? 0; });
378
+
379
+ // Callback sync
380
+ $effect(() => { controller.onNodeClickedCb = onNodeClicked; });
381
+ $effect(() => { controller.onNodeDragStartCb = onNodeDragStart; });
382
+ $effect(() => { controller.onNodeDragOverCb = onNodeDragOver; });
383
+ $effect(() => { controller.beforeDropCallbackCb = beforeDropCallback; });
384
+ $effect(() => { controller.onNodeDropCb = onNodeDrop; });
385
+ $effect(() => { controller.contextMenuCallbackCb = contextMenuCallback; });
386
+ $effect(() => { controller.onRenderStartCb = onRenderStart; });
387
+ $effect(() => { controller.onRenderProgressCb = onRenderProgress; });
388
+ $effect(() => { controller.onRenderCompleteCb = onRenderComplete; });
389
+
390
+ // ── Sync controller → bindable props (outputs flow back to parent) ──
391
+ $effect(() => { selectedNode = controller.selectedNode; });
392
+ $effect(() => { insertResult = controller.insertResult; });
393
+ $effect(() => { isRendering = controller.isRendering; });
394
+
395
+ // Bidirectional: parent can also SET selectedNode
396
+ $effect(() => { controller.selectedNode = selectedNode; });
397
+
398
+ // ── Floating drop zone helpers ───────────────────────────────────────
399
+ const formattedDropZoneStart = $derived(
400
+ typeof controller.dropZoneStart === 'number' ? `${controller.dropZoneStart}%` : controller.dropZoneStart
401
+ );
402
+
403
+ // ── Export public methods (thin proxies) ────────────────────────────
316
404
  export async function expandNodes(nodePath: string) {
317
- tree.expandNodes(nodePath);
405
+ controller.expandNodes(nodePath);
318
406
  }
319
407
 
320
408
  export async function collapseNodes(nodePath: string) {
321
- tree.collapseNodes(nodePath);
409
+ controller.collapseNodes(nodePath);
322
410
  }
323
411
 
324
412
  export function expandAll(nodePath?: string | null | undefined) {
325
- tree?.expandAll(nodePath);
413
+ controller.expandAll(nodePath);
326
414
  }
327
415
 
328
416
  export function collapseAll(nodePath?: string | null | undefined) {
329
- tree?.collapseAll(nodePath);
417
+ controller.collapseAll(nodePath);
330
418
  }
331
419
 
332
- export function filterNodes(searchText: string, searchOptions?: SearchOptions): void {
333
- tree?.filterNodes(searchText, searchOptions);
420
+ export function filterNodes(searchTextVal: string, searchOptions?: SearchOptions): void {
421
+ controller.filterNodes(searchTextVal, searchOptions);
334
422
  }
335
423
 
336
424
  export function searchNodes(
337
- searchText: string | null | undefined,
425
+ searchTextVal: string | null | undefined,
338
426
  searchOptions?: SearchOptions
339
427
  ): LTreeNode<T>[] {
340
- return tree?.searchNodes(searchText, searchOptions) || [];
428
+ return controller.searchNodes(searchTextVal, searchOptions);
341
429
  }
342
430
 
343
- // Tree editor helper methods
344
431
  export function getChildren(parentPath: string): LTreeNode<T>[] {
345
- return tree?.getChildren(parentPath) || [];
432
+ return controller.getChildren(parentPath);
346
433
  }
347
434
 
348
435
  export function getSiblings(path: string): LTreeNode<T>[] {
349
- return tree?.getSiblings(path) || [];
436
+ return controller.getSiblings(path);
350
437
  }
351
438
 
352
439
  export function refreshSiblings(parentPath: string): void {
353
- tree?.refreshSiblings(parentPath);
440
+ controller.refreshSiblings(parentPath);
354
441
  }
355
442
 
356
443
  export function refreshNode(path: string): void {
357
- tree?.refreshNode(path);
444
+ controller.refreshNode(path);
358
445
  }
359
446
 
360
447
  export function getNodeByPath(path: string): LTreeNode<T> | null {
361
- return tree?.getNodeByPath(path) || null;
448
+ return controller.getNodeByPath(path);
362
449
  }
363
450
 
364
- // Tree editor mutation methods
365
- // These set _skipInsertArray to prevent the data effect from re-running insertArray
366
- // since these methods already update the tree structure directly.
367
- // We use tick() to reset the flag - if user updates data prop synchronously,
368
- // the effect runs before tick resolves and sees the flag. Otherwise tick resets it.
369
- export function moveNode(sourcePath: string, targetPath: string, position: 'above' | 'below' | 'child'): { success: boolean; error?: string } {
370
- _skipInsertArray = true;
371
- const result = tree?.moveNode(sourcePath, targetPath, position) || { success: false, error: 'Tree not initialized' };
372
- tick().then(() => { _skipInsertArray = false; });
373
- return result;
451
+ export function moveNode(sourcePath: string, targetPath: string, position: 'before' | 'after' | 'child'): { success: boolean; error?: string } {
452
+ return controller.moveNode(sourcePath, targetPath, position);
374
453
  }
375
454
 
376
455
  export function removeNode(path: string, includeDescendants: boolean = true): { success: boolean; node?: LTreeNode<T>; error?: string } {
377
- _skipInsertArray = true;
378
- const result = tree?.removeNode(path, includeDescendants) || { success: false, error: 'Tree not initialized' };
379
- tick().then(() => { _skipInsertArray = false; });
380
- return result;
456
+ return controller.removeNode(path, includeDescendants);
381
457
  }
382
458
 
383
- export function addNode(parentPath: string, data: T, pathSegment?: string): { success: boolean; node?: LTreeNode<T>; error?: string } {
384
- _skipInsertArray = true;
385
- const result = tree?.addNode(parentPath, data, pathSegment) || { success: false, error: 'Tree not initialized' };
386
- tick().then(() => { _skipInsertArray = false; });
387
- return result;
459
+ export function addNode(parentPath: string, nodeData: T, pathSegment?: string): { success: boolean; node?: LTreeNode<T>; error?: string } {
460
+ return controller.addNode(parentPath, nodeData, pathSegment);
388
461
  }
389
462
 
390
463
  export function updateNode(path: string, dataUpdates: Partial<T>): { success: boolean; node?: LTreeNode<T>; error?: string } {
391
- _skipInsertArray = true;
392
- const result = tree?.updateNode(path, dataUpdates) || { success: false, error: 'Tree not initialized' };
393
- tick().then(() => { _skipInsertArray = false; });
394
- return result;
464
+ return controller.updateNode(path, dataUpdates);
395
465
  }
396
466
 
397
- export function applyChanges(changes: import('../ltree/types').TreeChange<T>[]): import('../ltree/types').ApplyChangesResult {
398
- _skipInsertArray = true;
399
- const result = tree?.applyChanges(changes) || { successful: 0, failed: [] };
400
- tick().then(() => { _skipInsertArray = false; });
401
- return result;
467
+ export function applyChanges(changes: TreeChange<T>[]): ApplyChangesResult {
468
+ return controller.applyChanges(changes);
402
469
  }
403
470
 
404
471
  export function copyNodeWithDescendants(
@@ -406,187 +473,38 @@
406
473
  targetParentPath: string,
407
474
  transformData: (data: T) => T,
408
475
  siblingPath?: string,
409
- position?: 'above' | 'below'
476
+ position?: 'before' | 'after'
410
477
  ): { success: boolean; rootNode?: LTreeNode<T>; count: number; error?: string } {
411
- _skipInsertArray = true;
412
- const result = tree?.copyNodeWithDescendants(sourceNode, targetParentPath, transformData, siblingPath, position) || { success: false, count: 0, error: 'Tree not initialized' };
413
- tick().then(() => { _skipInsertArray = false; });
414
- return result;
478
+ return controller.copyNodeWithDescendants(sourceNode, targetParentPath, transformData, siblingPath, position);
415
479
  }
416
480
 
417
- // State persistence methods
418
481
  export function getExpandedPaths(): string[] {
419
- return tree?.getExpandedPaths() || [];
482
+ return controller.getExpandedPaths();
420
483
  }
421
484
 
422
485
  export function setExpandedPaths(paths: string[]): void {
423
- tree?.setExpandedPaths(paths);
486
+ controller.setExpandedPaths(paths);
424
487
  }
425
488
 
426
489
  export function getAllData(): T[] {
427
- return tree?.getAllData() || [];
490
+ return controller.getAllData();
428
491
  }
429
492
 
430
- // svelte-ignore non_reactive_update
431
493
  export function closeContextMenu() {
432
- contextMenuVisible = false;
433
- contextMenuNode = null;
434
- isDebugMenuActive = false;
494
+ controller.closeContextMenu();
435
495
  }
436
496
 
437
497
  export async function scrollToPath(
438
498
  path: string,
439
499
  options?: {
440
- /** Expand ancestors to make the node visible (default: true) */
441
500
  expand?: boolean;
442
- /** Also expand the target node itself to show its children (default: false for performance) */
443
501
  expandTarget?: boolean;
444
502
  highlight?: boolean;
445
503
  scrollOptions?: ScrollIntoViewOptions;
446
- /** Scroll only within the nearest scrollable container (prevents page scroll) */
447
504
  containerScroll?: boolean;
448
505
  }
449
506
  ): Promise<boolean> {
450
- perfStart(`[${treeId}] scrollToPath`);
451
- const {
452
- expand = true,
453
- expandTarget = false,
454
- highlight = true,
455
- scrollOptions = { behavior: 'smooth', block: 'center' },
456
- containerScroll = false
457
- } = options || {};
458
-
459
- // First, find the node to get its ID
460
- const node = tree.getNodeByPath(path);
461
- if (!node || !node.id) {
462
- console.warn(`[Tree ${treeId}] Node not found for path: ${path}`);
463
- perfEnd(`[${treeId}] scrollToPath`);
464
- return false;
465
- }
466
-
467
- // Expand ancestors to make the node visible
468
- // The target node's visibility depends on its parent being expanded, not on its own isExpanded state
469
- if (expand && node.parentPath) {
470
- tree.expandNodes(node.parentPath);
471
- }
472
-
473
- // Optionally expand the target node itself (shows its children, but triggers re-render if not already expanded)
474
- if (expandTarget) {
475
- tree.expandNodes(path);
476
- }
477
-
478
- if (expand || expandTarget) {
479
- await tick();
480
- }
481
-
482
- // Helper: find DOM element and apply highlight
483
- const applyHighlight = (elementId: string): boolean => {
484
- const element = document.getElementById(elementId);
485
- const contentDiv = element?.querySelector('.ltree-node-content') as HTMLElement | null;
486
- if (!contentDiv) return false;
487
-
488
- if (currentHighlight) {
489
- currentHighlight.element.classList.remove(scrollHighlightClass!);
490
- clearTimeout(currentHighlight.timeoutId);
491
- currentHighlight = null;
492
- }
493
- contentDiv.classList.add(scrollHighlightClass!);
494
- const timeoutId = setTimeout(() => {
495
- contentDiv.classList.remove(scrollHighlightClass!);
496
- currentHighlight = null;
497
- }, scrollHighlightTimeout);
498
- currentHighlight = { element: contentDiv, timeoutId };
499
- return true;
500
- };
501
-
502
- // Virtual scroll: index-based scrolling instead of DOM query
503
- if (vsActive && vsContainerRef) {
504
- const nodeIndex = allFlatNodes.findIndex(n => n.path === path);
505
- if (nodeIndex === -1) {
506
- console.warn(`[Tree ${treeId}] Node not found in flat nodes for path: ${path}`);
507
- perfEnd(`[${treeId}] scrollToPath`);
508
- return false;
509
- }
510
-
511
- // Scroll virtual container to center the node
512
- const targetScroll = nodeIndex * vsRowHeight
513
- - (vsContainerRef.clientHeight / 2)
514
- + vsRowHeight / 2;
515
- vsContainerRef.scrollTo({
516
- top: Math.max(0, targetScroll),
517
- behavior: scrollOptions?.behavior || 'smooth'
518
- });
519
-
520
- // Wait for scroll + re-render — need multiple frames for
521
- // rAF-throttled scroll handler → reactive update → DOM render
522
- await tick();
523
- await new Promise(r => requestAnimationFrame(r));
524
- await tick();
525
- await new Promise(r => requestAnimationFrame(r));
526
-
527
- if (highlight && scrollHighlightClass) {
528
- const elementId = `${treeId}-${node.id}`;
529
- if (!applyHighlight(elementId)) {
530
- // Element might not be rendered yet — retry after another frame
531
- await tick();
532
- await new Promise(r => requestAnimationFrame(r));
533
- applyHighlight(elementId);
534
- }
535
- }
536
-
537
- perfEnd(`[${treeId}] scrollToPath`);
538
- return true;
539
- }
540
-
541
- // Find the DOM element using the generated ID
542
- const elementId = `${treeId}-${node.id}`;
543
- const element = document.getElementById(elementId);
544
- const contentDiv = element?.querySelector('.ltree-node-content') as HTMLElement | null;
545
-
546
- if (!contentDiv) {
547
- console.warn(`[Tree ${treeId}] DOM element not found for node ID: ${elementId}`);
548
- perfEnd(`[${treeId}] scrollToPath`);
549
- return false;
550
- }
551
-
552
- // Scroll to the element
553
- if (containerScroll) {
554
- // Find nearest scrollable ancestor and scroll within it only
555
- const container = findScrollableAncestor(contentDiv);
556
- if (container) {
557
- const containerRect = container.getBoundingClientRect();
558
- const elementRect = contentDiv.getBoundingClientRect();
559
- const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2);
560
- container.scrollTo({
561
- top: scrollTop,
562
- behavior: scrollOptions?.behavior || 'smooth'
563
- });
564
- }
565
- } else {
566
- contentDiv.scrollIntoView(scrollOptions);
567
- }
568
-
569
- // Highlight the node temporarily if requested
570
- if (highlight && scrollHighlightClass) {
571
- applyHighlight(elementId);
572
- }
573
-
574
- perfEnd(`[${treeId}] scrollToPath`);
575
- return true;
576
- }
577
-
578
- /** Find the nearest scrollable ancestor element */
579
- function findScrollableAncestor(element: HTMLElement): HTMLElement | null {
580
- let parent = element.parentElement;
581
- while (parent) {
582
- const style = getComputedStyle(parent);
583
- const overflowY = style.overflowY;
584
- if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) {
585
- return parent;
586
- }
587
- parent = parent.parentElement;
588
- }
589
- return null;
507
+ return controller.scrollToPath(path, options);
590
508
  }
591
509
 
592
510
  // External update method for HTML/JavaScript usage
@@ -604,11 +522,14 @@
604
522
  | "isExpandedMember"
605
523
  | "isSelectedMember"
606
524
  | "isDraggableMember"
525
+ | "getIsDraggableCallback"
607
526
  | "isDropAllowedMember"
608
527
  | "displayValueMember"
609
528
  | "getDisplayValueCallback"
610
529
  | "searchValueMember"
611
530
  | "getSearchValueCallback"
531
+ | "isCollapsibleMember"
532
+ | "getIsCollapsibleCallback"
612
533
  | "orderMember"
613
534
  | "isSorted"
614
535
  | "sortCallback"
@@ -629,6 +550,10 @@
629
550
  | "beforeDropCallback"
630
551
  | "onNodeDrop"
631
552
  | "contextMenuCallback"
553
+ | "virtualScroll"
554
+ | "virtualRowHeight"
555
+ | "virtualOverscan"
556
+ | "virtualContainerHeight"
632
557
  | "dragDropMode"
633
558
  | "dropZoneMode"
634
559
  | "bodyClass"
@@ -641,13 +566,10 @@
641
566
  | "scrollHighlightClass"
642
567
  | "contextMenuXOffset"
643
568
  | "contextMenuYOffset"
644
- | "virtualScroll"
645
- | "virtualRowHeight"
646
- | "virtualOverscan"
647
- | "virtualContainerHeight"
648
569
  >
649
570
  >
650
571
  ) {
572
+ // Update local props (triggers $effect syncs to controller)
651
573
  if (updates.treeId !== undefined) treeId = updates.treeId;
652
574
  if (updates.treePathSeparator !== undefined) treePathSeparator = updates.treePathSeparator;
653
575
  if (updates.idMember !== undefined) idMember = updates.idMember;
@@ -658,11 +580,14 @@
658
580
  if (updates.isExpandedMember !== undefined) isExpandedMember = updates.isExpandedMember;
659
581
  if (updates.isSelectedMember !== undefined) isSelectedMember = updates.isSelectedMember;
660
582
  if (updates.isDraggableMember !== undefined) isDraggableMember = updates.isDraggableMember;
583
+ if (updates.getIsDraggableCallback !== undefined) getIsDraggableCallback = updates.getIsDraggableCallback;
661
584
  if (updates.isDropAllowedMember !== undefined) isDropAllowedMember = updates.isDropAllowedMember;
662
585
  if (updates.displayValueMember !== undefined) displayValueMember = updates.displayValueMember;
663
586
  if (updates.getDisplayValueCallback !== undefined) getDisplayValueCallback = updates.getDisplayValueCallback;
664
587
  if (updates.searchValueMember !== undefined) searchValueMember = updates.searchValueMember;
665
588
  if (updates.getSearchValueCallback !== undefined) getSearchValueCallback = updates.getSearchValueCallback;
589
+ if (updates.isCollapsibleMember !== undefined) isCollapsibleMember = updates.isCollapsibleMember;
590
+ if (updates.getIsCollapsibleCallback !== undefined) getIsCollapsibleCallback = updates.getIsCollapsibleCallback;
666
591
  if (updates.orderMember !== undefined) orderMember = updates.orderMember;
667
592
  if (updates.isSorted !== undefined) isSorted = updates.isSorted;
668
593
  if (updates.sortCallback !== undefined) sortCallback = updates.sortCallback;
@@ -683,6 +608,10 @@
683
608
  if (updates.beforeDropCallback !== undefined) beforeDropCallback = updates.beforeDropCallback;
684
609
  if (updates.onNodeDrop !== undefined) onNodeDrop = updates.onNodeDrop;
685
610
  if (updates.contextMenuCallback !== undefined) contextMenuCallback = updates.contextMenuCallback;
611
+ if (updates.virtualScroll !== undefined) virtualScroll = updates.virtualScroll;
612
+ if (updates.virtualRowHeight !== undefined) virtualRowHeight = updates.virtualRowHeight;
613
+ if (updates.virtualOverscan !== undefined) virtualOverscan = updates.virtualOverscan;
614
+ if (updates.virtualContainerHeight !== undefined) virtualContainerHeight = updates.virtualContainerHeight;
686
615
  if (updates.dragDropMode !== undefined) dragDropMode = updates.dragDropMode;
687
616
  if (updates.dropZoneMode !== undefined) dropZoneMode = updates.dropZoneMode;
688
617
  if (updates.bodyClass !== undefined) bodyClass = updates.bodyClass;
@@ -695,1056 +624,34 @@
695
624
  if (updates.scrollHighlightClass !== undefined) scrollHighlightClass = updates.scrollHighlightClass;
696
625
  if (updates.contextMenuXOffset !== undefined) contextMenuXOffset = updates.contextMenuXOffset;
697
626
  if (updates.contextMenuYOffset !== undefined) contextMenuYOffset = updates.contextMenuYOffset;
698
- if (updates.virtualScroll !== undefined) virtualScroll = updates.virtualScroll;
699
- if (updates.virtualRowHeight !== undefined) virtualRowHeight = updates.virtualRowHeight;
700
- if (updates.virtualOverscan !== undefined) virtualOverscan = updates.virtualOverscan;
701
- if (updates.virtualContainerHeight !== undefined) virtualContainerHeight = updates.virtualContainerHeight;
702
- }
703
-
704
- // Stable auto-generated treeId — survives parent re-renders that reset prop to undefined
705
- const _autoTreeId = generateTreeId();
706
- treeId = treeId || _autoTreeId;
707
-
708
- // Keep treeId stable when parent re-renders without passing treeId prop
709
- $effect.pre(() => {
710
- if (!treeId) treeId = _autoTreeId;
711
- });
712
-
713
-
714
- // svelte-ignore non_reactive_update
715
- const tree: Ltree<T> = createLTree<T>(
716
- idMember,
717
- pathMember,
718
- parentPathMember,
719
- levelMember,
720
- hasChildrenMember,
721
-
722
- isExpandedMember,
723
- isSelectedMember,
724
- isDraggableMember,
725
- isDropAllowedMember,
726
- allowedDropPositionsMember,
727
-
728
- displayValueMember,
729
- getDisplayValueCallback,
730
- searchValueMember,
731
- getSearchValueCallback,
732
- getAllowedDropPositionsCallback,
733
- orderMember,
734
- treeId,
735
- treePathSeparator,
736
-
737
- expandLevel,
738
-
739
- shouldUseInternalSearchIndex,
740
- initializeIndexCallback,
741
- indexerBatchSize,
742
- indexerTimeout,
743
- {
744
- shouldDisplayDebugInformation,
745
- isSorted,
746
- sortCallback
747
- }
748
- );
749
-
750
- // Update tree separator when prop changes
751
- $effect(() => {
752
- tree.treePathSeparator = treePathSeparator;
753
- });
754
-
755
- setContext('Ltree', tree);
756
-
757
- // Create stable callback references to avoid inline arrow functions causing re-renders
758
- // These are defined as a plain object with function references, not new functions each render
759
- const nodeCallbacks: NodeCallbacks<T> = {
760
- onNodeClicked: _onNodeClicked,
761
- onNodeRightClicked: _onNodeRightClicked,
762
- onNodeDragStart: _onNodeDragStart,
763
- onNodeDragOver: _onNodeDragOver,
764
- onNodeDragLeave: _onNodeDragLeave,
765
- onNodeDrop: _onNodeDrop,
766
- onZoneDrop: _onZoneDrop,
767
- onTouchDragStart: _onTouchStart,
768
- onTouchDragMove: _onTouchMove,
769
- onTouchDragEnd: _onTouchEnd,
770
- };
771
- setContext('NodeCallbacks', nodeCallbacks);
772
-
773
- // Note: NodeConfig is set via $effect below since it depends on reactive props
774
-
775
- // Create and provide render coordinator for progressive rendering (recursive mode)
776
- // Process only 2 nodes per frame - each node renders initialBatchSize children
777
- // This prevents too many reactive updates per frame
778
- const renderCoordinator = progressiveRender ? createRenderCoordinator(2, {
779
- onStart: () => {
780
- isRendering = true;
781
- onRenderStart?.();
782
- },
783
- onProgress: (stats) => {
784
- onRenderProgress?.(stats);
785
- },
786
- onComplete: (stats) => {
787
- isRendering = false;
788
- onRenderComplete?.(stats);
789
- }
790
- }) : null;
791
- if (renderCoordinator) {
792
- setContext('RenderCoordinator', renderCoordinator);
793
- }
794
-
795
- // Create stable config object shared via context.
796
- // Must use $state (not $state.raw) so property mutations are tracked reactively.
797
- // We mutate individual properties in the $effect below so that Node components
798
- // (which hold the same proxy reference via getContext) see the updates.
799
- let nodeConfig = $state<NodeConfig>({
800
- shouldToggleOnNodeClick: shouldToggleOnNodeClick ?? true,
801
- expandIconClass: expandIconClass ?? 'ltree-icon-expand',
802
- collapseIconClass: collapseIconClass ?? 'ltree-icon-collapse',
803
- leafIconClass: leafIconClass ?? 'ltree-icon-leaf',
804
- selectedNodeClass,
805
- dragOverNodeClass,
806
- dragDropMode: dragDropMode ?? 'none',
807
- dropZoneMode: dropZoneMode ?? 'glow',
808
- dropZoneLayout: dropZoneLayout ?? 'around',
809
- dropZoneStart: dropZoneStart ?? 33,
810
- dropZoneMaxWidth: dropZoneMaxWidth ?? 120,
811
- allowCopy: allowCopy ?? false,
812
- });
813
- setContext('NodeConfig', nodeConfig);
814
-
815
- // Mutate the shared config proxy when props change
816
- $effect(() => {
817
- nodeConfig.shouldToggleOnNodeClick = shouldToggleOnNodeClick ?? true;
818
- nodeConfig.expandIconClass = expandIconClass ?? 'ltree-icon-expand';
819
- nodeConfig.collapseIconClass = collapseIconClass ?? 'ltree-icon-collapse';
820
- nodeConfig.leafIconClass = leafIconClass ?? 'ltree-icon-leaf';
821
- nodeConfig.selectedNodeClass = selectedNodeClass;
822
- nodeConfig.dragOverNodeClass = dragOverNodeClass;
823
- nodeConfig.dragDropMode = dragDropMode ?? 'none';
824
- nodeConfig.dropZoneMode = dropZoneMode ?? 'glow';
825
- nodeConfig.dropZoneLayout = dropZoneLayout ?? 'around';
826
- nodeConfig.dropZoneStart = dropZoneStart ?? 33;
827
- nodeConfig.dropZoneMaxWidth = dropZoneMaxWidth ?? 120;
828
- nodeConfig.allowCopy = allowCopy ?? false;
829
- });
830
-
831
- // Format dropZoneStart for CSS variable - number = percentage, string = as-is
832
- const formattedDropZoneStart = $derived(
833
- typeof dropZoneStart === 'number' ? `${dropZoneStart}%` : dropZoneStart
834
- );
835
-
836
- $effect(() => {
837
- tree.filterNodes(searchText);
838
- });
839
-
840
- $effect(() => {
841
- if (tree && data) {
842
- if (untrack(() => _skipInsertArray)) {
843
- _skipInsertArray = false; // Reset for next time
844
- return;
845
- }
846
- // Reset progressive render coordinator when data changes
847
- renderCoordinator?.reset();
848
- // Reset flat progressive render state when data changes
849
- flatRenderedIds = new Set();
850
- flatRenderQueue = [];
851
- currentBatchSize = 0; // Reset exponential batch size
852
- // Reset virtual scroll measurements
853
- vsMeasuredRowHeight = null;
854
- vsDetectedHeight = null;
855
- insertResult = tree.insertArray(data);
856
- }
857
- });
858
-
859
- // Progressive rendering for flat mode - detect new nodes and queue them
860
- // Use a separate tracker to avoid reactive loops (effect reads AND writes flatRenderedIds)
861
- let lastFlatNodesTracker: Symbol | null = null;
862
-
863
- $effect(() => {
864
- if (!useFlatRendering || !progressiveRender || !tree?.visibleFlatNodes) return;
865
-
866
- // Only react to changeTracker changes, not to our own state updates
867
- const tracker = tree.changeTracker;
868
- if (tracker === lastFlatNodesTracker) return;
869
- lastFlatNodesTracker = tracker;
870
-
871
- const allNodes = tree.visibleFlatNodes;
872
- const currentIds = new Set(allNodes.map(n => n.id));
873
-
874
- // Snapshot current state to avoid reactive reads during computation
875
- const renderedSnapshot = new Set(flatRenderedIds);
876
- const queueSnapshot = new Set(flatRenderQueue);
877
-
878
- // Find new nodes (in current but not yet rendered AND not already queued)
879
- const newIds: string[] = [];
880
- for (const node of allNodes) {
881
- if (!renderedSnapshot.has(node.id) && !queueSnapshot.has(node.id)) {
882
- newIds.push(node.id);
883
- }
884
- }
885
-
886
- // Find removed nodes (rendered but no longer in current)
887
- const removedIds: string[] = [];
888
- for (const id of renderedSnapshot) {
889
- if (!currentIds.has(id)) {
890
- removedIds.push(id);
891
- }
892
- }
893
-
894
- // Remove nodes that are no longer visible
895
- if (removedIds.length > 0) {
896
- const newRendered = new Set(renderedSnapshot);
897
- for (const id of removedIds) {
898
- newRendered.delete(id);
899
- }
900
- flatRenderedIds = newRendered;
901
- }
902
-
903
- // Queue new nodes for progressive rendering
904
- if (newIds.length > 0) {
905
- // If we already have many rendered nodes and adding few new ones,
906
- // skip progressive batching to minimize Svelte diffs (expand/collapse case)
907
- const alreadyHasManyNodes = renderedSnapshot.size > 1000;
908
- const addingFewNodes = newIds.length < 200;
909
-
910
- if (alreadyHasManyNodes && addingFewNodes) {
911
- // Add all at once - one diff is faster than multiple diffs on large arrays
912
- flatRenderedIds = new Set([...flatRenderedIds, ...newIds]);
913
- } else {
914
- // Progressive batching for initial load (exponential: 20 → 40 → 80 → 160...)
915
- currentBatchSize = initialBatchSize; // Start with initial batch size
916
- const immediateBatch = newIds.slice(0, currentBatchSize);
917
- const remaining = newIds.slice(currentBatchSize);
918
-
919
- if (immediateBatch.length > 0) {
920
- flatRenderedIds = new Set([...flatRenderedIds, ...immediateBatch]);
921
- }
922
-
923
- // Double batch size for next iteration (capped at maxBatchSize)
924
- currentBatchSize = Math.min(currentBatchSize * 2, maxBatchSize);
925
-
926
- if (remaining.length > 0) {
927
- flatRenderQueue = [...remaining]; // Replace queue, don't append
928
- scheduleFlatRenderBatch();
929
- }
930
- }
931
- }
932
- });
933
-
934
- // Process flat render queue in batches (exponential sizing)
935
- function scheduleFlatRenderBatch() {
936
- if (flatRenderAnimationFrame) return; // Already scheduled
937
-
938
- flatRenderAnimationFrame = requestAnimationFrame(() => {
939
- flatRenderAnimationFrame = null;
940
-
941
- if (flatRenderQueue.length === 0) return;
942
-
943
- const batchSize = currentBatchSize || initialBatchSize;
944
- const batch = flatRenderQueue.slice(0, batchSize);
945
- const remaining = flatRenderQueue.slice(batchSize);
946
-
947
- flatRenderedIds = new Set([...flatRenderedIds, ...batch]);
948
- flatRenderQueue = remaining;
949
-
950
- // Double batch size for next iteration (capped at maxBatchSize)
951
- currentBatchSize = Math.min(batchSize * 2, maxBatchSize);
952
-
953
- if (remaining.length > 0) {
954
- scheduleFlatRenderBatch();
955
- }
956
- });
957
- }
958
-
959
- // Derived: all flat nodes (with progressive render filter if active)
960
- const allFlatNodes = $derived(
961
- useFlatRendering && progressiveRender
962
- ? tree?.visibleFlatNodes?.filter(n => flatRenderedIds.has(n.id)) ?? []
963
- : tree?.visibleFlatNodes ?? []
964
- );
965
-
966
- // Virtual scrolling derived computations
967
- const vsRowHeight = $derived(virtualRowHeight ?? vsMeasuredRowHeight ?? 32);
968
- const vsActive = $derived(virtualScroll && useFlatRendering);
969
- const vsContainerStyle = $derived(
970
- virtualContainerHeight ?? vsDetectedHeight ?? '400px'
971
- );
972
- const vsTotalCount = $derived(allFlatNodes.length);
973
- const vsTotalHeight = $derived(vsTotalCount * vsRowHeight);
974
- const vsStartIndex = $derived(
975
- vsActive
976
- ? Math.max(0, Math.floor(vsScrollTop / vsRowHeight) - virtualOverscan)
977
- : 0
978
- );
979
- const vsEndIndex = $derived(
980
- vsActive
981
- ? Math.min(vsTotalCount,
982
- Math.ceil((vsScrollTop + (vsContainerRef?.clientHeight ?? 0)) / vsRowHeight) + virtualOverscan)
983
- : vsTotalCount
984
- );
985
- const vsOffsetY = $derived(vsStartIndex * vsRowHeight);
986
-
987
- // Final nodes to render — virtual window or all
988
- const flatNodesToRender = $derived(
989
- vsActive ? allFlatNodes.slice(vsStartIndex, vsEndIndex) : allFlatNodes
990
- );
991
-
992
- // Virtual scroll: rAF-throttled scroll handler
993
- let vsRafPending = false;
994
- function handleVirtualScroll(event: Event) {
995
- if (vsRafPending) return;
996
- vsRafPending = true;
997
- requestAnimationFrame(() => {
998
- vsScrollTop = (event.target as HTMLElement).scrollTop;
999
- vsRafPending = false;
1000
- });
1001
627
  }
1002
-
1003
- // Auto-measure row height from first rendered node
1004
- $effect(() => {
1005
- if (!vsActive || virtualRowHeight || vsMeasuredRowHeight) return;
1006
- if (allFlatNodes.length === 0) return;
1007
- tick().then(() => {
1008
- if (vsContainerRef) {
1009
- const firstNode = vsContainerRef.querySelector('.ltree-node');
1010
- if (firstNode) {
1011
- const height = firstNode.getBoundingClientRect().height;
1012
- if (height > 0) vsMeasuredRowHeight = height;
1013
- }
1014
- }
1015
- });
1016
- });
1017
-
1018
- // Auto-detect container height from parent element
1019
- $effect(() => {
1020
- if (!vsActive || virtualContainerHeight || vsDetectedHeight) return;
1021
- tick().then(() => {
1022
- if (vsContainerRef?.parentElement) {
1023
- const parentHeight = vsContainerRef.parentElement.clientHeight;
1024
- if (parentHeight > 100) {
1025
- vsDetectedHeight = parentHeight + 'px';
1026
- }
1027
- }
1028
- });
1029
- });
1030
-
1031
- // $inspect("tree change tracker", tree?.changeTracker?.toString());
1032
-
1033
- function generateTreeId(): string {
1034
- return `${Date.now()}${Math.floor(Math.random() * 10000)}`;
1035
- }
1036
-
1037
- async function _onNodeClicked(node: LTreeNode<T>) {
1038
- // Close context menu when clicking on any node
1039
- if (contextMenuVisible) {
1040
- closeContextMenu();
1041
- }
1042
-
1043
- const previousPath = selectedNode?.path;
1044
- if (selectedNode) {
1045
- const previousNode = tree.getNodeByPath(selectedNode.path);
1046
- if (previousNode) {
1047
- previousNode.isSelected = false;
1048
- tree.bumpNodeRev(previousNode);
1049
- } else selectedNode = null;
1050
- }
1051
-
1052
- node.isSelected = true;
1053
- tree.bumpNodeRev(node);
1054
- selectedNode = node;
1055
-
1056
- uiLogger.debug(`Node selected: ${node.path}`, {
1057
- previousPath,
1058
- newPath: node.path,
1059
- id: node.id
1060
- });
1061
-
1062
- onNodeClicked?.(node);
1063
- // NO tree.refresh() — fine-grained signals handle re-rendering
1064
- }
1065
-
1066
- function _onNodeRightClicked(node: LTreeNode<T>, event: MouseEvent) {
1067
- if (!contextMenu && !contextMenuCallback) {
1068
- return;
1069
- }
1070
-
1071
- uiLogger.debug(`Context menu opened: ${node.path}`);
1072
- event.preventDefault();
1073
- contextMenuNode = node;
1074
- contextMenuX = event.clientX + contextMenuXOffset;
1075
- contextMenuY = event.clientY + contextMenuYOffset;
1076
- contextMenuVisible = true;
1077
- isDebugMenuActive = false; // This is a user-triggered menu, not debug menu
1078
- }
1079
-
1080
-
1081
- // Check if drop is allowed based on dragDropMode
1082
- function isDropAllowedByMode(draggedNodeTreeId: string | undefined): boolean {
1083
- if (dragDropMode === 'none') return false;
1084
-
1085
- const isSameTree = draggedNodeTreeId === treeId;
1086
-
1087
- if (dragDropMode === 'self' && !isSameTree) return false;
1088
- if (dragDropMode === 'cross' && isSameTree) return false;
1089
-
1090
- return true;
1091
- }
1092
-
1093
- // Calculate drop position based on mouse Y relative to node
1094
- function calculateDropPosition(event: DragEvent | MouseEvent, element: Element): DropPosition {
1095
- const rect = element.getBoundingClientRect();
1096
- const y = event.clientY - rect.top;
1097
- const height = rect.height;
1098
-
1099
- if (y < height * 0.25) return 'above';
1100
- if (y > height * 0.75) return 'below';
1101
- return 'child';
1102
- }
1103
-
1104
- function _onNodeDragStart(node: LTreeNode<T>, event: DragEvent) {
1105
- if (dragDropMode === 'none') {
1106
- event.preventDefault();
1107
- return;
1108
- }
1109
- dragLogger.debug(`Drag started: ${node.path}`, {
1110
- ctrlKey: event.ctrlKey,
1111
- allowCopy,
1112
- treeId
1113
- });
1114
- draggedNode = node;
1115
- isDragInProgress = true;
1116
- onNodeDragStart?.(node, event);
1117
- }
1118
-
1119
- function _onNodeDragEnd(event: DragEvent) {
1120
- dragLogger.debug('Drag ended', {
1121
- dropEffect: event.dataTransfer?.dropEffect,
1122
- operation: currentDropOperation
1123
- });
1124
- isDragInProgress = false;
1125
- draggedNode = null;
1126
- hoveredNodeForDrop = null;
1127
- activeDropPosition = null;
1128
- isDropPlaceholderActive = false;
1129
- currentDropOperation = 'move';
1130
- floatingZoneRect = null;
1131
- floatingHoveredZone = null;
1132
- }
1133
-
1134
- /**
1135
- * Helper to handle beforeDropCallback and onNodeDrop callbacks
1136
- * Returns true if drop was processed, false if cancelled
1137
- *
1138
- * Same-tree moves are auto-handled by default - the library calls moveNode() internally.
1139
- * onNodeDrop is still called for notification/logging purposes.
1140
- */
1141
- async function _handleDrop(dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent): Promise<boolean> {
1142
- // Determine operation based on Ctrl key and allowCopy setting
1143
- // Touch events always use 'move' (no Ctrl key on mobile)
1144
- let operation: DropOperation = 'move';
1145
- const isDragEvent = event instanceof DragEvent;
1146
- const ctrlKey = isDragEvent ? event.ctrlKey : false;
1147
-
1148
- if (allowCopy && isDragEvent && ctrlKey) {
1149
- operation = 'copy';
1150
- }
1151
-
1152
- dragLogger.info(`Drop: ${draggedNode.path} -> ${dropNode?.path ?? 'empty tree'}`, {
1153
- position,
1154
- operation,
1155
- isCrossTree: draggedNode.treeId !== treeId
1156
- });
1157
-
1158
- // Call beforeDropCallback if provided (supports async for dialogs)
1159
- if (beforeDropCallback) {
1160
- const result = await beforeDropCallback(dropNode, draggedNode, position, event, operation);
1161
- if (result === false) {
1162
- // Drop cancelled
1163
- return false;
1164
- }
1165
- if (result && typeof result === 'object') {
1166
- // Position and/or operation override
1167
- if ('position' in result && result.position) {
1168
- position = result.position;
1169
- }
1170
- if ('operation' in result && result.operation) {
1171
- operation = result.operation;
1172
- }
1173
- }
1174
- }
1175
-
1176
- // AUTO-HANDLE: Same-tree move operations
1177
- const isSameTreeDrag = draggedNode.treeId === treeId;
1178
- if (isSameTreeDrag && operation === 'move' && dropNode) {
1179
- const result = moveNode(draggedNode.path, dropNode.path, position);
1180
- // Still call onNodeDrop for notification/logging
1181
- onNodeDrop?.(dropNode, draggedNode, position, event, operation);
1182
- return result.success;
1183
- }
1184
-
1185
- // AUTO-HANDLE: Same-tree copy operations (if enabled)
1186
- if (isSameTreeDrag && operation === 'copy' && dropNode && autoHandleCopy) {
1187
- // Calculate target parent and sibling based on position
1188
- const targetParentPath = position === 'child' ? dropNode.path : (dropNode.parentPath || '');
1189
- const siblingPath = position !== 'child' ? dropNode.path : undefined;
1190
- const copyPosition = position !== 'child' ? position : undefined;
1191
-
1192
- // Copy with a transform that generates new IDs
1193
- const result = tree.copyNodeWithDescendants(
1194
- draggedNode,
1195
- targetParentPath,
1196
- (data) => ({
1197
- ...data,
1198
- // Generate new ID - user can override via beforeDropCallback if needed
1199
- [tree.idMember || 'id']: `${(data as any)[tree.idMember || 'id']}_copy_${Date.now()}`
1200
- }),
1201
- siblingPath,
1202
- copyPosition
1203
- );
1204
- // Still call onNodeDrop for notification/logging
1205
- onNodeDrop?.(dropNode, draggedNode, position, event, operation);
1206
- return result.success;
1207
- }
1208
-
1209
- // Cross-tree drags - user handles in onNodeDrop
1210
- onNodeDrop?.(dropNode, draggedNode, position, event, operation);
1211
- return true;
1212
- }
1213
-
1214
- function _onNodeDragOver(node: LTreeNode<T>, event: DragEvent) {
1215
- // For cross-tree drag, draggedNode might be null in THIS tree - parse from dataTransfer
1216
- let effectiveDraggedNode = draggedNode;
1217
- let isCrossTreeDrag = false;
1218
- if (!effectiveDraggedNode && event.dataTransfer?.types.includes("application/svelte-treeview")) {
1219
- isCrossTreeDrag = true;
1220
- // Cross-tree drag - try to get node info from dataTransfer
1221
- try {
1222
- const data = event.dataTransfer.getData("application/svelte-treeview");
1223
- if (data) {
1224
- effectiveDraggedNode = JSON.parse(data);
1225
- }
1226
- } catch (e) {
1227
- // getData might fail during dragover in some browsers, that's ok
1228
- }
1229
- // Even if we can't get the data, we know a drag is in progress
1230
- isDragInProgress = true;
1231
- }
1232
-
1233
- // Check if drop is allowed by mode
1234
- // For cross-tree drags, we allow if mode is 'both' or 'cross', regardless of whether we could parse the node
1235
- const dropAllowed = isCrossTreeDrag
1236
- ? (dragDropMode === 'both' || dragDropMode === 'cross')
1237
- : isDropAllowedByMode(effectiveDraggedNode?.treeId);
1238
-
1239
- if (!dropAllowed) {
1240
- hoveredNodeForDrop = null; // Clear hover to prevent glow on invalid targets
1241
- return;
1242
- }
1243
-
1244
- // Set hoveredNodeForDrop if:
1245
- // 1. We have drag data AND it's a different node (or from different tree), OR
1246
- // 2. We know a drag is in progress (cross-tree where we can't read data yet)
1247
- const isValidDrop = effectiveDraggedNode
1248
- ? (isCrossTreeDrag || effectiveDraggedNode.path !== node.path)
1249
- : isDragInProgress; // For cross-tree, trust isDragInProgress
1250
-
1251
- if (isValidDrop) {
1252
- event.preventDefault();
1253
-
1254
- // Update hovered node and calculate position
1255
- hoveredNodeForDrop = node;
1256
- const nodeElement = (event.target as Element).closest('.ltree-node-content');
1257
- if (nodeElement) {
1258
- activeDropPosition = calculateDropPosition(event, nodeElement);
1259
- }
1260
-
1261
- // Capture node row rect for floating drop zone overlay
1262
- if (dropZoneMode === 'floating') {
1263
- const nodeRow = (event.target as Element).closest('.ltree-node-row');
1264
- if (nodeRow) {
1265
- const r = nodeRow.getBoundingClientRect();
1266
- floatingZoneRect = { top: r.top, left: r.left, width: r.width, height: r.height };
1267
- }
1268
- }
1269
-
1270
- // Update current operation based on Ctrl key
1271
- currentDropOperation = (allowCopy && event.ctrlKey) ? 'copy' : 'move';
1272
-
1273
- onNodeDragOver?.(node, event);
1274
-
1275
- // Set visual feedback based on operation
1276
- if (event.dataTransfer) {
1277
- event.dataTransfer.dropEffect = currentDropOperation;
1278
- }
1279
- }
1280
- }
1281
-
1282
- function _onNodeDragLeave(node: LTreeNode<T>, event: DragEvent) {
1283
- // Don't clear hoveredNodeForDrop here - let dragover on other nodes handle it
1284
- // This prevents the zones from flickering when moving between nodes
1285
- }
1286
-
1287
- function _onNodeDrop(node: LTreeNode<T>, event: DragEvent) {
1288
- event.preventDefault();
1289
-
1290
- let isCrossTreeDrag = false;
1291
- if (!draggedNode) {
1292
- const data = event.dataTransfer?.getData('application/svelte-treeview');
1293
- if (data) {
1294
- draggedNode = JSON.parse(data);
1295
- isCrossTreeDrag = draggedNode?.treeId !== treeId;
1296
- }
1297
- }
1298
-
1299
- // Check if drop is allowed by mode
1300
- const dropAllowed = isCrossTreeDrag
1301
- ? (dragDropMode === 'both' || dragDropMode === 'cross')
1302
- : isDropAllowedByMode(draggedNode?.treeId);
1303
-
1304
- if (!dropAllowed) {
1305
- _onNodeDragEnd(event);
1306
- return;
1307
- }
1308
-
1309
- // For cross-tree, always allow; for same-tree, check it's not the same node
1310
- if (draggedNode && (isCrossTreeDrag || draggedNode !== node)) {
1311
- // Use the calculated position, default to 'child'
1312
- const position = activeDropPosition || 'child';
1313
- _handleDrop(node, draggedNode, position, event);
1314
- }
1315
-
1316
- // Reset drag state
1317
- _onNodeDragEnd(event);
1318
- }
1319
-
1320
- // Zone drop handler - receives explicit position from drop zone panels
1321
- function _onZoneDrop(node: LTreeNode<T>, position: DropPosition, event: DragEvent) {
1322
- event.preventDefault();
1323
- console.log(`[ZoneDrop] dropNode=${node.path}, position=${position}, treeId=${treeId}`);
1324
-
1325
- let isCrossTreeDrag = false;
1326
- if (!draggedNode) {
1327
- const data = event.dataTransfer?.getData('application/svelte-treeview');
1328
- if (data) {
1329
- draggedNode = JSON.parse(data);
1330
- isCrossTreeDrag = draggedNode?.treeId !== treeId;
1331
- }
1332
- }
1333
-
1334
- if (!draggedNode) {
1335
- console.warn(`[ZoneDrop] No draggedNode found, aborting`);
1336
- _onNodeDragEnd(event);
1337
- return;
1338
- }
1339
-
1340
- console.log(`[ZoneDrop] draggedNode=${draggedNode.path} (treeId=${draggedNode.treeId}), isCrossTree=${isCrossTreeDrag}`);
1341
-
1342
- // Check if drop is allowed by mode
1343
- const dropAllowed = isCrossTreeDrag
1344
- ? (dragDropMode === 'both' || dragDropMode === 'cross')
1345
- : isDropAllowedByMode(draggedNode?.treeId);
1346
-
1347
- if (!dropAllowed) {
1348
- console.warn(`[ZoneDrop] Drop not allowed by mode=${dragDropMode}`);
1349
- _onNodeDragEnd(event);
1350
- return;
1351
- }
1352
-
1353
- // For cross-tree, always allow; for same-tree, check it's not the same node
1354
- if (isCrossTreeDrag || draggedNode !== node) {
1355
- _handleDrop(node, draggedNode, position, event);
1356
- } else {
1357
- console.warn(`[ZoneDrop] Same node — skipped`);
1358
- }
1359
-
1360
- // Reset drag state
1361
- _onNodeDragEnd(event);
1362
- }
1363
-
1364
- // Floating drop zone overlay helpers
1365
- function isFloatingPositionAllowed(position: DropPosition): boolean {
1366
- if (!hoveredNodeForDrop) return false;
1367
- const allowed = tree.getNodeAllowedDropPositions(hoveredNodeForDrop);
1368
- if (!allowed || allowed.length === 0) return true;
1369
- return allowed.includes(position);
1370
- }
1371
-
1372
- function handleFloatingZoneDragOver(position: DropPosition, event: DragEvent) {
1373
- event.preventDefault();
1374
- if (event.dataTransfer) event.dataTransfer.dropEffect = (allowCopy && event.ctrlKey) ? 'copy' : 'move';
1375
- floatingHoveredZone = position;
1376
- // Keep rect fresh (handles scroll while cursor on zone)
1377
- if (hoveredNodeForDrop && treeContainerRef) {
1378
- const nodeEl = treeContainerRef.querySelector(`#${treeId}-${hoveredNodeForDrop.id}`);
1379
- const nodeRow = nodeEl?.querySelector('.ltree-node-row');
1380
- if (nodeRow) {
1381
- const r = nodeRow.getBoundingClientRect();
1382
- floatingZoneRect = { top: r.top, left: r.left, width: r.width, height: r.height };
1383
- }
1384
- }
1385
- if (hoveredNodeForDrop) _onNodeDragOver(hoveredNodeForDrop, event);
1386
- }
1387
-
1388
- function handleFloatingZoneDragLeave() {
1389
- floatingHoveredZone = null;
1390
- }
1391
-
1392
- function handleFloatingZoneDrop(position: DropPosition, event: DragEvent) {
1393
- event.stopPropagation();
1394
- if (event.dataTransfer) event.dataTransfer.dropEffect = (allowCopy && event.ctrlKey) ? 'copy' : 'move';
1395
- console.log(`[FloatingZoneDrop] position=${position}, hoveredNode=${hoveredNodeForDrop?.path ?? 'null'}`);
1396
- floatingHoveredZone = null;
1397
- if (hoveredNodeForDrop) _onZoneDrop(hoveredNodeForDrop, position, event);
1398
- }
1399
-
1400
- // Touch drag handlers for mobile support
1401
- function _onTouchStart(node: LTreeNode<any>, event: TouchEvent) {
1402
- if (dragDropMode === 'none') return;
1403
- if (!node?.isDraggable) return;
1404
-
1405
- const touch = event.touches[0];
1406
- touchDragState = {
1407
- node,
1408
- startX: touch.clientX,
1409
- startY: touch.clientY,
1410
- isDragging: false,
1411
- ghostElement: null,
1412
- currentDropTarget: null
1413
- };
1414
-
1415
- // Start long-press timer (300ms)
1416
- touchTimer = setTimeout(() => {
1417
- touchDragState.isDragging = true;
1418
- draggedNode = node;
1419
- dragLogger.debug(`Touch drag started: ${node.path}`);
1420
- createGhostElement(node, touch.clientX, touch.clientY);
1421
- navigator.vibrate?.(50); // Haptic feedback
1422
- }, 300);
1423
- }
1424
-
1425
- function _onTouchMove(node: LTreeNode<any>, event: TouchEvent) {
1426
- if (!touchDragState.node) return;
1427
-
1428
- const touch = event.touches[0];
1429
-
1430
- if (!touchDragState.isDragging) {
1431
- // Check if moved too much before long-press completed - cancel drag
1432
- const dx = Math.abs(touch.clientX - touchDragState.startX);
1433
- const dy = Math.abs(touch.clientY - touchDragState.startY);
1434
- if (dx > 10 || dy > 10) {
1435
- if (touchTimer) clearTimeout(touchTimer);
1436
- touchDragState = { node: null, startX: 0, startY: 0, isDragging: false, ghostElement: null, currentDropTarget: null };
1437
- }
1438
- return;
1439
- }
1440
-
1441
- event.preventDefault(); // Prevent scroll during drag
1442
-
1443
- // Move ghost element
1444
- if (touchDragState.ghostElement) {
1445
- touchDragState.ghostElement.style.left = `${touch.clientX}px`;
1446
- touchDragState.ghostElement.style.top = `${touch.clientY}px`;
1447
- }
1448
-
1449
- // Find drop target under touch point (hide ghost temporarily to not interfere)
1450
- if (touchDragState.ghostElement) {
1451
- touchDragState.ghostElement.style.pointerEvents = 'none';
1452
- }
1453
- const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY);
1454
- if (touchDragState.ghostElement) {
1455
- touchDragState.ghostElement.style.pointerEvents = '';
1456
- }
1457
-
1458
- // Update drop target highlighting
1459
- updateDropTarget(elementUnderTouch);
1460
- }
1461
-
1462
- function _onTouchEnd(node: LTreeNode<any>, event: TouchEvent) {
1463
- if (touchTimer) clearTimeout(touchTimer);
1464
-
1465
- if (touchDragState.isDragging && draggedNode) {
1466
- const touch = event.changedTouches[0];
1467
-
1468
- // Hide ghost to find element underneath
1469
- if (touchDragState.ghostElement) {
1470
- touchDragState.ghostElement.style.display = 'none';
1471
- }
1472
-
1473
- const dropElement = document.elementFromPoint(touch.clientX, touch.clientY);
1474
- const dropNode = findNodeFromElement(dropElement);
1475
-
1476
- // Check if dropping on empty tree placeholder
1477
- const placeholder = dropElement?.closest('.ltree-empty-state');
1478
- const rootDropZone = dropElement?.closest('.ltree-root-drop-zone');
1479
- if ((placeholder || rootDropZone) && !dropNode) {
1480
- // Dropping on empty tree or root drop zone
1481
- dragLogger.debug(`Touch drag ended: ${draggedNode.path} -> empty tree`);
1482
- _handleDrop(null, draggedNode, 'child', event);
1483
- } else if (dropNode && dropNode !== draggedNode && dropNode.isDropAllowed) {
1484
- // For touch, default to 'child' since we don't track position during touch
1485
- dragLogger.debug(`Touch drag ended: ${draggedNode.path} -> ${dropNode.path}`);
1486
- _handleDrop(dropNode, draggedNode, 'child', event);
1487
- } else {
1488
- dragLogger.debug(`Touch drag cancelled: ${draggedNode.path}`);
1489
- }
1490
-
1491
- // Clean up ghost element
1492
- removeGhostElement();
1493
- clearDropTargetHighlight();
1494
- }
1495
-
1496
- // Reset state
1497
- touchDragState = { node: null, startX: 0, startY: 0, isDragging: false, ghostElement: null, currentDropTarget: null };
1498
- draggedNode = null;
1499
- isDropPlaceholderActive = false;
1500
- }
1501
-
1502
- function createGhostElement(node: LTreeNode<any>, x: number, y: number) {
1503
- const ghost = document.createElement('div');
1504
- ghost.className = 'ltree-touch-ghost';
1505
- ghost.textContent = tree.getNodeDisplayValue(node);
1506
- ghost.style.left = `${x}px`;
1507
- ghost.style.top = `${y}px`;
1508
- document.body.appendChild(ghost);
1509
- touchDragState.ghostElement = ghost;
1510
- }
1511
-
1512
- function removeGhostElement() {
1513
- if (touchDragState.ghostElement) {
1514
- touchDragState.ghostElement.remove();
1515
- touchDragState.ghostElement = null;
1516
- }
1517
- }
1518
-
1519
- function findNodeFromElement(element: Element | null): LTreeNode<any> | null {
1520
- if (!element) return null;
1521
-
1522
- const nodeElement = element.closest('.ltree-node');
1523
- if (!nodeElement) return null;
1524
-
1525
- const path = nodeElement.getAttribute('data-tree-path');
1526
- if (!path) return null;
1527
-
1528
- return tree.getNodeByPath(path);
1529
- }
1530
-
1531
- function updateDropTarget(element: Element | null) {
1532
- const newTarget = findNodeFromElement(element);
1533
-
1534
- // Clear previous highlight
1535
- if (touchDragState.currentDropTarget && touchDragState.currentDropTarget !== newTarget) {
1536
- const prevElement = document.querySelector(`[data-tree-path="${touchDragState.currentDropTarget.path}"] .ltree-node-content`);
1537
- prevElement?.classList.remove(dragOverNodeClass || 'ltree-dragover-highlight');
1538
- }
1539
-
1540
- // Check if we're over an empty tree placeholder
1541
- const placeholder = element?.closest('.ltree-empty-state');
1542
- if (placeholder && !newTarget) {
1543
- // We're over an empty tree's drop zone
1544
- isDropPlaceholderActive = true;
1545
- touchDragState.currentDropTarget = null;
1546
- return;
1547
- } else {
1548
- // Clear placeholder state if we're not over it
1549
- isDropPlaceholderActive = false;
1550
- }
1551
-
1552
- // Add highlight to new target
1553
- if (newTarget && newTarget !== draggedNode && newTarget.isDropAllowed) {
1554
- const targetElement = document.querySelector(`[data-tree-path="${newTarget.path}"] .ltree-node-content`);
1555
- targetElement?.classList.add(dragOverNodeClass || 'ltree-dragover-highlight');
1556
- touchDragState.currentDropTarget = newTarget;
1557
- } else {
1558
- touchDragState.currentDropTarget = null;
1559
- }
1560
- }
1561
-
1562
- function clearDropTargetHighlight() {
1563
- if (touchDragState.currentDropTarget) {
1564
- const element = document.querySelector(`[data-tree-path="${touchDragState.currentDropTarget.path}"] .ltree-node-content`);
1565
- element?.classList.remove(dragOverNodeClass || 'ltree-dragover-highlight');
1566
- }
1567
- }
1568
-
1569
- // Empty tree drop handlers
1570
- function handleEmptyTreeDragOver(event: DragEvent) {
1571
- if (dragDropMode === 'none') return;
1572
- if (event.dataTransfer?.types.includes("application/svelte-treeview")) {
1573
- // Check mode: for cross-tree drags, only allow if mode permits
1574
- const isCrossTree = !draggedNode; // If draggedNode is null, it's from another tree
1575
- if (isCrossTree && dragDropMode === 'self') return;
1576
- if (!isCrossTree && dragDropMode === 'cross') return;
1577
-
1578
- event.preventDefault();
1579
- isDropPlaceholderActive = true;
1580
- if (event.dataTransfer) {
1581
- event.dataTransfer.dropEffect = 'move';
1582
- }
1583
- }
1584
- }
1585
-
1586
- function handleEmptyTreeDragLeave(event: DragEvent) {
1587
- // Only deactivate if truly leaving the element
1588
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
1589
- const x = event.clientX;
1590
- const y = event.clientY;
1591
-
1592
- if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
1593
- isDropPlaceholderActive = false;
1594
- }
1595
- }
1596
-
1597
- function handleEmptyTreeDrop(event: DragEvent) {
1598
- if (dragDropMode === 'none') return;
1599
- event.preventDefault();
1600
- isDropPlaceholderActive = false;
1601
-
1602
- const draggedNodeData = event.dataTransfer?.getData('application/svelte-treeview');
1603
- if (draggedNodeData) {
1604
- const droppedNode = JSON.parse(draggedNodeData);
1605
- // Respect dragDropMode for empty tree drops too
1606
- const isCrossTree = droppedNode?.treeId !== treeId;
1607
- if (isCrossTree && dragDropMode === 'self') return;
1608
- if (!isCrossTree && dragDropMode === 'cross') return;
1609
-
1610
- // Call onNodeDrop with null as dropNode to indicate "root level drop"
1611
- _handleDrop(null, droppedNode, 'child', event);
1612
- }
1613
- _onNodeDragEnd(event);
1614
- }
1615
-
1616
- function handleEmptyTreeTouchEnd(event: TouchEvent) {
1617
- // Check if touch drag was active and we have a dragged node
1618
- if (draggedNode && isDropPlaceholderActive) {
1619
- _handleDrop(null, draggedNode, 'child', event);
1620
- isDropPlaceholderActive = false;
1621
- }
1622
- }
1623
-
1624
- // Tree-level dragenter for cross-tree drag detection
1625
- function handleTreeDragEnter(event: DragEvent) {
1626
- if (event.dataTransfer?.types.includes("application/svelte-treeview")) {
1627
- isDragInProgress = true;
1628
- }
1629
- }
1630
-
1631
- function handleTreeDragLeave(event: DragEvent) {
1632
- // Don't reset if moving to a child element (including fixed-position floating zones)
1633
- if (event.relatedTarget instanceof Node && (event.currentTarget as HTMLElement).contains(event.relatedTarget as globalThis.Node)) {
1634
- return;
1635
- }
1636
-
1637
- const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
1638
- const x = event.clientX;
1639
- const y = event.clientY;
1640
-
1641
- // Only reset if truly leaving the tree container
1642
- if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
1643
- // Don't reset isDragInProgress if we're the source tree
1644
- if (draggedNode?.treeId !== treeId) {
1645
- isDragInProgress = false;
1646
- hoveredNodeForDrop = null;
1647
- activeDropPosition = null;
1648
- floatingZoneRect = null;
1649
- floatingHoveredZone = null;
1650
- }
1651
- }
1652
- }
1653
-
1654
- // Close context menu when clicking outside
1655
- function handleDocumentClick(event: MouseEvent) {
1656
- if (contextMenuVisible) {
1657
- const target = event.target as Element;
1658
- if (!target.closest('.ltree-context-menu')) {
1659
- closeContextMenu();
1660
- }
1661
- }
1662
- }
1663
-
1664
- // Add global event listener for document clicks and scroll events
1665
- $effect(() => {
1666
- if (contextMenuVisible) {
1667
- const handleGlobalClick = (event: MouseEvent) => {
1668
- const target = event.target as Element;
1669
- if (!target.closest('.ltree-context-menu')) {
1670
- closeContextMenu();
1671
- }
1672
- };
1673
-
1674
- const handleGlobalScroll = (event?: Event) => {
1675
- closeContextMenu();
1676
- };
1677
-
1678
- // Add scroll listeners to both window and document to catch all scroll events
1679
- document.addEventListener('click', handleGlobalClick);
1680
- document.addEventListener('contextmenu', handleGlobalClick);
1681
- window.addEventListener('scroll', handleGlobalScroll, true);
1682
- document.addEventListener('scroll', handleGlobalScroll, true);
1683
-
1684
- // Also listen for wheel events which might not trigger scroll
1685
- window.addEventListener('wheel', handleGlobalScroll, { passive: true });
1686
-
1687
- return () => {
1688
- document.removeEventListener('click', handleGlobalClick);
1689
- document.removeEventListener('contextmenu', handleGlobalClick);
1690
- window.removeEventListener('scroll', handleGlobalScroll, true);
1691
- document.removeEventListener('scroll', handleGlobalScroll, true);
1692
- window.removeEventListener('wheel', handleGlobalScroll);
1693
- };
1694
- }
1695
- });
1696
-
1697
- // Debug context menu - show context menu on second node for styling development
1698
- let isDebugMenuActive = $state(false);
1699
- let treeContainerRef: HTMLDivElement;
1700
-
1701
- $effect(() => {
1702
- if (shouldDisplayContextMenuInDebugMode && (contextMenu || contextMenuCallback) && tree?.tree && tree.tree.length > 0) {
1703
- // Use the first available node for the context menu data
1704
- const targetNode = tree.tree.length > 1 ? tree.tree[1] : tree.tree[0];
1705
- if (targetNode && treeContainerRef) {
1706
- // Position the context menu relative to the tree container
1707
- const treeRect = treeContainerRef.getBoundingClientRect();
1708
- contextMenuNode = targetNode;
1709
- contextMenuX = treeRect.left + 200; // 200px from tree's left edge
1710
- contextMenuY = treeRect.top + 100; // 100px from tree's top edge
1711
- contextMenuVisible = true;
1712
- isDebugMenuActive = true;
1713
- }
1714
- } else if (!shouldDisplayContextMenuInDebugMode && isDebugMenuActive) {
1715
- // Only hide the context menu if it was opened by debug mode
1716
- contextMenuVisible = false;
1717
- contextMenuNode = null;
1718
- isDebugMenuActive = false;
1719
- }
1720
- });
1721
628
  </script>
1722
629
 
1723
630
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1724
631
  <div
1725
632
  class="ltree-container"
1726
633
  bind:this={treeContainerRef}
1727
- ondragenter={handleTreeDragEnter}
1728
- ondragleave={handleTreeDragLeave}
1729
- ondragend={_onNodeDragEnd}
634
+ ondragenter={controller.handleTreeDragEnter}
635
+ ondragleave={controller.handleTreeDragLeave}
636
+ ondragend={controller._onNodeDragEnd}
1730
637
  >
1731
- {#if shouldDisplayDebugInformation}
638
+ {#if controller.shouldDisplayDebugInformation}
1732
639
  <div class="ltree-debug-info">
1733
640
  <details>
1734
641
  <summary>Debug Info</summary>
1735
642
  <div class="ltree-debug-stats">
1736
- <span>Tree: {treeId}</span>
1737
- <span>Data: {data?.length || 0}</span>
643
+ <span>Tree: {controller.treeId}</span>
644
+ <span>Data: {controller.data?.length || 0}</span>
1738
645
  <span>Expand level: {expandLevel || 0}</span>
1739
- <span>Nodes: {tree?.statistics.nodeCount || 0}</span>
1740
- <span>Levels: {tree?.statistics.maxLevel || 0}</span>
1741
- {#if tree?.statistics.filteredNodeCount > 0}
1742
- <span>Filtered: {tree.statistics.filteredNodeCount}</span>
646
+ <span>Nodes: {controller.tree?.statistics.nodeCount || 0}</span>
647
+ <span>Levels: {controller.tree?.statistics.maxLevel || 0}</span>
648
+ {#if controller.tree?.statistics.filteredNodeCount > 0}
649
+ <span>Filtered: {controller.tree.statistics.filteredNodeCount}</span>
1743
650
  {/if}
1744
- {#if tree?.statistics.isIndexing}
1745
- <span>Indexing: {tree.statistics.pendingIndexCount} pending</span>
651
+ {#if controller.tree?.statistics.isIndexing}
652
+ <span>Indexing: {controller.tree.statistics.pendingIndexCount} pending</span>
1746
653
  {/if}
1747
- <span>Dragging: {draggedNode?.path || 'none'}</span>
654
+ <span>Dragging: {controller.draggedNode?.path || 'none'}</span>
1748
655
  </div>
1749
656
  </details>
1750
657
  </div>
@@ -1752,7 +659,7 @@
1752
659
 
1753
660
  {@render treeHeader?.()}
1754
661
 
1755
- {#if isLoading}
662
+ {#if controller.isLoading}
1756
663
  <div class="ltree-loading-overlay">
1757
664
  {#if loadingPlaceholder}
1758
665
  {@render loadingPlaceholder()}
@@ -1762,137 +669,135 @@
1762
669
  </div>
1763
670
  {/if}
1764
671
 
1765
- <div class={bodyClass}>
1766
- {#if tree?.root}
1767
- <!-- Flat rendering mode: no {#key} block, uses visibleFlatNodes for efficient updates -->
1768
- {#if useFlatRendering}
1769
- {#if vsActive}
1770
- <!-- Virtual scrolling mode -->
1771
- <div
1772
- class="ltree-tree ltree-flat-mode ltree-virtual-scroll"
1773
- style="height: {vsContainerStyle}; overflow-y: auto;"
1774
- bind:this={vsContainerRef}
1775
- onscroll={handleVirtualScroll}
1776
- >
1777
- <!-- Spacer for correct scrollbar -->
1778
- <div style="height: {vsTotalHeight}px; position: relative;">
1779
- <!-- Rendered window at correct offset -->
1780
- <div style="transform: translateY({vsOffsetY}px);">
1781
- {#each flatNodesToRender as node, i (node.id + '|' + node.path + '|' + node.hasChildren + '|' + node._rev)}
1782
- {@const prevNode = (vsStartIndex + i) > 0 ? allFlatNodes[vsStartIndex + i - 1] : null}
1783
- <Node
1784
- {node}
1785
- children={nodeTemplate}
1786
- progressiveRender={false}
1787
- isDraggedNode={draggedNode?.path === node.path}
1788
- {isDragInProgress}
1789
- hoveredNodeForDropPath={hoveredNodeForDrop?.path}
1790
- {activeDropPosition}
1791
- dropOperation={currentDropOperation}
1792
- flatMode={true}
1793
- flatGap={prevNode != null && node.level > prevNode.level}
1794
- />
1795
- {:else}
1796
- <!-- Empty state when tree has no items -->
1797
- <!-- svelte-ignore a11y_no_static_element_interactions -->
1798
- <div
1799
- class="ltree-empty-state"
1800
- class:ltree-drop-placeholder={isDropPlaceholderActive}
1801
- ondragenter={handleEmptyTreeDragOver}
1802
- ondragover={handleEmptyTreeDragOver}
1803
- ondragleave={handleEmptyTreeDragLeave}
1804
- ondrop={handleEmptyTreeDrop}
1805
- ontouchend={handleEmptyTreeTouchEnd}
1806
- >
1807
- {#if isDropPlaceholderActive}
1808
- {#if dropPlaceholder}
1809
- {@render dropPlaceholder()}
1810
- {:else}
1811
- <div class="ltree-drop-placeholder-content">
1812
- Drop here to add
1813
- </div>
1814
- {/if}
672
+ <div class={controller.bodyClass}>
673
+ {#if controller.tree?.root}
674
+ {#if controller.vsActive}
675
+ <!-- Virtual scrolling mode -->
676
+ <div
677
+ class="ltree-tree ltree-flat-mode ltree-virtual-scroll"
678
+ style="height: {controller.vsContainerStyle}; overflow-y: auto;"
679
+ bind:this={controller.vsContainerRef}
680
+ onscroll={controller.handleVirtualScroll}
681
+ >
682
+ <!-- Spacer for correct scrollbar -->
683
+ <div style="height: {controller.vsTotalHeight}px; position: relative;">
684
+ <!-- Rendered window at correct offset -->
685
+ <div style="transform: translateY({controller.vsOffsetY}px);">
686
+ {#each controller.flatNodesToRender as node, i (node.id + '|' + node.path + '|' + node.hasChildren + '|' + node._rev)}
687
+ {@const absoluteIndex = controller.vsStartIndex + i}
688
+ {@const prevNode = absoluteIndex > 0 ? controller.allFlatNodes[absoluteIndex - 1] : null}
689
+ <Node
690
+ {node}
691
+ children={nodeTemplate}
692
+ progressiveRender={false}
693
+ isDraggedNode={controller.draggedNode?.path === node.path}
694
+ isDragInProgress={controller.isDragInProgress}
695
+ hoveredNodeForDropPath={controller.hoveredNodeForDrop?.path}
696
+ activeDropPosition={controller.activeDropPosition}
697
+ dropOperation={controller.currentDropOperation}
698
+ flatMode={true}
699
+ flatGap={prevNode != null && (node.level ?? 0) > (prevNode.level ?? 0)}
700
+ />
701
+ {:else}
702
+ <!-- Empty state when tree has no items -->
703
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
704
+ <div
705
+ class="ltree-empty-state"
706
+ class:ltree-drop-placeholder={controller.isDropPlaceholderActive}
707
+ ondragenter={controller.handleEmptyTreeDragOver}
708
+ ondragover={controller.handleEmptyTreeDragOver}
709
+ ondragleave={controller.handleEmptyTreeDragLeave}
710
+ ondrop={controller.handleEmptyTreeDrop}
711
+ ontouchend={controller.handleEmptyTreeTouchEnd}
712
+ >
713
+ {#if controller.isDropPlaceholderActive}
714
+ {#if dropPlaceholder}
715
+ {@render dropPlaceholder()}
1815
716
  {:else}
1816
- {@render noDataFound?.()}
717
+ <div class="ltree-drop-placeholder-content">
718
+ Drop here to add
719
+ </div>
1817
720
  {/if}
1818
- </div>
1819
- {/each}
1820
- </div>
1821
- </div>
1822
- </div>
1823
- {:else}
1824
- <!-- Non-virtual flat rendering -->
1825
- <div class="ltree-tree ltree-flat-mode">
1826
- {#each flatNodesToRender as node, i (node.id + '|' + node.path + '|' + node.hasChildren + '|' + node._rev)}
1827
- {@const prevNode = i > 0 ? flatNodesToRender[i - 1] : null}
1828
- <Node
1829
- {node}
1830
- children={nodeTemplate}
1831
- progressiveRender={false}
1832
- isDraggedNode={draggedNode?.path === node.path}
1833
- {isDragInProgress}
1834
- hoveredNodeForDropPath={hoveredNodeForDrop?.path}
1835
- {activeDropPosition}
1836
- dropOperation={currentDropOperation}
1837
- flatMode={true}
1838
- flatGap={prevNode != null && node.level > prevNode.level}
1839
- />
1840
- {:else}
1841
- <!-- Empty state when tree has no items -->
1842
- <!-- svelte-ignore a11y_no_static_element_interactions -->
1843
- <div
1844
- class="ltree-empty-state"
1845
- class:ltree-drop-placeholder={isDropPlaceholderActive}
1846
- ondragenter={handleEmptyTreeDragOver}
1847
- ondragover={handleEmptyTreeDragOver}
1848
- ondragleave={handleEmptyTreeDragLeave}
1849
- ondrop={handleEmptyTreeDrop}
1850
- ontouchend={handleEmptyTreeTouchEnd}
1851
- >
1852
- {#if isDropPlaceholderActive}
1853
- {#if dropPlaceholder}
1854
- {@render dropPlaceholder()}
1855
721
  {:else}
1856
- <div class="ltree-drop-placeholder-content">
1857
- Drop here to add
1858
- </div>
722
+ {@render noDataFound?.()}
1859
723
  {/if}
724
+ </div>
725
+ {/each}
726
+ </div>
727
+ </div>
728
+ </div>
729
+ {:else if controller.useFlatRendering}
730
+ <!-- Flat rendering mode: no {#key} block, uses visibleFlatNodes for efficient updates -->
731
+ <div class="ltree-tree ltree-flat-mode">
732
+ {#each controller.flatNodesToRender as node, i (node.id + '|' + node.path + '|' + node.hasChildren + '|' + node._rev)}
733
+ {@const prevNode = i > 0 ? controller.flatNodesToRender[i - 1] : null}
734
+ <Node
735
+ {node}
736
+ children={nodeTemplate}
737
+ progressiveRender={false}
738
+ isDraggedNode={controller.draggedNode?.path === node.path}
739
+ isDragInProgress={controller.isDragInProgress}
740
+ hoveredNodeForDropPath={controller.hoveredNodeForDrop?.path}
741
+ activeDropPosition={controller.activeDropPosition}
742
+ dropOperation={controller.currentDropOperation}
743
+ flatMode={true}
744
+ flatGap={prevNode != null && (node.level ?? 0) > (prevNode.level ?? 0)}
745
+ />
746
+ {:else}
747
+ <!-- Empty state when tree has no items -->
748
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
749
+ <div
750
+ class="ltree-empty-state"
751
+ class:ltree-drop-placeholder={controller.isDropPlaceholderActive}
752
+ ondragenter={controller.handleEmptyTreeDragOver}
753
+ ondragover={controller.handleEmptyTreeDragOver}
754
+ ondragleave={controller.handleEmptyTreeDragLeave}
755
+ ondrop={controller.handleEmptyTreeDrop}
756
+ ontouchend={controller.handleEmptyTreeTouchEnd}
757
+ >
758
+ {#if controller.isDropPlaceholderActive}
759
+ {#if dropPlaceholder}
760
+ {@render dropPlaceholder()}
1860
761
  {:else}
1861
- {@render noDataFound?.()}
762
+ <div class="ltree-drop-placeholder-content">
763
+ Drop here to add
764
+ </div>
1862
765
  {/if}
1863
- </div>
1864
- {/each}
1865
- </div>
1866
- {/if}
766
+ {:else}
767
+ {@render noDataFound?.()}
768
+ {/if}
769
+ </div>
770
+ {/each}
771
+ </div>
1867
772
  {:else}
1868
773
  <!-- Recursive rendering mode: uses {#key} block for forced re-renders -->
1869
- {#key tree.changeTracker}
774
+ {#key controller.tree.changeTracker}
1870
775
  <div class="ltree-tree">
1871
- {#each tree.tree as node (node.id)}
776
+ {#each controller.tree.tree as node (node.id)}
1872
777
  <Node
1873
778
  {node}
1874
779
  children={nodeTemplate}
1875
- {progressiveRender}
1876
- renderBatchSize={initialBatchSize}
1877
- isDraggedNode={draggedNode?.path === node.path}
1878
- {isDragInProgress}
1879
- hoveredNodeForDropPath={hoveredNodeForDrop?.path}
1880
- {activeDropPosition}
1881
- dropOperation={currentDropOperation}
780
+ progressiveRender={controller.progressiveRender}
781
+ renderBatchSize={controller.initialBatchSize}
782
+ isDraggedNode={controller.draggedNode?.path === node.path}
783
+ isDragInProgress={controller.isDragInProgress}
784
+ hoveredNodeForDropPath={controller.hoveredNodeForDrop?.path}
785
+ activeDropPosition={controller.activeDropPosition}
786
+ dropOperation={controller.currentDropOperation}
1882
787
  />
1883
788
  {:else}
1884
789
  <!-- Empty state when tree has no items -->
1885
790
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1886
791
  <div
1887
792
  class="ltree-empty-state"
1888
- class:ltree-drop-placeholder={isDropPlaceholderActive}
1889
- ondragenter={handleEmptyTreeDragOver}
1890
- ondragover={handleEmptyTreeDragOver}
1891
- ondragleave={handleEmptyTreeDragLeave}
1892
- ondrop={handleEmptyTreeDrop}
1893
- ontouchend={handleEmptyTreeTouchEnd}
793
+ class:ltree-drop-placeholder={controller.isDropPlaceholderActive}
794
+ ondragenter={controller.handleEmptyTreeDragOver}
795
+ ondragover={controller.handleEmptyTreeDragOver}
796
+ ondragleave={controller.handleEmptyTreeDragLeave}
797
+ ondrop={controller.handleEmptyTreeDrop}
798
+ ontouchend={controller.handleEmptyTreeTouchEnd}
1894
799
  >
1895
- {#if isDropPlaceholderActive}
800
+ {#if controller.isDropPlaceholderActive}
1896
801
  {#if dropPlaceholder}
1897
802
  {@render dropPlaceholder()}
1898
803
  {:else}
@@ -1913,14 +818,14 @@
1913
818
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1914
819
  <div
1915
820
  class="ltree-empty-state"
1916
- class:ltree-drop-placeholder={isDropPlaceholderActive}
1917
- ondragenter={handleEmptyTreeDragOver}
1918
- ondragover={handleEmptyTreeDragOver}
1919
- ondragleave={handleEmptyTreeDragLeave}
1920
- ondrop={handleEmptyTreeDrop}
1921
- ontouchend={handleEmptyTreeTouchEnd}
821
+ class:ltree-drop-placeholder={controller.isDropPlaceholderActive}
822
+ ondragenter={controller.handleEmptyTreeDragOver}
823
+ ondragover={controller.handleEmptyTreeDragOver}
824
+ ondragleave={controller.handleEmptyTreeDragLeave}
825
+ ondrop={controller.handleEmptyTreeDrop}
826
+ ontouchend={controller.handleEmptyTreeTouchEnd}
1922
827
  >
1923
- {#if isDropPlaceholderActive}
828
+ {#if controller.isDropPlaceholderActive}
1924
829
  {#if dropPlaceholder}
1925
830
  {@render dropPlaceholder()}
1926
831
  {:else}
@@ -1937,45 +842,45 @@
1937
842
 
1938
843
  {@render treeFooter?.()}
1939
844
 
1940
- <!-- Floating drop zones overlay (position: fixed to escape overflow containers) -->
1941
- {#if dropZoneMode === 'floating' && isDragInProgress && hoveredNodeForDrop && floatingZoneRect}
845
+ <!-- Floating Drop Zones (position:fixed overlay, escapes overflow:hidden) -->
846
+ {#if controller.dropZoneMode === 'floating' && controller.isDragInProgress && controller.hoveredNodeForDrop && controller.floatingZoneRect}
1942
847
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1943
848
  <div
1944
- class="ltree-drop-zones ltree-drop-zones-{dropZoneLayout}"
1945
- style="position: fixed; top: {floatingZoneRect.top}px; left: {floatingZoneRect.left}px; width: {floatingZoneRect.width}px; height: {floatingZoneRect.height}px; z-index: 10000; --drop-zone-start: {formattedDropZoneStart}; --drop-zone-max-width: {dropZoneMaxWidth}px;"
849
+ class="ltree-drop-zones ltree-drop-zones-{controller.dropZoneLayout}"
850
+ style="position: fixed; top: {controller.floatingZoneRect.top}px; left: {controller.floatingZoneRect.left}px; width: {controller.floatingZoneRect.width}px; height: {controller.floatingZoneRect.height}px; z-index: 10000; --drop-zone-start: {formattedDropZoneStart}; --drop-zone-max-width: {controller.dropZoneMaxWidth}px;"
1946
851
  >
1947
- {#if isFloatingPositionAllowed('above')}
1948
- <div class="ltree-drop-zone ltree-drop-above"
1949
- class:ltree-drop-zone-active={floatingHoveredZone === 'above'}
1950
- ondragover={(e) => handleFloatingZoneDragOver('above', e)}
1951
- ondragleave={handleFloatingZoneDragLeave}
1952
- ondrop={(e) => handleFloatingZoneDrop('above', e)}
1953
- >↑ Above</div>
852
+ {#if controller.isFloatingPositionAllowed('before')}
853
+ <div class="ltree-drop-zone ltree-drop-before"
854
+ class:ltree-drop-zone-active={controller.floatingHoveredZone === 'before'}
855
+ ondragover={(e) => controller.handleFloatingZoneDragOver('before', e)}
856
+ ondragleave={() => controller.handleFloatingZoneDragLeave()}
857
+ ondrop={(e) => controller.handleFloatingZoneDrop('before', e)}
858
+ >↑ Before</div>
1954
859
  {/if}
1955
- {#if isFloatingPositionAllowed('below')}
1956
- <div class="ltree-drop-zone ltree-drop-below"
1957
- class:ltree-drop-zone-active={floatingHoveredZone === 'below'}
1958
- ondragover={(e) => handleFloatingZoneDragOver('below', e)}
1959
- ondragleave={handleFloatingZoneDragLeave}
1960
- ondrop={(e) => handleFloatingZoneDrop('below', e)}
1961
- >↓ Below</div>
860
+ {#if controller.isFloatingPositionAllowed('after')}
861
+ <div class="ltree-drop-zone ltree-drop-after"
862
+ class:ltree-drop-zone-active={controller.floatingHoveredZone === 'after'}
863
+ ondragover={(e) => controller.handleFloatingZoneDragOver('after', e)}
864
+ ondragleave={() => controller.handleFloatingZoneDragLeave()}
865
+ ondrop={(e) => controller.handleFloatingZoneDrop('after', e)}
866
+ >↓ After</div>
1962
867
  {/if}
1963
- {#if isFloatingPositionAllowed('child')}
868
+ {#if controller.isFloatingPositionAllowed('child')}
1964
869
  <div class="ltree-drop-zone ltree-drop-child"
1965
- class:ltree-drop-zone-active={floatingHoveredZone === 'child'}
1966
- ondragover={(e) => handleFloatingZoneDragOver('child', e)}
1967
- ondragleave={handleFloatingZoneDragLeave}
1968
- ondrop={(e) => handleFloatingZoneDrop('child', e)}
870
+ class:ltree-drop-zone-active={controller.floatingHoveredZone === 'child'}
871
+ ondragover={(e) => controller.handleFloatingZoneDragOver('child', e)}
872
+ ondragleave={() => controller.handleFloatingZoneDragLeave()}
873
+ ondrop={(e) => controller.handleFloatingZoneDrop('child', e)}
1969
874
  >→ Child</div>
1970
875
  {/if}
1971
876
  </div>
1972
877
  {/if}
1973
878
 
1974
879
  <!-- Context Menu -->
1975
- {#if contextMenuVisible && contextMenuNode}
1976
- <div class="ltree-context-menu" style="left: {contextMenuX}px; top: {contextMenuY}px;">
880
+ {#if controller.contextMenuVisible && controller.contextMenuNode}
881
+ <div class="ltree-context-menu" style="left: {controller.contextMenuX}px; top: {controller.contextMenuY}px;">
1977
882
  {#if contextMenuCallback}
1978
- {@const menuItems = contextMenuCallback(contextMenuNode, closeContextMenu)}
883
+ {@const menuItems = contextMenuCallback(controller.contextMenuNode, controller.closeContextMenu.bind(controller))}
1979
884
  {#each menuItems as item}
1980
885
  {#if item.isDivider}
1981
886
  <div class="ltree-context-menu-divider"></div>
@@ -2013,7 +918,7 @@
2013
918
  {/if}
2014
919
  {/each}
2015
920
  {:else if contextMenu}
2016
- {@render contextMenu(contextMenuNode, closeContextMenu)}
921
+ {@render contextMenu(controller.contextMenuNode, controller.closeContextMenu.bind(controller))}
2017
922
  {/if}
2018
923
  </div>
2019
924
  {/if}