import { Controller } from '@hotwired/stimulus'; import Sortable from 'sortablejs'; import { patch, post } from '@rails/request.js'; import { devWarn } from 'helpers/logger'; import { SortableState } from 'states/sortable_state'; /** * 정렬할 수 있는 리스트를 관리하는 Stimulus 컨트롤러입니다. * 이 컨트롤러는 Sortable.js 라이브러리를 사용하여 드래그 앤 드롭 기능을 구현합니다. * 사용자가 리스트 항목을 드래그하여 순서를 변경하거나 새로운 항목을 추가할 수 있습니다. * 또한, 변경된 순서를 서버에 비동기적으로 업데이트합니다. */ export default class extends Controller { static values = { updateUrl: String, animation: { default: 0, type: Number }, group: Object, sort: Boolean, }; static targets = ['draggable', 'handle']; static classes = ['chosen', 'drag', 'ghost', 'bodyDrag']; connect() { // 드래그 중이면 연결을 중단 if (SortableState.dragging) { devWarn('connect skipped due to drag in progress'); return; } if (this.sortable) { devWarn('Sortable already initialized, disconnecting first'); return; } this.sortable = Sortable.create(this.element, this.getOptions()); } disconnect() { // 드래그 중이면 연결을 중단 if (SortableState.dragging) { devWarn('disconnect skipped due to drag in progress'); return; } if (this.sortable) { devWarn('Disconnecting Sortable:', this.element); this.sortable.destroy(); this.sortable = null; } else { devWarn('No Sortable instance to disconnect:', this.element); } } getOptions() { const options = { animation: this.animationValue, draggable: "[data-sortable-target='draggable']", handle: "[data-sortable-target='handle']", onUpdate: this.onUpdate.bind(this), onAdd: this.onAdd.bind(this), onStart: this.onStart.bind(this), onEnd: this.onEnd.bind(this), // fallbackOnBody: true 설정은 드래그 중 생성되는 ghost 요소를
로 이동시켜, // 레이아웃 충돌 없이 안정적인 드래그 효과를 제공하기 위한 옵션입니다. // // ✅ 중첩(nested) 리스트 환경에서는 필수적으로 설정해야 하며, // 중첩된 리스트 간 드래그 시 ghost 위치 오류나 깜빡임 현상을 방지합니다. // // ✅ 비중첩(single-level) 리스트에서도 사용 가능하며, // 드래그 요소가 overflow, z-index 등 레이아웃 제약 없이 자연스럽게 보이게 도와줍니다. // // ⚠️ 단, ghost 요소가 body에 붙기 때문에 // absolute/fixed 컨테이너 내부에서는 위치 보정이 필요할 수 있으며, // 일부 레이아웃에서는 예상치 못한 위치 오류나 성능 이슈가 발생할 수 있습니다. // // ➤ 결론적으로, fallbackOnBody는 중첩 여부와 관계없이 사용 가능하나, // 레이아웃 구조에 따라 동작을 반드시 테스트 후 사용하는 것이 좋습니다. fallbackOnBody: true, }; if (this.hasGhostClass) options.ghostClass = this.ghostClass; if (this.hasChosenClass) options.chosenClass = this.chosenClass; if (this.hasDragClass) options.dragClass = this.dragClass; if (this.groupValue) options.group = this.groupValue; if (this.hasSortValue) options.sort = this.sortValue; return options; } async onUpdate(event) { try { const urlStr = this.updateUrlValue; if (!urlStr) return; const url = new URL(urlStr, document.baseURI); const formData = this._buildFormData(); await patch(url, { body: formData, responseKind: 'turbo-stream', }); } catch (e) { devWarn(`Update request failed: ${e}`); } } async onAdd(event) { try { const item = event.item; const urlStr = item.dataset.sortableAddUrl; if (!urlStr) return; const url = new URL(urlStr, document.baseURI); const formData = this._buildFormData(); await post(url, { body: formData, responseKind: 'turbo-stream', }); } catch (e) { devWarn(`Update request failed: ${e}`); } } // 부모 ID + 현재 리스트 안 draggable ID 수집을 담당하는 헬퍼 함수 _buildFormData() { const formData = new FormData(); // 컨테이너 내부 draggable 대상의 sortableItemId 수집 for (const element of this.draggableTargets) { const id = element.dataset.sortableItemId; if (!id) { devWarn('Sortable item is missing data-sortable-item-id attribute.'); continue; } formData.append('sortable_ids[]', id); } return formData; } onStart() { SortableState.dragging = true; if (this.hasBodyDragClass) { document.body.classList.add(...this.bodyDragClasses); } } onEnd() { SortableState.dragging = false; if (this.hasBodyDragClass) { document.body.classList.remove(...this.bodyDragClasses); } } }

AltStyle によって変換されたページ (->オリジナル) /