483 lines
13 KiB
JavaScript
483 lines
13 KiB
JavaScript
/**
|
|
@license
|
|
Copyright (c) 2025 trading_peter
|
|
This program is available under Apache License Version 2.0
|
|
*/
|
|
|
|
import { LitElement, html, css, svg } from 'lit';
|
|
import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
|
|
|
|
export class TpTreeNav extends 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%;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.context-menu-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.context-menu {
|
|
position: absolute;
|
|
min-width: 180px;
|
|
background: var(--tp-tree-menu-bg, #fff);
|
|
color: inherit;
|
|
border: 1px solid var(--tp-tree-menu-border, rgba(0,0,0,0.1));
|
|
border-radius: 6px;
|
|
box-shadow: 0 6px 18px rgba(0,0,0,0.18);
|
|
padding: 4px 0;
|
|
pointer-events: auto;
|
|
z-index: 10;
|
|
}
|
|
|
|
.menu-item {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: none;
|
|
border: none;
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
box-sizing: border-box;
|
|
font: inherit;
|
|
}
|
|
|
|
.menu-item:hover {
|
|
background: var(--tp-tree-menu-hover-bg, rgba(0,0,0,0.06));
|
|
}
|
|
|
|
.menu-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
flex: 0 0 auto;
|
|
}
|
|
`
|
|
];
|
|
}
|
|
|
|
render() {
|
|
const items = this._flattenNodes();
|
|
|
|
return html`
|
|
<div class="tree">
|
|
${virtualize({
|
|
items,
|
|
renderItem: (item) => this._renderItem(item),
|
|
keyFunction: (item) => item.key,
|
|
})}
|
|
</div>
|
|
${this._renderContextMenu()}
|
|
`;
|
|
}
|
|
|
|
_renderItem(item) {
|
|
const { node, depth, hasChildren, expanded } = item;
|
|
const icon = this._resolveIcon(node?.icon);
|
|
|
|
const custom = this.renderNode?.(item, {
|
|
depth,
|
|
states: Array.isArray(node?.states) ? node.states : [],
|
|
path: item.path,
|
|
hasChildren,
|
|
});
|
|
|
|
if (custom) {
|
|
return custom;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class="row"
|
|
part="row"
|
|
style="--depth: ${depth}"
|
|
@click=${(e) => this._onRowClick(item, e)}
|
|
@contextmenu=${(e) => this._onContextMenu(item, e)}
|
|
>
|
|
<div class="indent"></div>
|
|
<div
|
|
class="chevron-btn ${expanded ? 'expanded' : ''}"
|
|
part="chevron"
|
|
?hidden=${!hasChildren}
|
|
@click=${(e) => this._onChevronClick(item, e)}
|
|
>
|
|
<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>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderContextMenu() {
|
|
if (!this._contextMenu) return null;
|
|
const { x, y, item, actions } = this._contextMenu;
|
|
|
|
return html`
|
|
<div class="context-menu-overlay">
|
|
<div class="context-menu" part="context-menu" style="left:${x}px; top:${y}px;">
|
|
${actions.map((action) => html`
|
|
<button
|
|
class="menu-item"
|
|
part="context-menu-item"
|
|
@click=${(e) => this._onMenuAction(action, item, e)}
|
|
>
|
|
${action?.icon ? html`<tp-icon class="menu-icon" part="context-menu-icon" .icon=${this._resolveIcon(action.icon)}></tp-icon>` : html`<span class="menu-icon" part="context-menu-icon" aria-hidden="true"></span>`}
|
|
<span>${action?.label ?? ''}</span>
|
|
</button>
|
|
`)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
static get properties() {
|
|
return {
|
|
roots: { type: Array },
|
|
defaultActions: { type: Array },
|
|
renderNode: { type: Function },
|
|
beforeContextMenu: { type: Function },
|
|
};
|
|
}
|
|
|
|
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._contextMenu = null;
|
|
this._outsideHandler = null;
|
|
this._keyHandler = null;
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this._removeOutsideHandlers();
|
|
}
|
|
|
|
_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 = Array.isArray(node?.children) && node.children.length > 0;
|
|
const expanded = states.includes('expanded');
|
|
|
|
results.push({
|
|
node,
|
|
depth,
|
|
path: nextPath,
|
|
hasChildren,
|
|
expanded,
|
|
key: nextPath.join('/'),
|
|
});
|
|
|
|
if (hasChildren && expanded) {
|
|
walk(node.children, depth + 1, nextPath);
|
|
}
|
|
});
|
|
};
|
|
|
|
walk(roots, 0, []);
|
|
return results;
|
|
}
|
|
|
|
_onRowClick(item, originalEvent) {
|
|
const ev = new CustomEvent('node-click', {
|
|
detail: { node: item.node, path: item.path, originalEvent },
|
|
bubbles: true,
|
|
composed: true,
|
|
cancelable: true,
|
|
});
|
|
this.dispatchEvent(ev);
|
|
}
|
|
|
|
_onChevronClick(item, originalEvent) {
|
|
originalEvent.stopPropagation();
|
|
this._dispatchAction('toggle', item, 'chevron', originalEvent);
|
|
}
|
|
|
|
async _onContextMenu(item, originalEvent) {
|
|
originalEvent.preventDefault();
|
|
|
|
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 rect = this.getBoundingClientRect();
|
|
const x = originalEvent.clientX - rect.left;
|
|
const y = originalEvent.clientY - rect.top;
|
|
|
|
this._contextMenu = { x, y, item, actions: Array.isArray(actions) ? actions : baseActions };
|
|
this.requestUpdate();
|
|
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();
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
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(
|
|
node?.children,
|
|
mapper,
|
|
nextPath
|
|
);
|
|
|
|
const children = childChanged ? childNodes : node?.children;
|
|
const nodeChanged = mappedNode !== node || childChanged;
|
|
|
|
if (nodeChanged) {
|
|
changed = true;
|
|
return { ...mappedNode, children };
|
|
}
|
|
|
|
return mappedNode;
|
|
});
|
|
return { nodes: mapped, changed };
|
|
}
|
|
}
|
|
|
|
window.customElements.define('tp-tree-nav', TpTreeNav);
|