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);
}
}
}