@keenmate/svelte-treeview 5.0.0-rc07 → 5.0.0-rc09
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/CHANGELOG.md +51 -0
- package/README.md +35 -44
- package/ai/basic-setup.txt +336 -339
- package/ai/import-patterns.txt +290 -271
- package/ai/styling-theming.txt +355 -354
- package/component-variables.manifest.json +142 -0
- package/dist/components/Node.svelte +36 -26
- package/dist/components/Tree.svelte +87 -15
- package/dist/components/Tree.svelte.d.ts +100 -17
- package/dist/constants.generated.d.ts +1 -1
- package/dist/constants.generated.js +1 -1
- package/dist/core/TreeController.svelte.d.ts +84 -18
- package/dist/core/TreeController.svelte.js +310 -96
- package/dist/index.d.ts +1 -1
- package/dist/ltree/ltree.svelte.d.ts +1 -1
- package/dist/ltree/ltree.svelte.js +180 -48
- package/dist/ltree/types.d.ts +18 -4
- package/dist/styles/_base.css +41 -0
- package/dist/styles/_checkbox.css +83 -0
- package/dist/styles/_context-menu.css +134 -0
- package/dist/styles/_debug.css +45 -0
- package/dist/styles/_drag-drop.css +174 -0
- package/dist/styles/_drop-zones.css +270 -0
- package/dist/styles/_loading.css +40 -0
- package/dist/styles/_node.css +60 -0
- package/dist/styles/_states.css +92 -0
- package/dist/styles/_toggle-icons.css +97 -0
- package/dist/styles/_variables.css +189 -0
- package/dist/styles/main.css +37 -0
- package/dist/styles.css +651 -470
- package/package.json +103 -102
- package/dist/styles/main.scss +0 -1074
- package/dist/styles.css.map +0 -1
- package/dist/styles.scss +0 -2
|
@@ -20,6 +20,7 @@ export class TreeController {
|
|
|
20
20
|
clickBehavior: 'expand-and-focus',
|
|
21
21
|
showCheckboxes: false,
|
|
22
22
|
checkboxMode: 'independent',
|
|
23
|
+
clickTogglesCheckbox: false,
|
|
23
24
|
expandIconClass: 'ltree-icon-expand',
|
|
24
25
|
collapseIconClass: 'ltree-icon-collapse',
|
|
25
26
|
leafIconClass: 'ltree-icon-leaf',
|
|
@@ -41,7 +42,10 @@ export class TreeController {
|
|
|
41
42
|
data = $state.raw([]);
|
|
42
43
|
focusedNode = $state.raw(null);
|
|
43
44
|
highlightedPaths = $state.raw(new Set());
|
|
44
|
-
|
|
45
|
+
/** Hidden internal cursor used by Shift+Arrow / Shift+click to extend ranges
|
|
46
|
+
* from the focused node. Set on first Shift action, advances on subsequent
|
|
47
|
+
* Shift actions, cleared on any plain navigation. Not exposed via props. */
|
|
48
|
+
_shiftCursor = null;
|
|
45
49
|
selectedPaths = $state.raw(new Set());
|
|
46
50
|
insertResult = $state.raw(null);
|
|
47
51
|
searchText = $state(undefined);
|
|
@@ -84,8 +88,10 @@ export class TreeController {
|
|
|
84
88
|
getContextMenuItemsHandler;
|
|
85
89
|
// Visual config (for nodeConfig updates)
|
|
86
90
|
clickBehavior = $state('expand-and-focus');
|
|
91
|
+
selectionMode = $state('single');
|
|
87
92
|
showCheckboxes = $state(false);
|
|
88
93
|
checkboxMode = $state('independent');
|
|
94
|
+
clickTogglesCheckbox = $state(false);
|
|
89
95
|
expandIconClass = $state('ltree-icon-expand');
|
|
90
96
|
collapseIconClass = $state('ltree-icon-collapse');
|
|
91
97
|
leafIconClass = $state('ltree-icon-leaf');
|
|
@@ -207,8 +213,10 @@ export class TreeController {
|
|
|
207
213
|
this.autoHandlePaste = props.autoHandlePaste ?? true;
|
|
208
214
|
this.accordionExpand = props.accordionExpand ?? false;
|
|
209
215
|
this.clickBehavior = props.clickBehavior ?? 'expand-and-focus';
|
|
216
|
+
this.selectionMode = props.selectionMode ?? 'single';
|
|
210
217
|
this.showCheckboxes = props.showCheckboxes ?? false;
|
|
211
218
|
this.checkboxMode = props.checkboxMode ?? 'independent';
|
|
219
|
+
this.clickTogglesCheckbox = props.clickTogglesCheckbox ?? false;
|
|
212
220
|
this.beforeCheckboxToggleHandler = props.beforeCheckboxToggleCallback;
|
|
213
221
|
this.expandIconClass = props.expandIconClass ?? 'ltree-icon-expand';
|
|
214
222
|
this.collapseIconClass = props.collapseIconClass ?? 'ltree-icon-collapse';
|
|
@@ -249,7 +257,7 @@ export class TreeController {
|
|
|
249
257
|
this.onRenderCompleteHandler = props.onRenderComplete;
|
|
250
258
|
// ── Create LTree ────────────────────────────────────────────────
|
|
251
259
|
// svelte-ignore non_reactive_update
|
|
252
|
-
this.tree = createLTree(props.idMember, props.pathMember, props.parentPathMember, props.levelMember, props.hasChildrenMember, props.isExpandedMember, props.isSelectableMember, 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, {
|
|
260
|
+
this.tree = createLTree(props.idMember, props.pathMember, props.parentPathMember, props.levelMember, props.hasChildrenMember, props.isExpandedMember, props.getIsExpandedCallback, props.isSelectableMember, props.getIsSelectableCallback, props.isSelectedMember, props.getIsSelectedCallback, 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, {
|
|
253
261
|
shouldDisplayDebugInformation: props.shouldDisplayDebugInformation,
|
|
254
262
|
isSorted: props.isSorted,
|
|
255
263
|
sortCallback: props.sortCallback
|
|
@@ -273,7 +281,7 @@ export class TreeController {
|
|
|
273
281
|
// ── Create stable nodeCallbacks ─────────────────────────────────
|
|
274
282
|
this.nodeCallbacks = {
|
|
275
283
|
onNodeClicked: (node, modifiers) => this._onNodeClicked(node, modifiers),
|
|
276
|
-
onCheckboxToggle: (node) => this._onCheckboxToggle(node),
|
|
284
|
+
onCheckboxToggle: (node, options) => this._onCheckboxToggle(node, options),
|
|
277
285
|
onNodeRightClicked: this._onNodeRightClicked.bind(this),
|
|
278
286
|
onNodeDragStart: this._onNodeDragStart.bind(this),
|
|
279
287
|
onNodeDragOver: this._onNodeDragOver.bind(this),
|
|
@@ -289,6 +297,7 @@ export class TreeController {
|
|
|
289
297
|
clickBehavior: this.clickBehavior,
|
|
290
298
|
showCheckboxes: this.showCheckboxes,
|
|
291
299
|
checkboxMode: this.checkboxMode,
|
|
300
|
+
clickTogglesCheckbox: this.clickTogglesCheckbox,
|
|
292
301
|
expandIconClass: this.expandIconClass,
|
|
293
302
|
collapseIconClass: this.collapseIconClass,
|
|
294
303
|
leafIconClass: this.leafIconClass,
|
|
@@ -318,6 +327,7 @@ export class TreeController {
|
|
|
318
327
|
Object.assign(this.nodeConfig, {
|
|
319
328
|
clickBehavior: this.clickBehavior,
|
|
320
329
|
showCheckboxes: this.showCheckboxes,
|
|
330
|
+
clickTogglesCheckbox: this.clickTogglesCheckbox,
|
|
321
331
|
expandIconClass: this.expandIconClass,
|
|
322
332
|
collapseIconClass: this.collapseIconClass,
|
|
323
333
|
leafIconClass: this.leafIconClass,
|
|
@@ -353,7 +363,7 @@ export class TreeController {
|
|
|
353
363
|
this.vsDetectedHeight = null;
|
|
354
364
|
this.insertResult = this.tree.insertArray(this.data);
|
|
355
365
|
// Seed selectedPaths from node.isSelected flags written by insertArray
|
|
356
|
-
if (this.tree.isSelectedMember) {
|
|
366
|
+
if (this.tree.isSelectedMember || this.tree.getIsSelectedCallback) {
|
|
357
367
|
const seeded = new Set();
|
|
358
368
|
const walk = (node) => {
|
|
359
369
|
if (node.isSelected)
|
|
@@ -498,6 +508,26 @@ export class TreeController {
|
|
|
498
508
|
this.isDebugMenuActive = false;
|
|
499
509
|
}
|
|
500
510
|
});
|
|
511
|
+
// Mirror hoveredNodeForDrop → dragOverNodeClass on a single DOM element.
|
|
512
|
+
// Direct classList mutation (same approach as the touch path's updateDropTarget) so
|
|
513
|
+
// the cost is O(1) per node-crossing — no per-Node prop propagation across the tree.
|
|
514
|
+
let prevHoveredDragPath = null;
|
|
515
|
+
let prevDragOverClass = null;
|
|
516
|
+
$effect(() => {
|
|
517
|
+
const current = this.hoveredNodeForDrop?.path ?? null;
|
|
518
|
+
const cls = this.dragOverNodeClass ?? null;
|
|
519
|
+
if (current === prevHoveredDragPath && cls === prevDragOverClass)
|
|
520
|
+
return;
|
|
521
|
+
const root = this.containerElement ?? document;
|
|
522
|
+
if (prevHoveredDragPath && prevDragOverClass) {
|
|
523
|
+
root.querySelector(`[data-tree-path="${prevHoveredDragPath}"] .ltree-node-content`)?.classList.remove(prevDragOverClass);
|
|
524
|
+
}
|
|
525
|
+
if (current && cls) {
|
|
526
|
+
root.querySelector(`[data-tree-path="${current}"] .ltree-node-content`)?.classList.add(cls);
|
|
527
|
+
}
|
|
528
|
+
prevHoveredDragPath = current;
|
|
529
|
+
prevDragOverClass = cls;
|
|
530
|
+
});
|
|
501
531
|
}
|
|
502
532
|
// ── Virtual scroll handler ──────────────────────────────────────────
|
|
503
533
|
handleVirtualScroll = (event) => {
|
|
@@ -510,17 +540,17 @@ export class TreeController {
|
|
|
510
540
|
});
|
|
511
541
|
};
|
|
512
542
|
// ── Public API methods ──────────────────────────────────────────────
|
|
513
|
-
async expandNodes(nodePath) {
|
|
514
|
-
this.tree.expandNodes(nodePath);
|
|
543
|
+
async expandNodes(nodePath, options) {
|
|
544
|
+
this.tree.expandNodes(nodePath, options);
|
|
515
545
|
}
|
|
516
|
-
async collapseNodes(nodePath) {
|
|
517
|
-
this.tree.collapseNodes(nodePath);
|
|
546
|
+
async collapseNodes(nodePath, options) {
|
|
547
|
+
this.tree.collapseNodes(nodePath, options);
|
|
518
548
|
}
|
|
519
|
-
expandAll(nodePath) {
|
|
520
|
-
this.tree?.expandAll(nodePath);
|
|
549
|
+
expandAll(nodePath, options) {
|
|
550
|
+
this.tree?.expandAll(nodePath, options);
|
|
521
551
|
}
|
|
522
|
-
collapseAll(nodePath) {
|
|
523
|
-
this.tree?.collapseAll(nodePath);
|
|
552
|
+
collapseAll(nodePath, options) {
|
|
553
|
+
this.tree?.collapseAll(nodePath, options);
|
|
524
554
|
}
|
|
525
555
|
filterNodes(searchTextVal, searchOptions) {
|
|
526
556
|
this.tree?.filterNodes(searchTextVal, searchOptions);
|
|
@@ -1256,10 +1286,14 @@ export class TreeController {
|
|
|
1256
1286
|
this.virtualContainerHeight = updates.virtualContainerHeight;
|
|
1257
1287
|
if (updates.clickBehavior !== undefined)
|
|
1258
1288
|
this.clickBehavior = updates.clickBehavior ?? 'expand-and-focus';
|
|
1289
|
+
if (updates.selectionMode !== undefined)
|
|
1290
|
+
this.selectionMode = updates.selectionMode ?? 'single';
|
|
1259
1291
|
if (updates.showCheckboxes !== undefined)
|
|
1260
1292
|
this.showCheckboxes = updates.showCheckboxes ?? false;
|
|
1261
1293
|
if (updates.checkboxMode !== undefined)
|
|
1262
1294
|
this.checkboxMode = updates.checkboxMode ?? 'independent';
|
|
1295
|
+
if (updates.clickTogglesCheckbox !== undefined)
|
|
1296
|
+
this.clickTogglesCheckbox = updates.clickTogglesCheckbox ?? false;
|
|
1263
1297
|
if (updates.beforeCheckboxToggleCallback !== undefined)
|
|
1264
1298
|
this.beforeCheckboxToggleHandler = updates.beforeCheckboxToggleCallback;
|
|
1265
1299
|
if (updates.expandIconClass !== undefined)
|
|
@@ -1327,14 +1361,22 @@ export class TreeController {
|
|
|
1327
1361
|
this.onSelectionChangeHandler = updates.onSelectionChange;
|
|
1328
1362
|
}
|
|
1329
1363
|
// ── Internal event handlers ─────────────────────────────────────────
|
|
1330
|
-
async _onNodeClicked(node, modifiers) {
|
|
1364
|
+
async _onNodeClicked(node, modifiers, options) {
|
|
1331
1365
|
if (this.contextMenuVisible) {
|
|
1332
1366
|
this.closeContextMenu();
|
|
1333
1367
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1368
|
+
// In single mode, mouse Ctrl/Shift+click degrade to plain click. Programmatic
|
|
1369
|
+
// callers (highlightNode with mode='toggle'/'range') pass forceMultiSemantics
|
|
1370
|
+
// to opt out of the gate — the API contract should not depend on selectionMode.
|
|
1371
|
+
const isMulti = options?.forceMultiSemantics || this.selectionMode === 'multi';
|
|
1372
|
+
const ctrl = isMulti && (modifiers?.ctrl ?? false);
|
|
1373
|
+
const shift = isMulti && (modifiers?.shift ?? false);
|
|
1374
|
+
const silent = options?.silent ?? false;
|
|
1375
|
+
uiLogger.debug(`[highlight] Click on ${node.path}`, { ctrl, shift, mode: this.selectionMode, shiftCursor: this._shiftCursor, prevCount: this.highlightedPaths.size });
|
|
1376
|
+
// !isSelectable blocks highlight (and therefore the mirror in no-checkbox mode).
|
|
1377
|
+
// Focus still moves so consumers can show detail panels for unselectable rows.
|
|
1378
|
+
const canHighlight = node.isSelectable;
|
|
1379
|
+
if (ctrl && canHighlight) {
|
|
1338
1380
|
// Toggle this node in/out of highlight
|
|
1339
1381
|
const newPaths = new Set([...this.highlightedPaths]);
|
|
1340
1382
|
if (newPaths.has(node.path)) {
|
|
@@ -1347,42 +1389,82 @@ export class TreeController {
|
|
|
1347
1389
|
}
|
|
1348
1390
|
node._rev = (node._rev || 0) + 1;
|
|
1349
1391
|
this.highlightedPaths = newPaths;
|
|
1350
|
-
this.
|
|
1392
|
+
this._shiftCursor = node.path;
|
|
1351
1393
|
}
|
|
1352
|
-
else if (shift && this.
|
|
1353
|
-
// Range highlight from
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1394
|
+
else if (shift && canHighlight && (this._shiftCursor || this.focusedNode)) {
|
|
1395
|
+
// Range highlight from the shift cursor (or focused node if no cursor yet) to this node
|
|
1396
|
+
const anchor = this._shiftCursor ?? this.focusedNode.path;
|
|
1397
|
+
const rangePaths = this._getNodesBetween(anchor, node.path);
|
|
1356
1398
|
this._clearAllHighlightFlags();
|
|
1357
1399
|
const newPaths = new Set();
|
|
1358
1400
|
for (const path of rangePaths) {
|
|
1359
|
-
newPaths.add(path);
|
|
1360
1401
|
const n = this.tree.getNodeByPath(path);
|
|
1361
|
-
if (n)
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1402
|
+
if (!n || !n.isSelectable)
|
|
1403
|
+
continue;
|
|
1404
|
+
newPaths.add(path);
|
|
1405
|
+
n.isHighlighted = true;
|
|
1406
|
+
n._rev = (n._rev || 0) + 1;
|
|
1365
1407
|
}
|
|
1366
1408
|
this.highlightedPaths = newPaths;
|
|
1367
|
-
//
|
|
1409
|
+
// Anchor stays put on shift+click — _shiftCursor unchanged
|
|
1368
1410
|
}
|
|
1369
|
-
else {
|
|
1370
|
-
//
|
|
1411
|
+
else if (canHighlight) {
|
|
1412
|
+
// Plain click (or Ctrl/Shift+click in single mode → treated as plain):
|
|
1413
|
+
// clear all highlights, highlight only this node.
|
|
1371
1414
|
this._clearAllHighlightFlags();
|
|
1372
1415
|
node.isHighlighted = true;
|
|
1373
1416
|
node._rev = (node._rev || 0) + 1;
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
this.highlightedPaths = newPaths;
|
|
1377
|
-
this.lastHighlightedPath = node.path;
|
|
1417
|
+
this.highlightedPaths = new Set([node.path]);
|
|
1418
|
+
this._shiftCursor = node.path;
|
|
1378
1419
|
}
|
|
1379
|
-
|
|
1420
|
+
else {
|
|
1421
|
+
// Not selectable: clear any prior highlights but don't highlight this row.
|
|
1422
|
+
if (this.highlightedPaths.size > 0) {
|
|
1423
|
+
this._clearAllHighlightFlags();
|
|
1424
|
+
this.highlightedPaths = new Set();
|
|
1425
|
+
}
|
|
1426
|
+
this._shiftCursor = null;
|
|
1427
|
+
}
|
|
1428
|
+
// Update focus (always — focus is independent of selectability)
|
|
1380
1429
|
this._setFocusedNode(node);
|
|
1381
|
-
|
|
1382
|
-
|
|
1430
|
+
if (!silent) {
|
|
1431
|
+
this.onNodeClickHandler?.(node);
|
|
1432
|
+
this._notifyHighlightChanged();
|
|
1433
|
+
this._mirrorHighlightToSelected();
|
|
1434
|
+
}
|
|
1383
1435
|
this.tree.refresh();
|
|
1384
|
-
// Focus the tree container so keyboard navigation works after clicking a node
|
|
1385
|
-
|
|
1436
|
+
// Focus the tree container so keyboard navigation works after clicking a node.
|
|
1437
|
+
// Skip in silent mode — programmatic highlight (e.g. from URL params) shouldn't
|
|
1438
|
+
// steal focus from whatever the user is currently interacting with.
|
|
1439
|
+
if (!silent) {
|
|
1440
|
+
this.containerElement?.focus();
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* Top-level paths within `highlightedPaths` — paths whose nearest highlighted
|
|
1445
|
+
* ancestor is NOT in the highlighted set. Used by multi-drag to figure out
|
|
1446
|
+
* which subtrees actually need to move (descendants ride along inside).
|
|
1447
|
+
*/
|
|
1448
|
+
_getTopLevelHighlightedPaths() {
|
|
1449
|
+
const paths = this.highlightedPaths;
|
|
1450
|
+
if (paths.size === 0)
|
|
1451
|
+
return [];
|
|
1452
|
+
const sep = this.treePathSeparator;
|
|
1453
|
+
const result = [];
|
|
1454
|
+
for (const p of paths) {
|
|
1455
|
+
let cursor = p;
|
|
1456
|
+
let absorbed = false;
|
|
1457
|
+
while (cursor.includes(sep)) {
|
|
1458
|
+
cursor = cursor.substring(0, cursor.lastIndexOf(sep));
|
|
1459
|
+
if (paths.has(cursor)) {
|
|
1460
|
+
absorbed = true;
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (!absorbed)
|
|
1465
|
+
result.push(p);
|
|
1466
|
+
}
|
|
1467
|
+
return result;
|
|
1386
1468
|
}
|
|
1387
1469
|
/** Get all descendant paths of a node (depth-first) */
|
|
1388
1470
|
_getDescendantPaths(node) {
|
|
@@ -1397,7 +1479,7 @@ export class TreeController {
|
|
|
1397
1479
|
return result;
|
|
1398
1480
|
}
|
|
1399
1481
|
/** Handle checkbox toggle with cascade and interceptor support */
|
|
1400
|
-
_onCheckboxToggle(node) {
|
|
1482
|
+
_onCheckboxToggle(node, options) {
|
|
1401
1483
|
// In cascade mode, indeterminate → check all (not fully selected yet)
|
|
1402
1484
|
const newChecked = this.checkboxMode === 'cascade' && node.visualState === VisualState.indeterminate
|
|
1403
1485
|
? true
|
|
@@ -1452,27 +1534,30 @@ export class TreeController {
|
|
|
1452
1534
|
n._rev = (n._rev || 0) + 1;
|
|
1453
1535
|
}
|
|
1454
1536
|
this.selectedPaths = newPaths;
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1537
|
+
if (!options?.skipFocus)
|
|
1538
|
+
this._setFocusedNode(node);
|
|
1539
|
+
// Update visual states for toggled nodes and their ancestors.
|
|
1540
|
+
// Only in cascade mode — in independent mode, checkboxes are standalone and
|
|
1541
|
+
// parents must not be auto-checked just because their descendants are.
|
|
1542
|
+
if (this.checkboxMode === 'cascade') {
|
|
1543
|
+
const rootPaths = isMultiHighlighted ? [...this.highlightedPaths] : [node.path];
|
|
1544
|
+
for (const rp of rootPaths) {
|
|
1545
|
+
const rn = this.tree.getNodeByPath(rp);
|
|
1546
|
+
if (!rn)
|
|
1547
|
+
continue;
|
|
1464
1548
|
const vs = this._computeVisualState(rn);
|
|
1465
1549
|
if (rn.visualState !== vs) {
|
|
1466
1550
|
rn.visualState = vs;
|
|
1467
1551
|
rn._rev = (rn._rev || 0) + 1;
|
|
1468
1552
|
}
|
|
1553
|
+
this._updateAncestorVisualStates(rp);
|
|
1469
1554
|
}
|
|
1470
|
-
this._updateAncestorVisualStates(rp);
|
|
1471
1555
|
}
|
|
1472
1556
|
this.onNodeClickHandler?.(node);
|
|
1473
1557
|
this._notifySelectionChanged();
|
|
1474
1558
|
this.tree.refresh();
|
|
1475
|
-
|
|
1559
|
+
if (!options?.skipFocus)
|
|
1560
|
+
this.containerElement?.focus();
|
|
1476
1561
|
}
|
|
1477
1562
|
/** Walk up from a node path and set visualState on each ancestor based on descendant selection */
|
|
1478
1563
|
_updateAncestorVisualStates(startPath) {
|
|
@@ -1534,15 +1619,60 @@ export class TreeController {
|
|
|
1534
1619
|
}
|
|
1535
1620
|
/** Set focused node, clearing previous focus flag */
|
|
1536
1621
|
_setFocusedNode(node) {
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1622
|
+
// IMPORTANT: bidirectional bind on `focusedNode` can route the value through
|
|
1623
|
+
// the parent's `$state`, which deep-clones the node into a reactive proxy.
|
|
1624
|
+
// That clone shares the path but is a different object from the tree's
|
|
1625
|
+
// canonical node. We MUST mutate the tree's actual node — otherwise
|
|
1626
|
+
// `node.isFocused = false` writes to the clone and the rendered row
|
|
1627
|
+
// (which reads the canonical node's flag) never updates. Same applies to
|
|
1628
|
+
// the incoming `node`: prefer the tree's canonical ref.
|
|
1629
|
+
const prevPath = this.focusedNode?.path;
|
|
1630
|
+
if (prevPath && prevPath !== node?.path) {
|
|
1631
|
+
const prev = this.tree.getNodeByPath(prevPath);
|
|
1632
|
+
if (prev) {
|
|
1633
|
+
prev.isFocused = false;
|
|
1634
|
+
prev._rev = (prev._rev || 0) + 1;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
const canonical = node ? (this.tree.getNodeByPath(node.path) ?? node) : null;
|
|
1638
|
+
if (canonical) {
|
|
1639
|
+
canonical.isFocused = true;
|
|
1640
|
+
canonical._rev = (canonical._rev || 0) + 1;
|
|
1641
|
+
}
|
|
1642
|
+
this.focusedNode = canonical;
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Mirror highlightedPaths → selectedPaths when checkboxes are off.
|
|
1646
|
+
* Decision 1 + 10 from selection-highlight-model.md: in no-checkbox mode the
|
|
1647
|
+
* highlight set IS the form selection, so writes to highlightedPaths cascade
|
|
1648
|
+
* to selectedPaths and fire onSelectionChange alongside onHighlightChange.
|
|
1649
|
+
*/
|
|
1650
|
+
_mirrorHighlightToSelected() {
|
|
1651
|
+
if (this.showCheckboxes)
|
|
1652
|
+
return;
|
|
1653
|
+
// Take a snapshot to avoid identity-loop on parent rebinding
|
|
1654
|
+
const next = new Set(this.highlightedPaths);
|
|
1655
|
+
// Sync the per-node isSelected flag with the mirrored set.
|
|
1656
|
+
// First clear isSelected on anything currently in selectedPaths but not in next.
|
|
1657
|
+
for (const path of this.selectedPaths) {
|
|
1658
|
+
if (!next.has(path)) {
|
|
1659
|
+
const n = this.tree.getNodeByPath(path);
|
|
1660
|
+
if (n) {
|
|
1661
|
+
n.isSelected = false;
|
|
1662
|
+
n._rev = (n._rev || 0) + 1;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1540
1665
|
}
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1666
|
+
// Then set isSelected on the new set.
|
|
1667
|
+
for (const path of next) {
|
|
1668
|
+
const n = this.tree.getNodeByPath(path);
|
|
1669
|
+
if (n && !n.isSelected) {
|
|
1670
|
+
n.isSelected = true;
|
|
1671
|
+
n._rev = (n._rev || 0) + 1;
|
|
1672
|
+
}
|
|
1544
1673
|
}
|
|
1545
|
-
this.
|
|
1674
|
+
this.selectedPaths = next;
|
|
1675
|
+
this._notifySelectionChanged();
|
|
1546
1676
|
}
|
|
1547
1677
|
/** Clear isHighlighted flag on all currently highlighted nodes */
|
|
1548
1678
|
_clearAllHighlightFlags() {
|
|
@@ -1648,29 +1778,32 @@ export class TreeController {
|
|
|
1648
1778
|
return result;
|
|
1649
1779
|
}
|
|
1650
1780
|
// ── Public highlight methods (UI selection) ────────────────────────
|
|
1651
|
-
/** Highlight a node with the given mode
|
|
1652
|
-
|
|
1781
|
+
/** Highlight a node with the given mode.
|
|
1782
|
+
* Pass `{ silent: true }` to update state without firing `onNodeClick` / `onHighlightChange`
|
|
1783
|
+
* (useful when restoring state from URL params or other external sources). */
|
|
1784
|
+
highlightNode(path, mode = 'replace', options) {
|
|
1653
1785
|
const node = this.tree.getNodeByPath(path);
|
|
1654
1786
|
if (!node)
|
|
1655
1787
|
return;
|
|
1656
1788
|
if (mode === 'toggle') {
|
|
1657
|
-
this._onNodeClicked(node, { ctrl: true, shift: false });
|
|
1789
|
+
this._onNodeClicked(node, { ctrl: true, shift: false }, { ...options, forceMultiSemantics: true });
|
|
1658
1790
|
}
|
|
1659
1791
|
else if (mode === 'range') {
|
|
1660
|
-
this._onNodeClicked(node, { ctrl: false, shift: true });
|
|
1792
|
+
this._onNodeClicked(node, { ctrl: false, shift: true }, { ...options, forceMultiSemantics: true });
|
|
1661
1793
|
}
|
|
1662
1794
|
else {
|
|
1663
|
-
this._onNodeClicked(node);
|
|
1795
|
+
this._onNodeClicked(node, undefined, options);
|
|
1664
1796
|
}
|
|
1665
1797
|
}
|
|
1666
|
-
/** Highlight multiple nodes by paths (replaces current highlights)
|
|
1667
|
-
|
|
1798
|
+
/** Highlight multiple nodes by paths (replaces current highlights).
|
|
1799
|
+
* Pass `{ silent: true }` to skip `onHighlightChange`. */
|
|
1800
|
+
highlightNodes(paths, options) {
|
|
1668
1801
|
this._clearAllHighlightFlags();
|
|
1669
1802
|
const newPaths = new Set();
|
|
1670
1803
|
let lastNode = null;
|
|
1671
1804
|
for (const path of paths) {
|
|
1672
1805
|
const node = this.tree.getNodeByPath(path);
|
|
1673
|
-
if (node) {
|
|
1806
|
+
if (node && node.isSelectable) {
|
|
1674
1807
|
node.isHighlighted = true;
|
|
1675
1808
|
node._rev = (node._rev || 0) + 1;
|
|
1676
1809
|
newPaths.add(path);
|
|
@@ -1680,17 +1813,23 @@ export class TreeController {
|
|
|
1680
1813
|
this.highlightedPaths = newPaths;
|
|
1681
1814
|
if (lastNode) {
|
|
1682
1815
|
this._setFocusedNode(lastNode);
|
|
1683
|
-
this.
|
|
1816
|
+
this._shiftCursor = lastNode.path;
|
|
1817
|
+
}
|
|
1818
|
+
if (!options?.silent) {
|
|
1819
|
+
this._notifyHighlightChanged();
|
|
1820
|
+
this._mirrorHighlightToSelected();
|
|
1684
1821
|
}
|
|
1685
|
-
this._notifyHighlightChanged();
|
|
1686
1822
|
this.tree.refresh();
|
|
1687
1823
|
}
|
|
1688
|
-
/** Clear all highlights */
|
|
1689
|
-
clearHighlight() {
|
|
1824
|
+
/** Clear all highlights. Pass `{ silent: true }` to skip `onHighlightChange`. */
|
|
1825
|
+
clearHighlight(options) {
|
|
1690
1826
|
this._clearAllHighlightFlags();
|
|
1691
1827
|
this.highlightedPaths = new Set();
|
|
1692
|
-
this.
|
|
1693
|
-
|
|
1828
|
+
this._shiftCursor = null;
|
|
1829
|
+
if (!options?.silent) {
|
|
1830
|
+
this._notifyHighlightChanged();
|
|
1831
|
+
this._mirrorHighlightToSelected();
|
|
1832
|
+
}
|
|
1694
1833
|
this.tree.refresh();
|
|
1695
1834
|
}
|
|
1696
1835
|
/** Get all highlighted nodes */
|
|
@@ -1707,6 +1846,15 @@ export class TreeController {
|
|
|
1707
1846
|
isNodeHighlighted(path) {
|
|
1708
1847
|
return this.highlightedPaths.has(path);
|
|
1709
1848
|
}
|
|
1849
|
+
/** Toggle the focused node in/out of the highlight set. Multi-mode only. */
|
|
1850
|
+
toggleFocusedHighlight() {
|
|
1851
|
+
if (this.selectionMode !== 'multi')
|
|
1852
|
+
return;
|
|
1853
|
+
const node = this.focusedNode;
|
|
1854
|
+
if (!node || !node.isSelectable)
|
|
1855
|
+
return;
|
|
1856
|
+
this._onNodeClicked(node, { ctrl: true, shift: false }, { forceMultiSemantics: true });
|
|
1857
|
+
}
|
|
1710
1858
|
// ── Public selection methods (checkbox data state) ───────────────
|
|
1711
1859
|
/** Get all selected (checked) nodes */
|
|
1712
1860
|
getSelectedNodes() {
|
|
@@ -1722,36 +1870,30 @@ export class TreeController {
|
|
|
1722
1870
|
isNodeSelected(path) {
|
|
1723
1871
|
return this.selectedPaths.has(path);
|
|
1724
1872
|
}
|
|
1725
|
-
/** Clear all checkbox selections */
|
|
1726
|
-
deselectAll() {
|
|
1873
|
+
/** Clear all checkbox selections. Pass `{ silent: true }` to skip `onSelectionChange`. */
|
|
1874
|
+
deselectAll(options) {
|
|
1727
1875
|
this._clearAllSelectionFlags();
|
|
1728
1876
|
this.selectedPaths = new Set();
|
|
1729
|
-
|
|
1877
|
+
if (!options?.silent)
|
|
1878
|
+
this._notifySelectionChanged();
|
|
1730
1879
|
this.tree.refresh();
|
|
1731
1880
|
}
|
|
1732
1881
|
/** @deprecated Use highlightNode() instead */
|
|
1733
|
-
selectNode(path, mode = 'replace') {
|
|
1734
|
-
this.highlightNode(path, mode);
|
|
1882
|
+
selectNode(path, mode = 'replace', options) {
|
|
1883
|
+
this.highlightNode(path, mode, options);
|
|
1735
1884
|
}
|
|
1736
1885
|
/** @deprecated Use highlightNodes() instead */
|
|
1737
|
-
selectNodes(paths) {
|
|
1738
|
-
this.highlightNodes(paths);
|
|
1886
|
+
selectNodes(paths, options) {
|
|
1887
|
+
this.highlightNodes(paths, options);
|
|
1739
1888
|
}
|
|
1740
1889
|
_onNodeRightClicked(node, event) {
|
|
1741
1890
|
if (!this.hasContextMenuSnippet && !this.getContextMenuItemsHandler) {
|
|
1742
1891
|
return;
|
|
1743
1892
|
}
|
|
1744
|
-
//
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
node._rev = (node._rev || 0) + 1;
|
|
1749
|
-
this.highlightedPaths = new Set([node.path]);
|
|
1750
|
-
this._setFocusedNode(node);
|
|
1751
|
-
this.lastHighlightedPath = node.path;
|
|
1752
|
-
this._notifyHighlightChanged();
|
|
1753
|
-
this.tree.refresh();
|
|
1754
|
-
}
|
|
1893
|
+
// Decision 12: right-click does NOT move focus or highlight. It only opens
|
|
1894
|
+
// the context menu at the right-clicked node. If the consumer needs the
|
|
1895
|
+
// menu to act on something other than the highlight set, they can read
|
|
1896
|
+
// the node passed to their getContextMenuItemsCallback.
|
|
1755
1897
|
uiLogger.debug(`Context menu opened: ${node.path}`);
|
|
1756
1898
|
event.preventDefault();
|
|
1757
1899
|
this.openContextMenu(node, event.clientX, event.clientY);
|
|
@@ -1786,6 +1928,31 @@ export class TreeController {
|
|
|
1786
1928
|
this.draggedNode = node;
|
|
1787
1929
|
this.isDragInProgress = true;
|
|
1788
1930
|
this.onNodeDragStartHandler?.(node, event);
|
|
1931
|
+
// OS-convention selection sync: if the user grabs a node that isn't part
|
|
1932
|
+
// of the current highlight set, replace the highlight with just that node.
|
|
1933
|
+
// Mirrors Windows Explorer / macOS Finder where mousedown on an unselected
|
|
1934
|
+
// item selects it. Without this, the prior highlight stayed visible while
|
|
1935
|
+
// the drag silently carried only the single grabbed node — confusing the
|
|
1936
|
+
// user about what's moving. Deferred to rAF (not microtask): microtasks
|
|
1937
|
+
// drain before the browser commits the drag image, so mutating the source
|
|
1938
|
+
// row's DOM there causes `tree.refresh()` to re-create the dragged element
|
|
1939
|
+
// and the browser silently aborts the drag (no dragend fires). rAF runs as
|
|
1940
|
+
// part of the rendering steps, after the drag is committed. Drop handlers
|
|
1941
|
+
// fire well after this rAF, so they read the updated `highlightedPaths`.
|
|
1942
|
+
// Skipped when the node is already in the set (multi-drag) or not
|
|
1943
|
+
// selectable (preserves prior highlight state for unselectable rows).
|
|
1944
|
+
if (node.isSelectable && !this.highlightedPaths.has(node.path)) {
|
|
1945
|
+
requestAnimationFrame(() => {
|
|
1946
|
+
this._clearAllHighlightFlags();
|
|
1947
|
+
node.isHighlighted = true;
|
|
1948
|
+
node._rev = (node._rev || 0) + 1;
|
|
1949
|
+
this.highlightedPaths = new Set([node.path]);
|
|
1950
|
+
this._shiftCursor = node.path;
|
|
1951
|
+
this._notifyHighlightChanged();
|
|
1952
|
+
this._mirrorHighlightToSelected();
|
|
1953
|
+
this.tree.refresh();
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1789
1956
|
}
|
|
1790
1957
|
_onNodeDragEnd = (event) => {
|
|
1791
1958
|
dragLogger.debug('Drag ended', {
|
|
@@ -1829,6 +1996,50 @@ export class TreeController {
|
|
|
1829
1996
|
}
|
|
1830
1997
|
}
|
|
1831
1998
|
const isSameTreeDrag = draggedNodeRef.treeId === this.treeId;
|
|
1999
|
+
// Multi-drag (Decision 6 in selection-highlight-model.md):
|
|
2000
|
+
// When the dragged node is part of a multi-highlight, move the whole highlight
|
|
2001
|
+
// set as top-level-selected subtrees. Descendants whose nearest highlighted
|
|
2002
|
+
// ancestor is in the set are absorbed (ride along inside the subtree).
|
|
2003
|
+
const isMultiDrag = isSameTreeDrag &&
|
|
2004
|
+
operation === 'move' &&
|
|
2005
|
+
dropNode &&
|
|
2006
|
+
this.autoHandleMove &&
|
|
2007
|
+
this.highlightedPaths.has(draggedNodeRef.path) &&
|
|
2008
|
+
this.highlightedPaths.size > 1;
|
|
2009
|
+
if (isMultiDrag) {
|
|
2010
|
+
const topLevelPaths = this._getTopLevelHighlightedPaths()
|
|
2011
|
+
// drop target can't be moved onto itself
|
|
2012
|
+
.filter((p) => p !== dropNode.path);
|
|
2013
|
+
dragLogger.info(`Multi-drag: moving ${topLevelPaths.length} top-level subtree(s)`, {
|
|
2014
|
+
topLevelPaths,
|
|
2015
|
+
totalHighlighted: this.highlightedPaths.size,
|
|
2016
|
+
dropTarget: dropNode.path,
|
|
2017
|
+
position
|
|
2018
|
+
});
|
|
2019
|
+
let allOk = true;
|
|
2020
|
+
// First top-level node uses the requested position relative to dropNode.
|
|
2021
|
+
// Subsequent ones chain 'after' the previously moved node so the whole
|
|
2022
|
+
// set lands as siblings in source order: dropping A,B,C 'after D' yields
|
|
2023
|
+
// [D, A, B, C]; 'before D' yields [A, B, C, D]; 'child of D' yields D's
|
|
2024
|
+
// children = [A, B, C]. moveNode mutates the source LTreeNode in place,
|
|
2025
|
+
// so reading the held reference's .path post-move gives the new path.
|
|
2026
|
+
let prevMovedNode = null;
|
|
2027
|
+
for (let i = 0; i < topLevelPaths.length; i++) {
|
|
2028
|
+
const sourcePath = topLevelPaths[i];
|
|
2029
|
+
const targetPath = i === 0 ? dropNode.path : prevMovedNode.path;
|
|
2030
|
+
const pos = i === 0 ? position : 'after';
|
|
2031
|
+
const sourceNode = this.tree.getNodeByPath(sourcePath);
|
|
2032
|
+
const r = this.moveNode(sourcePath, targetPath, pos);
|
|
2033
|
+
if (!r.success) {
|
|
2034
|
+
allOk = false;
|
|
2035
|
+
}
|
|
2036
|
+
else if (sourceNode) {
|
|
2037
|
+
prevMovedNode = sourceNode;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
this.onNodeDropHandler?.(dropNode, draggedNodeRef, position, event, operation);
|
|
2041
|
+
return allOk;
|
|
2042
|
+
}
|
|
1832
2043
|
if (isSameTreeDrag && operation === 'move' && dropNode) {
|
|
1833
2044
|
if (this.autoHandleMove) {
|
|
1834
2045
|
const result = this.moveNode(draggedNodeRef.path, dropNode.path, position);
|
|
@@ -2510,14 +2721,17 @@ export class TreeController {
|
|
|
2510
2721
|
}
|
|
2511
2722
|
};
|
|
2512
2723
|
}
|
|
2513
|
-
/** Extend highlight to target path (Shift+nav) — uses range from
|
|
2724
|
+
/** Extend highlight to target path (Shift+nav) — uses range from the shift cursor
|
|
2725
|
+
* (or current focus if no cursor yet), moves focus. No-op in single mode. */
|
|
2514
2726
|
_navHighlightTo(path) {
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2727
|
+
if (this.selectionMode !== 'multi')
|
|
2728
|
+
return;
|
|
2729
|
+
// Seed the shift cursor from the focused node on the first Shift+Arrow
|
|
2730
|
+
if (!this._shiftCursor && this.focusedNode) {
|
|
2519
2731
|
const anchorNode = this.focusedNode;
|
|
2520
|
-
|
|
2732
|
+
this._shiftCursor = anchorNode.path;
|
|
2733
|
+
// Ensure anchor is highlighted so range computation has a starting point visible
|
|
2734
|
+
if (anchorNode.isSelectable && !anchorNode.isHighlighted) {
|
|
2521
2735
|
anchorNode.isHighlighted = true;
|
|
2522
2736
|
anchorNode._rev = (anchorNode._rev || 0) + 1;
|
|
2523
2737
|
this.highlightedPaths = new Set([anchorNode.path]);
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { TreeController } from "./core/TreeController.svelte";
|
|
|
6
6
|
export type { TreeControllerProps, PasteResult } from "./core/TreeController.svelte";
|
|
7
7
|
export { createTreeController } from "./core/createTreeController.js";
|
|
8
8
|
export type { LTreeNode, NodeId, VisualState } from "./ltree/ltree-node.svelte";
|
|
9
|
-
export type { Ltree, DropPosition, DragDropMode, DropOperation, ToggleIconMode, ClickBehavior, CheckboxMode, ContextMenuItem, ContextMenuDivider, ContextMenuEntry, InsertArrayResult, InsertBranchResult, DeleteBranchResult, TreeChange, ApplyChangesResult } from "./ltree/types.js";
|
|
9
|
+
export type { Ltree, DropPosition, DragDropMode, DropOperation, ToggleIconMode, ClickBehavior, CheckboxMode, SelectionMode, ContextMenuItem, ContextMenuDivider, ContextMenuEntry, InsertArrayResult, InsertBranchResult, DeleteBranchResult, TreeChange, ApplyChangesResult } from "./ltree/types.js";
|
|
10
10
|
export type { ClipboardEntry, TreeClipboard } from "./core/clipboard.js";
|
|
11
11
|
export { setClipboard, getClipboard, clearClipboard, hasClipboard, getClipboardOperation } from "./core/clipboard.js";
|
|
12
12
|
export type { TreeNavigation, TreeNavigationOverrides } from "./core/navigation.js";
|