diff --git a/tp-tree-nav.js b/tp-tree-nav.js index 21b6414..4b0d9ef 100644 --- a/tp-tree-nav.js +++ b/tp-tree-nav.js @@ -157,7 +157,7 @@ export class TpTreeNav extends Position(LitElement) { items, scroller: this, renderItem: (item) => this._renderItem(item), - keyFunction: (item) => item.key, + keyFunction: (item) => item?.key ?? '', })} ${this._renderContextMenu()} @@ -165,6 +165,7 @@ export class TpTreeNav extends Position(LitElement) { } _renderItem(item) { + if (!item) return null; const { node, depth, hasChildren, expanded } = item; const icon = this._resolveIcon(node?.icon); @@ -187,6 +188,7 @@ export class TpTreeNav extends Position(LitElement) {
this._onRowClick(item, e)} + @dblclick=${(e) => this._onRowDoubleClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} @dragstart=${(e) => this._onDragStart(item, e)} @dragend=${(e) => this._onDragEnd(item, e)} @@ -268,6 +270,10 @@ export class TpTreeNav extends Position(LitElement) { allowDragAndDrop: { type: Boolean }, canDrop: { 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.canDrop = null; this.loadChildren = null; + this.initialPath = ''; + this.initialSelect = true; + this.initialScroll = true; + this.initialOpen = false; + this._initialRevealDoneFor = ''; + this._revealController = null; this._contextMenu = null; this._outsideHandler = null; this._keyHandler = null; @@ -332,14 +344,183 @@ export class TpTreeNav extends Position(LitElement) { updated(changed) { super.updated?.(changed); + const managed = this.manageState; const itemsChanged = managed && changed.has('items'); const expandChanged = managed && changed.has('expandedPaths'); const selectChanged = managed && changed.has('selectedPaths'); - if (managed && (itemsChanged || expandChanged || selectChanged)) { + if (managed && (itemsChanged || expandChanged)) { 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() { @@ -378,14 +559,33 @@ export class TpTreeNav extends Position(LitElement) { if (!node) return false; if (Array.isArray(node.children) && node.children.length > 0) return true; if (typeof this.loadChildren === 'function') { - // If consumer provides an `isDir` hint, don't show chevrons for non-dirs. - if (typeof node.isDir === 'boolean') return node.isDir; + if (typeof node.showAsExpandable === 'boolean') return node.showAsExpandable; return true; } 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) { + if (this._suppressNextClick) { + originalEvent?.stopPropagation?.(); + originalEvent?.preventDefault?.(); + return; + } + const isDouble = this.expandOnDoubleClick && !this.expandOnSingleClick && originalEvent?.detail === 2; if (this.manageState) { @@ -405,7 +605,7 @@ export class TpTreeNav extends Position(LitElement) { this._selectedPathSet = set; this.selectedPaths = Array.from(set); - this._rebuildManagedTree(); + this._applySelectionStates(); this.dispatchEvent(new CustomEvent('node-selected', { 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.dispatchEvent(new CustomEvent('node-click', { - detail: { node: item.node, path: item.path, originalEvent }, - bubbles: true, - composed: true, - cancelable: true, - })); + if (!isDouble) { + this.dispatchEvent(new CustomEvent('node-click', { + detail: { node: item.node, path: item.path, originalEvent }, + bubbles: 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) {