thomwolf HF Staff Codex Claude Fable 5 commited on
Commit
e099afd
·
1 Parent(s): e5167b8

Add a generic pointer-based tree drag module

Browse files

Self-contained: no app imports, injects its own styles. Rows opt in via
data-td-* attributes; drops report {key, parentKey, index} with the
index counted after the dragged node leaves its old spot. Handles
touch long-press, subtree protection, ancestor outdent on the last
sibling, hover-to-expand, auto-scroll, and Escape cancel.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (1) hide show
  1. client/src/treedrag.js +470 -0
client/src/treedrag.js ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const STYLE_ID = 'tree-drag-styles'
2
+ const ROW_SELECTOR = '[data-td-row]'
3
+ const MOUSE_SLOP = 4
4
+ const TOUCH_DELAY = 350
5
+ const HOVER_EXPAND_DELAY = 600
6
+
7
+ function installStyles() {
8
+ if (document.getElementById(STYLE_ID)) return
9
+ const style = document.createElement('style')
10
+ style.id = STYLE_ID
11
+ style.textContent = `
12
+ body.td-dragging, body.td-dragging * { cursor: grabbing !important; }
13
+ body.td-dragging { user-select: none; -webkit-user-select: none; }
14
+ .td-drag-source { opacity: .38 !important; }
15
+ .td-drag-ghost {
16
+ position: fixed; z-index: 2147483647; pointer-events: none;
17
+ max-width: min(320px, calc(100vw - 24px)); overflow: hidden;
18
+ padding: 6px 10px; border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
19
+ border-radius: 6px; background: var(--bg, Canvas); color: var(--fg, CanvasText);
20
+ box-shadow: 0 5px 18px color-mix(in srgb, currentColor 16%, transparent);
21
+ font: 13px/1.25 system-ui, -apple-system, sans-serif; white-space: nowrap;
22
+ text-overflow: ellipsis; opacity: .94; transform: translate(10px, 10px);
23
+ }
24
+ .td-drop-line {
25
+ position: fixed; z-index: 2147483646; height: 2px; pointer-events: none;
26
+ border-radius: 2px; background: currentColor; color: var(--accent, currentColor);
27
+ box-shadow: 0 0 0 1px color-mix(in srgb, currentColor 12%, transparent);
28
+ }
29
+ .td-drop-line::before {
30
+ content: ''; position: absolute; left: -3px; top: -2px; width: 6px; height: 6px;
31
+ border: 1.5px solid currentColor; border-radius: 50%; background: var(--bg, Canvas);
32
+ }
33
+ .td-drop-into { outline: 1px solid color-mix(in srgb, currentColor 28%, transparent); outline-offset: -1px; }
34
+ `
35
+ document.head.appendChild(style)
36
+ }
37
+
38
+ function numberAttr(row, name, fallback = 0) {
39
+ const value = Number(row.dataset[name])
40
+ return Number.isFinite(value) ? value : fallback
41
+ }
42
+
43
+ /**
44
+ * Add pointer-driven tree reordering to a container.
45
+ *
46
+ * @param {HTMLElement} container
47
+ * @param {{
48
+ * onMove: (move: {key: string, parentKey: string, index: number}) => void,
49
+ * onHoverExpand?: (key: string) => void
50
+ * }} handlers
51
+ * @returns {() => void}
52
+ */
53
+ export function attachTreeDrag(container, { onMove, onHoverExpand } = {}) {
54
+ if (!(container instanceof Element)) throw new TypeError('attachTreeDrag requires an Element container')
55
+ installStyles()
56
+
57
+ let pending = null
58
+ let drag = null
59
+ let touchTimer = null
60
+ let hoverTimer = null
61
+ let hoverKey = null
62
+ let suppressClick = false
63
+ let suppressTimer = null
64
+ let scrollFrame = null
65
+ let scrollSpeed = 0
66
+
67
+ const directRow = target => {
68
+ const row = target instanceof Element ? target.closest(ROW_SELECTOR) : null
69
+ return row && container.contains(row) ? row : null
70
+ }
71
+
72
+ const readRows = () => [...container.querySelectorAll(ROW_SELECTOR)].map((el, order) => ({
73
+ el,
74
+ order,
75
+ key: el.dataset.key || '',
76
+ parentKey: el.dataset.parent || '',
77
+ index: numberAttr(el, 'index'),
78
+ depth: numberAttr(el, 'depth'),
79
+ }))
80
+
81
+ const clearTouchTimer = () => {
82
+ clearTimeout(touchTimer)
83
+ touchTimer = null
84
+ }
85
+
86
+ const clearHover = () => {
87
+ clearTimeout(hoverTimer)
88
+ hoverTimer = null
89
+ hoverKey = null
90
+ }
91
+
92
+ const clearDropVisual = () => {
93
+ drag?.intoRow?.classList.remove('td-drop-into')
94
+ if (drag) drag.intoRow = null
95
+ drag?.indicator?.remove()
96
+ if (drag) drag.indicator = null
97
+ }
98
+
99
+ const refreshRows = () => {
100
+ if (!drag) return []
101
+ for (const el of drag.sourceElements) el.classList.remove('td-drag-source')
102
+ drag.sourceElements.clear()
103
+
104
+ const rows = readRows()
105
+ const rootAt = rows.findIndex(row => row.key === drag.key)
106
+ drag.sourceKeys = new Set([drag.key])
107
+ if (rootAt >= 0) {
108
+ const rootDepth = rows[rootAt].depth
109
+ for (let i = rootAt + 1; i < rows.length && rows[i].depth > rootDepth; i++) {
110
+ drag.sourceKeys.add(rows[i].key)
111
+ }
112
+ }
113
+ for (const row of rows) {
114
+ if (!drag.sourceKeys.has(row.key)) continue
115
+ row.el.classList.add('td-drag-source')
116
+ drag.sourceElements.add(row.el)
117
+ }
118
+ return rows
119
+ }
120
+
121
+ const stopAutoScroll = () => {
122
+ scrollSpeed = 0
123
+ cancelAnimationFrame(scrollFrame)
124
+ scrollFrame = null
125
+ }
126
+
127
+ const cleanupDrag = () => {
128
+ clearTouchTimer()
129
+ clearHover()
130
+ stopAutoScroll()
131
+ clearDropVisual()
132
+ if (drag) {
133
+ const finished = drag
134
+ drag = null
135
+ for (const el of finished.sourceElements) el.classList.remove('td-drag-source')
136
+ finished.ghost.remove()
137
+ if (container.hasPointerCapture?.(finished.pointerId)) container.releasePointerCapture(finished.pointerId)
138
+ }
139
+ document.body.classList.remove('td-dragging')
140
+ pending = null
141
+ }
142
+
143
+ const cancel = () => cleanupDrag()
144
+
145
+ const beginDrag = event => {
146
+ if (!pending || drag) return
147
+ const rows = readRows()
148
+ const source = rows.find(row => row.el === pending.row)
149
+ if (!source) return cancel()
150
+
151
+ clearTouchTimer()
152
+ const ghost = document.createElement('div')
153
+ ghost.className = 'td-drag-ghost'
154
+ ghost.textContent = pending.row.getAttribute('aria-label') || pending.row.textContent.trim()
155
+ document.body.appendChild(ghost)
156
+ document.body.classList.add('td-dragging')
157
+
158
+ drag = {
159
+ pointerId: pending.pointerId,
160
+ key: source.key,
161
+ oldParentKey: source.parentKey,
162
+ oldIndex: source.index,
163
+ ghost,
164
+ indicator: null,
165
+ intoRow: null,
166
+ candidate: null,
167
+ sourceKeys: new Set(),
168
+ sourceElements: new Set(),
169
+ expandedKeys: new Set(),
170
+ x: event.clientX,
171
+ y: event.clientY,
172
+ }
173
+ container.setPointerCapture?.(pending.pointerId)
174
+ refreshRows()
175
+ moveGhost(event.clientX, event.clientY)
176
+ updateDrop(event.clientX, event.clientY)
177
+ }
178
+
179
+ const moveGhost = (x, y) => {
180
+ if (!drag) return
181
+ drag.ghost.style.left = `${x}px`
182
+ drag.ghost.style.top = `${y}px`
183
+ }
184
+
185
+ const subtreeEnd = (rows, row) => {
186
+ let end = row
187
+ for (let i = row.order + 1; i < rows.length && rows[i].depth > row.depth; i++) end = rows[i]
188
+ return end
189
+ }
190
+
191
+ const siblings = (rows, parentKey) => rows.filter(row => row.parentKey === parentKey && !drag.sourceKeys.has(row.key))
192
+
193
+ const isLastSibling = (rows, row) => {
194
+ const peers = rows.filter(item => item.parentKey === row.parentKey)
195
+ return peers.at(-1)?.key === row.key
196
+ }
197
+
198
+ const indentSize = rows => {
199
+ const samples = []
200
+ for (const row of rows) {
201
+ const parent = rows.find(item => item.key === row.parentKey)
202
+ if (!parent) continue
203
+ const delta = row.el.getBoundingClientRect().left - parent.el.getBoundingClientRect().left
204
+ if (delta > 4 && delta < 80) samples.push(delta)
205
+ }
206
+ return samples.length ? samples.sort((a, b) => a - b)[Math.floor(samples.length / 2)] : 18
207
+ }
208
+
209
+ const afterCandidate = (rows, hovered, pointerX) => {
210
+ let anchor = hovered
211
+ const indent = indentSize(rows)
212
+
213
+ // At the end of a branch, moving left promotes the insertion point one
214
+ // ancestor at a time. Only climb through last-child links: otherwise the
215
+ // drop still belongs before the next sibling in the current branch.
216
+ while (anchor.parentKey && isLastSibling(rows, anchor)) {
217
+ const parent = rows.find(row => row.key === anchor.parentKey)
218
+ if (!parent || pointerX >= anchor.el.getBoundingClientRect().left - indent * 0.45) break
219
+ anchor = parent
220
+ }
221
+
222
+ const end = subtreeEnd(rows, anchor)
223
+ return {
224
+ parentKey: anchor.parentKey,
225
+ rawIndex: anchor.index + 1,
226
+ depth: anchor.depth,
227
+ lineTop: end.el.getBoundingClientRect().bottom,
228
+ lineLeft: anchor.el.getBoundingClientRect().left,
229
+ intoRow: null,
230
+ }
231
+ }
232
+
233
+ const showCandidate = (candidate, containerRect) => {
234
+ clearDropVisual()
235
+ if (!drag || !candidate) return
236
+ drag.candidate = candidate
237
+ if (candidate.intoRow) {
238
+ candidate.intoRow.classList.add('td-drop-into')
239
+ drag.intoRow = candidate.intoRow
240
+ return
241
+ }
242
+ const line = document.createElement('div')
243
+ line.className = 'td-drop-line'
244
+ const left = Math.max(containerRect.left + 4, candidate.lineLeft)
245
+ line.style.left = `${left}px`
246
+ line.style.top = `${candidate.lineTop - 1}px`
247
+ line.style.width = `${Math.max(16, containerRect.right - left - 5)}px`
248
+ document.body.appendChild(line)
249
+ drag.indicator = line
250
+ }
251
+
252
+ const armHoverExpand = row => {
253
+ if (!drag || row.el.dataset.collapsed !== '1' || drag.expandedKeys.has(row.key)) return clearHover()
254
+ if (hoverKey === row.key && hoverTimer) return
255
+ clearHover()
256
+ hoverKey = row.key
257
+ hoverTimer = setTimeout(() => {
258
+ if (!drag || hoverKey !== row.key) return
259
+ drag.expandedKeys.add(row.key)
260
+ hoverTimer = null
261
+ hoverKey = null
262
+ if (typeof onHoverExpand === 'function') onHoverExpand(row.key)
263
+ requestAnimationFrame(() => {
264
+ if (!drag) return
265
+ refreshRows()
266
+ updateDrop(drag.x, drag.y)
267
+ })
268
+ }, HOVER_EXPAND_DELAY)
269
+ }
270
+
271
+ const updateDrop = (x, y) => {
272
+ if (!drag) return
273
+ drag.x = x
274
+ drag.y = y
275
+ const rows = refreshRows()
276
+ const containerRect = container.getBoundingClientRect()
277
+ const hit = document.elementFromPoint(x, y)
278
+ const hitEl = directRow(hit)
279
+ // A leftward "after" drop can sit in the indentation before the row, so
280
+ // fall back to the row occupying this vertical band when elementFromPoint
281
+ // lands on an ancestor wrapper instead of the row itself.
282
+ const hovered = hitEl
283
+ ? rows.find(row => row.el === hitEl)
284
+ : x >= containerRect.left && x <= containerRect.right
285
+ ? rows.find(row => {
286
+ const rect = row.el.getBoundingClientRect()
287
+ return y >= rect.top && y <= rect.bottom
288
+ })
289
+ : null
290
+
291
+ if (!hovered || drag.sourceKeys.has(hovered.key)) {
292
+ drag.candidate = null
293
+ clearHover()
294
+ clearDropVisual()
295
+ return
296
+ }
297
+
298
+ armHoverExpand(hovered)
299
+ const rect = hovered.el.getBoundingClientRect()
300
+ const ratio = rect.height ? (y - rect.top) / rect.height : 0.5
301
+ let candidate
302
+
303
+ if (ratio < 0.3) {
304
+ candidate = {
305
+ parentKey: hovered.parentKey,
306
+ rawIndex: hovered.index,
307
+ depth: hovered.depth,
308
+ lineTop: rect.top,
309
+ lineLeft: rect.left,
310
+ intoRow: null,
311
+ }
312
+ } else if (ratio > 0.7 || hovered.el.dataset.nest === '0') {
313
+ candidate = afterCandidate(rows, hovered, x)
314
+ } else {
315
+ candidate = {
316
+ parentKey: hovered.key,
317
+ rawIndex: siblings(rows, hovered.key).length,
318
+ depth: hovered.depth + 1,
319
+ intoRow: hovered.el,
320
+ }
321
+ }
322
+
323
+ if (drag.sourceKeys.has(candidate.parentKey)) {
324
+ drag.candidate = null
325
+ clearDropVisual()
326
+ return
327
+ }
328
+ showCandidate(candidate, containerRect)
329
+ }
330
+
331
+ const updateAutoScroll = y => {
332
+ if (!drag) return
333
+ const rect = container.getBoundingClientRect()
334
+ const edge = Math.min(48, Math.max(24, rect.height * 0.12))
335
+ if (y < rect.top + edge) scrollSpeed = -Math.max(2, ((rect.top + edge - y) / edge) * 12)
336
+ else if (y > rect.bottom - edge) scrollSpeed = Math.max(2, ((y - (rect.bottom - edge)) / edge) * 12)
337
+ else scrollSpeed = 0
338
+
339
+ if (!scrollSpeed || scrollFrame) return
340
+ const tick = () => {
341
+ scrollFrame = null
342
+ if (!drag || !scrollSpeed) return
343
+ const before = container.scrollTop
344
+ container.scrollTop += scrollSpeed
345
+ if (container.scrollTop !== before) updateDrop(drag.x, drag.y)
346
+ scrollFrame = requestAnimationFrame(tick)
347
+ }
348
+ scrollFrame = requestAnimationFrame(tick)
349
+ }
350
+
351
+ const pointerDown = event => {
352
+ if (pending || drag || (event.pointerType === 'mouse' && event.button !== 0)) return
353
+ const row = directRow(event.target)
354
+ if (!row) return
355
+ pending = {
356
+ row,
357
+ pointerId: event.pointerId,
358
+ pointerType: event.pointerType,
359
+ x: event.clientX,
360
+ y: event.clientY,
361
+ }
362
+ if (event.pointerType === 'touch') {
363
+ touchTimer = setTimeout(() => {
364
+ if (pending) beginDrag({ clientX: pending.x, clientY: pending.y })
365
+ }, TOUCH_DELAY)
366
+ }
367
+ }
368
+
369
+ const pointerMove = event => {
370
+ if (drag) {
371
+ if (event.pointerId !== drag.pointerId) return
372
+ event.preventDefault()
373
+ moveGhost(event.clientX, event.clientY)
374
+ updateDrop(event.clientX, event.clientY)
375
+ updateAutoScroll(event.clientY)
376
+ return
377
+ }
378
+ if (!pending || event.pointerId !== pending.pointerId) return
379
+ const distance = Math.hypot(event.clientX - pending.x, event.clientY - pending.y)
380
+ if (pending.pointerType === 'touch') {
381
+ // A finger that moves before the long press is scrolling, not dragging.
382
+ if (distance > MOUSE_SLOP) {
383
+ clearTouchTimer()
384
+ pending = null
385
+ }
386
+ } else if (distance >= MOUSE_SLOP) {
387
+ beginDrag(event)
388
+ event.preventDefault()
389
+ }
390
+ }
391
+
392
+ const pointerUp = event => {
393
+ if (!drag) {
394
+ if (pending?.pointerId === event.pointerId) {
395
+ clearTouchTimer()
396
+ pending = null
397
+ }
398
+ return
399
+ }
400
+ if (event.pointerId !== drag.pointerId) return
401
+ event.preventDefault()
402
+ const { key, oldParentKey, oldIndex, candidate } = drag
403
+ let move = null
404
+ if (candidate) {
405
+ let index = candidate.rawIndex
406
+ if (candidate.parentKey === oldParentKey && oldIndex < index) index--
407
+ index = Math.max(0, index)
408
+ if (candidate.parentKey !== oldParentKey || index !== oldIndex) {
409
+ move = { key, parentKey: candidate.parentKey, index }
410
+ }
411
+ }
412
+ suppressClick = true
413
+ clearTimeout(suppressTimer)
414
+ suppressTimer = setTimeout(() => { suppressClick = false }, 0)
415
+ cleanupDrag()
416
+ if (move && typeof onMove === 'function') onMove(move)
417
+ }
418
+
419
+ const pointerCancel = event => {
420
+ if (drag?.pointerId === event.pointerId || pending?.pointerId === event.pointerId) cancel()
421
+ }
422
+
423
+ const keyDown = event => {
424
+ if (event.key !== 'Escape' || (!pending && !drag)) return
425
+ event.preventDefault()
426
+ cancel()
427
+ }
428
+
429
+ const click = event => {
430
+ if (!suppressClick) return
431
+ suppressClick = false
432
+ event.preventDefault()
433
+ event.stopImmediatePropagation()
434
+ }
435
+
436
+ const nativeDragStart = event => {
437
+ if (directRow(event.target)) event.preventDefault()
438
+ }
439
+
440
+ // Browsers may turn a touch pointer into a scroll after the long press and
441
+ // issue pointercancel. Suppress that native scroll only once our drag is
442
+ // active; ordinary pre-drag swipes remain native container scrolling.
443
+ const touchMove = event => {
444
+ if (drag) event.preventDefault()
445
+ }
446
+
447
+ container.addEventListener('pointerdown', pointerDown)
448
+ container.addEventListener('pointermove', pointerMove, { passive: false })
449
+ container.addEventListener('pointerup', pointerUp)
450
+ container.addEventListener('pointercancel', pointerCancel)
451
+ container.addEventListener('lostpointercapture', pointerCancel)
452
+ container.addEventListener('click', click, true)
453
+ container.addEventListener('dragstart', nativeDragStart)
454
+ container.addEventListener('touchmove', touchMove, { passive: false })
455
+ document.addEventListener('keydown', keyDown)
456
+
457
+ return () => {
458
+ cleanupDrag()
459
+ clearTimeout(suppressTimer)
460
+ container.removeEventListener('pointerdown', pointerDown)
461
+ container.removeEventListener('pointermove', pointerMove)
462
+ container.removeEventListener('pointerup', pointerUp)
463
+ container.removeEventListener('pointercancel', pointerCancel)
464
+ container.removeEventListener('lostpointercapture', pointerCancel)
465
+ container.removeEventListener('click', click, true)
466
+ container.removeEventListener('dragstart', nativeDragStart)
467
+ container.removeEventListener('touchmove', touchMove)
468
+ document.removeEventListener('keydown', keyDown)
469
+ }
470
+ }