1// @swarm_description: Native HTML5 drag-and-drop for reordering list items with touch support for mobile 2import { Controller } from "@hotwired/stimulus" 3 4export default class extends Controller { 5 static values = { 6 url: String 7 } 8 9 connect() { 10 this.draggedItem = null 11 this.setupDragHandles() 12 this.setupTouchEvents() 13 } 14 15 setupDragHandles() { 16 this.element.querySelectorAll("[data-sortable-id]").forEach(item => { 17 const handle = item.querySelector("[data-sortable-handle]") 18 if (handle) { 19 handle.addEventListener("mousedown", () => { 20 item.draggable = true 21 }) 22 handle.addEventListener("mouseup", () => { 23 item.draggable = false 24 }) 25 } else { 26 item.draggable = true 27 } 28 29 item.addEventListener("dragstart", this.dragStart.bind(this)) 30 item.addEventListener("dragover", this.dragOver.bind(this)) 31 item.addEventListener("dragenter", this.dragEnter.bind(this)) 32 item.addEventListener("dragleave", this.dragLeave.bind(this)) 33 item.addEventListener("drop", this.drop.bind(this)) 34 item.addEventListener("dragend", this.dragEnd.bind(this)) 35 }) 36 } 37 38 setupTouchEvents() { 39 // Touch event handling for mobile 40 this.element.querySelectorAll("[data-sortable-handle]").forEach(handle => { 41 handle.addEventListener("touchstart", this.handleTouchStart.bind(this), { passive: false }) 42 handle.addEventListener("touchmove", this.handleTouchMove.bind(this), { passive: false }) 43 handle.addEventListener("touchend", this.handleTouchEnd.bind(this)) 44 }) 45 46 // Also allow touch on items without handles 47 this.element.querySelectorAll("[data-sortable-id]").forEach(item => { 48 if (!item.querySelector("[data-sortable-handle]")) { 49 item.addEventListener("touchstart", this.handleTouchStart.bind(this), { passive: false }) 50 item.addEventListener("touchmove", this.handleTouchMove.bind(this), { passive: false }) 51 item.addEventListener("touchend", this.handleTouchEnd.bind(this)) 52 } 53 }) 54 } 55 56 handleTouchStart(e) { 57 const item = e.target.closest("[data-sortable-id]") 58 if (!item) return 59 60 // Prevent default to avoid scrolling while dragging 61 // Note: We only prevent default on move, not start, to allow tap events 62 63 this.draggedItem = item 64 this.touchStartY = e.touches[0].clientY 65 this.touchStartX = e.touches[0].clientX 66 this.itemRect = item.getBoundingClientRect() 67 this.scrollStartY = window.scrollY 68 69 // Add visual feedback after a short delay to differentiate from taps 70 this.touchTimeout = setTimeout(() => { 71 item.classList.add("opacity-50", "scale-105", "z-10", "relative") 72 }, 100) 73 } 74 75 handleTouchMove(e) { 76 if (!this.draggedItem) return 77 78 const touchY = e.touches[0].clientY 79 const touchX = e.touches[0].clientX 80 const deltaY = touchY - this.touchStartY 81 const deltaX = touchX - this.touchStartX 82 83 // If user is scrolling horizontally (more X movement than Y), let it through 84 if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaY) < 10) { 85 return 86 } 87 88 // Prevent scrolling while dragging vertically 89 e.preventDefault() 90 91 // Clear the timeout if we're actively dragging 92 if (this.touchTimeout) { 93 clearTimeout(this.touchTimeout) 94 this.touchTimeout = null 95 this.draggedItem.classList.add("opacity-50", "scale-105", "z-10", "relative") 96 } 97 98 // Visual feedback - transform the dragged item 99 this.draggedItem.style.transform = `translateY(${deltaY}px)` 100 101 // Find drop target 102 const items = [...this.element.querySelectorAll("[data-sortable-id]")] 103 const currentIndex = items.indexOf(this.draggedItem) 104 105 items.forEach((item, index) => { 106 if (item === this.draggedItem) return 107 108 const rect = item.getBoundingClientRect() 109 const midY = rect.top + rect.height / 2 110 111 // Remove previous indicators 112 item.classList.remove("border-t-2", "border-b-2", "border-primary") 113 114 // Add indicator for drop position 115 if (touchY < midY && touchY > rect.top && index < currentIndex) { 116 item.classList.add("border-t-2", "border-primary") 117 } else if (touchY > midY && touchY < rect.bottom && index > currentIndex) { 118 item.classList.add("border-b-2", "border-primary") 119 } 120 }) 121 } 122 123 handleTouchEnd(e) { 124 if (!this.draggedItem) return 125
126 // Clear any pending timeout 127 if (this.touchTimeout) { 128 clearTimeout(this.touchTimeout) 129 this.touchTimeout = null 130 } 131 132 // Reset visual styles 133 this.draggedItem.style.transform = "" 134 this.draggedItem.classList.remove("opacity-50", "scale-105", "z-10", "relative") 135 136 // Find the drop target based on final touch position 137 const touchEndY = e.changedTouches[0].clientY 138 const items = [...this.element.querySelectorAll("[data-sortable-id]")] 139 const currentIndex = items.indexOf(this.draggedItem) 140 141 // Clear all border indicators and find drop target 142 let dropTarget = null 143 let insertBefore = false 144 145 items.forEach((item, index) => { 146 item.classList.remove("border-t-2", "border-b-2", "border-primary") 147 148 if (item === this.draggedItem) return 149 150 const rect = item.getBoundingClientRect() 151 const midY = rect.top + rect.height / 2 152 153 if (touchEndY >= rect.top && touchEndY <= rect.bottom) { 154 dropTarget = item 155 insertBefore = touchEndY < midY 156 } 157 }) 158 159 // Perform the move if we found a valid drop target 160 if (dropTarget && dropTarget !== this.draggedItem) { 161 if (insertBefore) { 162 dropTarget.parentNode.insertBefore(this.draggedItem, dropTarget) 163 } else { 164 dropTarget.parentNode.insertBefore(this.draggedItem, dropTarget.nextSibling) 165 } 166 167 // Save the new order 168 this.saveOrder() 169 } 170 171 this.draggedItem = null 172 } 173 174 dragStart(event) { 175 this.draggedItem = event.currentTarget 176 this.draggedItem.classList.add("opacity-50") 177 event.dataTransfer.effectAllowed = "move" 178 event.dataTransfer.setData("text/plain", event.currentTarget.dataset.sortableId) 179 } 180 181 dragOver(event) { 182 event.preventDefault() 183 event.dataTransfer.dropEffect = "move" 184 } 185 186 dragEnter(event) { 187 event.preventDefault() 188 const target = event.currentTarget 189 if (target !== this.draggedItem && target.dataset.sortableId) { 190 target.classList.add("border-primary", "border-2") 191 } 192 } 193 194 dragLeave(event) { 195 const target = event.currentTarget 196 target.classList.remove("border-primary", "border-2") 197 } 198 199 drop(event) { 200 event.preventDefault() 201 const target = event.currentTarget 202 target.classList.remove("border-primary", "border-2") 203 204 if (target !== this.draggedItem && target.dataset.sortableId) { 205 const items = [...this.element.querySelectorAll("[data-sortable-id]")] 206 const draggedIndex = items.indexOf(this.draggedItem) 207 const targetIndex = items.indexOf(target) 208 209 if (draggedIndex < targetIndex) { 210 target.after(this.draggedItem) 211 } else { 212 target.before(this.draggedItem) 213 } 214 } 215 } 216 217 dragEnd(event) { 218 event.currentTarget.classList.remove("opacity-50") 219 event.currentTarget.draggable = false 220 221 this.element.querySelectorAll("[data-sortable-id]").forEach(item => { 222 item.classList.remove("border-primary", "border-2") 223 }) 224 225 this.saveOrder() 226 } 227 228 async saveOrder() { 229 const items = this.element.querySelectorAll("[data-sortable-id]") 230 const contentIds = Array.from(items).map(item => item.dataset.sortableId) 231 232 const response = await fetch(this.urlValue, { 233 method: "PATCH", 234 headers: { 235 "Content-Type": "application/json", 236 "X-CSRF-Token": document.querySelector("[name='csrf-token']").content, 237 "Accept": "application/json" 238 }, 239 body: JSON.stringify({ content_ids: contentIds }) 240 }) 241 242 if (!response.ok) { 243 console.error("Failed to save order") 244 } 245 } 246}
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.