Add drag and drop of nodes

This commit is contained in:
2025-12-17 22:17:40 +01:00
parent a8f08ab02a
commit b92bbeb0c2
3 changed files with 304 additions and 3 deletions

View File

@@ -139,10 +139,16 @@ customElements.define('my-tree', MyTree);
- `node-click`: emitted on row click. - `node-click`: emitted on row click.
- `node-action`: emitted for toggles (`action: 'toggle'`, source chevron/double-click), inline actions, and context menu actions. - `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-context`: before showing context menu; `preventDefault` to cancel.
- `node-drop`: emitted on valid drop (`detail: { source, target, position }`).
### Helpers (both modes) ### Helpers (both modes)
- `TpTreeNav.buildTree(items, { expandedPaths, selectedPaths, selectionState, applyStates, knownPaths, autoExpandNew })` — pure helper used by managed mode; can be used externally. - `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. - `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 ### Data shape
- `slug` (unique per sibling), `label`, `icon` (string key or icon data), `children` (array), optional `actions` (`{ action, label?, icon?, tooltip? }`). - `slug` (unique per sibling), `label`, `icon` (string key or icon data), `children` (array), optional `actions` (`{ action, label?, icon?, tooltip? }`).

View File

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

View File

@@ -36,6 +36,7 @@ export class TpTreeNav extends Position(LitElement) {
user-select: none; user-select: none;
gap: 5px; gap: 5px;
width: 100%; width: 100%;
position: relative;
} }
.row:hover { .row:hover {
@@ -106,12 +107,42 @@ export class TpTreeNav extends Position(LitElement) {
box-shadow: 0 6px 18px rgba(0,0,0,0.18); box-shadow: 0 6px 18px rgba(0,0,0,0.18);
pointer-events: auto; 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() { render() {
const items = this._flattenNodes(); this._flatItems = this._flattenNodes();
const items = this._flatItems;
if (!items.length) { if (!items.length) {
return html`${this._renderEmpty()}`; return html`${this._renderEmpty()}`;
@@ -146,7 +177,16 @@ export class TpTreeNav extends Position(LitElement) {
} }
return html` return html`
<div class="row" part="row" style="--depth: ${depth}" @click=${(e) => this._onRowClick(item, e)} @contextmenu=${(e) => this._onContextMenu(item, e)} > <div class="row" part="row" style="--depth: ${depth}"
draggable="${this.allowDragAndDrop ? 'true' : 'false'}"
@click=${(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)}
>
<div class="indent"></div> <div class="indent"></div>
<div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} > <div class="chevron-btn ${expanded ? 'expanded' : ''}" part="chevron" ?hidden=${!hasChildren} @click=${(e) => this._onChevronClick(item, e)} >
<tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon> <tp-icon class="icon" part="chevron-icon" .icon=${TpTreeNav.chevron}></tp-icon>
@@ -215,6 +255,8 @@ export class TpTreeNav extends Position(LitElement) {
showActions: { type: Boolean }, showActions: { type: Boolean },
expandOnDoubleClick: { type: Boolean }, expandOnDoubleClick: { type: Boolean },
selectOnRightClick: { 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.showActions = false;
this.expandOnDoubleClick = false; this.expandOnDoubleClick = false;
this.selectOnRightClick = false; this.selectOnRightClick = false;
this.allowDragAndDrop = false;
this.canDrop = null;
this._contextMenu = null; this._contextMenu = null;
this._outsideHandler = null; this._outsideHandler = null;
this._keyHandler = null; this._keyHandler = null;
this._dragSource = null;
this._knownPaths = new Set(); this._knownPaths = new Set();
this._expandedPathSet = new Set(); this._expandedPathSet = new Set();
this._selectedPathSet = new Set(); this._selectedPathSet = new Set();
@@ -473,6 +518,256 @@ export class TpTreeNav extends Position(LitElement) {
this._dispatchAction(action?.action, item, 'inline-action', originalEvent); 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() { _attachOutsideHandlers() {
if (this._outsideHandler) return; if (this._outsideHandler) return;
this._outsideHandler = (e) => { this._outsideHandler = (e) => {