@keenmate/svelte-treeview 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -4
- package/dist/components/Node.svelte +249 -12
- package/dist/components/Node.svelte.d.ts +17 -0
- package/dist/components/RenderCoordinator.svelte.d.ts +29 -0
- package/dist/components/RenderCoordinator.svelte.js +115 -0
- package/dist/components/Tree.svelte +855 -38
- package/dist/components/Tree.svelte.d.ts +160 -8
- package/dist/constants.generated.d.ts +6 -0
- package/dist/constants.generated.js +8 -0
- package/dist/global-api.d.ts +35 -0
- package/dist/global-api.js +36 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- package/dist/logger.d.ts +56 -0
- package/dist/logger.js +159 -0
- package/dist/ltree/indexer.d.ts +0 -1
- package/dist/ltree/indexer.js +23 -19
- package/dist/ltree/ltree.svelte.d.ts +1 -1
- package/dist/ltree/ltree.svelte.js +593 -30
- package/dist/ltree/types.d.ts +62 -0
- package/dist/perf-logger.d.ts +70 -0
- package/dist/perf-logger.js +196 -0
- package/dist/styles/main.scss +437 -4
- package/dist/styles.css +329 -3
- package/dist/styles.css.map +1 -1
- package/dist/vendor/loglevel/index.d.ts +2 -0
- package/dist/vendor/loglevel/index.js +9 -0
- package/dist/vendor/loglevel/loglevel-esm.d.ts +2 -0
- package/dist/vendor/loglevel/loglevel-esm.js +349 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.d.ts +7 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix-esm.js +132 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix.d.ts +2 -0
- package/dist/vendor/loglevel/loglevel-plugin-prefix.js +149 -0
- package/dist/vendor/loglevel/loglevel.js +357 -0
- package/dist/vendor/loglevel/prefix.d.ts +2 -0
- package/dist/vendor/loglevel/prefix.js +9 -0
- package/package.json +3 -2
|
@@ -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
|
-
|
|
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?: {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 =
|
|
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
|
-
|
|
933
|
+
_onNodeDragEnd(event);
|
|
934
|
+
return;
|
|
477
935
|
}
|
|
478
936
|
|
|
479
|
-
if
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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>
|