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