PageSourceSearch

https://codefor.kr/assets/controllers/base/sortable_controller-41fb5a88.js

js codefor.kr collected 2026-10-05 11:48:36 UTC 8,025 bytes, 253 lines download raw bytes

1import { Controller } from '@hotwired/stimulus';
2import Sortable from 'sortablejs';
3import { patch, post } from '@rails/request.js';
4import { devWarn } from 'helpers/logger';
5import { generateUniqueId } from 'helpers/unique_id';
6import { SortableState } from 'states/sortable_state';
7
8/**
9 * @domain 레이아웃 제어
10 * @concern 드래그 앤 드롭으로 리스트 순서 변경
11 * @description Sortable.js로 리스트 항목의 드래그 앤 드롭 재정렬과 추가를 처리하고, 변경된 순서를 서버에 비동기 저장하는 컨트롤러
12 */
13export default class extends Controller {
14  static values = {
15    updateUrl: String,
16    animation: { default: 0, type: Number },
17    group: Object,
18    sort: Boolean,
19    disabled: { default: false, type: Boolean },
20  };
21
22  static targets = ['draggable', 'handle'];
23  static classes = ['chosen', 'drag', 'ghost', 'bodyDrag'];
24
25  connect() {
26    // 드래그 중이면 연결을 중단
27    if (SortableState.dragging) {
28      devWarn('connect skipped due to drag in progress');
29      return;
30    }
31
32    if (this.sortable) {
33      devWarn('Sortable already initialized, disconnecting first');
34      return;
35    }
36
37    this.sortable = Sortable.create(this.element, this.getOptions());
38
39    this.newIdCounter = 0; // 새 항목에 대한 고유 ID 카운터 초기화
40
41    this.element.addEventListener('dragstart', this.handleDragStart); // 드래그 시작 이벤트 리스너 등록
42
43    devWarn('Connecting Sortable:', this.element);
44  }
45
46  disconnect() {
47    // 드래그 중이면 연결을 중단
48    if (SortableState.dragging) {
49      devWarn('disconnect skipped due to drag in progress');
50      return;
51    }
52
53    if (this.sortable) {
54      devWarn('Disconnecting Sortable:', this.element);
55      this.sortable.destroy();
56      this.sortable = null;
57    } else {
58      devWarn('No Sortable instance to disconnect:', this.element);
59    }
60
61    this.element.removeEventListener('dragstart', this.handleDragStart);
62  }
63
64  getOptions() {
65    const options = {
66      animation: this.animationValue,
67      draggable: "[data-sortable-target='draggable']",
68      handle: "[data-sortable-target='handle']",
69      onUpdate: this.onUpdate.bind(this),
70      onAdd: this.onAdd.bind(this),
71      onStart: this.onStart.bind(this),
72      onEnd: this.onEnd.bind(this),
73      // fallbackOnBody: true 설정은 드래그 중 생성되는 ghost 요소를 <body>로 이동시켜,
74      // 레이아웃 충돌 없이 안정적인 드래그 효과를 제공하기 위한 옵션입니다.
75      //
76      // ✅ 중첩(nested) 리스트 환경에서는 필수적으로 설정해야 하며,
77      //    중첩된 리스트 간 드래그 시 ghost 위치 오류나 깜빡임 현상을 방지합니다.
78      //
79      // ✅ 비중첩(single-level) 리스트에서도 사용 가능하며,
80      //    드래그 요소가 overflow, z-index 등 레이아웃 제약 없이 자연스럽게 보이게 도와줍니다.
81      //
82      // ⚠️ 단, ghost 요소가 body에 붙기 때문에
83      //    absolute/fixed 컨테이너 내부에서는 위치 보정이 필요할 수 있으며,
84      //    일부 레이아웃에서는 예상치 못한 위치 오류나 성능 이슈가 발생할 수 있습니다.
85      //
86      // ➤ 결론적으로, fallbackOnBody는 중첩 여부와 관계없이 사용 가능하나,
87      //    레이아웃 구조에 따라 동작을 반드시 테스트 후 사용하는 것이 좋습니다.
88      fallbackOnBody: true,
89      disabled: this.hasDisabledValue ? this.disabledValue : false,
90      forceFallback: false,
91    };
92
93    if (this.hasGhostClass) options.ghostClass = this.ghostClass;
94    if (this.hasChosenClass) options.chosenClass = this.chosenClass;
95    if (this.hasDragClass) options.dragClass = this.dragClass;
96    if (this.groupValue) options.group = this.groupValue;
97    if (this.hasSortValue) options.sort = this.sortValue;
98    return options;
99  }
100
101  async onUpdate(event) {
102    try {
103      const urlStr = this.updateUrlValue;
104      if (!urlStr) return;
105
106      const url = new URL(urlStr, document.baseURI);
107      const formData = this._buildFormData();
108
109      await patch(url, {
110        body: formData,
111        responseKind: 'turbo-stream',
112      });
113    } catch (e) {
114      devWarn(`Update request failed: ${e}`);
115    }
116  }
117
118  async onAdd(event) {
119    try {
120      const item = event.item;
121      const urlStr = item.dataset.sortableAddUrl;
122      if (!urlStr) return;
123
124      if (!item.id) {
125        item.id = generateUniqueId(this.newIdCounter++);
126      }
127
128      // 서버 저장 중 표시
129      item.dataset.saving = 'true';
130
131      // 서버 저장 전에는 정렬 막기
132      this.sortable.option('sort', false);
133
134      const url = new URL(urlStr, document.baseURI);
135      const formData = this._buildFormData();
136      formData.append('item_id', item.id);
137
138      await post(url, {
139        body: formData,
140        responseKind: 'turbo-stream',
141      });
142
143      // 서버 저장 완료
144      delete item.dataset.saving;
145
146      // 모든 새 항목 저장 완료되면 정렬 허용
147      const hasSaving = this.draggableTargets.some((el) => el.dataset.saving === 'true');
148      if (!hasSaving) this.sortable.option('sort', true);
149    } catch (e) {
150      devWarn(`Add request failed: ${e}`);
151
152      const item = event.item;
153      if (item && item.parentNode) {
154        item.parentNode.removeChild(item);
155      }
156      alert('항목 추가에 실패했습니다. 잠시 후 다시 시도해주세요.');
157
158      const hasSaving = this.draggableTargets.some((el) => el.dataset.saving === 'true');
159      if (!hasSaving) this.sortable.option('sort', true);
160    }
161  }
162
163  // 부모 ID + 현재 리스트 안 draggable ID 수집을 담당하는 헬퍼 함수
164  _buildFormData() {
165    const formData = new FormData();
166
167    // 컨테이너 내부 draggable 대상의 sortableItemId 수집
168    for (const element of this.draggableTargets) {
169      const id = element.dataset.sortableItemId;
170      if (!id) {
171        devWarn('Sortable item is missing data-sortable-item-id attribute.');
172        continue;
173      }
174      formData.append('sortable_ids[]', id);
175    }
176
177    return formData;
178  }
179
180  onStart(event) {
181    const hasSavingItems = this.draggableTargets.some((el) => el.dataset.saving === 'true');
182
183    if (hasSavingItems) {
184      devWarn('정렬 불가: 새 항목 추가 처리 중');
185      alert('새 항목을 저장 중입니다. 잠시 후 다시 시도해주세요.');
186      event.preventDefault();
187      return false;
188    }
189
190    SortableState.dragging = true;
191
192    if (this.hasBodyDragClass) {
193      document.body.classList.add(...this.bodyDragClasses);
194    }
195  }
196
197  onEnd() {
198    SortableState.dragging = false;
199
200    if (this.hasBodyDragClass) {
201      document.body.classList.remove(...this.bodyDragClasses);
202    }
203  }
204
205  handleDragStart = (e) => {
206    const target = e.target.closest("[data-sortable-target='draggable']");
207    if (!target) return;
208
209    const dt = e.dataTransfer;
210    if (!dt) return;
211
212    const dragImage = this.createDragPreview();
213
214    dragImage.style.position = 'absolute';
215    dragImage.style.top = '-9999px';
216    dragImage.style.left = '-9999px';
217    dragImage.style.pointerEvents = 'none';
218
219    document.body.appendChild(dragImage);
220
221    dt.setDragImage(dragImage, 20, 20);
222
223    setTimeout(() => dragImage.remove(), 0);
224  };
225
226  createDragPreview() {
227    const el = document.createElement('div');
228
229    el.className = `
230    w-[140px] h-[40px]
231    flex items-center justify-center gap-2
232    border-2 border-dashed border-slate-300
233    rounded-lg
234    bg-slate-50
235    text-slate-500 text-xs font-medium
236    shadow-md
237  `;
238
239    el.innerHTML = `
240    <svg class="w-4 h-4 text-slate-400" viewBox="0 0 20 20" fill="currentColor">
241      <circle cx="6" cy="6" r="1.5"/>
242      <circle cx="6" cy="10" r="1.5"/>
243      <circle cx="6" cy="14" r="1.5"/>
244      <circle cx="10" cy="6" r="1.5"/>
245      <circle cx="10" cy="10" r="1.5"/>
246      <circle cx="10" cy="14" r="1.5"/>
247    </svg>
248    <span>Block</span>
249  `;
250
251    return el;
252  }
253}

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.