Add drag and drop of nodes
This commit is contained in:
@@ -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? }`).
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
299
tp-tree-nav.js
299
tp-tree-nav.js
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user