Add support for initial paths and some other improvements.

This commit is contained in:
2025-12-28 22:33:21 +01:00
parent 67e2d51e8b
commit 26e15356b5

View File

@@ -157,7 +157,7 @@ export class TpTreeNav extends Position(LitElement) {
items, items,
scroller: this, scroller: this,
renderItem: (item) => this._renderItem(item), renderItem: (item) => this._renderItem(item),
keyFunction: (item) => item.key, keyFunction: (item) => item?.key ?? '',
})} })}
</div> </div>
${this._renderContextMenu()} ${this._renderContextMenu()}
@@ -165,6 +165,7 @@ export class TpTreeNav extends Position(LitElement) {
} }
_renderItem(item) { _renderItem(item) {
if (!item) return null;
const { node, depth, hasChildren, expanded } = item; const { node, depth, hasChildren, expanded } = item;
const icon = this._resolveIcon(node?.icon); const icon = this._resolveIcon(node?.icon);
@@ -187,6 +188,7 @@ export class TpTreeNav extends Position(LitElement) {
<div class="row" part="row" style="--depth: ${depth}" <div class="row" part="row" style="--depth: ${depth}"
draggable="${this.allowDragAndDrop ? 'true' : 'false'}" draggable="${this.allowDragAndDrop ? 'true' : 'false'}"
@click=${(e) => this._onRowClick(item, e)} @click=${(e) => this._onRowClick(item, e)}
@dblclick=${(e) => this._onRowDoubleClick(item, e)}
@contextmenu=${(e) => this._onContextMenu(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)}
@dragstart=${(e) => this._onDragStart(item, e)} @dragstart=${(e) => this._onDragStart(item, e)}
@dragend=${(e) => this._onDragEnd(item, e)} @dragend=${(e) => this._onDragEnd(item, e)}
@@ -268,6 +270,10 @@ export class TpTreeNav extends Position(LitElement) {
allowDragAndDrop: { type: Boolean }, allowDragAndDrop: { type: Boolean },
canDrop: { type: Function }, canDrop: { type: Function },
loadChildren: { type: Function }, loadChildren: { type: Function },
initialPath: { type: String },
initialSelect: { type: Boolean },
initialScroll: { type: Boolean },
initialOpen: { type: Boolean },
}; };
} }
@@ -314,6 +320,12 @@ export class TpTreeNav extends Position(LitElement) {
this.allowDragAndDrop = false; this.allowDragAndDrop = false;
this.canDrop = null; this.canDrop = null;
this.loadChildren = null; this.loadChildren = null;
this.initialPath = '';
this.initialSelect = true;
this.initialScroll = true;
this.initialOpen = false;
this._initialRevealDoneFor = '';
this._revealController = null;
this._contextMenu = null; this._contextMenu = null;
this._outsideHandler = null; this._outsideHandler = null;
this._keyHandler = null; this._keyHandler = null;
@@ -332,14 +344,183 @@ export class TpTreeNav extends Position(LitElement) {
updated(changed) { updated(changed) {
super.updated?.(changed); super.updated?.(changed);
const managed = this.manageState; const managed = this.manageState;
const itemsChanged = managed && changed.has('items'); const itemsChanged = managed && changed.has('items');
const expandChanged = managed && changed.has('expandedPaths'); const expandChanged = managed && changed.has('expandedPaths');
const selectChanged = managed && changed.has('selectedPaths'); const selectChanged = managed && changed.has('selectedPaths');
if (managed && (itemsChanged || expandChanged || selectChanged)) { if (managed && (itemsChanged || expandChanged)) {
this._rebuildManagedTree(); this._rebuildManagedTree();
} else if (managed && selectChanged) {
// Preserve scroll position: selection-only changes shouldn't rebuild
// the whole tree (virtualizer may jump to top).
this._selectedPathSet = new Set(this.selectedPaths || []);
this._applySelectionStates();
} }
// Declarative initial reveal. Runs once per distinct initialPath value.
if (changed.has('initialPath') || itemsChanged) {
const p = typeof this.initialPath === 'string' ? this.initialPath.trim() : '';
if (p && this._initialRevealDoneFor !== p) {
// Only attempt when we have some tree data.
const hasData = this.manageState
? Array.isArray(this.items) && this.items.length > 0
: Array.isArray(this.roots) && this.roots.length > 0;
if (hasData) {
this._initialRevealDoneFor = p;
this.revealPath(p, {
select: this.initialSelect !== false,
scroll: this.initialScroll !== false,
open: this.initialOpen === true,
source: 'initialPath',
});
}
}
}
}
_normalizeKeyPath(path) {
if (Array.isArray(path)) return path.map((s) => String(s)).filter(Boolean);
if (typeof path !== 'string') return [];
return path.split('/').map((s) => s.trim()).filter(Boolean);
}
_findFlatIndexByKey(key) {
const items = Array.isArray(this._flatItems) ? this._flatItems : [];
return items.findIndex((it) => it?.key === key);
}
async scrollToKey(key) {
await this.updateComplete;
// Ensure flat list is up to date for this render.
// (render() assigns this._flatItems).
const idx = this._findFlatIndexByKey(key);
if (idx < 0) return false;
const scroller = this.shadowRoot?.querySelector('.tree');
if (scroller?.scrollToIndex) {
scroller.scrollToIndex(idx, { block: 'center' });
return true;
}
// Fallback: just let it be selected without auto-scroll.
return false;
}
/**
* Expand+load ancestors so `path` becomes visible, then optionally select/scroll it.
* `path` is a key path (slug path), like `root/dir/file`.
*/
async revealPath(path, options = {}) {
const segments = this._normalizeKeyPath(path);
if (!segments.length) return { found: false, key: '', node: undefined };
const select = options.select !== false;
const scroll = options.scroll !== false;
const open = options.open === true;
const source = options.source ?? 'revealPath';
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15000;
// Cancel any in-flight reveal.
if (this._revealController) {
try { this._revealController.abort(); } catch (e) { }
}
const controller = new AbortController();
this._revealController = controller;
const externalSignal = options.signal;
if (externalSignal?.aborted) return { found: false, key: segments.join('/') };
const abortIfExternal = () => {
try { controller.abort(); } catch (e) { }
};
externalSignal?.addEventListener?.('abort', abortIfExternal, { once: true });
const start = performance.now();
const ensureNotTimedOut = () => {
if (timeoutMs <= 0) return;
if (performance.now() - start > timeoutMs) {
const err = new Error('revealPath timeout');
err.name = 'TimeoutError';
throw err;
}
};
const ensureExpanded = (key) => {
if (!this.manageState) return;
const set = new Set(this._expandedPathSet);
if (!set.has(key)) {
set.add(key);
this._expandedPathSet = set;
this.expandedPaths = Array.from(set);
this._rebuildManagedTree();
}
};
const ensureSelected = (key) => {
if (!this.manageState) return;
const set = new Set();
set.add(key);
this._selectedPathSet = set;
this.selectedPaths = Array.from(set);
this._rebuildManagedTree();
};
let currentKey = '';
for (let i = 0; i < segments.length; i++) {
ensureNotTimedOut();
if (controller.signal.aborted) break;
currentKey = currentKey ? `${currentKey}/${segments[i]}` : segments[i];
const node = this._findNodeByKey(currentKey);
// If the node isn't present yet, we can't proceed.
if (!node) {
externalSignal?.removeEventListener?.('abort', abortIfExternal);
return { found: false, key: currentKey, node: undefined };
}
// Expand all ancestors; load children for everything except the leaf.
const isLeaf = i === segments.length - 1;
ensureExpanded(currentKey);
if (!isLeaf && typeof this.loadChildren === 'function') {
// Trigger a load for this expanded node. This is idempotent/cancelable.
await this._loadChildrenForExpanded(currentKey, source, null, node, currentKey.split('/'));
if (controller.signal.aborted) break;
// After loading, the next segment might still be missing (e.g. server returned no such dir).
const nextKey = `${currentKey}/${segments[i + 1]}`;
if (!this._findNodeByKey(nextKey)) {
externalSignal?.removeEventListener?.('abort', abortIfExternal);
return { found: false, key: nextKey, node: undefined };
}
}
}
if (controller.signal.aborted) {
externalSignal?.removeEventListener?.('abort', abortIfExternal);
return { found: false, key: currentKey, node: this._findNodeByKey(currentKey) };
}
// Optionally expand+load the leaf itself ("reveal into") so its children become visible.
if (open && typeof this.loadChildren === 'function') {
const leafKey = currentKey;
const leafNode = this._findNodeByKey(leafKey);
if (leafNode && this._isExpandableNode(leafNode)) {
ensureExpanded(leafKey);
await this._loadChildrenForExpanded(leafKey, source, null, leafNode, leafKey.split('/'));
}
}
const finalNode = this._findNodeByKey(currentKey);
if (finalNode && select) {
ensureSelected(currentKey);
}
if (finalNode && scroll) {
await this.scrollToKey(currentKey);
}
externalSignal?.removeEventListener?.('abort', abortIfExternal);
return { found: Boolean(finalNode), key: currentKey, node: finalNode };
} }
_flattenNodes() { _flattenNodes() {
@@ -378,14 +559,33 @@ export class TpTreeNav extends Position(LitElement) {
if (!node) return false; if (!node) return false;
if (Array.isArray(node.children) && node.children.length > 0) return true; if (Array.isArray(node.children) && node.children.length > 0) return true;
if (typeof this.loadChildren === 'function') { if (typeof this.loadChildren === 'function') {
// If consumer provides an `isDir` hint, don't show chevrons for non-dirs. if (typeof node.showAsExpandable === 'boolean') return node.showAsExpandable;
if (typeof node.isDir === 'boolean') return node.isDir;
return true; return true;
} }
return false; return false;
} }
_onRowDoubleClick(item, originalEvent) {
this._suppressNextClick = true;
queueMicrotask(() => {
this._suppressNextClick = false;
});
this.dispatchEvent(new CustomEvent('node-dblclick', {
detail: { node: item.node, path: item.path, originalEvent },
bubbles: true,
composed: true,
cancelable: true,
}));
}
_onRowClick(item, originalEvent) { _onRowClick(item, originalEvent) {
if (this._suppressNextClick) {
originalEvent?.stopPropagation?.();
originalEvent?.preventDefault?.();
return;
}
const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2; const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2;
if (this.manageState) { if (this.manageState) {
@@ -405,7 +605,7 @@ export class TpTreeNav extends Position(LitElement) {
this._selectedPathSet = set; this._selectedPathSet = set;
this.selectedPaths = Array.from(set); this.selectedPaths = Array.from(set);
this._rebuildManagedTree(); this._applySelectionStates();
this.dispatchEvent(new CustomEvent('node-selected', { this.dispatchEvent(new CustomEvent('node-selected', {
detail: { node: item.node, path: item.path, originalEvent }, detail: { node: item.node, path: item.path, originalEvent },
@@ -422,12 +622,33 @@ export class TpTreeNav extends Position(LitElement) {
this._toggleExpand(item, 'single-click', originalEvent); this._toggleExpand(item, 'single-click', originalEvent);
} }
this.dispatchEvent(new CustomEvent('node-click', { if (!isDouble) {
detail: { node: item.node, path: item.path, originalEvent }, this.dispatchEvent(new CustomEvent('node-click', {
bubbles: true, detail: { node: item.node, path: item.path, originalEvent },
composed: true, bubbles: true,
cancelable: true, composed: true,
})); cancelable: true,
}));
}
}
_applySelectionStates() {
const selected = new Set(this._selectedPathSet);
const selectionState = this.selectionState;
const { nodes } = this._mapTree(this.roots, (node, path) => {
const key = Array.isArray(path) ? path.join('/') : '';
const states = Array.isArray(node?.states) ? node.states : [];
const without = states.filter((s) => s !== selectionState);
if (selected.has(key)) {
if (!without.includes(selectionState)) without.push(selectionState);
return { ...node, states: without };
}
if (without.length !== states.length) {
return { ...node, states: without };
}
return node;
});
this.roots = nodes;
} }
_onChevronClick(item, originalEvent) { _onChevronClick(item, originalEvent) {