@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
@@ -0,0 +1,1503 @@
1
+ import {} from '../ltree/ltree-node.svelte.js';
2
+ import { createLTree } from '../ltree/ltree.svelte.js';
3
+ import {} from '../ltree/types.js';
4
+ import { tick } from 'svelte';
5
+ import { createRenderCoordinator } from '../components/RenderCoordinator.svelte.js';
6
+ import { uiLogger, dragLogger } from '../logger.js';
7
+ import { perfStart, perfEnd } from '../perf-logger.js';
8
+ // Re-register global API (safe to import multiple times)
9
+ import '../global-api.js';
10
+ // ─── TreeController ───────────────────────────────────────────────────────
11
+ export class TreeController {
12
+ // ── LTree instance ──────────────────────────────────────────────────
13
+ tree;
14
+ // ── Render coordinator ──────────────────────────────────────────────
15
+ renderCoordinator;
16
+ // ── Stable callback & config objects for Node context ───────────────
17
+ nodeCallbacks;
18
+ nodeConfig = $state({
19
+ shouldToggleOnNodeClick: true,
20
+ expandIconClass: 'ltree-icon-expand',
21
+ collapseIconClass: 'ltree-icon-collapse',
22
+ leafIconClass: 'ltree-icon-leaf',
23
+ selectedNodeClass: undefined,
24
+ dragOverNodeClass: undefined,
25
+ dropZoneMode: 'glow',
26
+ dropZoneLayout: 'around',
27
+ dropZoneStart: 33,
28
+ dropZoneMaxWidth: 120,
29
+ allowCopy: false
30
+ });
31
+ // ── Props stored as reactive state ──────────────────────────────────
32
+ treeId = $state('');
33
+ treePathSeparator = $state('.');
34
+ // DATA (bidirectional / output)
35
+ data = $state.raw([]);
36
+ selectedNode = $state.raw(null);
37
+ insertResult = $state.raw(null);
38
+ searchText = $state(undefined);
39
+ isRendering = $state(false);
40
+ // BEHAVIOUR
41
+ shouldDisplayDebugInformation = $state(false);
42
+ shouldDisplayContextMenuInDebugMode = $state(false);
43
+ isLoading = $state(false);
44
+ useFlatRendering = $state(true);
45
+ progressiveRender = $state(true);
46
+ initialBatchSize = $state(20);
47
+ maxBatchSize = $state(500);
48
+ bodyClass = $state(undefined);
49
+ // DRAG AND DROP
50
+ dragDropMode = $state('none');
51
+ allowCopy = $state(false);
52
+ autoHandleCopy = $state(true);
53
+ // EVENTS (stored for calling — plain assignments, not deeply proxied)
54
+ onNodeClickedCb;
55
+ onNodeDragStartCb;
56
+ onNodeDragOverCb;
57
+ beforeDropCallbackCb;
58
+ onNodeDropCb;
59
+ contextMenuCallbackCb;
60
+ onRenderStartCb;
61
+ onRenderProgressCb;
62
+ onRenderCompleteCb;
63
+ // Visual config (for nodeConfig updates)
64
+ shouldToggleOnNodeClick = $state(true);
65
+ expandIconClass = $state('ltree-icon-expand');
66
+ collapseIconClass = $state('ltree-icon-collapse');
67
+ leafIconClass = $state('ltree-icon-leaf');
68
+ selectedNodeClass = $state(undefined);
69
+ dragOverNodeClass = $state(undefined);
70
+ dropZoneMode = $state('glow');
71
+ dropZoneLayout = $state('around');
72
+ dropZoneStart = $state(33);
73
+ dropZoneMaxWidth = $state(120);
74
+ scrollHighlightTimeout = $state(4000);
75
+ scrollHighlightClass = $state('ltree-scroll-highlight');
76
+ contextMenuXOffset = $state(8);
77
+ contextMenuYOffset = $state(0);
78
+ hasContextMenuSnippet = $state(false);
79
+ // Virtual scrolling
80
+ virtualScroll = $state(false);
81
+ virtualRowHeight = $state(undefined);
82
+ virtualOverscan = $state(5);
83
+ virtualContainerHeight = $state(undefined);
84
+ // ── Internal mutable state ──────────────────────────────────────────
85
+ // Context menu
86
+ contextMenuVisible = $state(false);
87
+ contextMenuX = $state(0);
88
+ contextMenuY = $state(0);
89
+ contextMenuNode = $state.raw(null);
90
+ isDebugMenuActive = $state(false);
91
+ // Scroll highlight
92
+ currentHighlight = null;
93
+ // Drag and drop
94
+ draggedNode = $state.raw(null);
95
+ isDragInProgress = $state(false);
96
+ hoveredNodeForDrop = $state.raw(null);
97
+ activeDropPosition = $state(null);
98
+ currentDropOperation = $state('move');
99
+ // Floating drop zones (rendered at Tree level with position:fixed)
100
+ floatingZoneRect = $state(null);
101
+ floatingHoveredZone = $state(null);
102
+ // Touch drag
103
+ touchDragState = $state.raw({
104
+ node: null,
105
+ startX: 0,
106
+ startY: 0,
107
+ isDragging: false,
108
+ ghostElement: null,
109
+ currentDropTarget: null
110
+ });
111
+ touchTimer = null;
112
+ // Progressive flat rendering
113
+ flatRenderedIds = $state.raw(new Set());
114
+ flatRenderQueue = $state.raw([]);
115
+ flatRenderAnimationFrame = null;
116
+ currentBatchSize = 0;
117
+ // Virtual scrolling state
118
+ vsScrollTop = $state(0);
119
+ vsMeasuredRowHeight = $state(null);
120
+ vsContainerRef = $state();
121
+ vsDetectedHeight = $state(null);
122
+ vsRafPending = false;
123
+ // Drop placeholder
124
+ isDropPlaceholderActive = $state(false);
125
+ // Skip insertArray flag
126
+ _skipInsertArray = false;
127
+ // Progressive flat rendering tracker
128
+ lastFlatNodesTracker = null;
129
+ // Container element (set by the host component for scrollToPath / debug menu)
130
+ containerElement = null;
131
+ // ── Derived ─────────────────────────────────────────────────────────
132
+ // Virtual scroll derived computations
133
+ vsRowHeight = $derived(this.virtualRowHeight ?? this.vsMeasuredRowHeight ?? 32);
134
+ vsActive = $derived(this.virtualScroll && this.useFlatRendering);
135
+ vsContainerStyle = $derived(this.virtualContainerHeight ?? this.vsDetectedHeight ?? '400px');
136
+ allFlatNodes = $derived(this.tree?.visibleFlatNodes ?? []);
137
+ vsTotalCount = $derived(this.allFlatNodes.length);
138
+ vsTotalHeight = $derived(this.vsTotalCount * this.vsRowHeight);
139
+ vsStartIndex = $derived(this.vsActive
140
+ ? Math.max(0, Math.floor(this.vsScrollTop / this.vsRowHeight) - this.virtualOverscan)
141
+ : 0);
142
+ vsEndIndex = $derived(this.vsActive
143
+ ? Math.min(this.vsTotalCount, Math.ceil((this.vsScrollTop + (this.vsContainerRef?.clientHeight ?? 0)) / this.vsRowHeight) + this.virtualOverscan)
144
+ : this.vsTotalCount);
145
+ vsOffsetY = $derived(this.vsStartIndex * this.vsRowHeight);
146
+ flatNodesToRender = $derived(this.vsActive
147
+ ? this.allFlatNodes.slice(this.vsStartIndex, this.vsEndIndex)
148
+ : this.useFlatRendering && this.progressiveRender
149
+ ? (this.tree?.visibleFlatNodes?.filter((n) => this.flatRenderedIds.has(String(n.id))) ?? [])
150
+ : (this.tree?.visibleFlatNodes ?? []));
151
+ get statistics() {
152
+ return this.tree?.statistics;
153
+ }
154
+ // ── Constructor ─────────────────────────────────────────────────────
155
+ constructor(props) {
156
+ // Assign prop values (with defaults)
157
+ this.treeId = props.treeId || this.generateTreeId();
158
+ this.treePathSeparator = props.treePathSeparator ?? '.';
159
+ this.data = props.data;
160
+ this.selectedNode = props.selectedNode ?? null;
161
+ this.searchText = props.searchText;
162
+ this.shouldDisplayDebugInformation = props.shouldDisplayDebugInformation ?? false;
163
+ this.shouldDisplayContextMenuInDebugMode = props.shouldDisplayContextMenuInDebugMode ?? false;
164
+ this.isLoading = props.isLoading ?? false;
165
+ this.useFlatRendering = props.useFlatRendering ?? true;
166
+ this.progressiveRender = props.progressiveRender ?? true;
167
+ this.initialBatchSize = props.initialBatchSize ?? 20;
168
+ this.maxBatchSize = props.maxBatchSize ?? 500;
169
+ this.bodyClass = props.bodyClass;
170
+ this.dragDropMode = props.dragDropMode ?? 'none';
171
+ this.allowCopy = props.allowCopy ?? false;
172
+ this.autoHandleCopy = props.autoHandleCopy ?? true;
173
+ this.shouldToggleOnNodeClick = props.shouldToggleOnNodeClick ?? true;
174
+ this.expandIconClass = props.expandIconClass ?? 'ltree-icon-expand';
175
+ this.collapseIconClass = props.collapseIconClass ?? 'ltree-icon-collapse';
176
+ this.leafIconClass = props.leafIconClass ?? 'ltree-icon-leaf';
177
+ this.selectedNodeClass = props.selectedNodeClass;
178
+ this.dragOverNodeClass = props.dragOverNodeClass;
179
+ this.dropZoneMode = props.dropZoneMode ?? 'glow';
180
+ this.dropZoneLayout = props.dropZoneLayout ?? 'around';
181
+ this.dropZoneStart = props.dropZoneStart ?? 33;
182
+ this.dropZoneMaxWidth = props.dropZoneMaxWidth ?? 120;
183
+ this.scrollHighlightTimeout = props.scrollHighlightTimeout ?? 4000;
184
+ this.scrollHighlightClass = props.scrollHighlightClass ?? 'ltree-scroll-highlight';
185
+ this.contextMenuXOffset = props.contextMenuXOffset ?? 8;
186
+ this.contextMenuYOffset = props.contextMenuYOffset ?? 0;
187
+ this.hasContextMenuSnippet = props.hasContextMenuSnippet ?? false;
188
+ // Virtual scrolling
189
+ this.virtualScroll = props.virtualScroll ?? false;
190
+ this.virtualRowHeight = props.virtualRowHeight;
191
+ this.virtualOverscan = props.virtualOverscan ?? 5;
192
+ this.virtualContainerHeight = props.virtualContainerHeight;
193
+ // Store callbacks
194
+ this.onNodeClickedCb = props.onNodeClicked;
195
+ this.onNodeDragStartCb = props.onNodeDragStart;
196
+ this.onNodeDragOverCb = props.onNodeDragOver;
197
+ this.beforeDropCallbackCb = props.beforeDropCallback;
198
+ this.onNodeDropCb = props.onNodeDrop;
199
+ this.contextMenuCallbackCb = props.contextMenuCallback;
200
+ this.onRenderStartCb = props.onRenderStart;
201
+ this.onRenderProgressCb = props.onRenderProgress;
202
+ this.onRenderCompleteCb = props.onRenderComplete;
203
+ // ── Create LTree ────────────────────────────────────────────────
204
+ // svelte-ignore non_reactive_update
205
+ this.tree = createLTree(props.idMember, props.pathMember, props.parentPathMember, props.levelMember, props.hasChildrenMember, props.isExpandedMember, props.isSelectedMember, props.isDraggableMember, props.getIsDraggableCallback, props.isDropAllowedMember, props.allowedDropPositionsMember, props.displayValueMember, props.getDisplayValueCallback, props.searchValueMember, props.getSearchValueCallback, props.getAllowedDropPositionsCallback, props.isCollapsibleMember, props.getIsCollapsibleCallback, props.orderMember, this.treeId, this.treePathSeparator, props.expandLevel, props.shouldUseInternalSearchIndex, props.initializeIndexCallback, props.indexerBatchSize, props.indexerTimeout, {
206
+ shouldDisplayDebugInformation: props.shouldDisplayDebugInformation,
207
+ isSorted: props.isSorted,
208
+ sortCallback: props.sortCallback
209
+ });
210
+ // ── Create render coordinator ───────────────────────────────────
211
+ this.renderCoordinator = this.progressiveRender
212
+ ? createRenderCoordinator(2, {
213
+ onStart: () => {
214
+ this.isRendering = true;
215
+ this.onRenderStartCb?.();
216
+ },
217
+ onProgress: (stats) => {
218
+ this.onRenderProgressCb?.(stats);
219
+ },
220
+ onComplete: (stats) => {
221
+ this.isRendering = false;
222
+ this.onRenderCompleteCb?.(stats);
223
+ }
224
+ })
225
+ : null;
226
+ // ── Create stable nodeCallbacks ─────────────────────────────────
227
+ this.nodeCallbacks = {
228
+ onNodeClicked: this._onNodeClicked.bind(this),
229
+ onNodeRightClicked: this._onNodeRightClicked.bind(this),
230
+ onNodeDragStart: this._onNodeDragStart.bind(this),
231
+ onNodeDragOver: this._onNodeDragOver.bind(this),
232
+ onNodeDragLeave: this._onNodeDragLeave.bind(this),
233
+ onNodeDrop: this._onNodeDrop.bind(this),
234
+ onZoneDrop: this._onZoneDrop.bind(this),
235
+ onTouchDragStart: this._onTouchStart.bind(this),
236
+ onTouchDragMove: this._onTouchMove.bind(this),
237
+ onTouchDragEnd: this._onTouchEnd.bind(this)
238
+ };
239
+ // ── Initial nodeConfig ──────────────────────────────────────────
240
+ this.nodeConfig = {
241
+ shouldToggleOnNodeClick: this.shouldToggleOnNodeClick,
242
+ expandIconClass: this.expandIconClass,
243
+ collapseIconClass: this.collapseIconClass,
244
+ leafIconClass: this.leafIconClass,
245
+ selectedNodeClass: this.selectedNodeClass,
246
+ dragOverNodeClass: this.dragOverNodeClass,
247
+ dropZoneMode: this.dropZoneMode,
248
+ dropZoneLayout: this.dropZoneLayout,
249
+ dropZoneStart: this.dropZoneStart,
250
+ dropZoneMaxWidth: this.dropZoneMaxWidth,
251
+ allowCopy: this.allowCopy
252
+ };
253
+ // ── Effects ─────────────────────────────────────────────────────
254
+ // IMPORTANT: These $effect() calls bind to the lifecycle of whichever
255
+ // component instantiates this class. Must be created during component init.
256
+ // Sync treePathSeparator → LTree
257
+ $effect(() => {
258
+ this.tree.treePathSeparator = this.treePathSeparator;
259
+ });
260
+ // Mutate (don't replace) nodeConfig so the context reference stays the same.
261
+ // Using $state() (not .raw()) so the proxy makes property reads reactive in Node.svelte.
262
+ $effect(() => {
263
+ Object.assign(this.nodeConfig, {
264
+ shouldToggleOnNodeClick: this.shouldToggleOnNodeClick,
265
+ expandIconClass: this.expandIconClass,
266
+ collapseIconClass: this.collapseIconClass,
267
+ leafIconClass: this.leafIconClass,
268
+ selectedNodeClass: this.selectedNodeClass,
269
+ dragOverNodeClass: this.dragOverNodeClass,
270
+ dropZoneMode: this.dropZoneMode,
271
+ dropZoneLayout: this.dropZoneLayout,
272
+ dropZoneStart: this.dropZoneStart,
273
+ dropZoneMaxWidth: this.dropZoneMaxWidth,
274
+ allowCopy: this.allowCopy
275
+ });
276
+ });
277
+ // Filter when searchText changes
278
+ $effect(() => {
279
+ this.tree.filterNodes(this.searchText);
280
+ });
281
+ // InsertArray when data changes
282
+ $effect(() => {
283
+ if (this.tree && this.data) {
284
+ if (this._skipInsertArray) {
285
+ this._skipInsertArray = false;
286
+ return;
287
+ }
288
+ this.renderCoordinator?.reset();
289
+ this.flatRenderedIds = new Set();
290
+ this.flatRenderQueue = [];
291
+ this.currentBatchSize = 0;
292
+ // Reset virtual scroll measurements
293
+ this.vsMeasuredRowHeight = null;
294
+ this.vsDetectedHeight = null;
295
+ this.insertResult = this.tree.insertArray(this.data);
296
+ }
297
+ });
298
+ // Progressive rendering for flat mode
299
+ $effect(() => {
300
+ if (!this.useFlatRendering || !this.progressiveRender || !this.tree?.visibleFlatNodes)
301
+ return;
302
+ const tracker = this.tree.changeTracker;
303
+ if (tracker === this.lastFlatNodesTracker)
304
+ return;
305
+ this.lastFlatNodesTracker = tracker;
306
+ const allNodes = this.tree.visibleFlatNodes;
307
+ const currentIds = new Set(allNodes.map((n) => String(n.id)));
308
+ const renderedSnapshot = new Set(this.flatRenderedIds);
309
+ const queueSnapshot = new Set(this.flatRenderQueue);
310
+ const newIds = [];
311
+ for (const node of allNodes) {
312
+ const id = String(node.id);
313
+ if (!renderedSnapshot.has(id) && !queueSnapshot.has(id)) {
314
+ newIds.push(id);
315
+ }
316
+ }
317
+ const removedIds = [];
318
+ for (const id of renderedSnapshot) {
319
+ if (!currentIds.has(id)) {
320
+ removedIds.push(id);
321
+ }
322
+ }
323
+ if (removedIds.length > 0) {
324
+ const newRendered = new Set(renderedSnapshot);
325
+ for (const id of removedIds) {
326
+ newRendered.delete(id);
327
+ }
328
+ this.flatRenderedIds = newRendered;
329
+ }
330
+ if (newIds.length > 0) {
331
+ const alreadyHasManyNodes = renderedSnapshot.size > 1000;
332
+ const addingFewNodes = newIds.length < 200;
333
+ if (alreadyHasManyNodes && addingFewNodes) {
334
+ this.flatRenderedIds = new Set([...this.flatRenderedIds, ...newIds]);
335
+ }
336
+ else {
337
+ this.currentBatchSize = this.initialBatchSize;
338
+ const immediateBatch = newIds.slice(0, this.currentBatchSize);
339
+ const remaining = newIds.slice(this.currentBatchSize);
340
+ if (immediateBatch.length > 0) {
341
+ this.flatRenderedIds = new Set([...this.flatRenderedIds, ...immediateBatch]);
342
+ }
343
+ this.currentBatchSize = Math.min(this.currentBatchSize * 2, this.maxBatchSize);
344
+ if (remaining.length > 0) {
345
+ this.flatRenderQueue = [...remaining];
346
+ this.scheduleFlatRenderBatch();
347
+ }
348
+ }
349
+ }
350
+ });
351
+ // Virtual scroll: auto-measure row height from first rendered node
352
+ $effect(() => {
353
+ if (!this.vsActive || this.virtualRowHeight || this.vsMeasuredRowHeight)
354
+ return;
355
+ if (this.allFlatNodes.length === 0)
356
+ return;
357
+ tick().then(() => {
358
+ if (this.vsContainerRef) {
359
+ const firstNode = this.vsContainerRef.querySelector('.ltree-node');
360
+ if (firstNode) {
361
+ const height = firstNode.getBoundingClientRect().height;
362
+ if (height > 0)
363
+ this.vsMeasuredRowHeight = height;
364
+ }
365
+ }
366
+ });
367
+ });
368
+ // Virtual scroll: auto-detect container height from parent element
369
+ $effect(() => {
370
+ if (!this.vsActive || this.virtualContainerHeight || this.vsDetectedHeight)
371
+ return;
372
+ tick().then(() => {
373
+ if (this.vsContainerRef?.parentElement) {
374
+ const parentHeight = this.vsContainerRef.parentElement.clientHeight;
375
+ if (parentHeight > 100) {
376
+ this.vsDetectedHeight = parentHeight + 'px';
377
+ }
378
+ }
379
+ });
380
+ });
381
+ // Context menu global event listeners
382
+ $effect(() => {
383
+ if (this.contextMenuVisible) {
384
+ const handleGlobalClick = (event) => {
385
+ const target = event.target;
386
+ if (!target.closest('.ltree-context-menu')) {
387
+ this.closeContextMenu();
388
+ }
389
+ };
390
+ const handleGlobalScroll = () => {
391
+ this.closeContextMenu();
392
+ };
393
+ document.addEventListener('click', handleGlobalClick);
394
+ document.addEventListener('contextmenu', handleGlobalClick);
395
+ window.addEventListener('scroll', handleGlobalScroll, true);
396
+ document.addEventListener('scroll', handleGlobalScroll, true);
397
+ window.addEventListener('wheel', handleGlobalScroll, { passive: true });
398
+ return () => {
399
+ document.removeEventListener('click', handleGlobalClick);
400
+ document.removeEventListener('contextmenu', handleGlobalClick);
401
+ window.removeEventListener('scroll', handleGlobalScroll, true);
402
+ document.removeEventListener('scroll', handleGlobalScroll, true);
403
+ window.removeEventListener('wheel', handleGlobalScroll);
404
+ };
405
+ }
406
+ });
407
+ // Debug context menu
408
+ $effect(() => {
409
+ if (this.shouldDisplayContextMenuInDebugMode &&
410
+ (this.hasContextMenuSnippet || this.contextMenuCallbackCb) &&
411
+ this.tree?.tree &&
412
+ this.tree.tree.length > 0) {
413
+ const targetNode = this.tree.tree.length > 1 ? this.tree.tree[1] : this.tree.tree[0];
414
+ if (targetNode && this.containerElement) {
415
+ const treeRect = this.containerElement.getBoundingClientRect();
416
+ this.contextMenuNode = targetNode;
417
+ this.contextMenuX = treeRect.left + 200;
418
+ this.contextMenuY = treeRect.top + 100;
419
+ this.contextMenuVisible = true;
420
+ this.isDebugMenuActive = true;
421
+ }
422
+ }
423
+ else if (!this.shouldDisplayContextMenuInDebugMode && this.isDebugMenuActive) {
424
+ this.contextMenuVisible = false;
425
+ this.contextMenuNode = null;
426
+ this.isDebugMenuActive = false;
427
+ }
428
+ });
429
+ }
430
+ // ── Virtual scroll handler ──────────────────────────────────────────
431
+ handleVirtualScroll = (event) => {
432
+ if (this.vsRafPending)
433
+ return;
434
+ this.vsRafPending = true;
435
+ requestAnimationFrame(() => {
436
+ this.vsScrollTop = event.target.scrollTop;
437
+ this.vsRafPending = false;
438
+ });
439
+ };
440
+ // ── Public API methods ──────────────────────────────────────────────
441
+ async expandNodes(nodePath) {
442
+ this.tree.expandNodes(nodePath);
443
+ }
444
+ async collapseNodes(nodePath) {
445
+ this.tree.collapseNodes(nodePath);
446
+ }
447
+ expandAll(nodePath) {
448
+ this.tree?.expandAll(nodePath);
449
+ }
450
+ collapseAll(nodePath) {
451
+ this.tree?.collapseAll(nodePath);
452
+ }
453
+ filterNodes(searchTextVal, searchOptions) {
454
+ this.tree?.filterNodes(searchTextVal, searchOptions);
455
+ }
456
+ searchNodes(searchTextVal, searchOptions) {
457
+ return this.tree?.searchNodes(searchTextVal, searchOptions) || [];
458
+ }
459
+ getChildren(parentPath) {
460
+ return this.tree?.getChildren(parentPath) || [];
461
+ }
462
+ getSiblings(path) {
463
+ return this.tree?.getSiblings(path) || [];
464
+ }
465
+ refreshSiblings(parentPath) {
466
+ this.tree?.refreshSiblings(parentPath);
467
+ }
468
+ refreshNode(path) {
469
+ this.tree?.refreshNode(path);
470
+ }
471
+ getNodeByPath(path) {
472
+ return this.tree?.getNodeByPath(path) || null;
473
+ }
474
+ // ── Tree editor mutation methods ────────────────────────────────────
475
+ moveNode(sourcePath, targetPath, position) {
476
+ this._skipInsertArray = true;
477
+ const result = this.tree?.moveNode(sourcePath, targetPath, position) || {
478
+ success: false,
479
+ error: 'Tree not initialized'
480
+ };
481
+ tick().then(() => {
482
+ this._skipInsertArray = false;
483
+ });
484
+ return result;
485
+ }
486
+ removeNode(path, includeDescendants = true) {
487
+ this._skipInsertArray = true;
488
+ const result = this.tree?.removeNode(path, includeDescendants) || {
489
+ success: false,
490
+ error: 'Tree not initialized'
491
+ };
492
+ tick().then(() => {
493
+ this._skipInsertArray = false;
494
+ });
495
+ return result;
496
+ }
497
+ addNode(parentPath, nodeData, pathSegment) {
498
+ this._skipInsertArray = true;
499
+ const result = this.tree?.addNode(parentPath, nodeData, pathSegment) || {
500
+ success: false,
501
+ error: 'Tree not initialized'
502
+ };
503
+ tick().then(() => {
504
+ this._skipInsertArray = false;
505
+ });
506
+ return result;
507
+ }
508
+ updateNode(path, dataUpdates) {
509
+ this._skipInsertArray = true;
510
+ const result = this.tree?.updateNode(path, dataUpdates) || {
511
+ success: false,
512
+ error: 'Tree not initialized'
513
+ };
514
+ tick().then(() => {
515
+ this._skipInsertArray = false;
516
+ });
517
+ return result;
518
+ }
519
+ applyChanges(changes) {
520
+ this._skipInsertArray = true;
521
+ const result = this.tree?.applyChanges(changes) || { successful: 0, failed: [] };
522
+ tick().then(() => {
523
+ this._skipInsertArray = false;
524
+ });
525
+ return result;
526
+ }
527
+ copyNodeWithDescendants(sourceNode, targetParentPath, transformData, siblingPath, position) {
528
+ this._skipInsertArray = true;
529
+ const result = this.tree?.copyNodeWithDescendants(sourceNode, targetParentPath, transformData, siblingPath, position) || { success: false, count: 0, error: 'Tree not initialized' };
530
+ tick().then(() => {
531
+ this._skipInsertArray = false;
532
+ });
533
+ return result;
534
+ }
535
+ getExpandedPaths() {
536
+ return this.tree?.getExpandedPaths() || [];
537
+ }
538
+ setExpandedPaths(paths) {
539
+ this.tree?.setExpandedPaths(paths);
540
+ }
541
+ getAllData() {
542
+ return this.tree?.getAllData() || [];
543
+ }
544
+ /** Open the context menu at the given screen coordinates (offsets are applied automatically). */
545
+ openContextMenu(node, screenX, screenY) {
546
+ this.contextMenuNode = node;
547
+ this.contextMenuX = screenX + this.contextMenuXOffset;
548
+ this.contextMenuY = screenY + this.contextMenuYOffset;
549
+ this.contextMenuVisible = true;
550
+ this.isDebugMenuActive = false;
551
+ }
552
+ // svelte-ignore non_reactive_update
553
+ closeContextMenu() {
554
+ this.contextMenuVisible = false;
555
+ this.contextMenuNode = null;
556
+ this.isDebugMenuActive = false;
557
+ }
558
+ // ── Public Drag-and-Drop API for custom renderers ────────────────
559
+ /** Call from ondragstart. Sets up dataTransfer, stores drag state, fires callback. */
560
+ startDrag(node, event) {
561
+ dragLogger.debug('startDrag', { path: node.path, isDraggable: this.getNodeIsDraggable(node), hasDataTransfer: !!event.dataTransfer });
562
+ if (!this.getNodeIsDraggable(node) || !event.dataTransfer)
563
+ return;
564
+ event.dataTransfer.effectAllowed = this.allowCopy ? 'copyMove' : 'move';
565
+ event.dataTransfer.setData('application/svelte-treeview', JSON.stringify(node));
566
+ const displayValue = this.tree.getNodeDisplayValue(node);
567
+ if (displayValue)
568
+ event.dataTransfer.setData('text/plain', displayValue);
569
+ this._onNodeDragStart(node, event);
570
+ }
571
+ /** Call from ondragover. preventDefault, calculates drop position, updates hover state.
572
+ * Pass `element` for position calculation (before/after/child based on cursor). */
573
+ dragOver(node, event, element) {
574
+ if (!event.dataTransfer?.types.includes('application/svelte-treeview')) {
575
+ dragLogger.debug('dragOver SKIP - no svelte-treeview type', { path: node.path, types: Array.from(event.dataTransfer?.types ?? []) });
576
+ return;
577
+ }
578
+ // Cross-tree detection
579
+ let effectiveDraggedNode = this.draggedNode;
580
+ let isCrossTreeDrag = false;
581
+ if (!effectiveDraggedNode) {
582
+ isCrossTreeDrag = true;
583
+ try {
584
+ const data = event.dataTransfer.getData('application/svelte-treeview');
585
+ if (data)
586
+ effectiveDraggedNode = JSON.parse(data);
587
+ }
588
+ catch {
589
+ // getData might fail during dragover in some browsers
590
+ }
591
+ this.isDragInProgress = true;
592
+ }
593
+ // Check if drop is allowed by mode
594
+ const dropAllowed = isCrossTreeDrag
595
+ ? this.dragDropMode === 'both' || this.dragDropMode === 'cross'
596
+ : this.isDropAllowedByMode(effectiveDraggedNode?.treeId);
597
+ if (!dropAllowed) {
598
+ dragLogger.debug('dragOver REJECTED - mode not allowed', { path: node.path, dragDropMode: this.dragDropMode, isCrossTreeDrag, draggedTreeId: effectiveDraggedNode?.treeId, thisTreeId: this.treeId });
599
+ this.hoveredNodeForDrop = null;
600
+ return;
601
+ }
602
+ const isValidDrop = effectiveDraggedNode
603
+ ? isCrossTreeDrag || effectiveDraggedNode.path !== node.path
604
+ : this.isDragInProgress;
605
+ if (!isValidDrop) {
606
+ dragLogger.debug('dragOver REJECTED - invalid drop (same node?)', { path: node.path, draggedPath: effectiveDraggedNode?.path });
607
+ return;
608
+ }
609
+ event.preventDefault();
610
+ this.hoveredNodeForDrop = node;
611
+ this.currentDropOperation = (this.allowCopy && event.ctrlKey) ? 'copy' : 'move';
612
+ if (event.dataTransfer) {
613
+ event.dataTransfer.dropEffect = this.currentDropOperation;
614
+ }
615
+ // Calculate drop position from element if provided
616
+ if (element) {
617
+ const positions = this.getNodeAllowedDropPositions(node);
618
+ this.activeDropPosition = this.calculateDropPositionFromEvent(event, element, positions);
619
+ }
620
+ else {
621
+ // Fallback: use event.currentTarget for basic position calculation
622
+ const el = (event.currentTarget || event.target);
623
+ if (el) {
624
+ this.activeDropPosition = this.calculateDropPosition(event, el);
625
+ }
626
+ }
627
+ dragLogger.debug('dragOver OK', { target: node.path, position: this.activeDropPosition, operation: this.currentDropOperation, hasElement: !!element });
628
+ this.onNodeDragOverCb?.(node, event);
629
+ }
630
+ /** Call from ondragleave. Clears hover state when cursor leaves element bounds. */
631
+ dragLeave(_node, event) {
632
+ const target = event.currentTarget;
633
+ if (!target)
634
+ return;
635
+ const rect = target.getBoundingClientRect();
636
+ const x = event.clientX;
637
+ const y = event.clientY;
638
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
639
+ dragLogger.debug('dragLeave', { path: _node.path });
640
+ this.hoveredNodeForDrop = null;
641
+ this.activeDropPosition = null;
642
+ }
643
+ }
644
+ /** Call from ondrop. Uses calculated position or defaults to 'child'. */
645
+ drop(node, event) {
646
+ dragLogger.debug('drop called', { target: node.path, draggedNode: this.draggedNode?.path, activeDropPosition: this.activeDropPosition });
647
+ event.preventDefault();
648
+ event.stopPropagation();
649
+ if (event.dataTransfer) {
650
+ event.dataTransfer.dropEffect = (this.allowCopy && event.ctrlKey) ? 'copy' : 'move';
651
+ }
652
+ // Extract dragged node from dataTransfer if not set (cross-tree)
653
+ let isCrossTreeDrag = false;
654
+ if (!this.draggedNode) {
655
+ const data = event.dataTransfer?.getData('application/svelte-treeview');
656
+ dragLogger.debug('drop - no draggedNode, read from dataTransfer:', { hasData: !!data });
657
+ if (data) {
658
+ this.draggedNode = JSON.parse(data);
659
+ isCrossTreeDrag = this.draggedNode?.treeId !== this.treeId;
660
+ }
661
+ }
662
+ if (this.draggedNode) {
663
+ const dropAllowed = isCrossTreeDrag
664
+ ? this.dragDropMode === 'both' || this.dragDropMode === 'cross'
665
+ : this.isDropAllowedByMode(this.draggedNode.treeId);
666
+ const sameNode = !isCrossTreeDrag && this.draggedNode.path === node.path;
667
+ dragLogger.debug('drop check', { dropAllowed, isCrossTreeDrag, sameNode, draggedPath: this.draggedNode.path, targetPath: node.path, dragDropMode: this.dragDropMode });
668
+ if (dropAllowed && (isCrossTreeDrag || this.draggedNode.path !== node.path)) {
669
+ const position = this.activeDropPosition || 'child';
670
+ dragLogger.debug('drop EXECUTING', { from: this.draggedNode.path, to: node.path, position });
671
+ this._handleDrop(node, this.draggedNode, position, event);
672
+ }
673
+ else {
674
+ dragLogger.debug('drop REJECTED', { dropAllowed, sameNode });
675
+ }
676
+ }
677
+ else {
678
+ dragLogger.debug('drop - no draggedNode available, skipping');
679
+ }
680
+ this._resetDragState();
681
+ }
682
+ /** Drop with explicit position (for custom drop zones or floating-style UI). */
683
+ dropAt(node, position, event) {
684
+ dragLogger.debug('dropAt', { target: node.path, position, draggedNode: this.draggedNode?.path });
685
+ if (event instanceof DragEvent) {
686
+ event.preventDefault();
687
+ if (!this.draggedNode) {
688
+ const data = event.dataTransfer?.getData('application/svelte-treeview');
689
+ if (data) {
690
+ this.draggedNode = JSON.parse(data);
691
+ }
692
+ }
693
+ }
694
+ if (this.draggedNode) {
695
+ dragLogger.debug('dropAt EXECUTING', { from: this.draggedNode.path, to: node.path, position });
696
+ this._handleDrop(node, this.draggedNode, position, event);
697
+ }
698
+ else {
699
+ dragLogger.debug('dropAt - no draggedNode, skipping');
700
+ }
701
+ this._resetDragState();
702
+ }
703
+ /** Cancel current drag and reset all state. */
704
+ cancelDrag() {
705
+ dragLogger.debug('Drag cancelled via public API');
706
+ this._resetDragState();
707
+ }
708
+ /** Touch drag start — proxy to internal touch handler. */
709
+ touchStart(node, event) {
710
+ this._onTouchStart(node, event);
711
+ }
712
+ /** Touch drag move — proxy to internal touch handler. */
713
+ touchMove(node, event) {
714
+ this._onTouchMove(node, event);
715
+ }
716
+ /** Touch drag end — proxy to internal touch handler. */
717
+ touchEnd(node, event) {
718
+ this._onTouchEnd(node, event);
719
+ }
720
+ /** Get allowed drop positions for a node (proxies LTree method). */
721
+ getNodeAllowedDropPositions(node) {
722
+ return this.tree?.getNodeAllowedDropPositions(node) ?? null;
723
+ }
724
+ /** Get whether a node is draggable (proxies LTree resolution: callback > member > node property). */
725
+ getNodeIsDraggable(node) {
726
+ return this.tree?.getNodeIsDraggable(node) ?? true;
727
+ }
728
+ /** Get whether a node is collapsible (proxies LTree resolution: callback > member > node property). */
729
+ getNodeIsCollapsible(node) {
730
+ return this.tree?.getNodeIsCollapsible(node) ?? true;
731
+ }
732
+ /** Calculate drop position from cursor location within an element (before/after/child).
733
+ * Same logic as Node.svelte's calculateGlowPosition, respecting allowed positions. */
734
+ calculateDropPositionFromEvent(event, element, allowedPositions) {
735
+ const rect = element.getBoundingClientRect();
736
+ const x = event.clientX - rect.left;
737
+ const y = event.clientY - rect.top;
738
+ const width = rect.width;
739
+ const height = rect.height;
740
+ // Calculate the ideal position based on mouse position
741
+ let idealPosition;
742
+ if (x > width / 2) {
743
+ idealPosition = 'child';
744
+ }
745
+ else if (y < height / 2) {
746
+ idealPosition = 'before';
747
+ }
748
+ else {
749
+ idealPosition = 'after';
750
+ }
751
+ // If no restrictions, return the ideal position
752
+ if (!allowedPositions || allowedPositions.length === 0) {
753
+ return idealPosition;
754
+ }
755
+ // If the ideal position is allowed, use it
756
+ if (allowedPositions.includes(idealPosition)) {
757
+ return idealPosition;
758
+ }
759
+ // Otherwise, snap to the nearest allowed position
760
+ if (allowedPositions.length === 1) {
761
+ return allowedPositions[0];
762
+ }
763
+ // Multiple positions allowed but not the ideal one
764
+ if (allowedPositions.includes('before') && allowedPositions.includes('after')) {
765
+ return y < height / 2 ? 'before' : 'after';
766
+ }
767
+ return allowedPositions[0];
768
+ }
769
+ async scrollToPath(path, options) {
770
+ perfStart(`[${this.treeId}] scrollToPath`);
771
+ const { expand = true, expandTarget = false, highlight = true, scrollOptions = { behavior: 'smooth', block: 'center' }, containerScroll = false, containerElement } = options || {};
772
+ const node = this.tree.getNodeByPath(path);
773
+ if (!node || !node.id) {
774
+ console.warn(`[Tree ${this.treeId}] Node not found for path: ${path}`);
775
+ perfEnd(`[${this.treeId}] scrollToPath`);
776
+ return false;
777
+ }
778
+ if (expand && node.parentPath) {
779
+ this.tree.expandNodes(node.parentPath);
780
+ }
781
+ if (expandTarget) {
782
+ this.tree.expandNodes(path);
783
+ }
784
+ if (expand || expandTarget) {
785
+ await tick();
786
+ }
787
+ // Virtual scroll: index-based scrolling instead of DOM query
788
+ if (this.vsActive && this.vsContainerRef) {
789
+ const nodeIndex = this.allFlatNodes.findIndex(n => n.path === path);
790
+ if (nodeIndex === -1) {
791
+ console.warn(`[Tree ${this.treeId}] Node not found in flat nodes for path: ${path}`);
792
+ perfEnd(`[${this.treeId}] scrollToPath`);
793
+ return false;
794
+ }
795
+ // Scroll virtual container to center the node
796
+ const targetScroll = nodeIndex * this.vsRowHeight
797
+ - (this.vsContainerRef.clientHeight / 2)
798
+ + this.vsRowHeight / 2;
799
+ this.vsContainerRef.scrollTo({
800
+ top: Math.max(0, targetScroll),
801
+ behavior: scrollOptions?.behavior || 'smooth'
802
+ });
803
+ // Wait for scroll + re-render — need multiple frames for
804
+ // rAF-throttled scroll handler → reactive update → DOM render
805
+ await tick();
806
+ await new Promise(r => requestAnimationFrame(r));
807
+ await tick();
808
+ await new Promise(r => requestAnimationFrame(r));
809
+ if (highlight && this.scrollHighlightClass) {
810
+ const elementId = `${this.treeId}-${node.id}`;
811
+ if (!this.applyHighlight(elementId)) {
812
+ // Element might not be rendered yet — retry after another frame
813
+ await tick();
814
+ await new Promise(r => requestAnimationFrame(r));
815
+ this.applyHighlight(elementId);
816
+ }
817
+ }
818
+ perfEnd(`[${this.treeId}] scrollToPath`);
819
+ return true;
820
+ }
821
+ const elementId = `${this.treeId}-${node.id}`;
822
+ const rootEl = containerElement || this.containerElement;
823
+ const element = rootEl
824
+ ? rootEl.querySelector(`#${CSS.escape(elementId)}`)
825
+ : document.getElementById(elementId);
826
+ const contentDiv = element?.querySelector('.ltree-node-content');
827
+ if (!contentDiv) {
828
+ console.warn(`[Tree ${this.treeId}] DOM element not found for node ID: ${elementId}`);
829
+ perfEnd(`[${this.treeId}] scrollToPath`);
830
+ return false;
831
+ }
832
+ if (containerScroll) {
833
+ const container = this.findScrollableAncestor(contentDiv);
834
+ if (container) {
835
+ const containerRect = container.getBoundingClientRect();
836
+ const elementRect = contentDiv.getBoundingClientRect();
837
+ const scrollTop = container.scrollTop +
838
+ (elementRect.top - containerRect.top) -
839
+ containerRect.height / 2 +
840
+ elementRect.height / 2;
841
+ container.scrollTo({
842
+ top: scrollTop,
843
+ behavior: scrollOptions?.behavior || 'smooth'
844
+ });
845
+ }
846
+ }
847
+ else {
848
+ contentDiv.scrollIntoView(scrollOptions);
849
+ }
850
+ if (highlight && this.scrollHighlightClass) {
851
+ this.applyHighlight(elementId);
852
+ }
853
+ perfEnd(`[${this.treeId}] scrollToPath`);
854
+ return true;
855
+ }
856
+ /**
857
+ * Apply scroll highlight to a node element by ID.
858
+ * Returns true if the element was found and highlighted, false otherwise.
859
+ */
860
+ applyHighlight(elementId) {
861
+ const rootEl = this.containerElement;
862
+ const element = rootEl
863
+ ? rootEl.querySelector(`#${CSS.escape(elementId)}`)
864
+ : document.getElementById(elementId);
865
+ const contentDiv = element?.querySelector('.ltree-node-content');
866
+ if (!contentDiv || !this.scrollHighlightClass)
867
+ return false;
868
+ if (this.currentHighlight) {
869
+ this.currentHighlight.element.classList.remove(this.scrollHighlightClass);
870
+ clearTimeout(this.currentHighlight.timeoutId);
871
+ this.currentHighlight = null;
872
+ }
873
+ contentDiv.classList.add(this.scrollHighlightClass);
874
+ const highlightClass = this.scrollHighlightClass;
875
+ const timeoutId = setTimeout(() => {
876
+ contentDiv.classList.remove(highlightClass);
877
+ this.currentHighlight = null;
878
+ }, this.scrollHighlightTimeout);
879
+ this.currentHighlight = { element: contentDiv, timeoutId };
880
+ return true;
881
+ }
882
+ // ── updateProps (for external JS usage) ─────────────────────────────
883
+ updateProps(updates) {
884
+ if (updates.treeId !== undefined)
885
+ this.treeId = updates.treeId || this.treeId;
886
+ if (updates.treePathSeparator !== undefined)
887
+ this.treePathSeparator = updates.treePathSeparator ?? '.';
888
+ if (updates.data !== undefined)
889
+ this.data = updates.data;
890
+ if (updates.selectedNode !== undefined)
891
+ this.selectedNode = updates.selectedNode;
892
+ if (updates.searchText !== undefined)
893
+ this.searchText = updates.searchText;
894
+ if (updates.shouldDisplayDebugInformation !== undefined)
895
+ this.shouldDisplayDebugInformation = updates.shouldDisplayDebugInformation;
896
+ if (updates.shouldDisplayContextMenuInDebugMode !== undefined)
897
+ this.shouldDisplayContextMenuInDebugMode =
898
+ updates.shouldDisplayContextMenuInDebugMode ?? false;
899
+ if (updates.isLoading !== undefined)
900
+ this.isLoading = updates.isLoading ?? false;
901
+ if (updates.bodyClass !== undefined)
902
+ this.bodyClass = updates.bodyClass;
903
+ if (updates.virtualScroll !== undefined)
904
+ this.virtualScroll = updates.virtualScroll ?? false;
905
+ if (updates.virtualRowHeight !== undefined)
906
+ this.virtualRowHeight = updates.virtualRowHeight;
907
+ if (updates.virtualOverscan !== undefined)
908
+ this.virtualOverscan = updates.virtualOverscan ?? 5;
909
+ if (updates.virtualContainerHeight !== undefined)
910
+ this.virtualContainerHeight = updates.virtualContainerHeight;
911
+ if (updates.shouldToggleOnNodeClick !== undefined)
912
+ this.shouldToggleOnNodeClick = updates.shouldToggleOnNodeClick ?? true;
913
+ if (updates.expandIconClass !== undefined)
914
+ this.expandIconClass = updates.expandIconClass ?? 'ltree-icon-expand';
915
+ if (updates.collapseIconClass !== undefined)
916
+ this.collapseIconClass = updates.collapseIconClass ?? 'ltree-icon-collapse';
917
+ if (updates.leafIconClass !== undefined)
918
+ this.leafIconClass = updates.leafIconClass ?? 'ltree-icon-leaf';
919
+ if (updates.selectedNodeClass !== undefined)
920
+ this.selectedNodeClass = updates.selectedNodeClass;
921
+ if (updates.dragOverNodeClass !== undefined)
922
+ this.dragOverNodeClass = updates.dragOverNodeClass;
923
+ if (updates.dropZoneMode !== undefined)
924
+ this.dropZoneMode = updates.dropZoneMode ?? 'glow';
925
+ if (updates.dropZoneLayout !== undefined)
926
+ this.dropZoneLayout = updates.dropZoneLayout ?? 'around';
927
+ if (updates.dropZoneStart !== undefined)
928
+ this.dropZoneStart = updates.dropZoneStart ?? 33;
929
+ if (updates.dropZoneMaxWidth !== undefined)
930
+ this.dropZoneMaxWidth = updates.dropZoneMaxWidth ?? 120;
931
+ if (updates.allowCopy !== undefined)
932
+ this.allowCopy = updates.allowCopy ?? false;
933
+ if (updates.autoHandleCopy !== undefined)
934
+ this.autoHandleCopy = updates.autoHandleCopy ?? true;
935
+ if (updates.dragDropMode !== undefined)
936
+ this.dragDropMode = updates.dragDropMode ?? 'none';
937
+ if (updates.scrollHighlightTimeout !== undefined)
938
+ this.scrollHighlightTimeout = updates.scrollHighlightTimeout ?? 4000;
939
+ if (updates.scrollHighlightClass !== undefined)
940
+ this.scrollHighlightClass = updates.scrollHighlightClass ?? 'ltree-scroll-highlight';
941
+ if (updates.contextMenuXOffset !== undefined)
942
+ this.contextMenuXOffset = updates.contextMenuXOffset ?? 8;
943
+ if (updates.contextMenuYOffset !== undefined)
944
+ this.contextMenuYOffset = updates.contextMenuYOffset ?? 0;
945
+ // Callbacks
946
+ if (updates.onNodeClicked !== undefined)
947
+ this.onNodeClickedCb = updates.onNodeClicked;
948
+ if (updates.onNodeDragStart !== undefined)
949
+ this.onNodeDragStartCb = updates.onNodeDragStart;
950
+ if (updates.onNodeDragOver !== undefined)
951
+ this.onNodeDragOverCb = updates.onNodeDragOver;
952
+ if (updates.beforeDropCallback !== undefined)
953
+ this.beforeDropCallbackCb = updates.beforeDropCallback;
954
+ if (updates.onNodeDrop !== undefined)
955
+ this.onNodeDropCb = updates.onNodeDrop;
956
+ if (updates.contextMenuCallback !== undefined)
957
+ this.contextMenuCallbackCb = updates.contextMenuCallback;
958
+ }
959
+ // ── Internal event handlers ─────────────────────────────────────────
960
+ async _onNodeClicked(node) {
961
+ if (this.contextMenuVisible) {
962
+ this.closeContextMenu();
963
+ }
964
+ if (this.selectedNode) {
965
+ const previousNode = this.tree.getNodeByPath(this.selectedNode.path);
966
+ if (previousNode) {
967
+ previousNode.isSelected = false;
968
+ }
969
+ else {
970
+ this.selectedNode = null;
971
+ }
972
+ }
973
+ node.isSelected = true;
974
+ this.selectedNode = node;
975
+ uiLogger.debug(`Node selected: ${node.path}`, {
976
+ newPath: node.path,
977
+ id: node.id
978
+ });
979
+ this.onNodeClickedCb?.(node);
980
+ this.tree.refresh();
981
+ }
982
+ _onNodeRightClicked(node, event) {
983
+ if (!this.hasContextMenuSnippet && !this.contextMenuCallbackCb) {
984
+ return;
985
+ }
986
+ uiLogger.debug(`Context menu opened: ${node.path}`);
987
+ event.preventDefault();
988
+ this.openContextMenu(node, event.clientX, event.clientY);
989
+ }
990
+ // ── Drag and drop ───────────────────────────────────────────────────
991
+ isDropAllowedByMode(draggedNodeTreeId) {
992
+ if (this.dragDropMode === 'none')
993
+ return false;
994
+ const isSameTree = draggedNodeTreeId === this.treeId;
995
+ if (this.dragDropMode === 'self' && !isSameTree)
996
+ return false;
997
+ if (this.dragDropMode === 'cross' && isSameTree)
998
+ return false;
999
+ return true;
1000
+ }
1001
+ calculateDropPosition(event, element) {
1002
+ const rect = element.getBoundingClientRect();
1003
+ const y = event.clientY - rect.top;
1004
+ const height = rect.height;
1005
+ if (y < height * 0.25)
1006
+ return 'before';
1007
+ if (y > height * 0.75)
1008
+ return 'after';
1009
+ return 'child';
1010
+ }
1011
+ _onNodeDragStart(node, event) {
1012
+ dragLogger.debug(`Drag started: ${node.path}`, {
1013
+ ctrlKey: event.ctrlKey,
1014
+ allowCopy: this.allowCopy,
1015
+ treeId: this.treeId
1016
+ });
1017
+ this.draggedNode = node;
1018
+ this.isDragInProgress = true;
1019
+ this.onNodeDragStartCb?.(node, event);
1020
+ }
1021
+ _onNodeDragEnd = (event) => {
1022
+ dragLogger.debug('Drag ended', {
1023
+ dropEffect: event.dataTransfer?.dropEffect,
1024
+ operation: this.currentDropOperation
1025
+ });
1026
+ this._resetDragState();
1027
+ };
1028
+ _resetDragState() {
1029
+ dragLogger.debug('_resetDragState');
1030
+ this.isDragInProgress = false;
1031
+ this.draggedNode = null;
1032
+ this.hoveredNodeForDrop = null;
1033
+ this.activeDropPosition = null;
1034
+ this.isDropPlaceholderActive = false;
1035
+ this.currentDropOperation = 'move';
1036
+ this.floatingZoneRect = null;
1037
+ this.floatingHoveredZone = null;
1038
+ }
1039
+ async _handleDrop(dropNode, draggedNodeRef, position, event) {
1040
+ let operation = 'move';
1041
+ const isDragEvent = event instanceof DragEvent;
1042
+ const ctrlKey = isDragEvent ? event.ctrlKey : false;
1043
+ if (this.allowCopy && isDragEvent && ctrlKey) {
1044
+ operation = 'copy';
1045
+ }
1046
+ dragLogger.info(`Drop: ${draggedNodeRef.path} -> ${dropNode?.path ?? 'empty tree'}`, {
1047
+ position,
1048
+ operation,
1049
+ isCrossTree: draggedNodeRef.treeId !== this.treeId
1050
+ });
1051
+ if (this.beforeDropCallbackCb) {
1052
+ const result = await this.beforeDropCallbackCb(dropNode, draggedNodeRef, position, event, operation);
1053
+ if (result === false)
1054
+ return false;
1055
+ if (result && typeof result === 'object') {
1056
+ if ('position' in result && result.position)
1057
+ position = result.position;
1058
+ if ('operation' in result && result.operation)
1059
+ operation = result.operation;
1060
+ }
1061
+ }
1062
+ const isSameTreeDrag = draggedNodeRef.treeId === this.treeId;
1063
+ if (isSameTreeDrag && operation === 'move' && dropNode) {
1064
+ const result = this.moveNode(draggedNodeRef.path, dropNode.path, position);
1065
+ this.onNodeDropCb?.(dropNode, draggedNodeRef, position, event, operation);
1066
+ return result.success;
1067
+ }
1068
+ if (isSameTreeDrag && operation === 'copy' && dropNode && this.autoHandleCopy) {
1069
+ const targetParentPath = position === 'child' ? dropNode.path : dropNode.parentPath || '';
1070
+ const siblingPath = position !== 'child' ? dropNode.path : undefined;
1071
+ const copyPosition = position !== 'child' ? position : undefined;
1072
+ const result = this.tree.copyNodeWithDescendants(draggedNodeRef, targetParentPath, (data) => ({
1073
+ ...data,
1074
+ [this.tree.idMember || 'id']: `${data[this.tree.idMember || 'id']}_copy_${Date.now()}`
1075
+ }), siblingPath, copyPosition);
1076
+ this.onNodeDropCb?.(dropNode, draggedNodeRef, position, event, operation);
1077
+ return result.success;
1078
+ }
1079
+ this.onNodeDropCb?.(dropNode, draggedNodeRef, position, event, operation);
1080
+ return true;
1081
+ }
1082
+ _onNodeDragOver(node, event) {
1083
+ let effectiveDraggedNode = this.draggedNode;
1084
+ let isCrossTreeDrag = false;
1085
+ if (!effectiveDraggedNode &&
1086
+ event.dataTransfer?.types.includes('application/svelte-treeview')) {
1087
+ isCrossTreeDrag = true;
1088
+ try {
1089
+ const data = event.dataTransfer.getData('application/svelte-treeview');
1090
+ if (data) {
1091
+ effectiveDraggedNode = JSON.parse(data);
1092
+ }
1093
+ }
1094
+ catch {
1095
+ // getData might fail during dragover in some browsers
1096
+ }
1097
+ this.isDragInProgress = true;
1098
+ }
1099
+ const dropAllowed = isCrossTreeDrag
1100
+ ? this.dragDropMode === 'both' || this.dragDropMode === 'cross'
1101
+ : this.isDropAllowedByMode(effectiveDraggedNode?.treeId);
1102
+ if (!dropAllowed) {
1103
+ this.hoveredNodeForDrop = null;
1104
+ return;
1105
+ }
1106
+ const isValidDrop = effectiveDraggedNode
1107
+ ? isCrossTreeDrag || effectiveDraggedNode.path !== node.path
1108
+ : this.isDragInProgress;
1109
+ if (isValidDrop) {
1110
+ event.preventDefault();
1111
+ this.hoveredNodeForDrop = node;
1112
+ const nodeElement = event.target.closest('.ltree-node-content');
1113
+ if (nodeElement) {
1114
+ this.activeDropPosition = this.calculateDropPosition(event, nodeElement);
1115
+ }
1116
+ this.currentDropOperation = this.allowCopy && event.ctrlKey ? 'copy' : 'move';
1117
+ this.onNodeDragOverCb?.(node, event);
1118
+ if (event.dataTransfer) {
1119
+ event.dataTransfer.dropEffect = this.currentDropOperation;
1120
+ }
1121
+ // Capture node rect for floating drop zones (rendered at Tree level with position:fixed)
1122
+ if (this.dropZoneMode === 'floating') {
1123
+ const nodeRow = event.target.closest('.ltree-node-row');
1124
+ if (nodeRow) {
1125
+ const r = nodeRow.getBoundingClientRect();
1126
+ this.floatingZoneRect = { top: r.top, left: r.left, width: r.width, height: r.height };
1127
+ }
1128
+ }
1129
+ }
1130
+ }
1131
+ _onNodeDragLeave(_node, _event) {
1132
+ // Don't clear hoveredNodeForDrop — let dragover on other nodes handle it
1133
+ }
1134
+ _onNodeDrop(node, event) {
1135
+ event.preventDefault();
1136
+ let isCrossTreeDrag = false;
1137
+ if (!this.draggedNode) {
1138
+ const data = event.dataTransfer?.getData('application/svelte-treeview');
1139
+ if (data) {
1140
+ this.draggedNode = JSON.parse(data);
1141
+ isCrossTreeDrag = this.draggedNode?.treeId !== this.treeId;
1142
+ }
1143
+ }
1144
+ const dropAllowed = isCrossTreeDrag
1145
+ ? this.dragDropMode === 'both' || this.dragDropMode === 'cross'
1146
+ : this.isDropAllowedByMode(this.draggedNode?.treeId);
1147
+ if (!dropAllowed) {
1148
+ this._onNodeDragEnd(event);
1149
+ return;
1150
+ }
1151
+ if (this.draggedNode && (isCrossTreeDrag || this.draggedNode !== node)) {
1152
+ const position = this.activeDropPosition || 'child';
1153
+ this._handleDrop(node, this.draggedNode, position, event);
1154
+ }
1155
+ this._onNodeDragEnd(event);
1156
+ }
1157
+ _onZoneDrop(node, position, event) {
1158
+ event.preventDefault();
1159
+ let isCrossTreeDrag = false;
1160
+ if (!this.draggedNode) {
1161
+ const data = event.dataTransfer?.getData('application/svelte-treeview');
1162
+ if (data) {
1163
+ this.draggedNode = JSON.parse(data);
1164
+ isCrossTreeDrag = this.draggedNode?.treeId !== this.treeId;
1165
+ }
1166
+ }
1167
+ if (!this.draggedNode) {
1168
+ this._onNodeDragEnd(event);
1169
+ return;
1170
+ }
1171
+ const dropAllowed = isCrossTreeDrag
1172
+ ? this.dragDropMode === 'both' || this.dragDropMode === 'cross'
1173
+ : this.isDropAllowedByMode(this.draggedNode?.treeId);
1174
+ if (!dropAllowed) {
1175
+ this._onNodeDragEnd(event);
1176
+ return;
1177
+ }
1178
+ if (isCrossTreeDrag || this.draggedNode !== node) {
1179
+ this._handleDrop(node, this.draggedNode, position, event);
1180
+ }
1181
+ this._onNodeDragEnd(event);
1182
+ }
1183
+ // ── Floating drop zone handlers (Tree-level overlay) ────────────────
1184
+ isFloatingPositionAllowed(position) {
1185
+ if (!this.hoveredNodeForDrop)
1186
+ return false;
1187
+ const allowed = this.tree.getNodeAllowedDropPositions(this.hoveredNodeForDrop);
1188
+ if (!allowed || allowed.length === 0)
1189
+ return true; // All positions allowed by default
1190
+ return allowed.includes(position);
1191
+ }
1192
+ handleFloatingZoneDragOver(position, event) {
1193
+ event.preventDefault();
1194
+ if (event.dataTransfer) {
1195
+ event.dataTransfer.dropEffect = (this.allowCopy && event.ctrlKey) ? 'copy' : 'move';
1196
+ }
1197
+ this.floatingHoveredZone = position;
1198
+ // Refresh rect from node row
1199
+ if (this.hoveredNodeForDrop) {
1200
+ this._onNodeDragOver(this.hoveredNodeForDrop, event);
1201
+ }
1202
+ }
1203
+ handleFloatingZoneDragLeave() {
1204
+ this.floatingHoveredZone = null;
1205
+ }
1206
+ handleFloatingZoneDrop(position, event) {
1207
+ this.floatingHoveredZone = null;
1208
+ if (this.hoveredNodeForDrop) {
1209
+ this._onZoneDrop(this.hoveredNodeForDrop, position, event);
1210
+ }
1211
+ }
1212
+ // ── Touch drag handlers ─────────────────────────────────────────────
1213
+ _onTouchStart(node, event) {
1214
+ if (!this.getNodeIsDraggable(node))
1215
+ return;
1216
+ const touch = event.touches[0];
1217
+ this.touchDragState = {
1218
+ node,
1219
+ startX: touch.clientX,
1220
+ startY: touch.clientY,
1221
+ isDragging: false,
1222
+ ghostElement: null,
1223
+ currentDropTarget: null
1224
+ };
1225
+ // Attach document-level listeners with { passive: false } so we can
1226
+ // preventDefault on touchmove (Svelte's delegated handlers are passive
1227
+ // and cannot prevent scrolling).
1228
+ this._addDocumentTouchListeners();
1229
+ this.touchTimer = setTimeout(() => {
1230
+ this.touchDragState.isDragging = true;
1231
+ this.draggedNode = node;
1232
+ this.isDragInProgress = true;
1233
+ dragLogger.debug(`Touch drag started: ${node.path}`);
1234
+ this.createGhostElement(node, touch.clientX, touch.clientY);
1235
+ try {
1236
+ navigator.vibrate?.(50);
1237
+ }
1238
+ catch { /* blocked by browser policy */ }
1239
+ }, 300);
1240
+ }
1241
+ // The per-node Svelte handlers are kept as no-ops so the callbacks interface
1242
+ // stays intact, but all real work happens on document-level listeners.
1243
+ _onTouchMove(_node, _event) {
1244
+ // Handled by _docTouchMove
1245
+ }
1246
+ _onTouchEnd(_node, _event) {
1247
+ // Handled by _docTouchEnd
1248
+ }
1249
+ // ── Document-level touch listeners (non-passive) ─────────────────────
1250
+ _boundDocTouchMove = null;
1251
+ _boundDocTouchEnd = null;
1252
+ _addDocumentTouchListeners() {
1253
+ this._removeDocumentTouchListeners();
1254
+ this._boundDocTouchMove = (e) => this._docTouchMove(e);
1255
+ this._boundDocTouchEnd = (e) => this._docTouchEnd(e);
1256
+ document.addEventListener('touchmove', this._boundDocTouchMove, { passive: false });
1257
+ document.addEventListener('touchend', this._boundDocTouchEnd);
1258
+ document.addEventListener('touchcancel', this._boundDocTouchEnd);
1259
+ }
1260
+ _removeDocumentTouchListeners() {
1261
+ if (this._boundDocTouchMove) {
1262
+ document.removeEventListener('touchmove', this._boundDocTouchMove);
1263
+ this._boundDocTouchMove = null;
1264
+ }
1265
+ if (this._boundDocTouchEnd) {
1266
+ document.removeEventListener('touchend', this._boundDocTouchEnd);
1267
+ document.removeEventListener('touchcancel', this._boundDocTouchEnd);
1268
+ this._boundDocTouchEnd = null;
1269
+ }
1270
+ }
1271
+ _docTouchMove(event) {
1272
+ if (!this.touchDragState.node)
1273
+ return;
1274
+ const touch = event.touches[0];
1275
+ if (!this.touchDragState.isDragging) {
1276
+ const dx = Math.abs(touch.clientX - this.touchDragState.startX);
1277
+ const dy = Math.abs(touch.clientY - this.touchDragState.startY);
1278
+ if (dx > 10 || dy > 10) {
1279
+ if (this.touchTimer)
1280
+ clearTimeout(this.touchTimer);
1281
+ this._resetTouchState();
1282
+ }
1283
+ return;
1284
+ }
1285
+ // Non-passive listener: this actually prevents scrolling
1286
+ event.preventDefault();
1287
+ if (this.touchDragState.ghostElement) {
1288
+ this.touchDragState.ghostElement.style.left = `${touch.clientX}px`;
1289
+ this.touchDragState.ghostElement.style.top = `${touch.clientY}px`;
1290
+ }
1291
+ if (this.touchDragState.ghostElement) {
1292
+ this.touchDragState.ghostElement.style.pointerEvents = 'none';
1293
+ }
1294
+ const elementUnderTouch = document.elementFromPoint(touch.clientX, touch.clientY);
1295
+ if (this.touchDragState.ghostElement) {
1296
+ this.touchDragState.ghostElement.style.pointerEvents = '';
1297
+ }
1298
+ this.updateDropTarget(elementUnderTouch);
1299
+ }
1300
+ _docTouchEnd(event) {
1301
+ if (this.touchTimer)
1302
+ clearTimeout(this.touchTimer);
1303
+ if (this.touchDragState.isDragging && this.draggedNode) {
1304
+ const touch = event.changedTouches[0];
1305
+ if (this.touchDragState.ghostElement) {
1306
+ this.touchDragState.ghostElement.style.display = 'none';
1307
+ }
1308
+ const dropElement = document.elementFromPoint(touch.clientX, touch.clientY);
1309
+ const dropNode = this.findNodeFromElement(dropElement);
1310
+ const placeholder = dropElement?.closest('.ltree-empty-state');
1311
+ const rootDropZone = dropElement?.closest('.ltree-root-drop-zone');
1312
+ if ((placeholder || rootDropZone) && !dropNode) {
1313
+ dragLogger.debug(`Touch drag ended: ${this.draggedNode.path} -> empty tree`);
1314
+ this._handleDrop(null, this.draggedNode, 'child', event);
1315
+ }
1316
+ else if (dropNode && dropNode !== this.draggedNode && dropNode.isDropAllowed) {
1317
+ dragLogger.debug(`Touch drag ended: ${this.draggedNode.path} -> ${dropNode.path}`);
1318
+ this._handleDrop(dropNode, this.draggedNode, 'child', event);
1319
+ }
1320
+ else {
1321
+ dragLogger.debug(`Touch drag cancelled: ${this.draggedNode.path}`);
1322
+ }
1323
+ this.removeGhostElement();
1324
+ this.clearDropTargetHighlight();
1325
+ }
1326
+ this._resetTouchState();
1327
+ }
1328
+ _resetTouchState() {
1329
+ this._removeDocumentTouchListeners();
1330
+ this.touchDragState = {
1331
+ node: null,
1332
+ startX: 0,
1333
+ startY: 0,
1334
+ isDragging: false,
1335
+ ghostElement: null,
1336
+ currentDropTarget: null
1337
+ };
1338
+ this.draggedNode = null;
1339
+ this.isDragInProgress = false;
1340
+ this.isDropPlaceholderActive = false;
1341
+ }
1342
+ createGhostElement(node, x, y) {
1343
+ // Remove any stale ghost elements (e.g. from interrupted drags)
1344
+ this.removeGhostElement();
1345
+ document.querySelectorAll('.ltree-touch-ghost').forEach(el => el.remove());
1346
+ const ghost = document.createElement('div');
1347
+ ghost.className = 'ltree-touch-ghost';
1348
+ ghost.textContent = this.tree.getNodeDisplayValue(node);
1349
+ ghost.style.left = `${x}px`;
1350
+ ghost.style.top = `${y}px`;
1351
+ document.body.appendChild(ghost);
1352
+ this.touchDragState.ghostElement = ghost;
1353
+ }
1354
+ removeGhostElement() {
1355
+ if (this.touchDragState.ghostElement) {
1356
+ this.touchDragState.ghostElement.remove();
1357
+ this.touchDragState.ghostElement = null;
1358
+ }
1359
+ }
1360
+ /** Clean up document-level listeners and ghost elements. Called on component destroy. */
1361
+ destroy() {
1362
+ if (typeof document === 'undefined')
1363
+ return;
1364
+ this._removeDocumentTouchListeners();
1365
+ this.removeGhostElement();
1366
+ // Remove any orphaned ghosts from document body
1367
+ document.querySelectorAll('.ltree-touch-ghost').forEach(el => el.remove());
1368
+ }
1369
+ findNodeFromElement(element) {
1370
+ if (!element)
1371
+ return null;
1372
+ const nodeElement = element.closest('.ltree-node');
1373
+ if (!nodeElement)
1374
+ return null;
1375
+ const path = nodeElement.getAttribute('data-tree-path');
1376
+ if (!path)
1377
+ return null;
1378
+ return this.tree.getNodeByPath(path);
1379
+ }
1380
+ updateDropTarget(element) {
1381
+ const newTarget = this.findNodeFromElement(element);
1382
+ if (this.touchDragState.currentDropTarget && this.touchDragState.currentDropTarget !== newTarget) {
1383
+ const prevElement = document.querySelector(`[data-tree-path="${this.touchDragState.currentDropTarget.path}"] .ltree-node-content`);
1384
+ prevElement?.classList.remove(this.dragOverNodeClass || 'ltree-dragover-highlight');
1385
+ }
1386
+ const placeholder = element?.closest('.ltree-empty-state');
1387
+ if (placeholder && !newTarget) {
1388
+ this.isDropPlaceholderActive = true;
1389
+ this.touchDragState.currentDropTarget = null;
1390
+ return;
1391
+ }
1392
+ else {
1393
+ this.isDropPlaceholderActive = false;
1394
+ }
1395
+ if (newTarget && newTarget !== this.draggedNode && newTarget.isDropAllowed) {
1396
+ const targetElement = document.querySelector(`[data-tree-path="${newTarget.path}"] .ltree-node-content`);
1397
+ targetElement?.classList.add(this.dragOverNodeClass || 'ltree-dragover-highlight');
1398
+ this.touchDragState.currentDropTarget = newTarget;
1399
+ }
1400
+ else {
1401
+ this.touchDragState.currentDropTarget = null;
1402
+ }
1403
+ }
1404
+ clearDropTargetHighlight() {
1405
+ if (this.touchDragState.currentDropTarget) {
1406
+ const element = document.querySelector(`[data-tree-path="${this.touchDragState.currentDropTarget.path}"] .ltree-node-content`);
1407
+ element?.classList.remove(this.dragOverNodeClass || 'ltree-dragover-highlight');
1408
+ }
1409
+ }
1410
+ // ── Empty tree drop handlers (used directly in template) ────────────
1411
+ handleEmptyTreeDragOver = (event) => {
1412
+ if (this.dragDropMode === 'none')
1413
+ return;
1414
+ if (event.dataTransfer?.types.includes('application/svelte-treeview')) {
1415
+ event.preventDefault();
1416
+ this.isDropPlaceholderActive = true;
1417
+ if (event.dataTransfer) {
1418
+ event.dataTransfer.dropEffect = 'move';
1419
+ }
1420
+ }
1421
+ };
1422
+ handleEmptyTreeDragLeave = (event) => {
1423
+ const rect = event.currentTarget.getBoundingClientRect();
1424
+ const x = event.clientX;
1425
+ const y = event.clientY;
1426
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
1427
+ this.isDropPlaceholderActive = false;
1428
+ }
1429
+ };
1430
+ handleEmptyTreeDrop = (event) => {
1431
+ event.preventDefault();
1432
+ this.isDropPlaceholderActive = false;
1433
+ if (this.dragDropMode === 'none')
1434
+ return;
1435
+ const draggedNodeData = event.dataTransfer?.getData('application/svelte-treeview');
1436
+ if (draggedNodeData) {
1437
+ const droppedNode = JSON.parse(draggedNodeData);
1438
+ this._handleDrop(null, droppedNode, 'child', event);
1439
+ }
1440
+ this._onNodeDragEnd(event);
1441
+ };
1442
+ handleEmptyTreeTouchEnd = (event) => {
1443
+ if (this.dragDropMode === 'none')
1444
+ return;
1445
+ if (this.draggedNode && this.isDropPlaceholderActive) {
1446
+ this._handleDrop(null, this.draggedNode, 'child', event);
1447
+ this.isDropPlaceholderActive = false;
1448
+ }
1449
+ };
1450
+ // ── Tree-level drag handlers (used directly in template) ────────────
1451
+ handleTreeDragEnter = (event) => {
1452
+ if (event.dataTransfer?.types.includes('application/svelte-treeview')) {
1453
+ this.isDragInProgress = true;
1454
+ }
1455
+ };
1456
+ handleTreeDragLeave = (event) => {
1457
+ const rect = event.currentTarget.getBoundingClientRect();
1458
+ const x = event.clientX;
1459
+ const y = event.clientY;
1460
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
1461
+ if (this.draggedNode?.treeId !== this.treeId) {
1462
+ this.isDragInProgress = false;
1463
+ this.hoveredNodeForDrop = null;
1464
+ this.activeDropPosition = null;
1465
+ }
1466
+ }
1467
+ };
1468
+ // ── Helpers ──────────────────────────────────────────────────────────
1469
+ scheduleFlatRenderBatch() {
1470
+ if (this.flatRenderAnimationFrame)
1471
+ return;
1472
+ this.flatRenderAnimationFrame = requestAnimationFrame(() => {
1473
+ this.flatRenderAnimationFrame = null;
1474
+ if (this.flatRenderQueue.length === 0)
1475
+ return;
1476
+ const batchSize = this.currentBatchSize || this.initialBatchSize;
1477
+ const batch = this.flatRenderQueue.slice(0, batchSize);
1478
+ const remaining = this.flatRenderQueue.slice(batchSize);
1479
+ this.flatRenderedIds = new Set([...this.flatRenderedIds, ...batch]);
1480
+ this.flatRenderQueue = remaining;
1481
+ this.currentBatchSize = Math.min(batchSize * 2, this.maxBatchSize);
1482
+ if (remaining.length > 0) {
1483
+ this.scheduleFlatRenderBatch();
1484
+ }
1485
+ });
1486
+ }
1487
+ generateTreeId() {
1488
+ return `${Date.now()}${Math.floor(Math.random() * 10000)}`;
1489
+ }
1490
+ findScrollableAncestor(element) {
1491
+ let parent = element.parentElement;
1492
+ while (parent) {
1493
+ const style = getComputedStyle(parent);
1494
+ const overflowY = style.overflowY;
1495
+ if ((overflowY === 'auto' || overflowY === 'scroll') &&
1496
+ parent.scrollHeight > parent.clientHeight) {
1497
+ return parent;
1498
+ }
1499
+ parent = parent.parentElement;
1500
+ }
1501
+ return null;
1502
+ }
1503
+ }