@keenmate/svelte-treeview 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +39 -4
  2. package/dist/components/Node.svelte +249 -12
  3. package/dist/components/Node.svelte.d.ts +17 -0
  4. package/dist/components/RenderCoordinator.svelte.d.ts +29 -0
  5. package/dist/components/RenderCoordinator.svelte.js +115 -0
  6. package/dist/components/Tree.svelte +855 -38
  7. package/dist/components/Tree.svelte.d.ts +160 -8
  8. package/dist/constants.generated.d.ts +6 -0
  9. package/dist/constants.generated.js +8 -0
  10. package/dist/global-api.d.ts +35 -0
  11. package/dist/global-api.js +36 -0
  12. package/dist/index.d.ts +6 -1
  13. package/dist/index.js +5 -0
  14. package/dist/logger.d.ts +56 -0
  15. package/dist/logger.js +159 -0
  16. package/dist/ltree/indexer.d.ts +0 -1
  17. package/dist/ltree/indexer.js +23 -19
  18. package/dist/ltree/ltree.svelte.d.ts +1 -1
  19. package/dist/ltree/ltree.svelte.js +593 -30
  20. package/dist/ltree/types.d.ts +62 -0
  21. package/dist/perf-logger.d.ts +70 -0
  22. package/dist/perf-logger.js +196 -0
  23. package/dist/styles/main.scss +437 -4
  24. package/dist/styles.css +329 -3
  25. package/dist/styles.css.map +1 -1
  26. package/dist/vendor/loglevel/index.d.ts +2 -0
  27. package/dist/vendor/loglevel/index.js +9 -0
  28. package/dist/vendor/loglevel/loglevel-esm.d.ts +2 -0
  29. package/dist/vendor/loglevel/loglevel-esm.js +349 -0
  30. package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.d.ts +7 -0
  31. package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.js +132 -0
  32. package/dist/vendor/loglevel/loglevel-plugin-prefix.d.ts +2 -0
  33. package/dist/vendor/loglevel/loglevel-plugin-prefix.js +149 -0
  34. package/dist/vendor/loglevel/loglevel.js +357 -0
  35. package/dist/vendor/loglevel/prefix.d.ts +2 -0
  36. package/dist/vendor/loglevel/prefix.js +9 -0
  37. package/package.json +3 -2
@@ -3,8 +3,13 @@
3
3
  import Node from './Node.svelte';
4
4
  import { type LTreeNode } from '../ltree/ltree-node.svelte.js';
5
5
  import { createLTree } from '../ltree/ltree.svelte.js';
6
- import { type Ltree, type InsertArrayResult, type ContextMenuItem } from '../ltree/types.js';
6
+ import { type Ltree, type InsertArrayResult, type ContextMenuItem, type DropPosition, type DragDropMode, type DropOperation } from '../ltree/types.js';
7
7
  import { setContext, tick } from 'svelte';
8
+ import { createRenderCoordinator, type RenderCoordinator, type RenderStats } from './RenderCoordinator.svelte.js';
9
+ import { uiLogger, dragLogger } from '../logger.js';
10
+
11
+ // Register global API for runtime logging control
12
+ import '../global-api.js';
8
13
 
9
14
  // Context menu state
10
15
  let contextMenuVisible = $state(false);
@@ -15,6 +20,30 @@
15
20
  // Drag and drop state
16
21
  let draggedNode: LTreeNode<any> | null = $state.raw(null);
17
22
 
23
+ // Touch drag state for mobile support
24
+ let touchDragState = $state<{
25
+ node: LTreeNode<any> | null;
26
+ startX: number;
27
+ startY: number;
28
+ isDragging: boolean;
29
+ ghostElement: HTMLElement | null;
30
+ currentDropTarget: LTreeNode<any> | null;
31
+ }>({ node: null, startX: 0, startY: 0, isDragging: false, ghostElement: null, currentDropTarget: null });
32
+
33
+ let touchTimer: ReturnType<typeof setTimeout> | null = null;
34
+
35
+ // Drop placeholder state for empty trees
36
+ let isDropPlaceholderActive = $state(false);
37
+
38
+ // Advanced drag state for position indicators
39
+ let isDragInProgress = $state(false);
40
+ let hoveredNodeForDrop = $state<LTreeNode<any> | null>(null);
41
+ let activeDropPosition = $state<DropPosition | null>(null);
42
+ let currentDropOperation = $state<DropOperation>('move');
43
+
44
+ // Flag to skip insertArray during internal mutations (addNode, moveNode, removeNode)
45
+ let _skipInsertArray = false;
46
+
18
47
  interface Props {
19
48
  // MAPPINGS
20
49
  idMember: string;
@@ -34,6 +63,9 @@
34
63
  searchValueMember?: string | null | undefined;
35
64
  getSearchValueCallback?: (node: LTreeNode<T>) => string;
36
65
 
66
+ // For sibling ordering in drag-drop (above/below positioning)
67
+ orderMember?: string | null | undefined;
68
+
37
69
  treeId?: string | null | undefined;
38
70
  treePathSeparator?: string | null | undefined;
39
71
  sortCallback?: (items: LTreeNode<T>[]) => LTreeNode<T>[];
@@ -50,6 +82,8 @@
50
82
  treeFooter?: any;
51
83
  noDataFound?: any;
52
84
  contextMenu?: any;
85
+ dropPlaceholder?: any;
86
+ loadingPlaceholder?: any;
53
87
 
54
88
  // BEHAVIOUR
55
89
  expandLevel?: number | null | undefined;
@@ -61,12 +95,36 @@
61
95
  indexerTimeout?: number | null | undefined;
62
96
  shouldDisplayDebugInformation?: boolean;
63
97
  shouldDisplayContextMenuInDebugMode?: boolean;
98
+ isLoading?: boolean;
99
+
100
+ // Progressive rendering - render children in batches to avoid UI freeze
101
+ progressiveRender?: boolean;
102
+ renderBatchSize?: number;
103
+ isRendering?: boolean; // Bindable: true while progressive rendering is active
104
+ onRenderStart?: () => void;
105
+ onRenderProgress?: (stats: RenderStats) => void;
106
+ onRenderComplete?: (stats: RenderStats) => void;
107
+
108
+ // DRAG AND DROP
109
+ dragDropMode?: DragDropMode;
110
+ dropZoneMode?: 'floating' | 'glow'; // 'floating' = original floating zones, 'glow' = border glow indicators
111
+ dropZoneLayout?: 'around' | 'above' | 'below' | 'wave' | 'wave2';
112
+ dropZoneStart?: number | string; // number = percentage (0-100), string = any CSS value ("33%", "50px", "3rem")
113
+ dropZoneMaxWidth?: number; // max width in pixels for wave layouts
114
+ allowCopy?: boolean; // Enable Ctrl+drag to copy instead of move (default: false)
115
+ autoHandleCopy?: boolean; // Auto-handle same-tree copy operations (default: true). Set to false for external DB/API handling.
64
116
 
65
117
  // EVENTS
66
118
  onNodeClicked?: (node: LTreeNode<T>) => void;
67
119
  onNodeDragStart?: (node: LTreeNode<T>, event: DragEvent) => void;
68
120
  onNodeDragOver?: (node: LTreeNode<T>, event: DragEvent) => void;
69
- onNodeDrop?: (node: LTreeNode<T>, draggedNode: LTreeNode<T>, event: DragEvent) => void;
121
+ /**
122
+ * Called before a drop is processed. Return false to cancel the drop.
123
+ * Return { position, operation } to override the drop position or operation.
124
+ * Return true or undefined to proceed normally.
125
+ */
126
+ beforeDropCallback?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => boolean | { position?: DropPosition; operation?: DropOperation } | void;
127
+ onNodeDrop?: (dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent, operation: DropOperation) => void;
70
128
  contextMenuCallback?: (node: LTreeNode<T>, closeMenuCallback: () => void) => ContextMenuItem[];
71
129
 
72
130
  // VISUALS
@@ -102,6 +160,7 @@
102
160
  getDisplayValueCallback,
103
161
  searchValueMember,
104
162
  getSearchValueCallback,
163
+ orderMember,
105
164
  isSorted,
106
165
  sortCallback,
107
166
 
@@ -116,6 +175,8 @@
116
175
  treeFooter = undefined,
117
176
  noDataFound = undefined,
118
177
  contextMenu = undefined,
178
+ dropPlaceholder = undefined,
179
+ loadingPlaceholder = undefined,
119
180
 
120
181
  // BEHAVIOUR
121
182
  expandLevel = 2,
@@ -128,11 +189,30 @@
128
189
  indexerTimeout = 50,
129
190
  shouldDisplayDebugInformation = false,
130
191
  shouldDisplayContextMenuInDebugMode = false,
192
+ isLoading = false,
193
+
194
+ // Progressive rendering
195
+ progressiveRender = false,
196
+ renderBatchSize = 50,
197
+ isRendering = $bindable(false),
198
+ onRenderStart,
199
+ onRenderProgress,
200
+ onRenderComplete,
201
+
202
+ // DRAG AND DROP
203
+ dragDropMode = 'both',
204
+ dropZoneMode = 'glow',
205
+ dropZoneLayout = 'around',
206
+ dropZoneStart = 33,
207
+ dropZoneMaxWidth = 120,
208
+ allowCopy = false,
209
+ autoHandleCopy = true,
131
210
 
132
211
  // EVENTS
133
212
  onNodeClicked,
134
213
  onNodeDragStart,
135
214
  onNodeDragOver,
215
+ beforeDropCallback,
136
216
  onNodeDrop,
137
217
  contextMenuCallback,
138
218
 
@@ -176,6 +256,91 @@
176
256
  return tree?.searchNodes(searchText, searchOptions) || [];
177
257
  }
178
258
 
259
+ // Tree editor helper methods
260
+ export function getChildren(parentPath: string): LTreeNode<T>[] {
261
+ return tree?.getChildren(parentPath) || [];
262
+ }
263
+
264
+ export function getSiblings(path: string): LTreeNode<T>[] {
265
+ return tree?.getSiblings(path) || [];
266
+ }
267
+
268
+ export function refreshSiblings(parentPath: string): void {
269
+ tree?.refreshSiblings(parentPath);
270
+ }
271
+
272
+ export function refreshNode(path: string): void {
273
+ tree?.refreshNode(path);
274
+ }
275
+
276
+ export function getNodeByPath(path: string): LTreeNode<T> | null {
277
+ return tree?.getNodeByPath(path) || null;
278
+ }
279
+
280
+ // Tree editor mutation methods
281
+ // These set _skipInsertArray to prevent the data effect from re-running insertArray
282
+ // since these methods already update the tree structure directly.
283
+ // We use tick() to reset the flag - if user updates data prop synchronously,
284
+ // the effect runs before tick resolves and sees the flag. Otherwise tick resets it.
285
+ export function moveNode(sourcePath: string, targetPath: string, position: 'above' | 'below' | 'child'): { success: boolean; error?: string } {
286
+ _skipInsertArray = true;
287
+ const result = tree?.moveNode(sourcePath, targetPath, position) || { success: false, error: 'Tree not initialized' };
288
+ tick().then(() => { _skipInsertArray = false; });
289
+ return result;
290
+ }
291
+
292
+ export function removeNode(path: string, includeDescendants: boolean = true): { success: boolean; node?: LTreeNode<T>; error?: string } {
293
+ _skipInsertArray = true;
294
+ const result = tree?.removeNode(path, includeDescendants) || { success: false, error: 'Tree not initialized' };
295
+ tick().then(() => { _skipInsertArray = false; });
296
+ return result;
297
+ }
298
+
299
+ export function addNode(parentPath: string, data: T, pathSegment?: string): { success: boolean; node?: LTreeNode<T>; error?: string } {
300
+ _skipInsertArray = true;
301
+ const result = tree?.addNode(parentPath, data, pathSegment) || { success: false, error: 'Tree not initialized' };
302
+ tick().then(() => { _skipInsertArray = false; });
303
+ return result;
304
+ }
305
+
306
+ export function updateNode(path: string, dataUpdates: Partial<T>): { success: boolean; node?: LTreeNode<T>; error?: string } {
307
+ _skipInsertArray = true;
308
+ const result = tree?.updateNode(path, dataUpdates) || { success: false, error: 'Tree not initialized' };
309
+ tick().then(() => { _skipInsertArray = false; });
310
+ return result;
311
+ }
312
+
313
+ export function applyChanges(changes: import('../ltree/types').TreeChange<T>[]): import('../ltree/types').ApplyChangesResult {
314
+ _skipInsertArray = true;
315
+ const result = tree?.applyChanges(changes) || { successful: 0, failed: [] };
316
+ tick().then(() => { _skipInsertArray = false; });
317
+ return result;
318
+ }
319
+
320
+ export function copyNodeWithDescendants(
321
+ sourceNode: LTreeNode<T>,
322
+ targetParentPath: string,
323
+ transformData: (data: T) => T
324
+ ): { success: boolean; rootNode?: LTreeNode<T>; count: number; error?: string } {
325
+ _skipInsertArray = true;
326
+ const result = tree?.copyNodeWithDescendants(sourceNode, targetParentPath, transformData) || { success: false, count: 0, error: 'Tree not initialized' };
327
+ tick().then(() => { _skipInsertArray = false; });
328
+ return result;
329
+ }
330
+
331
+ // State persistence methods
332
+ export function getExpandedPaths(): string[] {
333
+ return tree?.getExpandedPaths() || [];
334
+ }
335
+
336
+ export function setExpandedPaths(paths: string[]): void {
337
+ tree?.setExpandedPaths(paths);
338
+ }
339
+
340
+ export function getAllData(): T[] {
341
+ return tree?.getAllData() || [];
342
+ }
343
+
179
344
  // svelte-ignore non_reactive_update
180
345
  export function closeContextMenu() {
181
346
  contextMenuVisible = false;
@@ -185,12 +350,19 @@
185
350
 
186
351
  export async function scrollToPath(
187
352
  path: string,
188
- options?: { expand?: boolean; highlight?: boolean; scrollOptions?: ScrollIntoViewOptions }
353
+ options?: {
354
+ expand?: boolean;
355
+ highlight?: boolean;
356
+ scrollOptions?: ScrollIntoViewOptions;
357
+ /** Scroll only within the nearest scrollable container (prevents page scroll) */
358
+ containerScroll?: boolean;
359
+ }
189
360
  ): Promise<boolean> {
190
361
  const {
191
362
  expand = true,
192
363
  highlight = true,
193
- scrollOptions = { behavior: 'smooth', block: 'center' }
364
+ scrollOptions = { behavior: 'smooth', block: 'center' },
365
+ containerScroll = false
194
366
  } = options || {};
195
367
 
196
368
  // First, find the node to get its ID
@@ -205,14 +377,12 @@
205
377
  tree.expandNodes(path);
206
378
  tree.refresh();
207
379
  await tick();
208
- // Wait for DOM update
209
- // await new Promise((resolve) => setTimeout(resolve, 100));
210
380
  }
211
381
 
212
382
  // Find the DOM element using the generated ID
213
383
  const elementId = `${treeId}-${node.id}`;
214
384
  const element = document.getElementById(elementId);
215
- const contentDiv = element.querySelector('.ltree-node-content');
385
+ const contentDiv = element?.querySelector('.ltree-node-content') as HTMLElement | null;
216
386
 
217
387
  if (!contentDiv) {
218
388
  console.warn(`[Tree ${treeId}] DOM element not found for node ID: ${elementId}`);
@@ -220,7 +390,21 @@
220
390
  }
221
391
 
222
392
  // Scroll to the element
223
- contentDiv.scrollIntoView(scrollOptions);
393
+ if (containerScroll) {
394
+ // Find nearest scrollable ancestor and scroll within it only
395
+ const container = findScrollableAncestor(contentDiv);
396
+ if (container) {
397
+ const containerRect = container.getBoundingClientRect();
398
+ const elementRect = contentDiv.getBoundingClientRect();
399
+ const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - (containerRect.height / 2) + (elementRect.height / 2);
400
+ container.scrollTo({
401
+ top: scrollTop,
402
+ behavior: scrollOptions?.behavior || 'smooth'
403
+ });
404
+ }
405
+ } else {
406
+ contentDiv.scrollIntoView(scrollOptions);
407
+ }
224
408
 
225
409
  // Highlight the node temporarily if requested
226
410
  if (highlight && scrollHighlightClass) {
@@ -233,6 +417,20 @@
233
417
  return true;
234
418
  }
235
419
 
420
+ /** Find the nearest scrollable ancestor element */
421
+ function findScrollableAncestor(element: HTMLElement): HTMLElement | null {
422
+ let parent = element.parentElement;
423
+ while (parent) {
424
+ const style = getComputedStyle(parent);
425
+ const overflowY = style.overflowY;
426
+ if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) {
427
+ return parent;
428
+ }
429
+ parent = parent.parentElement;
430
+ }
431
+ return null;
432
+ }
433
+
236
434
  // External update method for HTML/JavaScript usage
237
435
  export function update(
238
436
  updates: Partial<
@@ -253,6 +451,7 @@
253
451
  | "getDisplayValueCallback"
254
452
  | "searchValueMember"
255
453
  | "getSearchValueCallback"
454
+ | "orderMember"
256
455
  | "isSorted"
257
456
  | "sortCallback"
258
457
  | "data"
@@ -269,8 +468,11 @@
269
468
  | "onNodeClicked"
270
469
  | "onNodeDragStart"
271
470
  | "onNodeDragOver"
471
+ | "beforeDropCallback"
272
472
  | "onNodeDrop"
273
473
  | "contextMenuCallback"
474
+ | "dragDropMode"
475
+ | "dropZoneMode"
274
476
  | "bodyClass"
275
477
  | "expandIconClass"
276
478
  | "collapseIconClass"
@@ -299,6 +501,7 @@
299
501
  if (updates.getDisplayValueCallback !== undefined) getDisplayValueCallback = updates.getDisplayValueCallback;
300
502
  if (updates.searchValueMember !== undefined) searchValueMember = updates.searchValueMember;
301
503
  if (updates.getSearchValueCallback !== undefined) getSearchValueCallback = updates.getSearchValueCallback;
504
+ if (updates.orderMember !== undefined) orderMember = updates.orderMember;
302
505
  if (updates.isSorted !== undefined) isSorted = updates.isSorted;
303
506
  if (updates.sortCallback !== undefined) sortCallback = updates.sortCallback;
304
507
  if (updates.data !== undefined) data = updates.data;
@@ -315,8 +518,11 @@
315
518
  if (updates.onNodeClicked !== undefined) onNodeClicked = updates.onNodeClicked;
316
519
  if (updates.onNodeDragStart !== undefined) onNodeDragStart = updates.onNodeDragStart;
317
520
  if (updates.onNodeDragOver !== undefined) onNodeDragOver = updates.onNodeDragOver;
521
+ if (updates.beforeDropCallback !== undefined) beforeDropCallback = updates.beforeDropCallback;
318
522
  if (updates.onNodeDrop !== undefined) onNodeDrop = updates.onNodeDrop;
319
523
  if (updates.contextMenuCallback !== undefined) contextMenuCallback = updates.contextMenuCallback;
524
+ if (updates.dragDropMode !== undefined) dragDropMode = updates.dragDropMode;
525
+ if (updates.dropZoneMode !== undefined) dropZoneMode = updates.dropZoneMode;
320
526
  if (updates.bodyClass !== undefined) bodyClass = updates.bodyClass;
321
527
  if (updates.expandIconClass !== undefined) expandIconClass = updates.expandIconClass;
322
528
  if (updates.collapseIconClass !== undefined) collapseIconClass = updates.collapseIconClass;
@@ -351,6 +557,7 @@
351
557
  getDisplayValueCallback,
352
558
  searchValueMember,
353
559
  getSearchValueCallback,
560
+ orderMember,
354
561
  treeId,
355
562
  treePathSeparator,
356
563
 
@@ -374,12 +581,40 @@
374
581
 
375
582
  setContext('Ltree', tree);
376
583
 
584
+ // Create and provide render coordinator for progressive rendering
585
+ // Process only 2 nodes per frame - each node renders renderBatchSize children
586
+ // This prevents too many reactive updates per frame
587
+ const renderCoordinator = progressiveRender ? createRenderCoordinator(2, {
588
+ onStart: () => {
589
+ isRendering = true;
590
+ onRenderStart?.();
591
+ },
592
+ onProgress: (stats) => {
593
+ onRenderProgress?.(stats);
594
+ },
595
+ onComplete: (stats) => {
596
+ isRendering = false;
597
+ onRenderComplete?.(stats);
598
+ }
599
+ }) : null;
600
+ if (renderCoordinator) {
601
+ setContext('RenderCoordinator', renderCoordinator);
602
+ }
603
+
377
604
  $effect(() => {
378
605
  tree.filterNodes(searchText);
379
606
  });
380
607
 
381
608
  $effect(() => {
382
609
  if (tree && data) {
610
+ if (_skipInsertArray) {
611
+ console.log('[Tree] Skipping insertArray due to internal mutation');
612
+ _skipInsertArray = false; // Reset for next time
613
+ return;
614
+ }
615
+ // Reset progressive render coordinator when data changes
616
+ renderCoordinator?.reset();
617
+ console.log('[Tree] Running insertArray with', data.length, 'items');
383
618
  insertResult = tree.insertArray(data);
384
619
  }
385
620
  });
@@ -396,6 +631,7 @@
396
631
  closeContextMenu();
397
632
  }
398
633
 
634
+ const previousPath = selectedNode?.path;
399
635
  if (selectedNode) {
400
636
  const previousNode = tree.getNodeByPath(selectedNode.path);
401
637
  if (previousNode) {
@@ -406,6 +642,12 @@
406
642
  node.isSelected = true;
407
643
  selectedNode = node;
408
644
 
645
+ uiLogger.debug(`Node selected: ${node.path}`, {
646
+ previousPath,
647
+ newPath: node.path,
648
+ id: node.id
649
+ });
650
+
409
651
  onNodeClicked?.(node);
410
652
 
411
653
  // if (!node.hasChildren) {
@@ -418,6 +660,7 @@
418
660
  return;
419
661
  }
420
662
 
663
+ uiLogger.debug(`Context menu opened: ${node.path}`);
421
664
  event.preventDefault();
422
665
  contextMenuNode = node;
423
666
  contextMenuX = event.clientX + contextMenuXOffset;
@@ -427,42 +670,210 @@
427
670
  }
428
671
 
429
672
 
673
+ // Check if drop is allowed based on dragDropMode
674
+ function isDropAllowedByMode(draggedNodeTreeId: string | undefined): boolean {
675
+ if (dragDropMode === 'none') return false;
676
+
677
+ const isSameTree = draggedNodeTreeId === treeId;
678
+
679
+ if (dragDropMode === 'self' && !isSameTree) return false;
680
+ if (dragDropMode === 'cross' && isSameTree) return false;
681
+
682
+ return true;
683
+ }
684
+
685
+ // Calculate drop position based on mouse Y relative to node
686
+ function calculateDropPosition(event: DragEvent | MouseEvent, element: Element): DropPosition {
687
+ const rect = element.getBoundingClientRect();
688
+ const y = event.clientY - rect.top;
689
+ const height = rect.height;
690
+
691
+ if (y < height * 0.25) return 'above';
692
+ if (y > height * 0.75) return 'below';
693
+ return 'child';
694
+ }
695
+
430
696
  function _onNodeDragStart(node: LTreeNode<T>, event: DragEvent) {
697
+ dragLogger.debug(`Drag started: ${node.path}`, {
698
+ ctrlKey: event.ctrlKey,
699
+ allowCopy,
700
+ treeId
701
+ });
431
702
  draggedNode = node;
703
+ isDragInProgress = true;
432
704
  onNodeDragStart?.(node, event);
705
+ }
433
706
 
434
- // Set drag effect and data
435
- // if (event.dataTransfer) {
436
- // event.dataTransfer.effectAllowed = "move";
437
- // event.dataTransfer.setData("text/plain", node.path);
438
- // }
707
+ function _onNodeDragEnd(event: DragEvent) {
708
+ dragLogger.debug('Drag ended', {
709
+ dropEffect: event.dataTransfer?.dropEffect,
710
+ operation: currentDropOperation
711
+ });
712
+ isDragInProgress = false;
713
+ draggedNode = null;
714
+ hoveredNodeForDrop = null;
715
+ activeDropPosition = null;
716
+ isDropPlaceholderActive = false;
717
+ currentDropOperation = 'move';
718
+ }
719
+
720
+ /**
721
+ * Helper to handle beforeDropCallback and onNodeDrop callbacks
722
+ * Returns true if drop was processed, false if cancelled
723
+ *
724
+ * Same-tree moves are auto-handled by default - the library calls moveNode() internally.
725
+ * onNodeDrop is still called for notification/logging purposes.
726
+ */
727
+ function _handleDrop(dropNode: LTreeNode<T> | null, draggedNode: LTreeNode<T>, position: DropPosition, event: DragEvent | TouchEvent): boolean {
728
+ // Determine operation based on Ctrl key and allowCopy setting
729
+ // Touch events always use 'move' (no Ctrl key on mobile)
730
+ let operation: DropOperation = 'move';
731
+ const isDragEvent = event instanceof DragEvent;
732
+ const ctrlKey = isDragEvent ? event.ctrlKey : false;
733
+
734
+ if (allowCopy && isDragEvent && ctrlKey) {
735
+ operation = 'copy';
736
+ }
737
+
738
+ dragLogger.info(`Drop: ${draggedNode.path} -> ${dropNode?.path ?? 'empty tree'}`, {
739
+ position,
740
+ operation,
741
+ isCrossTree: draggedNode.treeId !== treeId
742
+ });
743
+
744
+ // Call beforeDropCallback if provided
745
+ if (beforeDropCallback) {
746
+ const result = beforeDropCallback(dropNode, draggedNode, position, event, operation);
747
+ if (result === false) {
748
+ // Drop cancelled
749
+ return false;
750
+ }
751
+ if (result && typeof result === 'object') {
752
+ // Position and/or operation override
753
+ if ('position' in result && result.position) {
754
+ position = result.position;
755
+ }
756
+ if ('operation' in result && result.operation) {
757
+ operation = result.operation;
758
+ }
759
+ }
760
+ }
761
+
762
+ // AUTO-HANDLE: Same-tree move operations
763
+ const isSameTreeDrag = draggedNode.treeId === treeId;
764
+ if (isSameTreeDrag && operation === 'move' && dropNode) {
765
+ const result = moveNode(draggedNode.path, dropNode.path, position);
766
+ if (shouldDisplayDebugInformation) {
767
+ console.log('[Tree] Auto-moved node:', result);
768
+ }
769
+ // Still call onNodeDrop for notification/logging
770
+ onNodeDrop?.(dropNode, draggedNode, position, event, operation);
771
+ return result.success;
772
+ }
773
+
774
+ // AUTO-HANDLE: Same-tree copy operations (if enabled)
775
+ if (isSameTreeDrag && operation === 'copy' && dropNode && autoHandleCopy) {
776
+ // Calculate target parent and sibling based on position
777
+ const targetParentPath = position === 'child' ? dropNode.path : (dropNode.parentPath || '');
778
+ const siblingPath = position !== 'child' ? dropNode.path : undefined;
779
+ const copyPosition = position !== 'child' ? position : undefined;
780
+
781
+ // Copy with a transform that generates new IDs
782
+ const result = tree.copyNodeWithDescendants(
783
+ draggedNode,
784
+ targetParentPath,
785
+ (data) => ({
786
+ ...data,
787
+ // Generate new ID - user can override via beforeDropCallback if needed
788
+ [tree.idMember || 'id']: `${(data as any)[tree.idMember || 'id']}_copy_${Date.now()}`
789
+ }),
790
+ siblingPath,
791
+ copyPosition
792
+ );
793
+ if (shouldDisplayDebugInformation) {
794
+ console.log('[Tree] Auto-copied node:', result);
795
+ }
796
+ // Still call onNodeDrop for notification/logging
797
+ onNodeDrop?.(dropNode, draggedNode, position, event, operation);
798
+ return result.success;
799
+ }
800
+
801
+ // Cross-tree drags - user handles in onNodeDrop
802
+ onNodeDrop?.(dropNode, draggedNode, position, event, operation);
803
+ return true;
439
804
  }
440
805
 
441
806
  function _onNodeDragOver(node: LTreeNode<T>, event: DragEvent) {
442
- if (node.treeId !== treeId) {
443
- console.warn('Updating draggedNode to node from a different tree');
444
- draggedNode = node;
445
- } // this is for cases when we drag node from one tree to another
446
-
447
- // console.log(
448
- // "🚀 ~ _onNodeDragOver ~ draggedNode:",
449
- // treeId,
450
- // draggedNode,
451
- // $state.snapshot(draggedNode),
452
- // node,
453
- // event
454
- // );
455
- if (draggedNode && $state.snapshot(draggedNode) !== node) {
807
+ // For cross-tree drag, draggedNode might be null in THIS tree - parse from dataTransfer
808
+ let effectiveDraggedNode = draggedNode;
809
+ let isCrossTreeDrag = false;
810
+ if (!effectiveDraggedNode && event.dataTransfer?.types.includes("application/svelte-treeview")) {
811
+ isCrossTreeDrag = true;
812
+ // Cross-tree drag - try to get node info from dataTransfer
813
+ try {
814
+ const data = event.dataTransfer.getData("application/svelte-treeview");
815
+ if (data) {
816
+ effectiveDraggedNode = JSON.parse(data);
817
+ }
818
+ } catch (e) {
819
+ // getData might fail during dragover in some browsers, that's ok
820
+ }
821
+ // Even if we can't get the data, we know a drag is in progress
822
+ isDragInProgress = true;
823
+ }
824
+
825
+ // Check if drop is allowed by mode
826
+ // For cross-tree drags, we allow if mode is 'both' or 'cross', regardless of whether we could parse the node
827
+ const dropAllowed = isCrossTreeDrag
828
+ ? (dragDropMode === 'both' || dragDropMode === 'cross')
829
+ : isDropAllowedByMode(effectiveDraggedNode?.treeId);
830
+
831
+ if (!dropAllowed) {
832
+ if (shouldDisplayDebugInformation) {
833
+ console.log('[Tree] Drop not allowed:', { treeId, dragDropMode, isCrossTreeDrag, effectiveDraggedNodeTreeId: effectiveDraggedNode?.treeId });
834
+ }
835
+ hoveredNodeForDrop = null; // Clear hover to prevent glow on invalid targets
836
+ return;
837
+ }
838
+
839
+ // Set hoveredNodeForDrop if:
840
+ // 1. We have drag data AND it's a different node (or from different tree), OR
841
+ // 2. We know a drag is in progress (cross-tree where we can't read data yet)
842
+ const isValidDrop = effectiveDraggedNode
843
+ ? (isCrossTreeDrag || effectiveDraggedNode.path !== node.path)
844
+ : isDragInProgress; // For cross-tree, trust isDragInProgress
845
+
846
+ if (isValidDrop) {
456
847
  event.preventDefault();
848
+
849
+ // Update hovered node and calculate position
850
+ hoveredNodeForDrop = node;
851
+ const nodeElement = (event.target as Element).closest('.ltree-node-content');
852
+ if (nodeElement) {
853
+ activeDropPosition = calculateDropPosition(event, nodeElement);
854
+ }
855
+
856
+ // Update current operation based on Ctrl key
857
+ const prevOperation = currentDropOperation;
858
+ currentDropOperation = (allowCopy && event.ctrlKey) ? 'copy' : 'move';
859
+ if (shouldDisplayDebugInformation && prevOperation !== currentDropOperation) {
860
+ console.log('[Tree] _onNodeDragOver - operation changed:', prevOperation, '->', currentDropOperation, 'ctrlKey:', event.ctrlKey, 'allowCopy:', allowCopy);
861
+ }
862
+
457
863
  onNodeDragOver?.(node, event);
458
864
 
459
- // Set visual feedback
865
+ // Set visual feedback based on operation
460
866
  if (event.dataTransfer) {
461
- event.dataTransfer.dropEffect = 'move';
867
+ event.dataTransfer.dropEffect = currentDropOperation;
462
868
  }
463
869
  }
464
870
  }
465
871
 
872
+ function _onNodeDragLeave(node: LTreeNode<T>, event: DragEvent) {
873
+ // Don't clear hoveredNodeForDrop here - let dragover on other nodes handle it
874
+ // This prevents the zones from flickering when moving between nodes
875
+ }
876
+
466
877
  function _onNodeDrop(node: LTreeNode<T>, event: DragEvent) {
467
878
  if (shouldDisplayDebugInformation)
468
879
  console.log(
@@ -472,16 +883,336 @@
472
883
  );
473
884
  event.preventDefault();
474
885
 
886
+ let isCrossTreeDrag = false;
887
+ if (!draggedNode) {
888
+ const data = event.dataTransfer?.getData('application/svelte-treeview');
889
+ if (data) {
890
+ draggedNode = JSON.parse(data);
891
+ isCrossTreeDrag = draggedNode?.treeId !== treeId;
892
+ }
893
+ }
894
+
895
+ // Check if drop is allowed by mode
896
+ const dropAllowed = isCrossTreeDrag
897
+ ? (dragDropMode === 'both' || dragDropMode === 'cross')
898
+ : isDropAllowedByMode(draggedNode?.treeId);
899
+
900
+ if (!dropAllowed) {
901
+ _onNodeDragEnd(event);
902
+ return;
903
+ }
904
+
905
+ // For cross-tree, always allow; for same-tree, check it's not the same node
906
+ if (draggedNode && (isCrossTreeDrag || draggedNode !== node)) {
907
+ // Use the calculated position, default to 'child'
908
+ const position = activeDropPosition || 'child';
909
+ _handleDrop(node, draggedNode, position, event);
910
+ }
911
+
912
+ // Reset drag state
913
+ _onNodeDragEnd(event);
914
+ }
915
+
916
+ // Zone drop handler - receives explicit position from drop zone panels
917
+ function _onZoneDrop(node: LTreeNode<T>, position: DropPosition, event: DragEvent) {
918
+ if (shouldDisplayDebugInformation)
919
+ console.log('🎯 ~ _onZoneDrop ~ position:', position, 'node:', node.path);
920
+
921
+ event.preventDefault();
922
+
923
+ let isCrossTreeDrag = false;
924
+ if (!draggedNode) {
925
+ const data = event.dataTransfer?.getData('application/svelte-treeview');
926
+ if (data) {
927
+ draggedNode = JSON.parse(data);
928
+ isCrossTreeDrag = draggedNode?.treeId !== treeId;
929
+ }
930
+ }
931
+
475
932
  if (!draggedNode) {
476
- draggedNode = JSON.parse(event.dataTransfer?.getData('application/svelte-treeview'));
933
+ _onNodeDragEnd(event);
934
+ return;
477
935
  }
478
936
 
479
- if (draggedNode && draggedNode !== node) {
480
- onNodeDrop?.(node, draggedNode, event);
937
+ // Check if drop is allowed by mode
938
+ const dropAllowed = isCrossTreeDrag
939
+ ? (dragDropMode === 'both' || dragDropMode === 'cross')
940
+ : isDropAllowedByMode(draggedNode?.treeId);
941
+
942
+ if (!dropAllowed) {
943
+ _onNodeDragEnd(event);
944
+ return;
945
+ }
946
+
947
+ // For cross-tree, always allow; for same-tree, check it's not the same node
948
+ if (isCrossTreeDrag || draggedNode !== node) {
949
+ _handleDrop(node, draggedNode, position, event);
481
950
  }
482
951
 
483
952
  // Reset drag state
953
+ _onNodeDragEnd(event);
954
+ }
955
+
956
+ // Touch drag handlers for mobile support
957
+ function _onTouchStart(node: LTreeNode<any>, event: TouchEvent) {
958
+ if (!node?.isDraggable) return;
959
+
960
+ const touch = event.touches[0];
961
+ touchDragState = {
962
+ node,
963
+ startX: touch.clientX,
964
+ startY: touch.clientY,
965
+ isDragging: false,
966
+ ghostElement: null,
967
+ currentDropTarget: null
968
+ };
969
+
970
+ // Start long-press timer (300ms)
971
+ touchTimer = setTimeout(() => {
972
+ touchDragState.isDragging = true;
973
+ draggedNode = node;
974
+ dragLogger.debug(`Touch drag started: ${node.path}`);
975
+ createGhostElement(node, touch.clientX, touch.clientY);
976
+ navigator.vibrate?.(50); // Haptic feedback
977
+ }, 300);
978
+ }
979
+
980
+ function _onTouchMove(node: LTreeNode<any>, event: TouchEvent) {
981
+ if (!touchDragState.node) return;
982
+
983
+ const touch = event.touches[0];
984
+
985
+ if (!touchDragState.isDragging) {
986
+ // Check if moved too much before long-press completed - cancel drag
987
+ const dx = Math.abs(touch.clientX - touchDragState.startX);
988
+ const dy = Math.abs(touch.clientY - touchDragState.startY);
989
+ if (dx > 10 || dy > 10) {
990
+ if (touchTimer) clearTimeout(touchTimer);
991
+ touchDragState = { node: null, startX: 0, startY: 0, isDragging: false, ghostElement: null, currentDropTarget: null };
992
+ }
993
+ return;
994
+ }
995
+
996
+ event.preventDefault(); // Prevent scroll during drag
997
+
998
+ // Move ghost element
999
+ if (touchDragState.ghostElement) {
1000
+ touchDragState.ghostElement.style.left = `${touch.clientX}px`;
1001
+ touchDragState.ghostElement.style.top = `${touch.clientY}px`;
1002
+ }
1003
+
1004
+ // Find drop target under touch point (hide ghost temporarily to not interfere)
1005
+ if (touchDragState.ghostElement) {
1006
+ touchDragState.ghostElement.style.pointerEvents = 'none';
1007
+ }
1008
+ const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY);
1009
+ if (touchDragState.ghostElement) {
1010
+ touchDragState.ghostElement.style.pointerEvents = '';
1011
+ }
1012
+
1013
+ // Update drop target highlighting
1014
+ updateDropTarget(elementUnderTouch);
1015
+ }
1016
+
1017
+ function _onTouchEnd(node: LTreeNode<any>, event: TouchEvent) {
1018
+ if (touchTimer) clearTimeout(touchTimer);
1019
+
1020
+ if (touchDragState.isDragging && draggedNode) {
1021
+ const touch = event.changedTouches[0];
1022
+
1023
+ // Hide ghost to find element underneath
1024
+ if (touchDragState.ghostElement) {
1025
+ touchDragState.ghostElement.style.display = 'none';
1026
+ }
1027
+
1028
+ const dropElement = document.elementFromPoint(touch.clientX, touch.clientY);
1029
+ const dropNode = findNodeFromElement(dropElement);
1030
+
1031
+ // Check if dropping on empty tree placeholder
1032
+ const placeholder = dropElement?.closest('.ltree-empty-state');
1033
+ const rootDropZone = dropElement?.closest('.ltree-root-drop-zone');
1034
+ if ((placeholder || rootDropZone) && !dropNode) {
1035
+ // Dropping on empty tree or root drop zone
1036
+ dragLogger.debug(`Touch drag ended: ${draggedNode.path} -> empty tree`);
1037
+ _handleDrop(null, draggedNode, 'child', event);
1038
+ } else if (dropNode && dropNode !== draggedNode && dropNode.isDropAllowed) {
1039
+ // For touch, default to 'child' since we don't track position during touch
1040
+ dragLogger.debug(`Touch drag ended: ${draggedNode.path} -> ${dropNode.path}`);
1041
+ _handleDrop(dropNode, draggedNode, 'child', event);
1042
+ } else {
1043
+ dragLogger.debug(`Touch drag cancelled: ${draggedNode.path}`);
1044
+ }
1045
+
1046
+ // Clean up ghost element
1047
+ removeGhostElement();
1048
+ clearDropTargetHighlight();
1049
+ }
1050
+
1051
+ // Reset state
1052
+ touchDragState = { node: null, startX: 0, startY: 0, isDragging: false, ghostElement: null, currentDropTarget: null };
484
1053
  draggedNode = null;
1054
+ isDropPlaceholderActive = false;
1055
+ }
1056
+
1057
+ function createGhostElement(node: LTreeNode<any>, x: number, y: number) {
1058
+ const ghost = document.createElement('div');
1059
+ ghost.className = 'ltree-touch-ghost';
1060
+ ghost.textContent = tree.getNodeDisplayValue(node);
1061
+ ghost.style.left = `${x}px`;
1062
+ ghost.style.top = `${y}px`;
1063
+ document.body.appendChild(ghost);
1064
+ touchDragState.ghostElement = ghost;
1065
+ }
1066
+
1067
+ function removeGhostElement() {
1068
+ if (touchDragState.ghostElement) {
1069
+ touchDragState.ghostElement.remove();
1070
+ touchDragState.ghostElement = null;
1071
+ }
1072
+ }
1073
+
1074
+ function findNodeFromElement(element: Element | null): LTreeNode<any> | null {
1075
+ if (!element) return null;
1076
+
1077
+ const nodeElement = element.closest('.ltree-node');
1078
+ if (!nodeElement) return null;
1079
+
1080
+ const path = nodeElement.getAttribute('data-tree-path');
1081
+ if (!path) return null;
1082
+
1083
+ return tree.getNodeByPath(path);
1084
+ }
1085
+
1086
+ function updateDropTarget(element: Element | null) {
1087
+ const newTarget = findNodeFromElement(element);
1088
+
1089
+ // Clear previous highlight
1090
+ if (touchDragState.currentDropTarget && touchDragState.currentDropTarget !== newTarget) {
1091
+ const prevElement = document.querySelector(`[data-tree-path="${touchDragState.currentDropTarget.path}"] .ltree-node-content`);
1092
+ prevElement?.classList.remove(dragOverNodeClass || 'ltree-dragover-highlight');
1093
+ }
1094
+
1095
+ // Check if we're over an empty tree placeholder
1096
+ const placeholder = element?.closest('.ltree-empty-state');
1097
+ if (placeholder && !newTarget) {
1098
+ // We're over an empty tree's drop zone
1099
+ isDropPlaceholderActive = true;
1100
+ touchDragState.currentDropTarget = null;
1101
+ return;
1102
+ } else {
1103
+ // Clear placeholder state if we're not over it
1104
+ isDropPlaceholderActive = false;
1105
+ }
1106
+
1107
+ // Add highlight to new target
1108
+ if (newTarget && newTarget !== draggedNode && newTarget.isDropAllowed) {
1109
+ const targetElement = document.querySelector(`[data-tree-path="${newTarget.path}"] .ltree-node-content`);
1110
+ targetElement?.classList.add(dragOverNodeClass || 'ltree-dragover-highlight');
1111
+ touchDragState.currentDropTarget = newTarget;
1112
+ } else {
1113
+ touchDragState.currentDropTarget = null;
1114
+ }
1115
+ }
1116
+
1117
+ function clearDropTargetHighlight() {
1118
+ if (touchDragState.currentDropTarget) {
1119
+ const element = document.querySelector(`[data-tree-path="${touchDragState.currentDropTarget.path}"] .ltree-node-content`);
1120
+ element?.classList.remove(dragOverNodeClass || 'ltree-dragover-highlight');
1121
+ }
1122
+ }
1123
+
1124
+ // Empty tree drop handlers
1125
+ function handleEmptyTreeDragOver(event: DragEvent) {
1126
+ console.log('[EmptyTree] dragover/dragenter fired', {
1127
+ types: event.dataTransfer?.types,
1128
+ hasTreeviewType: event.dataTransfer?.types.includes("application/svelte-treeview"),
1129
+ isDropPlaceholderActive,
1130
+ treeId
1131
+ });
1132
+ if (event.dataTransfer?.types.includes("application/svelte-treeview")) {
1133
+ event.preventDefault();
1134
+ isDropPlaceholderActive = true;
1135
+ console.log('[EmptyTree] isDropPlaceholderActive set to true');
1136
+ if (event.dataTransfer) {
1137
+ event.dataTransfer.dropEffect = 'move';
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ function handleEmptyTreeDragLeave(event: DragEvent) {
1143
+ // Only deactivate if truly leaving the element
1144
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
1145
+ const x = event.clientX;
1146
+ const y = event.clientY;
1147
+
1148
+ console.log('[EmptyTree] dragleave fired', {
1149
+ x, y,
1150
+ rect: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom },
1151
+ isOutside: x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom,
1152
+ treeId
1153
+ });
1154
+
1155
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
1156
+ isDropPlaceholderActive = false;
1157
+ console.log('[EmptyTree] isDropPlaceholderActive set to false (left element)');
1158
+ }
1159
+ }
1160
+
1161
+ function handleEmptyTreeDrop(event: DragEvent) {
1162
+ console.log('[EmptyTree] drop fired', {
1163
+ types: event.dataTransfer?.types,
1164
+ data: event.dataTransfer?.getData('application/svelte-treeview'),
1165
+ treeId
1166
+ });
1167
+ event.preventDefault();
1168
+ isDropPlaceholderActive = false;
1169
+
1170
+ const draggedNodeData = event.dataTransfer?.getData('application/svelte-treeview');
1171
+ if (draggedNodeData) {
1172
+ const droppedNode = JSON.parse(draggedNodeData);
1173
+ console.log('[EmptyTree] calling _handleDrop with', { droppedNode });
1174
+ // Call onNodeDrop with null as dropNode to indicate "root level drop"
1175
+ _handleDrop(null, droppedNode, 'child', event);
1176
+ } else {
1177
+ console.log('[EmptyTree] no draggedNodeData found!');
1178
+ }
1179
+ _onNodeDragEnd(event);
1180
+ }
1181
+
1182
+ function handleEmptyTreeTouchEnd(event: TouchEvent) {
1183
+ console.log('[EmptyTree] touchend fired', {
1184
+ draggedNode,
1185
+ isDropPlaceholderActive,
1186
+ treeId
1187
+ });
1188
+ // Check if touch drag was active and we have a dragged node
1189
+ if (draggedNode && isDropPlaceholderActive) {
1190
+ _handleDrop(null, draggedNode, 'child', event);
1191
+ isDropPlaceholderActive = false;
1192
+ }
1193
+ }
1194
+
1195
+ // Tree-level dragenter for cross-tree drag detection
1196
+ function handleTreeDragEnter(event: DragEvent) {
1197
+ if (event.dataTransfer?.types.includes("application/svelte-treeview")) {
1198
+ isDragInProgress = true;
1199
+ }
1200
+ }
1201
+
1202
+ function handleTreeDragLeave(event: DragEvent) {
1203
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
1204
+ const x = event.clientX;
1205
+ const y = event.clientY;
1206
+
1207
+ // Only reset if truly leaving the tree container
1208
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
1209
+ // Don't reset isDragInProgress if we're the source tree
1210
+ if (draggedNode?.treeId !== treeId) {
1211
+ isDragInProgress = false;
1212
+ hoveredNodeForDrop = null;
1213
+ activeDropPosition = null;
1214
+ }
1215
+ }
485
1216
  }
486
1217
 
487
1218
  // Close context menu when clicking outside
@@ -560,7 +1291,14 @@
560
1291
  });
561
1292
  </script>
562
1293
 
563
- <div bind:this={treeContainerRef}>
1294
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1295
+ <div
1296
+ class="ltree-container"
1297
+ bind:this={treeContainerRef}
1298
+ ondragenter={handleTreeDragEnter}
1299
+ ondragleave={handleTreeDragLeave}
1300
+ ondragend={_onNodeDragEnd}
1301
+ >
564
1302
  {#if shouldDisplayDebugInformation}
565
1303
  <div class="ltree-debug-info">
566
1304
  <details>
@@ -584,6 +1322,17 @@
584
1322
  {/if}
585
1323
 
586
1324
  {@render treeHeader?.()}
1325
+
1326
+ {#if isLoading}
1327
+ <div class="ltree-loading-overlay">
1328
+ {#if loadingPlaceholder}
1329
+ {@render loadingPlaceholder()}
1330
+ {:else}
1331
+ <div class="ltree-loading-spinner"></div>
1332
+ {/if}
1333
+ </div>
1334
+ {/if}
1335
+
587
1336
  <div class:bodyClass>
588
1337
  {#if tree?.root}
589
1338
  {#key tree.changeTracker}
@@ -593,28 +1342,84 @@
593
1342
  {node}
594
1343
  children={nodeTemplate}
595
1344
  {shouldToggleOnNodeClick}
1345
+ {progressiveRender}
1346
+ {renderBatchSize}
596
1347
  onNodeClicked={(node) => _onNodeClicked(node)}
597
1348
  onNodeRightClicked={(node, event) => _onNodeRightClicked(node, event)}
598
1349
  onNodeDragStart={(node, event) => _onNodeDragStart(node, event)}
599
1350
  onNodeDragOver={(node, event) => _onNodeDragOver(node, event)}
1351
+ onNodeDragLeave={(node, event) => _onNodeDragLeave(node, event)}
600
1352
  onNodeDrop={(node, event) => _onNodeDrop(node, event)}
1353
+ onZoneDrop={(node, position, event) => _onZoneDrop(node, position, event)}
1354
+ onTouchDragStart={(node, event) => _onTouchStart(node, event)}
1355
+ onTouchDragMove={(node, event) => _onTouchMove(node, event)}
1356
+ onTouchDragEnd={(node, event) => _onTouchEnd(node, event)}
601
1357
  {expandIconClass}
602
1358
  {collapseIconClass}
603
1359
  {leafIconClass}
604
1360
  {selectedNodeClass}
605
1361
  {dragOverNodeClass}
606
- isDraggedNode={draggedNode === node}
1362
+ isDraggedNode={draggedNode?.path === node.path}
1363
+ {isDragInProgress}
1364
+ hoveredNodeForDropPath={hoveredNodeForDrop?.path}
1365
+ {activeDropPosition}
1366
+ {dropZoneMode}
1367
+ {dropZoneLayout}
1368
+ {dropZoneStart}
1369
+ {dropZoneMaxWidth}
1370
+ dropOperation={currentDropOperation}
1371
+ {allowCopy}
607
1372
  />
608
1373
  {:else}
609
- <div class="ltree-empty-state">
610
- {@render noDataFound?.()}
1374
+ <!-- Empty state when tree has no items -->
1375
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1376
+ <div
1377
+ class="ltree-empty-state"
1378
+ class:ltree-drop-placeholder={isDropPlaceholderActive}
1379
+ ondragenter={handleEmptyTreeDragOver}
1380
+ ondragover={handleEmptyTreeDragOver}
1381
+ ondragleave={handleEmptyTreeDragLeave}
1382
+ ondrop={handleEmptyTreeDrop}
1383
+ ontouchend={handleEmptyTreeTouchEnd}
1384
+ >
1385
+ {#if isDropPlaceholderActive}
1386
+ {#if dropPlaceholder}
1387
+ {@render dropPlaceholder()}
1388
+ {:else}
1389
+ <div class="ltree-drop-placeholder-content">
1390
+ Drop here to add
1391
+ </div>
1392
+ {/if}
1393
+ {:else}
1394
+ {@render noDataFound?.()}
1395
+ {/if}
611
1396
  </div>
612
1397
  {/each}
613
1398
  </div>
614
1399
  {/key}
615
1400
  {:else}
616
- <div class="ltree-empty-state">
617
- {@render noDataFound?.()}
1401
+ <!-- Empty tree drop zone -->
1402
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1403
+ <div
1404
+ class="ltree-empty-state"
1405
+ class:ltree-drop-placeholder={isDropPlaceholderActive}
1406
+ ondragenter={handleEmptyTreeDragOver}
1407
+ ondragover={handleEmptyTreeDragOver}
1408
+ ondragleave={handleEmptyTreeDragLeave}
1409
+ ondrop={handleEmptyTreeDrop}
1410
+ ontouchend={handleEmptyTreeTouchEnd}
1411
+ >
1412
+ {#if isDropPlaceholderActive}
1413
+ {#if dropPlaceholder}
1414
+ {@render dropPlaceholder()}
1415
+ {:else}
1416
+ <div class="ltree-drop-placeholder-content">
1417
+ Drop here to add
1418
+ </div>
1419
+ {/if}
1420
+ {:else}
1421
+ {@render noDataFound?.()}
1422
+ {/if}
618
1423
  </div>
619
1424
  {/if}
620
1425
  </div>
@@ -633,6 +1438,8 @@
633
1438
  <div
634
1439
  class="ltree-context-menu-item {item.className || ''}"
635
1440
  class:ltree-context-menu-item-disabled={item.isDisabled}
1441
+ role="menuitem"
1442
+ tabindex={item.isDisabled ? -1 : 0}
636
1443
  onclick={async () => {
637
1444
  if (!item.isDisabled) {
638
1445
  try {
@@ -642,6 +1449,16 @@
642
1449
  }
643
1450
  }
644
1451
  }}
1452
+ onkeydown={async (e) => {
1453
+ if ((e.key === 'Enter' || e.key === ' ') && !item.isDisabled) {
1454
+ e.preventDefault();
1455
+ try {
1456
+ await item.callback();
1457
+ } catch (error) {
1458
+ console.error('Context menu callback error:', error);
1459
+ }
1460
+ }
1461
+ }}
645
1462
  >
646
1463
  {#if item.icon}
647
1464
  <span class="ltree-context-menu-icon">{item.icon}</span>