diff --git a/mixins/tree-context-menu.js b/mixins/tree-context-menu.js new file mode 100644 index 0000000..e5be027 --- /dev/null +++ b/mixins/tree-context-menu.js @@ -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(); + } +}; diff --git a/mixins/tree-dnd.js b/mixins/tree-dnd.js new file mode 100644 index 0000000..bc318c4 --- /dev/null +++ b/mixins/tree-dnd.js @@ -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'); + } +}; diff --git a/mixins/tree-flatten.js b/mixins/tree-flatten.js new file mode 100644 index 0000000..97675ce --- /dev/null +++ b/mixins/tree-flatten.js @@ -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); + } +}; diff --git a/mixins/tree-interactions.js b/mixins/tree-interactions.js new file mode 100644 index 0000000..52ffe06 --- /dev/null +++ b/mixins/tree-interactions.js @@ -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); + } +}; diff --git a/mixins/tree-load-children.js b/mixins/tree-load-children.js new file mode 100644 index 0000000..f1ed713 --- /dev/null +++ b/mixins/tree-load-children.js @@ -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); + } + } + } +}; diff --git a/mixins/tree-managed-state.js b/mixins/tree-managed-state.js new file mode 100644 index 0000000..f77349f --- /dev/null +++ b/mixins/tree-managed-state.js @@ -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; + } +}; diff --git a/mixins/tree-reveal.js b/mixins/tree-reveal.js new file mode 100644 index 0000000..e2a1964 --- /dev/null +++ b/mixins/tree-reveal.js @@ -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 }; + } +}; diff --git a/mixins/tree-utils.js b/mixins/tree-utils.js new file mode 100644 index 0000000..fe7ed35 --- /dev/null +++ b/mixins/tree-utils.js @@ -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 }; + } +}; diff --git a/tp-tree-nav.js b/tp-tree-nav.js index 4b0d9ef..1710ac6 100644 --- a/tp-tree-nav.js +++ b/tp-tree-nav.js @@ -11,7 +11,33 @@ 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) { +import { TreeUtilsMixin } from './mixins/tree-utils.js'; +import { TreeFlattenMixin } from './mixins/tree-flatten.js'; +import { TreeManagedStateMixin } from './mixins/tree-managed-state.js'; +import { TreeLoadChildrenMixin } from './mixins/tree-load-children.js'; +import { TreeRevealMixin } from './mixins/tree-reveal.js'; +import { TreeContextMenuMixin } from './mixins/tree-context-menu.js'; +import { TreeDnDMixin } from './mixins/tree-dnd.js'; +import { TreeInteractionsMixin } from './mixins/tree-interactions.js'; + +const mixins = [ + TreeInteractionsMixin, + TreeDnDMixin, + TreeContextMenuMixin, + TreeRevealMixin, + TreeLoadChildrenMixin, + TreeManagedStateMixin, + TreeFlattenMixin, + TreeUtilsMixin, + Position +]; + +/* @litElement */ +const BaseElement = mixins.reduce((baseClass, mixin) => { + return mixin(baseClass); +}, LitElement); + +export class TpTreeNav extends BaseElement { static get styles() { return [ css` @@ -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 = {}) { const { expandedPaths = new Set(),