1import { Controller } from '@hotwired/stimulus' 2 3// Connects to data-controller='sortable' 4// Pointer drag-to-reorder of [data-sortable-target=item] rows, grabbed by their 5// [data-sortable-target=handle] grip. A thin bar shows where the row will land; on release 6// the row is moved there and (when it has data-url) its new position is PATCHed. 7// Pointer events -- not native HTML5 draggable -- so a rich-text editor inside a row can't 8// swallow the drag and there is no native snap-back animation. Subclasses override reorder(). 9export default class extends Controller { 10 static targets = ['item', 'handle'] 11 12 connect () { 13 this.draggingItem = null 14 this.dropTarget = null 15 this.dropAfter = false 16 // Slid into the target gap during a drag; pointer-events:none so it never blocks hit-testing. 17 this.indicator = document.createElement('div') 18 this.indicator.setAttribute('aria-hidden', 'true') 19 this.indicator.style.cssText = 'height:2px;border-radius:9999px;background:#2563eb;pointer-events:none;' 20 } 21 22 disconnect () { 23 this.#stopTracking() 24 } 25 26 handleTargetConnected (handle) { 27 handle.addEventListener('pointerdown', this.#onPointerDown) 28 } 29 30 #onPointerDown = (event) => { 31 if (event.button !== 0) return 32 const item = event.target.closest(this.#itemSelector) 33 if (!item) return 34 event.preventDefault() 35 this.draggingItem = item 36 this.dropTarget = null 37 item.classList.add('tw:opacity-50') 38 document.addEventListener('pointermove', this.#onPointerMove) 39 document.addEventListener('pointerup', this.#onPointerUp, { once: true }) 40 document.addEventListener('pointercancel', this.#onPointerCancel, { once: true }) 41 } 42 43 #onPointerMove = (event) => { 44 const item = this.itemTargets.find((row) => { 45 const box = row.getBoundingClientRect() 46 return event.clientY >= box.top && event.clientY <= box.bottom 47 }) 48 if (!item) return 49 this.dropTarget = item 50 this.dropAfter = this.#after(item, event.clientY) 51 item.parentNode.insertBefore(this.indicator, this.dropAfter ? item.nextSibling : item) 52 } 53 54 #onPointerUp = () => { 55 this.#stopTracking() 56 this.draggingItem.classList.remove('tw:opacity-50') 57 if (this.dropTarget && this.dropTarget !== this.draggingItem) { 58 this.reorder(this.dropTarget, this.dropAfter) 59 } 60 this.draggingItem = null 61 this.dropTarget = null 62 } 63 64 // The browser can hijack a touch gesture (scroll/zoom) and fire pointercancel; abort cleanly. 65 #onPointerCancel = () => { 66 this.#stopTracking() 67 this.draggingItem?.classList.remove('tw:opacity-50') 68 this.draggingItem = null 69 this.dropTarget = null 70 } 71 72 #stopTracking () { 73 document.removeEventListener('pointermove', this.#onPointerMove) 74 document.removeEventListener('pointerup', this.#onPointerUp) 75 document.removeEventListener('pointercancel', this.#onPointerCancel) 76 this.indicator.remove() 77 } 78 79 // Default: move the dragged row next to the target, then persist its new position. 80 reorder (target, after) { 81 target.parentNode.insertBefore(this.draggingItem, after ? target.nextSibling : target) 82 this.persist(this.draggingItem) 83 } 84 85 persist (item) { 86 if (!item.dataset.url) return 87 const position = this.itemTargets.indexOf(item) 88 const token = document.querySelector('meta[name="csrf-token"]')?.content 89 fetch(item.dataset.url, { 90 method: 'PATCH', 91 headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, 92 body: JSON.stringify({ position }) 93 }) 94 } 95 96 #after (item, clientY) { 97 const box = item.getBoundingClientRect() 98 return clientY > box.top + box.height / 2 99 } 100 101 get #itemSelector () { 102 return `[data-${this.identifier}-target="item"]` 103 } 104};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.