From b92bbeb0c22b028734848c35bb0f862bfd8590ab Mon Sep 17 00:00:00 2001 From: pk Date: Wed, 17 Dec 2025 22:17:40 +0100 Subject: [PATCH] Add drag and drop of nodes --- README.md | 6 + package.json | 2 +- tp-tree-nav.js | 299 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 304 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba5361e..13593a6 100644 --- a/README.md +++ b/README.md @@ -139,10 +139,16 @@ customElements.define('my-tree', MyTree); - `node-click`: emitted on row click. - `node-action`: emitted for toggles (`action: 'toggle'`, source chevron/double-click), inline actions, and context menu actions. - `node-context`: before showing context menu; `preventDefault` to cancel. +- `node-drop`: emitted on valid drop (`detail: { source, target, position }`). ### Helpers (both modes) - `TpTreeNav.buildTree(items, { expandedPaths, selectedPaths, selectionState, applyStates, knownPaths, autoExpandNew })` — pure helper used by managed mode; can be used externally. - `applyStateIf(state, predicate)`, `clearState(state)`, `getNodesWithState(state)` for immutable transforms/queries. +### Drag and Drop +Enable drag and drop by setting `allowDragAndDrop = true`. +- **Validation**: Provide `canDrop(source, target, position)` to allow/deny drops. `position` is `'inside'`, `'before'`, or `'after'`. +- **Event**: Listen to `node-drop` event (`detail: { source, target, position, originalEvent }`). + ### Data shape - `slug` (unique per sibling), `label`, `icon` (string key or icon data), `children` (array), optional `actions` (`{ action, label?, icon?, tooltip? }`). diff --git a/package.json b/package.json index e28c4d5..3940ea2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tp/tp-tree-nav", - "version": "1.1.0", + "version": "1.2.0", "description": "", "main": "tp-tree-nav.js", "scripts": { diff --git a/tp-tree-nav.js b/tp-tree-nav.js index f2911b5..9a26132 100644 --- a/tp-tree-nav.js +++ b/tp-tree-nav.js @@ -36,6 +36,7 @@ export class TpTreeNav extends Position(LitElement) { user-select: none; gap: 5px; width: 100%; + position: relative; } .row:hover { @@ -106,12 +107,42 @@ export class TpTreeNav extends Position(LitElement) { box-shadow: 0 6px 18px rgba(0,0,0,0.18); pointer-events: auto; } + + .row.drag-over-inside { + background: var(--tp-tree-drag-over-bg, rgba(0,0,0,0.1)); + } + + .row.drag-over-before::after, + .row.drag-over-after::after { + content: ""; + position: absolute; + left: calc(var(--tp-tree-indent) * var(--drag-depth, var(--depth))); + right: 0; + height: 2px; + background-color: var(--tp-tree-drag-line-color, #2196f3); + pointer-events: none; + } + + .row.drag-over-before::after { + top: 0; + } + + .row.drag-over-after::after { + bottom: 0; + } + + .drag-clone { + opacity: 0.5; + position: absolute; + z-index: 1000; + } ` ]; } render() { - const items = this._flattenNodes(); + this._flatItems = this._flattenNodes(); + const items = this._flatItems; if (!items.length) { return html`${this._renderEmpty()}`; @@ -146,7 +177,16 @@ export class TpTreeNav extends Position(LitElement) { } return html` -
this._onRowClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} > +
this._onRowClick(item, e)} + @contextmenu=${(e) => this._onContextMenu(item, e)} + @dragstart=${(e) => this._onDragStart(item, e)} + @dragend=${(e) => this._onDragEnd(item, e)} + @dragover=${(e) => this._onDragOver(item, e)} + @dragleave=${(e) => this._onDragLeave(item, e)} + @drop=${(e) => this._onDrop(item, e)} + >
this._onChevronClick(item, e)} > @@ -215,6 +255,8 @@ export class TpTreeNav extends Position(LitElement) { showActions: { type: Boolean }, expandOnDoubleClick: { type: Boolean }, selectOnRightClick: { type: Boolean }, + allowDragAndDrop: { type: Boolean }, + canDrop: { type: Function }, }; } @@ -257,9 +299,12 @@ export class TpTreeNav extends Position(LitElement) { this.showActions = false; this.expandOnDoubleClick = false; this.selectOnRightClick = false; + this.allowDragAndDrop = false; + this.canDrop = null; this._contextMenu = null; this._outsideHandler = null; this._keyHandler = null; + this._dragSource = null; this._knownPaths = new Set(); this._expandedPathSet = new Set(); this._selectedPathSet = new Set(); @@ -473,6 +518,256 @@ export class TpTreeNav extends Position(LitElement) { 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 = row.cloneNode(true); + clone.classList.add('drag-clone'); + clone.setAttribute('part', 'drag-clone'); + + clone.style.width = `${rect.width}px`; + clone.style.height = `${rect.height}px`; + clone.style.top = '-1000px'; + clone.style.left = '-1000px'; + + this.shadowRoot.appendChild(clone); + + const offsetX = e.clientX - rect.left; + const offsetY = e.clientY - rect.top; + + e.dataTransfer.setDragImage(clone, offsetX, offsetY); + + 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 indent = 16; // Hardcoded for now, matching CSS default + const padding = 8; // Matching CSS padding + const rowRect = e.currentTarget.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) => {