@keenmate/svelte-treeview 5.0.0-rc08 → 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 +41 -0
- package/README.md +33 -23
- 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 +49 -1
- package/dist/components/Tree.svelte.d.ts +36 -3
- package/dist/constants.generated.d.ts +1 -1
- package/dist/constants.generated.js +1 -1
- package/dist/core/TreeController.svelte.d.ts +45 -4
- package/dist/core/TreeController.svelte.js +270 -69
- package/dist/index.d.ts +1 -1
- package/dist/ltree/ltree.svelte.d.ts +1 -1
- package/dist/ltree/ltree.svelte.js +28 -7
- package/dist/ltree/types.d.ts +4 -0
- 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) => {
|
|
@@ -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)
|
|
@@ -1331,11 +1365,18 @@ export class TreeController {
|
|
|
1331
1365
|
if (this.contextMenuVisible) {
|
|
1332
1366
|
this.closeContextMenu();
|
|
1333
1367
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
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);
|
|
1336
1374
|
const silent = options?.silent ?? false;
|
|
1337
|
-
uiLogger.debug(`[highlight] Click on ${node.path}`, { ctrl, shift,
|
|
1338
|
-
|
|
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) {
|
|
1339
1380
|
// Toggle this node in/out of highlight
|
|
1340
1381
|
const newPaths = new Set([...this.highlightedPaths]);
|
|
1341
1382
|
if (newPaths.has(node.path)) {
|
|
@@ -1348,40 +1389,48 @@ export class TreeController {
|
|
|
1348
1389
|
}
|
|
1349
1390
|
node._rev = (node._rev || 0) + 1;
|
|
1350
1391
|
this.highlightedPaths = newPaths;
|
|
1351
|
-
this.
|
|
1392
|
+
this._shiftCursor = node.path;
|
|
1352
1393
|
}
|
|
1353
|
-
else if (shift && this.
|
|
1354
|
-
// Range highlight from
|
|
1355
|
-
const
|
|
1356
|
-
|
|
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);
|
|
1357
1398
|
this._clearAllHighlightFlags();
|
|
1358
1399
|
const newPaths = new Set();
|
|
1359
1400
|
for (const path of rangePaths) {
|
|
1360
|
-
newPaths.add(path);
|
|
1361
1401
|
const n = this.tree.getNodeByPath(path);
|
|
1362
|
-
if (n)
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1402
|
+
if (!n || !n.isSelectable)
|
|
1403
|
+
continue;
|
|
1404
|
+
newPaths.add(path);
|
|
1405
|
+
n.isHighlighted = true;
|
|
1406
|
+
n._rev = (n._rev || 0) + 1;
|
|
1366
1407
|
}
|
|
1367
1408
|
this.highlightedPaths = newPaths;
|
|
1368
|
-
//
|
|
1409
|
+
// Anchor stays put on shift+click — _shiftCursor unchanged
|
|
1369
1410
|
}
|
|
1370
|
-
else {
|
|
1371
|
-
//
|
|
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.
|
|
1372
1414
|
this._clearAllHighlightFlags();
|
|
1373
1415
|
node.isHighlighted = true;
|
|
1374
1416
|
node._rev = (node._rev || 0) + 1;
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
this.highlightedPaths = newPaths;
|
|
1378
|
-
this.lastHighlightedPath = node.path;
|
|
1417
|
+
this.highlightedPaths = new Set([node.path]);
|
|
1418
|
+
this._shiftCursor = node.path;
|
|
1379
1419
|
}
|
|
1380
|
-
|
|
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)
|
|
1381
1429
|
this._setFocusedNode(node);
|
|
1382
1430
|
if (!silent) {
|
|
1383
1431
|
this.onNodeClickHandler?.(node);
|
|
1384
1432
|
this._notifyHighlightChanged();
|
|
1433
|
+
this._mirrorHighlightToSelected();
|
|
1385
1434
|
}
|
|
1386
1435
|
this.tree.refresh();
|
|
1387
1436
|
// Focus the tree container so keyboard navigation works after clicking a node.
|
|
@@ -1391,6 +1440,32 @@ export class TreeController {
|
|
|
1391
1440
|
this.containerElement?.focus();
|
|
1392
1441
|
}
|
|
1393
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;
|
|
1468
|
+
}
|
|
1394
1469
|
/** Get all descendant paths of a node (depth-first) */
|
|
1395
1470
|
_getDescendantPaths(node) {
|
|
1396
1471
|
const result = [];
|
|
@@ -1404,7 +1479,7 @@ export class TreeController {
|
|
|
1404
1479
|
return result;
|
|
1405
1480
|
}
|
|
1406
1481
|
/** Handle checkbox toggle with cascade and interceptor support */
|
|
1407
|
-
_onCheckboxToggle(node) {
|
|
1482
|
+
_onCheckboxToggle(node, options) {
|
|
1408
1483
|
// In cascade mode, indeterminate → check all (not fully selected yet)
|
|
1409
1484
|
const newChecked = this.checkboxMode === 'cascade' && node.visualState === VisualState.indeterminate
|
|
1410
1485
|
? true
|
|
@@ -1459,27 +1534,30 @@ export class TreeController {
|
|
|
1459
1534
|
n._rev = (n._rev || 0) + 1;
|
|
1460
1535
|
}
|
|
1461
1536
|
this.selectedPaths = newPaths;
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
//
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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;
|
|
1471
1548
|
const vs = this._computeVisualState(rn);
|
|
1472
1549
|
if (rn.visualState !== vs) {
|
|
1473
1550
|
rn.visualState = vs;
|
|
1474
1551
|
rn._rev = (rn._rev || 0) + 1;
|
|
1475
1552
|
}
|
|
1553
|
+
this._updateAncestorVisualStates(rp);
|
|
1476
1554
|
}
|
|
1477
|
-
this._updateAncestorVisualStates(rp);
|
|
1478
1555
|
}
|
|
1479
1556
|
this.onNodeClickHandler?.(node);
|
|
1480
1557
|
this._notifySelectionChanged();
|
|
1481
1558
|
this.tree.refresh();
|
|
1482
|
-
|
|
1559
|
+
if (!options?.skipFocus)
|
|
1560
|
+
this.containerElement?.focus();
|
|
1483
1561
|
}
|
|
1484
1562
|
/** Walk up from a node path and set visualState on each ancestor based on descendant selection */
|
|
1485
1563
|
_updateAncestorVisualStates(startPath) {
|
|
@@ -1541,15 +1619,60 @@ export class TreeController {
|
|
|
1541
1619
|
}
|
|
1542
1620
|
/** Set focused node, clearing previous focus flag */
|
|
1543
1621
|
_setFocusedNode(node) {
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
+
}
|
|
1547
1665
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
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
|
+
}
|
|
1551
1673
|
}
|
|
1552
|
-
this.
|
|
1674
|
+
this.selectedPaths = next;
|
|
1675
|
+
this._notifySelectionChanged();
|
|
1553
1676
|
}
|
|
1554
1677
|
/** Clear isHighlighted flag on all currently highlighted nodes */
|
|
1555
1678
|
_clearAllHighlightFlags() {
|
|
@@ -1663,10 +1786,10 @@ export class TreeController {
|
|
|
1663
1786
|
if (!node)
|
|
1664
1787
|
return;
|
|
1665
1788
|
if (mode === 'toggle') {
|
|
1666
|
-
this._onNodeClicked(node, { ctrl: true, shift: false }, options);
|
|
1789
|
+
this._onNodeClicked(node, { ctrl: true, shift: false }, { ...options, forceMultiSemantics: true });
|
|
1667
1790
|
}
|
|
1668
1791
|
else if (mode === 'range') {
|
|
1669
|
-
this._onNodeClicked(node, { ctrl: false, shift: true }, options);
|
|
1792
|
+
this._onNodeClicked(node, { ctrl: false, shift: true }, { ...options, forceMultiSemantics: true });
|
|
1670
1793
|
}
|
|
1671
1794
|
else {
|
|
1672
1795
|
this._onNodeClicked(node, undefined, options);
|
|
@@ -1680,7 +1803,7 @@ export class TreeController {
|
|
|
1680
1803
|
let lastNode = null;
|
|
1681
1804
|
for (const path of paths) {
|
|
1682
1805
|
const node = this.tree.getNodeByPath(path);
|
|
1683
|
-
if (node) {
|
|
1806
|
+
if (node && node.isSelectable) {
|
|
1684
1807
|
node.isHighlighted = true;
|
|
1685
1808
|
node._rev = (node._rev || 0) + 1;
|
|
1686
1809
|
newPaths.add(path);
|
|
@@ -1690,19 +1813,23 @@ export class TreeController {
|
|
|
1690
1813
|
this.highlightedPaths = newPaths;
|
|
1691
1814
|
if (lastNode) {
|
|
1692
1815
|
this._setFocusedNode(lastNode);
|
|
1693
|
-
this.
|
|
1816
|
+
this._shiftCursor = lastNode.path;
|
|
1694
1817
|
}
|
|
1695
|
-
if (!options?.silent)
|
|
1818
|
+
if (!options?.silent) {
|
|
1696
1819
|
this._notifyHighlightChanged();
|
|
1820
|
+
this._mirrorHighlightToSelected();
|
|
1821
|
+
}
|
|
1697
1822
|
this.tree.refresh();
|
|
1698
1823
|
}
|
|
1699
1824
|
/** Clear all highlights. Pass `{ silent: true }` to skip `onHighlightChange`. */
|
|
1700
1825
|
clearHighlight(options) {
|
|
1701
1826
|
this._clearAllHighlightFlags();
|
|
1702
1827
|
this.highlightedPaths = new Set();
|
|
1703
|
-
this.
|
|
1704
|
-
if (!options?.silent)
|
|
1828
|
+
this._shiftCursor = null;
|
|
1829
|
+
if (!options?.silent) {
|
|
1705
1830
|
this._notifyHighlightChanged();
|
|
1831
|
+
this._mirrorHighlightToSelected();
|
|
1832
|
+
}
|
|
1706
1833
|
this.tree.refresh();
|
|
1707
1834
|
}
|
|
1708
1835
|
/** Get all highlighted nodes */
|
|
@@ -1719,6 +1846,15 @@ export class TreeController {
|
|
|
1719
1846
|
isNodeHighlighted(path) {
|
|
1720
1847
|
return this.highlightedPaths.has(path);
|
|
1721
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
|
+
}
|
|
1722
1858
|
// ── Public selection methods (checkbox data state) ───────────────
|
|
1723
1859
|
/** Get all selected (checked) nodes */
|
|
1724
1860
|
getSelectedNodes() {
|
|
@@ -1754,17 +1890,10 @@ export class TreeController {
|
|
|
1754
1890
|
if (!this.hasContextMenuSnippet && !this.getContextMenuItemsHandler) {
|
|
1755
1891
|
return;
|
|
1756
1892
|
}
|
|
1757
|
-
//
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
node._rev = (node._rev || 0) + 1;
|
|
1762
|
-
this.highlightedPaths = new Set([node.path]);
|
|
1763
|
-
this._setFocusedNode(node);
|
|
1764
|
-
this.lastHighlightedPath = node.path;
|
|
1765
|
-
this._notifyHighlightChanged();
|
|
1766
|
-
this.tree.refresh();
|
|
1767
|
-
}
|
|
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.
|
|
1768
1897
|
uiLogger.debug(`Context menu opened: ${node.path}`);
|
|
1769
1898
|
event.preventDefault();
|
|
1770
1899
|
this.openContextMenu(node, event.clientX, event.clientY);
|
|
@@ -1799,6 +1928,31 @@ export class TreeController {
|
|
|
1799
1928
|
this.draggedNode = node;
|
|
1800
1929
|
this.isDragInProgress = true;
|
|
1801
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
|
+
}
|
|
1802
1956
|
}
|
|
1803
1957
|
_onNodeDragEnd = (event) => {
|
|
1804
1958
|
dragLogger.debug('Drag ended', {
|
|
@@ -1842,6 +1996,50 @@ export class TreeController {
|
|
|
1842
1996
|
}
|
|
1843
1997
|
}
|
|
1844
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
|
+
}
|
|
1845
2043
|
if (isSameTreeDrag && operation === 'move' && dropNode) {
|
|
1846
2044
|
if (this.autoHandleMove) {
|
|
1847
2045
|
const result = this.moveNode(draggedNodeRef.path, dropNode.path, position);
|
|
@@ -2523,14 +2721,17 @@ export class TreeController {
|
|
|
2523
2721
|
}
|
|
2524
2722
|
};
|
|
2525
2723
|
}
|
|
2526
|
-
/** 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. */
|
|
2527
2726
|
_navHighlightTo(path) {
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
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) {
|
|
2532
2731
|
const anchorNode = this.focusedNode;
|
|
2533
|
-
|
|
2732
|
+
this._shiftCursor = anchorNode.path;
|
|
2733
|
+
// Ensure anchor is highlighted so range computation has a starting point visible
|
|
2734
|
+
if (anchorNode.isSelectable && !anchorNode.isHighlighted) {
|
|
2534
2735
|
anchorNode.isHighlighted = true;
|
|
2535
2736
|
anchorNode._rev = (anchorNode._rev || 0) + 1;
|
|
2536
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";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { Index } from 'flexsearch';
|
|
2
2
|
import { type LTreeNode } from './ltree-node.svelte';
|
|
3
3
|
import type { Ltree } from './types.js';
|
|
4
|
-
export declare function createLTree<T>(_idMember: string, _pathMember: string, _parentPathMember?: string | null | undefined, _levelMember?: string | null | undefined, _hasChildrenMember?: string | null | undefined, _isExpandedMember?: string | null | undefined, _isSelectableMember?: string | null | undefined, _isSelectedMember?: string | null | undefined, _isDraggableMember?: string | null | undefined, _getIsDraggableCallback?: (node: LTreeNode<T>) => boolean, _isDropAllowedMember?: string | null | undefined, _allowedDropPositionsMember?: string | null | undefined, _displayValueMember?: string | null | undefined, _getDisplayValueCallback?: (node: LTreeNode<T>) => string, _searchValueMember?: string | null | undefined, _getSearchValueCallback?: (node: LTreeNode<T>) => string, _getAllowedDropPositionsCallback?: (node: LTreeNode<T>) => import('./types.js').DropPosition[] | null | undefined, _isCollapsibleMember?: string | null | undefined, _getIsCollapsibleCallback?: (node: LTreeNode<T>) => boolean, _orderMember?: string | null | undefined, _treeId?: string, _treePathSeparator?: string | null | undefined, _expandLevel?: number | null | undefined, _shouldUseInternalSearchIndex?: boolean | null | undefined, _initializeIndexCallback?: () => Index, _indexerBatchSize?: number | null | undefined, _indexerTimeout?: number | null | undefined, opts?: Partial<Ltree<T>>): Ltree<T>;
|
|
4
|
+
export declare function createLTree<T>(_idMember: string, _pathMember: string, _parentPathMember?: string | null | undefined, _levelMember?: string | null | undefined, _hasChildrenMember?: string | null | undefined, _isExpandedMember?: string | null | undefined, _getIsExpandedCallback?: (node: LTreeNode<T>) => boolean, _isSelectableMember?: string | null | undefined, _getIsSelectableCallback?: (node: LTreeNode<T>) => boolean, _isSelectedMember?: string | null | undefined, _getIsSelectedCallback?: (node: LTreeNode<T>) => boolean, _isDraggableMember?: string | null | undefined, _getIsDraggableCallback?: (node: LTreeNode<T>) => boolean, _isDropAllowedMember?: string | null | undefined, _allowedDropPositionsMember?: string | null | undefined, _displayValueMember?: string | null | undefined, _getDisplayValueCallback?: (node: LTreeNode<T>) => string, _searchValueMember?: string | null | undefined, _getSearchValueCallback?: (node: LTreeNode<T>) => string, _getAllowedDropPositionsCallback?: (node: LTreeNode<T>) => import('./types.js').DropPosition[] | null | undefined, _isCollapsibleMember?: string | null | undefined, _getIsCollapsibleCallback?: (node: LTreeNode<T>) => boolean, _orderMember?: string | null | undefined, _treeId?: string, _treePathSeparator?: string | null | undefined, _expandLevel?: number | null | undefined, _shouldUseInternalSearchIndex?: boolean | null | undefined, _initializeIndexCallback?: () => Index, _indexerBatchSize?: number | null | undefined, _indexerTimeout?: number | null | undefined, opts?: Partial<Ltree<T>>): Ltree<T>;
|