const STYLE_ID = 'tree-drag-styles' const ROW_SELECTOR = '[data-td-row]' const INTERACTIVE_SELECTOR = 'button, input, select, textarea, [contenteditable]' const MOUSE_SLOP = 4 const TOUCH_DELAY = 350 const HOVER_EXPAND_DELAY = 600 function installStyles() { if (document.getElementById(STYLE_ID)) return const style = document.createElement('style') style.id = STYLE_ID style.textContent = ` body.td-dragging, body.td-dragging * { cursor: grabbing !important; } body.td-dragging { user-select: none; -webkit-user-select: none; } .td-drag-source { opacity: .38 !important; } .td-drag-ghost { position: fixed; z-index: 2147483647; pointer-events: none; display: flex; align-items: center; gap: 8px; max-width: min(320px, calc(100vw - 24px)); overflow: hidden; padding: 6px 10px; border: 1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius: 6px; background: var(--bg, Canvas); color: var(--fg, CanvasText); box-shadow: 0 5px 18px color-mix(in srgb, currentColor 16%, transparent); font: 13px/1.25 system-ui, -apple-system, sans-serif; white-space: nowrap; text-overflow: ellipsis; opacity: .94; transform: translate(10px, 10px); } .td-drag-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .td-drag-count { flex: none; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, currentColor 10%, transparent); font-size: 11px; font-weight: 600; } .td-drop-line { position: fixed; z-index: 2147483646; height: 2px; pointer-events: none; border-radius: 2px; background: currentColor; color: var(--accent, currentColor); box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent); } .td-drop-line::before { content: ''; position: absolute; left: -3px; top: -2px; width: 6px; height: 6px; border: 1.5px solid currentColor; border-radius: 50%; background: var(--bg, Canvas); } .td-drop-into { outline: 1px solid color-mix(in srgb, currentColor 28%, transparent); outline-offset: -1px; } ` document.head.appendChild(style) } function numberAttr(row, name, fallback = 0) { const value = Number(row.dataset[name]) return Number.isFinite(value) ? value : fallback } /** * Add pointer-driven tree reordering to a container. * * @param {HTMLElement} container * @param {{ * onMove: (move: {keys: string[], parentKey: string, index: number}) => void, * onHoverExpand?: (key: string) => void * }} handlers * @returns {() => void} */ export function attachTreeDrag(container, { onMove, onHoverExpand } = {}) { if (!(container instanceof Element)) throw new TypeError('attachTreeDrag requires an Element container') installStyles() let pending = null let drag = null let touchTimer = null let hoverTimer = null let hoverKey = null let suppressClick = false let suppressTimer = null let scrollFrame = null let scrollSpeed = 0 const directRow = target => { const row = target instanceof Element ? target.closest(ROW_SELECTOR) : null return row && container.contains(row) ? row : null } const readRows = () => [...container.querySelectorAll(ROW_SELECTOR)].map((el, order) => ({ el, order, key: el.dataset.key || '', parentKey: el.dataset.parent || '', index: numberAttr(el, 'index'), depth: numberAttr(el, 'depth'), selected: el.dataset.selected === '1', })) const clearTouchTimer = () => { clearTimeout(touchTimer) touchTimer = null } const clearHover = () => { clearTimeout(hoverTimer) hoverTimer = null hoverKey = null } const clearDropVisual = () => { drag?.intoRow?.classList.remove('td-drop-into') if (drag) drag.intoRow = null drag?.indicator?.remove() if (drag) drag.indicator = null } const refreshRows = () => { if (!drag) return [] for (const el of drag.sourceElements) el.classList.remove('td-drag-source') drag.sourceElements.clear() const rows = readRows() drag.sourceKeys = new Set(drag.keys) for (const key of drag.keys) { const rootAt = rows.findIndex(row => row.key === key) if (rootAt < 0) continue const rootDepth = rows[rootAt].depth for (let i = rootAt + 1; i < rows.length && rows[i].depth > rootDepth; i++) { drag.sourceKeys.add(rows[i].key) } } for (const row of rows) { if (!drag.sourceKeys.has(row.key)) continue row.el.classList.add('td-drag-source') drag.sourceElements.add(row.el) } return rows } const stopAutoScroll = () => { scrollSpeed = 0 cancelAnimationFrame(scrollFrame) scrollFrame = null } const cleanupDrag = () => { clearTouchTimer() clearHover() stopAutoScroll() clearDropVisual() if (drag) { const finished = drag drag = null for (const el of finished.sourceElements) el.classList.remove('td-drag-source') finished.ghost.remove() if (container.hasPointerCapture?.(finished.pointerId)) container.releasePointerCapture(finished.pointerId) } document.body.classList.remove('td-dragging') pending = null } const cancel = () => cleanupDrag() const beginDrag = event => { if (!pending || drag) return const rows = readRows() const source = rows.find(row => row.el === pending.row) if (!source) return cancel() const selected = source.selected ? rows.filter(row => row.selected) : [] const roots = selected.length >= 2 ? selected : [source] clearTouchTimer() const ghost = document.createElement('div') ghost.className = 'td-drag-ghost' const label = document.createElement('span') label.className = 'td-drag-label' label.textContent = pending.row.getAttribute('aria-label') || pending.row.textContent.trim() ghost.appendChild(label) if (roots.length > 1) { const count = document.createElement('span') count.className = 'td-drag-count' count.textContent = `${roots.length} items` ghost.appendChild(count) } document.body.appendChild(ghost) document.body.classList.add('td-dragging') drag = { pointerId: pending.pointerId, keys: roots.map(row => row.key), roots: roots.map(row => ({ key: row.key, parentKey: row.parentKey, index: row.index })), ghost, indicator: null, intoRow: null, candidate: null, sourceKeys: new Set(), sourceElements: new Set(), expandedKeys: new Set(), x: event.clientX, y: event.clientY, } container.setPointerCapture?.(pending.pointerId) refreshRows() moveGhost(event.clientX, event.clientY) updateDrop(event.clientX, event.clientY) } const moveGhost = (x, y) => { if (!drag) return drag.ghost.style.left = `${x}px` drag.ghost.style.top = `${y}px` } const subtreeEnd = (rows, row) => { let end = row for (let i = row.order + 1; i < rows.length && rows[i].depth > row.depth; i++) end = rows[i] return end } const childCount = (rows, parentKey) => rows.filter(row => row.parentKey === parentKey).length const isLastSibling = (rows, row) => { const peers = rows.filter(item => item.parentKey === row.parentKey && !drag.sourceKeys.has(item.key)) return peers.at(-1)?.key === row.key } const indentSize = rows => { const samples = [] for (const row of rows) { const parent = rows.find(item => item.key === row.parentKey) if (!parent) continue const delta = row.el.getBoundingClientRect().left - parent.el.getBoundingClientRect().left if (delta > 4 && delta < 80) samples.push(delta) } return samples.length ? samples.sort((a, b) => a - b)[Math.floor(samples.length / 2)] : 18 } const afterCandidate = (rows, hovered, pointerX) => { let anchor = hovered const indent = indentSize(rows) // At the end of a branch, moving left promotes the insertion point one // ancestor at a time. Only climb through last-child links: otherwise the // drop still belongs before the next sibling in the current branch. while (anchor.parentKey && isLastSibling(rows, anchor)) { const parent = rows.find(row => row.key === anchor.parentKey) if (!parent || pointerX >= anchor.el.getBoundingClientRect().left - indent * 0.45) break anchor = parent } const end = subtreeEnd(rows, anchor) return { parentKey: anchor.parentKey, rawIndex: anchor.index + 1, depth: anchor.depth, lineTop: end.el.getBoundingClientRect().bottom, lineLeft: anchor.el.getBoundingClientRect().left, intoRow: null, } } const showCandidate = (candidate, containerRect) => { clearDropVisual() if (!drag || !candidate) return drag.candidate = candidate if (candidate.intoRow) { candidate.intoRow.classList.add('td-drop-into') drag.intoRow = candidate.intoRow return } const line = document.createElement('div') line.className = 'td-drop-line' const left = Math.max(containerRect.left + 4, candidate.lineLeft) line.style.left = `${left}px` line.style.top = `${candidate.lineTop - 1}px` line.style.width = `${Math.max(16, containerRect.right - left - 5)}px` document.body.appendChild(line) drag.indicator = line } const armHoverExpand = row => { if (!drag || row.el.dataset.collapsed !== '1' || drag.expandedKeys.has(row.key)) return clearHover() if (hoverKey === row.key && hoverTimer) return clearHover() hoverKey = row.key hoverTimer = setTimeout(() => { if (!drag || hoverKey !== row.key) return drag.expandedKeys.add(row.key) hoverTimer = null hoverKey = null if (typeof onHoverExpand === 'function') onHoverExpand(row.key) requestAnimationFrame(() => { if (!drag) return refreshRows() updateDrop(drag.x, drag.y) }) }, HOVER_EXPAND_DELAY) } const updateDrop = (x, y) => { if (!drag) return drag.x = x drag.y = y const rows = refreshRows() const containerRect = container.getBoundingClientRect() const hit = document.elementFromPoint(x, y) const hitEl = directRow(hit) // A leftward "after" drop can sit in the indentation before the row, so // fall back to the row occupying this vertical band when elementFromPoint // lands on an ancestor wrapper instead of the row itself. const hovered = hitEl ? rows.find(row => row.el === hitEl) : x >= containerRect.left && x <= containerRect.right ? rows.find(row => { const rect = row.el.getBoundingClientRect() return y >= rect.top && y <= rect.bottom }) : null if (!hovered || drag.sourceKeys.has(hovered.key)) { drag.candidate = null clearHover() clearDropVisual() return } armHoverExpand(hovered) const rect = hovered.el.getBoundingClientRect() const ratio = rect.height ? (y - rect.top) / rect.height : 0.5 let candidate if (ratio < 0.3) { candidate = { parentKey: hovered.parentKey, rawIndex: hovered.index, depth: hovered.depth, lineTop: rect.top, lineLeft: rect.left, intoRow: null, } } else if (ratio > 0.7 || hovered.el.dataset.nest === '0') { candidate = afterCandidate(rows, hovered, x) } else { candidate = { parentKey: hovered.key, // Keep this in pre-removal coordinates, like before/after candidates. // pointerUp subtracts every moved direct child to produce the API's // post-removal index. rawIndex: childCount(rows, hovered.key), depth: hovered.depth + 1, intoRow: hovered.el, } } if (drag.sourceKeys.has(candidate.parentKey)) { drag.candidate = null clearDropVisual() return } showCandidate(candidate, containerRect) } const updateAutoScroll = y => { if (!drag) return const rect = container.getBoundingClientRect() const edge = Math.min(48, Math.max(24, rect.height * 0.12)) if (y < rect.top + edge) scrollSpeed = -Math.max(2, ((rect.top + edge - y) / edge) * 12) else if (y > rect.bottom - edge) scrollSpeed = Math.max(2, ((y - (rect.bottom - edge)) / edge) * 12) else scrollSpeed = 0 if (!scrollSpeed || scrollFrame) return const tick = () => { scrollFrame = null if (!drag || !scrollSpeed) return const before = container.scrollTop container.scrollTop += scrollSpeed if (container.scrollTop !== before) updateDrop(drag.x, drag.y) scrollFrame = requestAnimationFrame(tick) } scrollFrame = requestAnimationFrame(tick) } const pointerDown = event => { if (pending || drag || (event.pointerType === 'mouse' && event.button !== 0)) return // controls inside a row (trash, caret) must not arm a drag — except ones // that opt back in with data-td-drag (a row whose label IS a button) const control = event.target instanceof Element && event.target.closest(INTERACTIVE_SELECTOR) if (control && control.dataset.tdDrag == null) return const row = directRow(event.target) if (!row) return pending = { row, pointerId: event.pointerId, pointerType: event.pointerType, x: event.clientX, y: event.clientY, } if (event.pointerType === 'touch') { touchTimer = setTimeout(() => { if (pending) beginDrag({ clientX: pending.x, clientY: pending.y }) }, TOUCH_DELAY) } } const pointerMove = event => { if (drag) { if (event.pointerId !== drag.pointerId) return event.preventDefault() moveGhost(event.clientX, event.clientY) updateDrop(event.clientX, event.clientY) updateAutoScroll(event.clientY) return } if (!pending || event.pointerId !== pending.pointerId) return const distance = Math.hypot(event.clientX - pending.x, event.clientY - pending.y) if (pending.pointerType === 'touch') { // A finger that moves before the long press is scrolling, not dragging. if (distance > MOUSE_SLOP) { clearTouchTimer() pending = null } } else if (distance >= MOUSE_SLOP) { beginDrag(event) event.preventDefault() } } const pointerUp = event => { if (!drag) { if (pending?.pointerId === event.pointerId) { clearTouchTimer() pending = null } return } if (event.pointerId !== drag.pointerId) return event.preventDefault() const { keys, roots, candidate } = drag let move = null if (candidate) { const removedBefore = roots.filter(root => root.parentKey === candidate.parentKey && root.index < candidate.rawIndex ).length const index = Math.max(0, candidate.rawIndex - removedBefore) const only = roots[0] if (roots.length > 1 || candidate.parentKey !== only.parentKey || index !== only.index) { move = { keys, parentKey: candidate.parentKey, index } } } suppressClick = true clearTimeout(suppressTimer) suppressTimer = setTimeout(() => { suppressClick = false }, 0) cleanupDrag() if (move && typeof onMove === 'function') onMove(move) } const pointerCancel = event => { if (drag?.pointerId === event.pointerId || pending?.pointerId === event.pointerId) cancel() } const keyDown = event => { if (event.key !== 'Escape' || (!pending && !drag)) return event.preventDefault() cancel() } const click = event => { if (!suppressClick) return suppressClick = false event.preventDefault() event.stopImmediatePropagation() } const nativeDragStart = event => { if (directRow(event.target)) event.preventDefault() } // Browsers may turn a touch pointer into a scroll after the long press and // issue pointercancel. Suppress that native scroll only once our drag is // active; ordinary pre-drag swipes remain native container scrolling. const touchMove = event => { if (drag) event.preventDefault() } container.addEventListener('pointerdown', pointerDown) container.addEventListener('pointermove', pointerMove, { passive: false }) container.addEventListener('pointerup', pointerUp) container.addEventListener('pointercancel', pointerCancel) container.addEventListener('lostpointercapture', pointerCancel) container.addEventListener('click', click, true) container.addEventListener('dragstart', nativeDragStart) container.addEventListener('touchmove', touchMove, { passive: false }) document.addEventListener('keydown', keyDown) return () => { cleanupDrag() clearTimeout(suppressTimer) container.removeEventListener('pointerdown', pointerDown) container.removeEventListener('pointermove', pointerMove) container.removeEventListener('pointerup', pointerUp) container.removeEventListener('pointercancel', pointerCancel) container.removeEventListener('lostpointercapture', pointerCancel) container.removeEventListener('click', click, true) container.removeEventListener('dragstart', nativeDragStart) container.removeEventListener('touchmove', touchMove) document.removeEventListener('keydown', keyDown) } }