Spaces:
Sleeping
Sleeping
Download client/src/treedrag.js from lvwerra/cowrite-dev: direct link, hf CLI and curl.
- Browser
- Download file 17.2 kB
-
https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/client/src/treedrag.js
- Command line
-
hf download hf://spaces/lvwerra/cowrite-dev/client/src/treedrag.js
-
curl -L -o treedrag.js https://huggingface.co/spaces/lvwerra/cowrite-dev/resolve/main/client/src/treedrag.js
17.2 kB
| 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) | |
| } | |
| } | |