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