PageSourceSearch

https://cooksclub.worldspice.com/assets/controllers/sortable_controller-6ab6d3d1.js

js worldspice.com collected 2026-10-03 21:51:30 UTC 7,931 bytes, 246 lines download raw bytes

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.