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) => {