Compare commits

...

2 Commits

Author SHA1 Message Date
pk
64aacf2ac8 Bump version 2025-12-28 23:06:47 +01:00
pk
e7b858a511 Restructure code 2025-12-28 23:06:37 +01:00
10 changed files with 929 additions and 952 deletions

116
mixins/tree-context-menu.js Normal file
View File

@@ -0,0 +1,116 @@
export const TreeContextMenuMixin = (superClass) => class TreeContextMenuMixin extends superClass {
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();
}
_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();
}
};

214
mixins/tree-dnd.js Normal file
View File

@@ -0,0 +1,214 @@
export const TreeDnDMixin = (superClass) => class TreeDnDMixin extends superClass {
_onDragStart(item, e) {
if (!this.allowDragAndDrop) {
e.preventDefault();
return;
}
this._dragSource = item;
e.dataTransfer.effectAllowed = 'move';
const row = e.currentTarget;
const clone = document.createElement('div');
clone.classList.add('drag-clone');
clone.textContent = row.textContent;
this.shadowRoot.appendChild(clone);
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();
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';
if (this._dragSource.key === item.key) {
this._clearDragClasses(row);
e.dataTransfer.dropEffect = 'none';
return;
}
if (position === 'after' && item.expanded && item.hasChildren) {
this._clearDragClasses(row);
e.dataTransfer.dropEffect = 'none';
return;
}
const { target: logicalTarget, position: logicalPosition, depth: dragDepth } = this._resolveDropTarget(item, position, e);
if (this._isNoOp(this._dragSource, logicalTarget, logicalPosition)) {
this._clearDragClasses(row);
e.dataTransfer.dropEffect = 'none';
return;
}
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;
if (position === 'after' && item.expanded && item.hasChildren) {
return;
}
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];
if (position === 'after' && e) {
const nextItem = this._flatItems[index + 1];
const currentDepth = currentItem.depth;
const nextDepth = nextItem ? nextItem.depth : -1;
if (nextDepth < currentDepth) {
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;
let targetDepth = Math.floor((mouseX - padding) / indent);
if (targetDepth < nextDepth) targetDepth = nextDepth;
if (targetDepth > currentDepth) targetDepth = currentDepth;
if (targetDepth < currentDepth) {
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');
}
};

38
mixins/tree-flatten.js Normal file
View File

@@ -0,0 +1,38 @@
export const TreeFlattenMixin = (superClass) => class TreeFlattenMixin extends superClass {
_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;
}
_findFlatIndexByKey(key) {
const items = Array.isArray(this._flatItems) ? this._flatItems : [];
return items.findIndex((it) => it?.key === key);
}
};

129
mixins/tree-interactions.js Normal file
View File

@@ -0,0 +1,129 @@
export const TreeInteractionsMixin = (superClass) => class TreeInteractionsMixin extends superClass {
_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);
}
_onInlineAction(item, action, originalEvent) {
originalEvent.stopPropagation();
this._dispatchAction(action?.action, item, 'inline-action', originalEvent);
}
_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,
}));
}
}
_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 (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) {
this._loadChildrenForExpanded(item.key, source, originalEvent, item.node, item.path);
}
return;
}
this._dispatchAction('toggle', item, source, originalEvent);
}
};

View File

@@ -0,0 +1,62 @@
export const TreeLoadChildrenMixin = (superClass) => class TreeLoadChildrenMixin extends superClass {
async _loadChildrenForExpanded(key, source, originalEvent, nodeHint = null, pathHint = null) {
if (typeof this.loadChildren !== 'function') return;
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,
};
await Promise.resolve(this.loadChildren(ctx));
if (controller.signal.aborted) return;
if (this._childLoadControllers.get(key) !== controller) return;
if (seq !== this._childLoadSeq) 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);
}
}
}
};

View File

@@ -0,0 +1,144 @@
export const TreeManagedStateMixin = (superClass) => class TreeManagedStateMixin extends superClass {
_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;
}
_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);
}
_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;
}
_rebuildManagedTree() {
this._expandedPathSet = new Set(this.expandedPaths || this._expandedPathSet);
this._selectedPathSet = new Set(this.selectedPaths || this._selectedPathSet);
const { nodes, allPaths, expandedPaths, selectedPaths } = this.constructor.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;
}
};

123
mixins/tree-reveal.js Normal file
View File

@@ -0,0 +1,123 @@
export const TreeRevealMixin = (superClass) => class TreeRevealMixin extends superClass {
async scrollToKey(key) {
await this.updateComplete;
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;
}
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;
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 (!node) {
externalSignal?.removeEventListener?.('abort', abortIfExternal);
return { found: false, key: currentKey, node: undefined };
}
const isLeaf = i === segments.length - 1;
ensureExpanded(currentKey);
if (!isLeaf && typeof this.loadChildren === 'function') {
await this._loadChildrenForExpanded(currentKey, source, null, node, currentKey.split('/'));
if (controller.signal.aborted) break;
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) };
}
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 };
}
};

75
mixins/tree-utils.js Normal file
View File

@@ -0,0 +1,75 @@
export const TreeUtilsMixin = (superClass) => class TreeUtilsMixin extends superClass {
_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);
}
_resolveIcon(icon) {
if (!icon) return null;
if (typeof icon === 'string') {
return this.constructor?.defaultIcons?.[icon] ?? null;
}
return icon;
}
_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());
}
_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 };
}
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "@tp/tp-tree-nav", "name": "@tp/tp-tree-nav",
"version": "1.4.0", "version": "1.5.0",
"description": "", "description": "",
"main": "tp-tree-nav.js", "main": "tp-tree-nav.js",
"scripts": { "scripts": {

View File

@@ -11,7 +11,33 @@ import { LitElement, html, css, svg } from 'lit';
import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; import { virtualize } from '@lit-labs/virtualizer/virtualize.js';
import { Position } from '../helpers/position.js'; import { Position } from '../helpers/position.js';
export class TpTreeNav extends Position(LitElement) { 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() { static get styles() {
return [ return [
css` css`
@@ -381,956 +407,6 @@ export class TpTreeNav extends Position(LitElement) {
} }
} }
_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 = {}) { static buildTree(items = [], options = {}) {
const { const {
expandedPaths = new Set(), expandedPaths = new Set(),