1388 lines
42 KiB
JavaScript
1388 lines
42 KiB
JavaScript
/**
|
|
@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';
|
|
|
|
export class TpTreeNav extends Position(LitElement) {
|
|
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`
|
|
<div class="tree" part="tree">
|
|
${virtualize({
|
|
items,
|
|
scroller: this,
|
|
renderItem: (item) => this._renderItem(item),
|
|
keyFunction: (item) => item?.key ?? '',
|
|
})}
|
|
</div>
|
|
${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`
|
|
<div class="row" part="row" style="--depth: ${depth}"
|
|
draggable="${this.allowDragAndDrop ? 'true' : 'false'}"
|
|
@click=${(e) => 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)}
|
|
>
|
|
<div class="indent"></div>
|
|
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
|
|
${isLoading
|
|
? html`<tp-spinner class="icon" part="spinner"></tp-spinner>`
|
|
: html`<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>`}
|
|
</div>
|
|
${icon ? html`<tp-icon class="icon" part="icon" .icon=${icon}></tp-icon>` : html`<span class="icon" part="icon" aria-hidden="true"></span>`}
|
|
<div class="label" part="label">${node?.label ?? ''}</div>
|
|
${this.showActions && Array.isArray(node?.actions) && node.actions.length
|
|
? html`
|
|
<div class="actions" part="actions">
|
|
${node.actions.map((action) => html`
|
|
<tp-icon
|
|
class="action-icon"
|
|
part="action"
|
|
.icon=${this._resolveIcon(action.icon)}
|
|
.tooltip=${action.tooltip}
|
|
@click=${(e) => this._onInlineAction(item, action, e)}
|
|
></tp-icon>
|
|
`)}
|
|
</div>
|
|
`
|
|
: null}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderContextMenu() {
|
|
if (!this._contextMenu) return null;
|
|
const { item, actions } = this._contextMenu;
|
|
|
|
return html`
|
|
<div class="context-menu-overlay">
|
|
<tp-popup-menu class="context-menu" part="context-menu">
|
|
${actions.map((action) => html`
|
|
<tp-popup-menu-item .icon=${action.icon} part="context-menu-item" @click=${(e) => this._onMenuAction(action, item, e)}>
|
|
${action?.label ?? null}
|
|
</tp-popup-menu-item>
|
|
`)}
|
|
</tp-popup-menu>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderEmpty() {
|
|
if (typeof this.renderEmpty === 'function') {
|
|
return this.renderEmpty();
|
|
}
|
|
return html`<div class="empty-state" part="empty">${this.emptyMessage}</div>`;
|
|
}
|
|
|
|
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`<path fill="var(--tp-icon-color)" d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />`;
|
|
}
|
|
|
|
static get folder() {
|
|
return svg`<path fill="var(--tp-icon-color)" d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z" />`;
|
|
}
|
|
|
|
static get file() {
|
|
return svg`<path fill="var(--tp-icon-color)" d="M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z" />`;
|
|
}
|
|
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_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() {
|
|
const results = [];
|
|
const roots = Array.isArray(this.roots) ? this.roots : [];
|
|
|
|
const walk = (nodes, depth, path) => {
|
|
if (!Array.isArray(nodes)) return;
|
|
nodes.forEach((node, index) => {
|
|
const slug = node?.slug ?? `${index}`;
|
|
const nextPath = [...path, slug];
|
|
const states = Array.isArray(node?.states) ? node.states : [];
|
|
const hasChildren = this._isExpandableNode(node);
|
|
const expanded = states.includes('expanded');
|
|
|
|
results.push({
|
|
node,
|
|
depth,
|
|
path: nextPath,
|
|
hasChildren,
|
|
expanded,
|
|
key: nextPath.join('/'),
|
|
});
|
|
|
|
if (hasChildren && expanded && Array.isArray(node?.children) && node.children.length > 0) {
|
|
walk(node.children, depth + 1, nextPath);
|
|
}
|
|
});
|
|
};
|
|
|
|
walk(roots, 0, []);
|
|
return results;
|
|
}
|
|
|
|
_isExpandableNode(node) {
|
|
if (!node) return false;
|
|
if (Array.isArray(node.children) && node.children.length > 0) return true;
|
|
if (typeof this.loadChildren === 'function') {
|
|
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) {
|
|
const pathStr = item.path.join('/');
|
|
const set = new Set(this._selectedPathSet);
|
|
|
|
if (this.multiSelect) {
|
|
if (set.has(pathStr)) {
|
|
set.delete(pathStr);
|
|
} else {
|
|
set.add(pathStr);
|
|
}
|
|
} else {
|
|
set.clear();
|
|
set.add(pathStr);
|
|
}
|
|
|
|
this._selectedPathSet = set;
|
|
this.selectedPaths = Array.from(set);
|
|
this._applySelectionStates();
|
|
|
|
this.dispatchEvent(new CustomEvent('node-selected', {
|
|
detail: { node: item.node, path: item.path, originalEvent },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
|
|
if (isDouble) {
|
|
this._toggleExpand(item, 'double-click', originalEvent);
|
|
}
|
|
|
|
if (this.expandOnSingleClick) {
|
|
this._toggleExpand(item, 'single-click', originalEvent);
|
|
}
|
|
|
|
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) {
|
|
originalEvent.stopPropagation();
|
|
this._toggleExpand(item, 'chevron', originalEvent);
|
|
}
|
|
|
|
_toggleExpand(item, source, originalEvent) {
|
|
const shouldLoad = typeof this.loadChildren === 'function';
|
|
|
|
if (this.manageState) {
|
|
const pathStr = item.path.join('/');
|
|
const set = new Set(this._expandedPathSet);
|
|
const wasExpanded = set.has(pathStr);
|
|
|
|
if (wasExpanded) {
|
|
set.delete(pathStr);
|
|
this._expandedPathSet = set;
|
|
this.expandedPaths = Array.from(set);
|
|
this._rebuildManagedTree();
|
|
this._dispatchAction('toggle', item, source, originalEvent);
|
|
return;
|
|
}
|
|
|
|
// If we're about to load children, set loading state first so the immediate rebuild
|
|
// renders a spinner (and the row stays expandable) rather than flashing an "empty" state.
|
|
if (shouldLoad) {
|
|
this._updateNodeStatesByKey(item.key, (n) => {
|
|
const states = Array.isArray(n.states) ? n.states : [];
|
|
const next = states.filter((s) => s !== 'loaded');
|
|
if (!next.includes('loading')) next.push('loading');
|
|
return { ...n, states: next };
|
|
});
|
|
}
|
|
|
|
set.add(pathStr);
|
|
this._expandedPathSet = set;
|
|
this.expandedPaths = Array.from(set);
|
|
this._rebuildManagedTree();
|
|
this._dispatchAction('toggle', item, source, originalEvent);
|
|
|
|
if (shouldLoad) {
|
|
// Load immediately; don't rely on a microtask where external code may reset `roots`.
|
|
// Prefer the node from the item we just interacted with.
|
|
this._loadChildrenForExpanded(item.key, source, originalEvent, item.node, item.path);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this._dispatchAction('toggle', item, source, originalEvent);
|
|
}
|
|
|
|
_findNodeByKey(key) {
|
|
let found = null;
|
|
const walk = (nodes, path) => {
|
|
if (!Array.isArray(nodes)) return;
|
|
nodes.forEach((node, index) => {
|
|
if (found) return;
|
|
const slug = node?.slug ?? `${index}`;
|
|
const nextPath = [...path, slug];
|
|
const nodeKey = nextPath.join('/');
|
|
if (nodeKey === key) {
|
|
found = node;
|
|
return;
|
|
}
|
|
if (Array.isArray(node?.children) && node.children.length > 0) {
|
|
walk(node.children, nextPath);
|
|
}
|
|
});
|
|
};
|
|
walk(this.roots, []);
|
|
return found;
|
|
}
|
|
|
|
getRootKey() {
|
|
const roots = Array.isArray(this.roots) ? this.roots : [];
|
|
if (!roots.length) return '';
|
|
const first = roots[0];
|
|
const slug = first?.slug ?? '0';
|
|
return String(slug);
|
|
}
|
|
|
|
_updateNodeStatesByKey(key, updater) {
|
|
const map = (nodes, path = []) => {
|
|
if (!Array.isArray(nodes)) return nodes;
|
|
return nodes.map((node, index) => {
|
|
const slug = node?.slug ?? `${index}`;
|
|
const nextPath = [...path, slug];
|
|
const nodeKey = nextPath.join('/');
|
|
|
|
let nextNode = node;
|
|
if (nodeKey === key) {
|
|
nextNode = updater(node) ?? node;
|
|
}
|
|
|
|
const children = Array.isArray(nextNode?.children)
|
|
? map(nextNode.children, nextPath)
|
|
: nextNode?.children;
|
|
|
|
if (children !== nextNode?.children) {
|
|
return { ...nextNode, children };
|
|
}
|
|
|
|
return nextNode;
|
|
});
|
|
};
|
|
|
|
this.roots = map(this.roots);
|
|
}
|
|
|
|
async _loadChildrenForExpanded(key, source, originalEvent, nodeHint = null, pathHint = null) {
|
|
if (typeof this.loadChildren !== 'function') return;
|
|
|
|
// Prefer the node passed from the click handler; it is the most stable reference.
|
|
// Fall back to searching the current tree by key.
|
|
const node = nodeHint ?? this._findNodeByKey(key);
|
|
if (!node) {
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const seq = ++this._childLoadSeq;
|
|
const previous = this._childLoadControllers.get(key);
|
|
if (previous) {
|
|
try { previous.abort(); } catch (e) { }
|
|
}
|
|
this._childLoadControllers.set(key, controller);
|
|
|
|
this._updateNodeStatesByKey(key, (n) => {
|
|
const states = Array.isArray(n.states) ? n.states : [];
|
|
const next = states.filter((s) => s !== 'loaded');
|
|
if (!next.includes('loading')) next.push('loading');
|
|
return { ...n, states: next };
|
|
});
|
|
|
|
try {
|
|
const ctx = {
|
|
node,
|
|
key,
|
|
path: Array.isArray(pathHint) ? pathHint : key.split('/').filter(Boolean),
|
|
signal: controller.signal,
|
|
source,
|
|
originalEvent,
|
|
};
|
|
|
|
// loadChildren is responsible for updating the tree (e.g. setting `node.children`).
|
|
await Promise.resolve(this.loadChildren(ctx));
|
|
if (controller.signal.aborted) return;
|
|
if (this._childLoadControllers.get(key) !== controller) return;
|
|
if (seq !== this._childLoadSeq) {
|
|
// newer load started elsewhere; ignore
|
|
return;
|
|
}
|
|
|
|
this._updateNodeStatesByKey(key, (n) => {
|
|
const states = Array.isArray(n.states) ? n.states : [];
|
|
const next = states.filter((s) => s !== 'loading');
|
|
if (!next.includes('loaded')) next.push('loaded');
|
|
return { ...n, states: next };
|
|
});
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return;
|
|
this._updateNodeStatesByKey(key, (n) => {
|
|
const states = Array.isArray(n.states) ? n.states : [];
|
|
const next = states.filter((s) => s !== 'loading');
|
|
return { ...n, states: next };
|
|
});
|
|
this.dispatchEvent(new CustomEvent('node-children-error', {
|
|
detail: { key, node, error: err, source, originalEvent },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
} finally {
|
|
if (this._childLoadControllers.get(key) === controller) {
|
|
this._childLoadControllers.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
async _onContextMenu(item, originalEvent) {
|
|
originalEvent.preventDefault();
|
|
|
|
if (this.selectOnRightClick && this.manageState) {
|
|
const pathStr = item.path.join('/');
|
|
const set = new Set(this._selectedPathSet);
|
|
let changed = false;
|
|
|
|
if (this.multiSelect) {
|
|
if (!set.has(pathStr)) {
|
|
set.add(pathStr);
|
|
changed = true;
|
|
}
|
|
} else {
|
|
if (!set.has(pathStr) || set.size !== 1) {
|
|
set.clear();
|
|
set.add(pathStr);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
this._selectedPathSet = set;
|
|
this.selectedPaths = Array.from(set);
|
|
this._rebuildManagedTree();
|
|
|
|
this.dispatchEvent(new CustomEvent('node-selected', {
|
|
detail: { node: item.node, path: item.path, originalEvent },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
}
|
|
|
|
const contextEvent = new CustomEvent('node-context', {
|
|
detail: { item, originalEvent },
|
|
bubbles: true,
|
|
composed: true,
|
|
cancelable: true,
|
|
});
|
|
|
|
this.dispatchEvent(contextEvent);
|
|
if (contextEvent.defaultPrevented) return;
|
|
|
|
const baseActions = this._mergeActions(
|
|
this.defaultActions,
|
|
this._normalizeActions(item.node?.actions)
|
|
);
|
|
|
|
const maybeModified = this.beforeContextMenu
|
|
? this.beforeContextMenu(item, baseActions)
|
|
: baseActions;
|
|
|
|
const actions = await Promise.resolve(maybeModified);
|
|
if (actions === false) return;
|
|
|
|
const x = originalEvent.clientX;
|
|
const y = originalEvent.clientY;
|
|
|
|
this._contextMenu = { item, actions: Array.isArray(actions) ? actions : baseActions };
|
|
this.requestUpdate();
|
|
await this.updateComplete;
|
|
|
|
const menu = this.shadowRoot.querySelector('.context-menu');
|
|
if (menu) {
|
|
const anchor = {
|
|
getBoundingClientRect: () => ({
|
|
top: y, bottom: y, left: x, right: x, width: 0, height: 0
|
|
})
|
|
};
|
|
this._posFixed(anchor, menu, { valign: 'bottom', halign: 'left' });
|
|
}
|
|
|
|
this._attachOutsideHandlers();
|
|
}
|
|
|
|
_dispatchAction(action, item, source, originalEvent) {
|
|
const ev = new CustomEvent('node-action', {
|
|
detail: { action, item, source, originalEvent },
|
|
bubbles: true,
|
|
composed: true,
|
|
cancelable: true,
|
|
});
|
|
this.dispatchEvent(ev);
|
|
}
|
|
|
|
_onMenuAction(action, item, originalEvent) {
|
|
originalEvent.stopPropagation();
|
|
this._dispatchAction(action?.action, item, 'context-menu', originalEvent);
|
|
this._closeContextMenu();
|
|
}
|
|
|
|
_onInlineAction(item, action, originalEvent) {
|
|
originalEvent.stopPropagation();
|
|
this._dispatchAction(action?.action, item, 'inline-action', originalEvent);
|
|
}
|
|
|
|
_onDragStart(item, e) {
|
|
if (!this.allowDragAndDrop) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
this._dragSource = item;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
const row = e.currentTarget;
|
|
const rect = row.getBoundingClientRect();
|
|
const clone = document.createElement('div');
|
|
clone.classList.add('drag-clone');
|
|
|
|
clone.textContent = row.textContent;
|
|
this.shadowRoot.appendChild(clone);
|
|
|
|
const offsetX = e.clientX - rect.left;
|
|
const offsetY = e.clientY - rect.top;
|
|
|
|
e.dataTransfer.setDragImage(clone, 0, 0);
|
|
|
|
setTimeout(() => {
|
|
clone.remove();
|
|
}, 0);
|
|
}
|
|
|
|
_onDragEnd(item, e) {
|
|
this._dragSource = null;
|
|
}
|
|
|
|
_onDragOver(item, e) {
|
|
if (!this.allowDragAndDrop || !this._dragSource) return;
|
|
|
|
e.preventDefault(); // Allow drop
|
|
|
|
const row = e.currentTarget;
|
|
const rect = row.getBoundingClientRect();
|
|
const y = e.clientY - rect.top;
|
|
const h = rect.height;
|
|
|
|
let position = 'inside';
|
|
if (y < h * 0.25) position = 'before';
|
|
else if (y > h * 0.75) position = 'after';
|
|
|
|
// Don't allow dropping on itself
|
|
if (this._dragSource.key === item.key) {
|
|
this._clearDragClasses(row);
|
|
e.dataTransfer.dropEffect = 'none';
|
|
return;
|
|
}
|
|
|
|
// Avoid redundant drop target (After Expanded Parent vs Before First Child)
|
|
if (position === 'after' && item.expanded && item.hasChildren) {
|
|
this._clearDragClasses(row);
|
|
e.dataTransfer.dropEffect = 'none';
|
|
return;
|
|
}
|
|
|
|
// Resolve logical target (handle expanded parent case and unindent)
|
|
const { target: logicalTarget, position: logicalPosition, depth: dragDepth } = this._resolveDropTarget(item, position, e);
|
|
|
|
// Check for no-op
|
|
if (this._isNoOp(this._dragSource, logicalTarget, logicalPosition)) {
|
|
this._clearDragClasses(row);
|
|
e.dataTransfer.dropEffect = 'none';
|
|
return;
|
|
}
|
|
|
|
// Check user validation
|
|
if (this.canDrop && !this.canDrop(this._dragSource, logicalTarget, logicalPosition)) {
|
|
this._clearDragClasses(row);
|
|
e.dataTransfer.dropEffect = 'none';
|
|
return;
|
|
}
|
|
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
this._clearDragClasses(row);
|
|
row.classList.add(`drag-over-${position}`);
|
|
if (dragDepth !== undefined) {
|
|
row.style.setProperty('--drag-depth', dragDepth);
|
|
}
|
|
}
|
|
|
|
_onDragLeave(item, e) {
|
|
const row = e.currentTarget;
|
|
if (row.contains(e.relatedTarget)) return;
|
|
this._clearDragClasses(row);
|
|
}
|
|
|
|
_onDrop(item, e) {
|
|
if (!this.allowDragAndDrop || !this._dragSource) return;
|
|
e.preventDefault();
|
|
const row = e.currentTarget;
|
|
this._clearDragClasses(row);
|
|
|
|
const rect = row.getBoundingClientRect();
|
|
const y = e.clientY - rect.top;
|
|
const h = rect.height;
|
|
|
|
let position = 'inside';
|
|
if (y < h * 0.25) position = 'before';
|
|
else if (y > h * 0.75) position = 'after';
|
|
|
|
if (this._dragSource.key === item.key) return;
|
|
|
|
// Avoid redundant drop target (After Expanded Parent vs Before First Child)
|
|
if (position === 'after' && item.expanded && item.hasChildren) {
|
|
return;
|
|
}
|
|
|
|
// Resolve logical target (handle expanded parent case and unindent)
|
|
const { target: logicalTarget, position: logicalPosition } = this._resolveDropTarget(item, position, e);
|
|
|
|
if (this._isNoOp(this._dragSource, logicalTarget, logicalPosition)) {
|
|
return;
|
|
}
|
|
|
|
if (this.canDrop && !this.canDrop(this._dragSource, logicalTarget, logicalPosition)) {
|
|
return;
|
|
}
|
|
|
|
this.dispatchEvent(new CustomEvent('node-drop', {
|
|
detail: {
|
|
source: this._dragSource,
|
|
target: logicalTarget,
|
|
position: logicalPosition,
|
|
originalEvent: e
|
|
},
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
|
|
this._dragSource = null;
|
|
}
|
|
|
|
_isNoOp(source, target, position) {
|
|
if (!source || !target) return true;
|
|
if (source.key === target.key) return true;
|
|
|
|
const srcPathStr = source.path.slice(0, -1).join('/');
|
|
const tgtPathStr = target.path.slice(0, -1).join('/');
|
|
|
|
if (srcPathStr !== tgtPathStr) return false;
|
|
|
|
const siblings = this._getSiblings(source.path);
|
|
if (!siblings) return false;
|
|
|
|
const srcIdx = siblings.indexOf(source.node);
|
|
const tgtIdx = siblings.indexOf(target.node);
|
|
|
|
if (srcIdx === -1 || tgtIdx === -1) return false;
|
|
|
|
if (position === 'before') {
|
|
if (srcIdx === tgtIdx - 1) return true;
|
|
}
|
|
if (position === 'after') {
|
|
if (srcIdx === tgtIdx + 1) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
_getSiblings(path) {
|
|
if (path.length === 1) return this.roots;
|
|
|
|
let nodes = this.roots;
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
const slug = path[i];
|
|
const node = nodes.find((n, idx) => (n.slug ?? `${idx}`) === slug);
|
|
if (!node) return null;
|
|
nodes = node.children || [];
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
_resolveDropTarget(item, position, e) {
|
|
if (!this._flatItems) return { target: item, position };
|
|
|
|
const index = this._flatItems.findIndex(i => i.key === item.key);
|
|
if (index === -1) return { target: item, position };
|
|
|
|
const currentItem = this._flatItems[index];
|
|
|
|
// Handle Unindent on "After"
|
|
// Only if we are at the bottom of a block (next item has lower depth or is end of list)
|
|
if (position === 'after' && e) {
|
|
const nextItem = this._flatItems[index + 1];
|
|
const currentDepth = currentItem.depth;
|
|
const nextDepth = nextItem ? nextItem.depth : -1; // -1 allows unindenting to root (depth 0)
|
|
|
|
if (nextDepth < currentDepth) {
|
|
// We are at a step down. Calculate target depth from mouse X.
|
|
const row = e.currentTarget;
|
|
const style = getComputedStyle(row);
|
|
const indentVal = style.getPropertyValue('--tp-tree-indent').trim();
|
|
const indent = indentVal ? parseInt(indentVal, 10) : 16;
|
|
const padding = parseFloat(style.paddingLeft) || 8;
|
|
|
|
const rowRect = row.getBoundingClientRect();
|
|
const mouseX = e.clientX - rowRect.left;
|
|
|
|
// Calculate desired depth based on mouse position
|
|
let targetDepth = Math.floor((mouseX - padding) / indent);
|
|
|
|
// Clamp target depth
|
|
// Can't be deeper than current
|
|
// Can't be shallower than next item's depth (visual constraint)
|
|
// Actually, we can target any ancestor that ends here.
|
|
// The ancestors end at depths: currentDepth, currentDepth-1, ... nextDepth.
|
|
|
|
// Example:
|
|
// A (0)
|
|
// B (1)
|
|
// C (2) <- Item. Next is D (0).
|
|
// We can drop after C (depth 2), after B (depth 1), after A (depth 0).
|
|
// So valid depths are [nextDepth ... currentDepth].
|
|
// Note: nextDepth is the depth of the *next sibling* of the ancestor we target.
|
|
// If next is D(0), we can target A(0).
|
|
|
|
// Ensure targetDepth is within valid range
|
|
if (targetDepth < nextDepth) targetDepth = nextDepth;
|
|
if (targetDepth > currentDepth) targetDepth = currentDepth;
|
|
|
|
if (targetDepth < currentDepth) {
|
|
// Find the ancestor at targetDepth
|
|
// Walk backwards from current item? No, ancestors are before.
|
|
// But we want the ancestor that *contains* current item.
|
|
// We can walk backwards from index.
|
|
let ancestor = currentItem;
|
|
for (let i = index; i >= 0; i--) {
|
|
if (this._flatItems[i].depth === targetDepth) {
|
|
ancestor = this._flatItems[i];
|
|
break;
|
|
}
|
|
}
|
|
return { target: ancestor, position: 'after', depth: targetDepth };
|
|
}
|
|
}
|
|
}
|
|
|
|
return { target: currentItem, position };
|
|
}
|
|
|
|
_clearDragClasses(row) {
|
|
row.classList.remove('drag-over-inside', 'drag-over-before', 'drag-over-after');
|
|
row.style.removeProperty('--drag-depth');
|
|
}
|
|
|
|
_attachOutsideHandlers() {
|
|
if (this._outsideHandler) return;
|
|
this._outsideHandler = (e) => {
|
|
const menu = this.shadowRoot.querySelector('.context-menu');
|
|
if (menu && e.composedPath().includes(menu)) return;
|
|
this._closeContextMenu();
|
|
};
|
|
this._keyHandler = (e) => {
|
|
if (e.key === 'Escape') {
|
|
this._closeContextMenu();
|
|
}
|
|
};
|
|
window.addEventListener('pointerdown', this._outsideHandler, true);
|
|
window.addEventListener('keydown', this._keyHandler, true);
|
|
}
|
|
|
|
_removeOutsideHandlers() {
|
|
if (this._outsideHandler) {
|
|
window.removeEventListener('pointerdown', this._outsideHandler, true);
|
|
this._outsideHandler = null;
|
|
}
|
|
if (this._keyHandler) {
|
|
window.removeEventListener('keydown', this._keyHandler, true);
|
|
this._keyHandler = null;
|
|
}
|
|
}
|
|
|
|
_closeContextMenu() {
|
|
this._contextMenu = null;
|
|
this.requestUpdate();
|
|
this._removeOutsideHandlers();
|
|
}
|
|
|
|
_normalizeActions(actions) {
|
|
if (!actions) return [];
|
|
if (Array.isArray(actions)) return actions;
|
|
if (typeof actions === 'object') return Object.values(actions);
|
|
return [];
|
|
}
|
|
|
|
_mergeActions(defaults = [], nodeActions = []) {
|
|
const map = new Map();
|
|
const add = (list) => {
|
|
list?.forEach((a) => {
|
|
if (a && a.action) {
|
|
map.set(a.action, a);
|
|
}
|
|
});
|
|
};
|
|
add(defaults);
|
|
add(nodeActions);
|
|
return Array.from(map.values());
|
|
}
|
|
|
|
_resolveIcon(icon) {
|
|
if (!icon) return null;
|
|
if (typeof icon === 'string') {
|
|
return TpTreeNav.defaultIcons[icon] ?? null;
|
|
}
|
|
return icon;
|
|
}
|
|
|
|
_rebuildManagedTree() {
|
|
this._expandedPathSet = new Set(this.expandedPaths || this._expandedPathSet);
|
|
this._selectedPathSet = new Set(this.selectedPaths || this._selectedPathSet);
|
|
|
|
const { nodes, allPaths, expandedPaths, selectedPaths } = TpTreeNav.buildTree(
|
|
Array.isArray(this.items) ? this.items : [],
|
|
{
|
|
expandedPaths: this._expandedPathSet,
|
|
selectedPaths: this._selectedPathSet,
|
|
selectionState: this.selectionState,
|
|
applyStates: this.applyStates,
|
|
knownPaths: this._knownPaths,
|
|
autoExpandNew: this.autoExpandNew,
|
|
}
|
|
);
|
|
|
|
this._knownPaths = allPaths;
|
|
this._expandedPathSet = new Set([...expandedPaths].filter((p) => allPaths.has(p)));
|
|
this._selectedPathSet = new Set([...selectedPaths].filter((p) => allPaths.has(p)));
|
|
this.roots = nodes;
|
|
}
|
|
|
|
getNodesWithState(state) {
|
|
const matches = [];
|
|
this._walkNodes(this.roots, [], (node, path) => {
|
|
if (Array.isArray(node?.states) && node.states.includes(state)) {
|
|
matches.push({ node, path });
|
|
}
|
|
});
|
|
return matches;
|
|
}
|
|
|
|
clearState(state) {
|
|
const { nodes } = this._mapTree(this.roots, (node) => {
|
|
const states = Array.isArray(node?.states)
|
|
? node.states.filter((s) => s !== state)
|
|
: [];
|
|
const changed = (node.states?.length ?? 0) !== states.length;
|
|
return changed ? { ...node, states } : node;
|
|
});
|
|
return nodes;
|
|
}
|
|
|
|
applyStateIf(state, predicate) {
|
|
if (typeof predicate !== 'function') return this.roots;
|
|
const { nodes } = this._mapTree(this.roots, (node, path) => {
|
|
const states = Array.isArray(node?.states) ? [...node.states] : [];
|
|
if (predicate(node, path) && !states.includes(state)) {
|
|
states.push(state);
|
|
return { ...node, states };
|
|
}
|
|
return node;
|
|
});
|
|
return nodes;
|
|
}
|
|
|
|
_walkNodes(nodes, path, visitor) {
|
|
if (!Array.isArray(nodes)) return;
|
|
nodes.forEach((node, index) => {
|
|
const slug = node?.slug ?? `${index}`;
|
|
const nextPath = [...path, slug];
|
|
visitor(node, nextPath);
|
|
if (Array.isArray(node?.children) && node.children.length) {
|
|
this._walkNodes(node.children, nextPath, visitor);
|
|
}
|
|
});
|
|
}
|
|
|
|
_mapTree(nodes, mapper, path = []) {
|
|
if (!Array.isArray(nodes)) return { nodes: [], changed: false };
|
|
let changed = false;
|
|
const mapped = nodes.map((node, index) => {
|
|
const slug = node?.slug ?? `${index}`;
|
|
const nextPath = [...path, slug];
|
|
const mappedNode = mapper(node, nextPath) ?? node;
|
|
|
|
const { nodes: childNodes, changed: childChanged } = this._mapTree(
|
|
mappedNode?.children,
|
|
mapper,
|
|
nextPath
|
|
);
|
|
|
|
const children = childChanged ? childNodes : mappedNode?.children;
|
|
const nodeChanged = mappedNode !== node || childChanged;
|
|
|
|
if (nodeChanged) {
|
|
changed = true;
|
|
return { ...mappedNode, children };
|
|
}
|
|
|
|
return mappedNode;
|
|
});
|
|
return { nodes: mapped, changed };
|
|
}
|
|
|
|
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);
|