/** @license Copyright (c) 2025 trading_peter This program is available under Apache License Version 2.0 */ import '@tp/tp-icon/tp-icon.js'; import '@tp/tp-spinner/tp-spinner.js'; import '@tp/tp-popup/tp-popup-menu.js'; import { LitElement, html, css, svg } from 'lit'; import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; import { Position } from '../helpers/position.js'; import { TreeUtilsMixin } from './mixins/tree-utils.js'; import { TreeFlattenMixin } from './mixins/tree-flatten.js'; import { TreeManagedStateMixin } from './mixins/tree-managed-state.js'; import { TreeLoadChildrenMixin } from './mixins/tree-load-children.js'; import { TreeRevealMixin } from './mixins/tree-reveal.js'; import { TreeContextMenuMixin } from './mixins/tree-context-menu.js'; import { TreeDnDMixin } from './mixins/tree-dnd.js'; import { TreeInteractionsMixin } from './mixins/tree-interactions.js'; const mixins = [ TreeInteractionsMixin, TreeDnDMixin, TreeContextMenuMixin, TreeRevealMixin, TreeLoadChildrenMixin, TreeManagedStateMixin, TreeFlattenMixin, TreeUtilsMixin, Position ]; /* @litElement */ const BaseElement = mixins.reduce((baseClass, mixin) => { return mixin(baseClass); }, LitElement); export class TpTreeNav extends BaseElement { static get styles() { return [ css` :host { display: block; position: relative; --tp-tree-indent: 16px; --tp-tree-row-height: 28px; } .tree { width: 100%; height: 100%; } .row { display: flex; align-items: center; height: var(--tp-tree-row-height); box-sizing: border-box; padding: 0 8px; cursor: default; user-select: none; gap: 5px; width: 100%; position: relative; } .row:hover { background: var(--tp-tree-hover-bg, rgba(0,0,0,0.04)); } .row:active { background: var(--tp-tree-active-bg, rgba(0,0,0,0.06)); } .indent { width: calc(var(--tp-tree-indent) * var(--depth)); flex: 0 0 auto; } .chevron-btn { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; cursor: pointer; transition: transform 120ms ease; } .chevron-btn[hidden] { visibility: hidden; } .chevron-btn.expanded { transform: rotate(90deg); } .icon { width: 18px; height: 18px; flex: 0 0 auto; } .label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .actions { display: flex; gap: 6px; flex: 0 0 auto; } .empty-state { padding: 16px; text-align: center; color: var(--tp-tree-empty-color, rgba(0,0,0,0.56)); } .context-menu-overlay { position: absolute; inset: 0; pointer-events: none; } tp-popup-menu.context-menu { min-width: 180px; box-shadow: 0 6px 18px rgba(0,0,0,0.18); pointer-events: auto; } .row.drag-over-inside { background: var(--tp-tree-drag-over-bg, rgba(0,0,0,0.1)); } .row.drag-over-before::after, .row.drag-over-after::after { content: ""; position: absolute; left: calc(var(--tp-tree-indent) * var(--drag-depth, var(--depth))); right: 0; height: 2px; background-color: var(--tp-tree-drag-line-color, #2196f3); pointer-events: none; } .row.drag-over-before::after { top: 0; } .row.drag-over-after::after { bottom: 0; } .drag-clone { opacity: 0.5; position: absolute; z-index: 1000; top: -1000px; left: -1000px; } ` ]; } render() { this._flatItems = this._flattenNodes(); const items = this._flatItems; if (!items.length) { return html`${this._renderEmpty()}`; } return html`
${virtualize({ items, scroller: this, renderItem: (item) => this._renderItem(item), keyFunction: (item) => item?.key ?? '', })}
${this._renderContextMenu()} `; } _renderItem(item) { if (!item) return null; const { node, depth, hasChildren, expanded } = item; const icon = this._resolveIcon(node?.icon); const states = Array.isArray(node?.states) ? node.states : []; const isLoading = states.includes('loading'); const custom = this.renderNode?.(item, { depth, states, path: item.path, hasChildren, expanded, }); if (custom) { return custom; } return html`
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)} @dragover=${(e) => this._onDragOver(item, e)} @dragleave=${(e) => this._onDragLeave(item, e)} @drop=${(e) => this._onDrop(item, e)} >
this._onChevronClick(item, e)} > ${isLoading ? html`` : html``}
${icon ? html`` : html``}
${node?.label ?? ''}
${this.showActions && Array.isArray(node?.actions) && node.actions.length ? html`
${node.actions.map((action) => html` this._onInlineAction(item, action, e)} > `)}
` : null}
`; } _renderContextMenu() { if (!this._contextMenu) return null; const { item, actions } = this._contextMenu; return html`
${actions.map((action) => html` this._onMenuAction(action, item, e)}> ${action?.label ?? null} `)}
`; } _renderEmpty() { if (typeof this.renderEmpty === 'function') { return this.renderEmpty(); } return html`
${this.emptyMessage}
`; } static get properties() { return { roots: { type: Array }, defaultActions: { type: Array }, renderNode: { type: Function }, beforeContextMenu: { type: Function }, renderEmpty: { type: Function }, items: { type: Array }, manageState: { type: Boolean }, multiSelect: { type: Boolean }, expandedPaths: { type: Array }, selectedPaths: { type: Array }, selectionState: { type: String }, autoExpandNew: { type: Boolean }, applyStates: { type: Function }, emptyMessage: { type: String }, showActions: { type: Boolean }, expandOnSingleClick: { type: Boolean }, expandOnDoubleClick: { type: Boolean }, selectOnRightClick: { type: Boolean }, allowDragAndDrop: { type: Boolean }, canDrop: { type: Function }, loadChildren: { type: Function }, initialPath: { type: String }, initialSelect: { type: Boolean }, initialScroll: { type: Boolean }, initialOpen: { type: Boolean }, }; } static get chevron() { return svg``; } static get folder() { return svg``; } static get file() { return svg``; } static get defaultIcons() { return { chevron: TpTreeNav.chevron, folder: TpTreeNav.folder, file: TpTreeNav.file, }; } constructor() { super(); this.roots = []; this.defaultActions = []; this.renderNode = null; this.beforeContextMenu = null; this.renderEmpty = null; this.items = null; this.manageState = false; this.multiSelect = false; this.expandedPaths = []; this.selectedPaths = []; this.selectionState = 'selected'; this.autoExpandNew = false; this.applyStates = null; this.emptyMessage = 'No items'; this.showActions = false; this.expandOnSingleClick = false; this.expandOnDoubleClick = false; this.selectOnRightClick = false; 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; this._dragSource = null; this._knownPaths = new Set(); this._expandedPathSet = new Set(); this._selectedPathSet = new Set(); this._childLoadControllers = new Map(); this._childLoadSeq = 0; } disconnectedCallback() { super.disconnectedCallback(); this._removeOutsideHandlers(); } 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)) { 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', }); } } } } static buildTree(items = [], options = {}) { const { expandedPaths = new Set(), selectedPaths = new Set(), selectionState = 'selected', applyStates, knownPaths, autoExpandNew = false, } = options; const exp = expandedPaths instanceof Set ? new Set(expandedPaths) : new Set(expandedPaths); const sel = selectedPaths instanceof Set ? new Set(selectedPaths) : new Set(selectedPaths); const allPaths = new Set(); const mapNode = (node, parentPath = '') => { const segment = node?.slug ?? ''; const fullPath = parentPath ? `${parentPath}/${segment}` : segment; allPaths.add(fullPath); if (autoExpandNew && knownPaths && !knownPaths.has(fullPath)) { exp.add(fullPath); } const states = []; if (exp.has(fullPath)) states.push('expanded'); if (sel.has(fullPath) && selectionState) states.push(selectionState); const extraStates = applyStates ? applyStates(node, fullPath.split('/'), states) : null; if (Array.isArray(extraStates)) { extraStates.forEach((s) => { if (s && !states.includes(s)) states.push(s); }); } const children = Array.isArray(node?.children) ? node.children.map((child) => mapNode(child, fullPath)) : node?.children; return { ...node, states, fullPath, children, source: node, }; }; const nodes = Array.isArray(items) ? items.map((n) => mapNode(n, '')) : []; return { nodes, allPaths, expandedPaths: exp, selectedPaths: sel }; } } window.customElements.define('tp-tree-nav', TpTreeNav);