fix: 메뉴 드래그 인덴트/아웃덴트 시각적 피드백 개선
- Notion 스타일 좌우 드래그로 계층 이동 방식 변경 - → 오른쪽 드래그: 상위 메뉴의 하위로 이동 (파란색 하이라이트) - ← 왼쪽 드래그: 상위 레벨로 이동 (주황색 하이라이트) - 드래그 인디케이터 툴팁 추가 (인덴트/아웃덴트/순서변경) - CSS 펄스 애니메이션으로 타겟 행 강조 - updateRowHighlight 함수 수정: 아웃덴트 시 드래그 중인 행 하이라이트
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">메뉴 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
드래그: 같은 레벨 순서 변경 | <span class="font-medium">Shift+드래그</span>: 위 메뉴의 하위로 이동
|
||||
드래그: 순서 변경 | <span class="font-medium text-blue-600">→ 오른쪽</span>: 하위로 이동 | <span class="font-medium text-orange-600">← 왼쪽</span>: 상위로 이동
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('menus.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
||||
@@ -57,6 +57,74 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
/* 드래그 인디케이터 스타일 */
|
||||
.drag-indicator {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
transition: background-color 0.15s, transform 0.1s;
|
||||
}
|
||||
.drag-indicator.indent {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
.drag-indicator.outdent {
|
||||
background: linear-gradient(135deg, #f97316, #ea580c);
|
||||
color: white;
|
||||
}
|
||||
.drag-indicator.reorder {
|
||||
background: linear-gradient(135deg, #6b7280, #4b5563);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 드래그 중 행 하이라이트 - 더 눈에 띄게 */
|
||||
.menu-row.drag-target-indent {
|
||||
background: rgba(59, 130, 246, 0.25) !important;
|
||||
border: 2px solid #3b82f6 !important;
|
||||
border-left-width: 6px !important;
|
||||
position: relative;
|
||||
animation: pulse-indent 0.8s ease-in-out infinite;
|
||||
}
|
||||
.menu-row.drag-target-outdent {
|
||||
background: rgba(249, 115, 22, 0.25) !important;
|
||||
border: 2px solid #f97316 !important;
|
||||
border-right-width: 6px !important;
|
||||
position: relative;
|
||||
animation: pulse-outdent 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 펄스 애니메이션 */
|
||||
@keyframes pulse-indent {
|
||||
0%, 100% { background: rgba(59, 130, 246, 0.15); }
|
||||
50% { background: rgba(59, 130, 246, 0.35); }
|
||||
}
|
||||
@keyframes pulse-outdent {
|
||||
0%, 100% { background: rgba(249, 115, 22, 0.15); }
|
||||
50% { background: rgba(249, 115, 22, 0.35); }
|
||||
}
|
||||
|
||||
/* 인덴트 프리뷰 라인 */
|
||||
.indent-preview-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: #3b82f6;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
transition: width 0.15s, left 0.15s;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
@@ -79,6 +147,79 @@ class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
}
|
||||
});
|
||||
|
||||
// 드래그 상태 관리
|
||||
let dragStartX = 0;
|
||||
let dragIndicator = null;
|
||||
let currentDragItem = null;
|
||||
let lastHighlightedRow = null;
|
||||
const INDENT_THRESHOLD = 40; // px - 인덴트 임계값
|
||||
|
||||
// 드래그 인디케이터 생성
|
||||
function createDragIndicator() {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'drag-indicator reorder';
|
||||
indicator.innerHTML = '↕️ 순서 변경';
|
||||
indicator.style.display = 'none';
|
||||
document.body.appendChild(indicator);
|
||||
return indicator;
|
||||
}
|
||||
|
||||
// 드래그 인디케이터 업데이트
|
||||
function updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName) {
|
||||
if (!dragIndicator) return;
|
||||
|
||||
dragIndicator.style.display = 'block';
|
||||
dragIndicator.style.left = (mouseX + 15) + 'px';
|
||||
dragIndicator.style.top = (mouseY - 15) + 'px';
|
||||
|
||||
// 클래스 및 텍스트 업데이트
|
||||
dragIndicator.classList.remove('indent', 'outdent', 'reorder');
|
||||
|
||||
if (deltaX > INDENT_THRESHOLD && canIndent) {
|
||||
dragIndicator.classList.add('indent');
|
||||
dragIndicator.innerHTML = `→ <b>${targetName}</b>의 하위로`;
|
||||
} else if (deltaX < -INDENT_THRESHOLD && canOutdent) {
|
||||
dragIndicator.classList.add('outdent');
|
||||
dragIndicator.innerHTML = '← 상위 레벨로';
|
||||
} else {
|
||||
dragIndicator.classList.add('reorder');
|
||||
dragIndicator.innerHTML = '↕️ 순서 변경';
|
||||
}
|
||||
}
|
||||
|
||||
// 행 하이라이트 업데이트
|
||||
function updateRowHighlight(prevRow, draggedRow, deltaX, canIndent, canOutdent) {
|
||||
// 이전 하이라이트 제거
|
||||
if (lastHighlightedRow) {
|
||||
lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent');
|
||||
}
|
||||
|
||||
// 새 하이라이트 적용
|
||||
if (deltaX > INDENT_THRESHOLD && canIndent && prevRow) {
|
||||
// 인덴트: prevRow(새 부모가 될 행) 하이라이트
|
||||
prevRow.classList.add('drag-target-indent');
|
||||
lastHighlightedRow = prevRow;
|
||||
} else if (deltaX < -INDENT_THRESHOLD && canOutdent && draggedRow) {
|
||||
// 아웃덴트: 드래그 중인 행 자체 하이라이트
|
||||
draggedRow.classList.add('drag-target-outdent');
|
||||
lastHighlightedRow = draggedRow;
|
||||
} else {
|
||||
lastHighlightedRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 드래그 인디케이터 제거
|
||||
function removeDragIndicator() {
|
||||
if (dragIndicator) {
|
||||
dragIndicator.remove();
|
||||
dragIndicator = null;
|
||||
}
|
||||
if (lastHighlightedRow) {
|
||||
lastHighlightedRow.classList.remove('drag-target-indent', 'drag-target-outdent');
|
||||
lastHighlightedRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
// SortableJS 초기화 함수
|
||||
function initMenuSortable() {
|
||||
const tbody = document.getElementById('menu-sortable');
|
||||
@@ -89,63 +230,173 @@ function initMenuSortable() {
|
||||
tbody.sortableInstance.destroy();
|
||||
}
|
||||
|
||||
// SortableJS 초기화 - 계층 이동 지원
|
||||
// 드래그 중 마우스 이동 핸들러
|
||||
function onDragMove(e) {
|
||||
if (!currentDragItem) return;
|
||||
|
||||
const mouseX = e.clientX || e.touches?.[0]?.clientX || 0;
|
||||
const mouseY = e.clientY || e.touches?.[0]?.clientY || 0;
|
||||
const deltaX = mouseX - dragStartX;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
|
||||
const draggedIndex = rows.indexOf(currentDragItem);
|
||||
|
||||
// 위 행 찾기 (드래그 중인 항목 제외)
|
||||
let prevRow = null;
|
||||
for (let i = draggedIndex - 1; i >= 0; i--) {
|
||||
if (rows[i] !== currentDragItem && !rows[i].classList.contains('sortable-ghost')) {
|
||||
prevRow = rows[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 인덴트/아웃덴트 가능 여부 체크
|
||||
const oldParentIdRaw = currentDragItem.dataset.parentId;
|
||||
const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null);
|
||||
const canIndent = prevRow !== null;
|
||||
const canOutdent = oldPid !== null;
|
||||
|
||||
// 타겟 이름 가져오기
|
||||
let targetName = '';
|
||||
if (prevRow && canIndent) {
|
||||
const nameCell = prevRow.querySelector('td:nth-child(3) span');
|
||||
targetName = nameCell?.textContent?.trim() || '위 항목';
|
||||
}
|
||||
|
||||
updateDragIndicator(deltaX, mouseX, mouseY, canIndent, canOutdent, targetName);
|
||||
updateRowHighlight(prevRow, currentDragItem, deltaX, canIndent, canOutdent);
|
||||
}
|
||||
|
||||
// SortableJS 초기화 - 노션 스타일 인덴트
|
||||
tbody.sortableInstance = new Sortable(tbody, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'bg-blue-50',
|
||||
chosenClass: 'bg-blue-100',
|
||||
dragClass: 'shadow-lg',
|
||||
// 모든 위치로 이동 허용 (계층 변경 가능)
|
||||
|
||||
// 드래그 시작 시
|
||||
onStart: function(evt) {
|
||||
dragStartX = evt.originalEvent?.clientX || evt.originalEvent?.touches?.[0]?.clientX || 0;
|
||||
currentDragItem = evt.item;
|
||||
dragIndicator = createDragIndicator();
|
||||
|
||||
// 마우스 이동 이벤트 리스너 추가
|
||||
document.addEventListener('mousemove', onDragMove);
|
||||
document.addEventListener('touchmove', onDragMove);
|
||||
|
||||
console.log('=== Drag Start ===');
|
||||
console.log('dragStartX:', dragStartX);
|
||||
},
|
||||
|
||||
// 드래그 종료 시 처리
|
||||
onEnd: function(evt) {
|
||||
// 이벤트 리스너 및 인디케이터 정리
|
||||
document.removeEventListener('mousemove', onDragMove);
|
||||
document.removeEventListener('touchmove', onDragMove);
|
||||
removeDragIndicator();
|
||||
currentDragItem = null;
|
||||
|
||||
const movedItem = evt.item;
|
||||
const menuId = parseInt(movedItem.dataset.menuId);
|
||||
const oldParentId = movedItem.dataset.parentId || null;
|
||||
const currentDepth = parseInt(movedItem.dataset.depth) || 0;
|
||||
const oldParentIdRaw = movedItem.dataset.parentId;
|
||||
const oldPid = oldParentIdRaw === '' ? null : (oldParentIdRaw ? parseInt(oldParentIdRaw) : null);
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
|
||||
const newIndex = rows.indexOf(movedItem);
|
||||
const oldIndex = evt.oldIndex;
|
||||
|
||||
// 새 부모 결정: Shift 키로 계층 이동
|
||||
let newParentId = null;
|
||||
// X 이동량 계산
|
||||
const dragEndX = evt.originalEvent?.clientX || evt.originalEvent?.changedTouches?.[0]?.clientX || dragStartX;
|
||||
const deltaX = dragEndX - dragStartX;
|
||||
|
||||
console.log('=== Menu Drag Debug (Indent Style) ===');
|
||||
console.log('menuId:', menuId, 'currentDepth:', currentDepth);
|
||||
console.log('oldIndex:', oldIndex, '→ newIndex:', newIndex);
|
||||
console.log('dragStartX:', dragStartX, 'dragEndX:', dragEndX, 'deltaX:', deltaX);
|
||||
console.log('oldParentId:', oldPid);
|
||||
|
||||
let newParentId = oldPid;
|
||||
let sortOrder = 1;
|
||||
let action = 'reorder'; // 'reorder', 'indent', 'outdent'
|
||||
|
||||
if (newIndex > 0) {
|
||||
// 오른쪽으로 이동 → 인덴트 (위 항목의 자식으로)
|
||||
if (deltaX > INDENT_THRESHOLD && newIndex > 0) {
|
||||
const prevRow = rows[newIndex - 1];
|
||||
if (prevRow) {
|
||||
const prevId = parseInt(prevRow.dataset.menuId);
|
||||
const prevParentId = prevRow.dataset.parentId || null;
|
||||
|
||||
// Shift+드래그: 위 행의 하위로 이동
|
||||
if (evt.originalEvent && evt.originalEvent.shiftKey) {
|
||||
// 자기 자신의 자식으로는 이동 불가
|
||||
if (prevId !== menuId) {
|
||||
newParentId = prevId;
|
||||
} else {
|
||||
// 일반 드래그: 위 행과 같은 부모
|
||||
newParentId = prevParentId ? parseInt(prevParentId) : null;
|
||||
action = 'indent';
|
||||
console.log('→ INDENT: 위 항목의 자식으로 이동, newParentId:', newParentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 왼쪽으로 이동 → 아웃덴트 (부모의 형제로)
|
||||
else if (deltaX < -INDENT_THRESHOLD && oldPid !== null) {
|
||||
// 현재 부모를 찾아서 그 부모의 parent_id를 가져옴
|
||||
const parentRow = rows.find(r => parseInt(r.dataset.menuId) === oldPid);
|
||||
if (parentRow) {
|
||||
const grandParentIdRaw = parentRow.dataset.parentId;
|
||||
newParentId = grandParentIdRaw === '' ? null : (grandParentIdRaw ? parseInt(grandParentIdRaw) : null);
|
||||
action = 'outdent';
|
||||
console.log('← OUTDENT: 부모의 형제로 이동, newParentId:', newParentId);
|
||||
} else {
|
||||
// 부모를 찾을 수 없으면 최상위로
|
||||
newParentId = null;
|
||||
action = 'outdent';
|
||||
console.log('← OUTDENT: 최상위로 이동');
|
||||
}
|
||||
}
|
||||
// 수평 이동 없음 → 같은 레벨에서 순서만 변경
|
||||
else {
|
||||
// 위 행과 같은 부모로 (기존 동작)
|
||||
if (newIndex > 0) {
|
||||
const prevRow = rows[newIndex - 1];
|
||||
if (prevRow) {
|
||||
const prevParentIdRaw = prevRow.dataset.parentId;
|
||||
newParentId = prevParentIdRaw === '' ? null : (prevParentIdRaw ? parseInt(prevParentIdRaw) : null);
|
||||
}
|
||||
} else {
|
||||
newParentId = null;
|
||||
}
|
||||
console.log('↔ REORDER: 위 행과 같은 레벨로, newParentId:', newParentId);
|
||||
}
|
||||
|
||||
// 부모 변경 여부 확인
|
||||
const oldPid = oldParentId === '' ? null : (oldParentId ? parseInt(oldParentId) : null);
|
||||
const newPid = newParentId;
|
||||
const parentChanged = oldPid !== newPid;
|
||||
const parentChanged = oldPid !== newParentId;
|
||||
console.log('oldPid:', oldPid, 'newPid:', newParentId, 'parentChanged:', parentChanged);
|
||||
console.log('action:', action);
|
||||
|
||||
if (parentChanged) {
|
||||
// 새 부모 하위에서의 순서 계산
|
||||
const siblingRows = rows.filter(row => {
|
||||
const rowParentId = row.dataset.parentId || null;
|
||||
const rowPid = rowParentId === '' ? null : (rowParentId ? parseInt(rowParentId) : null);
|
||||
return rowPid === newPid;
|
||||
const existingSiblings = rows.filter(row => {
|
||||
if (row === movedItem) return false;
|
||||
const rowParentId = row.dataset.parentId || '';
|
||||
const rowPid = rowParentId === '' ? null : parseInt(rowParentId);
|
||||
return rowPid === newParentId;
|
||||
});
|
||||
sortOrder = siblingRows.indexOf(movedItem) + 1;
|
||||
if (sortOrder <= 0) sortOrder = siblingRows.length + 1;
|
||||
|
||||
// DOM에서 movedItem보다 앞에 있는 형제의 수 = 새 순서
|
||||
let siblingsBeforeMe = 0;
|
||||
for (const sibling of existingSiblings) {
|
||||
if (rows.indexOf(sibling) < newIndex) {
|
||||
siblingsBeforeMe++;
|
||||
}
|
||||
}
|
||||
sortOrder = siblingsBeforeMe + 1;
|
||||
|
||||
console.log('existingSiblings count:', existingSiblings.length);
|
||||
console.log('siblingsBeforeMe:', siblingsBeforeMe, 'sortOrder:', sortOrder);
|
||||
|
||||
// 계층 변경 API 호출
|
||||
moveMenu(menuId, newPid, sortOrder);
|
||||
moveMenu(menuId, newParentId, sortOrder);
|
||||
} else {
|
||||
// 같은 레벨 내 순서 변경
|
||||
const siblingRows = rows.filter(row => {
|
||||
const rowParentId = row.dataset.parentId || null;
|
||||
const rowPid = rowParentId === '' ? null : (rowParentId ? parseInt(rowParentId) : null);
|
||||
const rowParentId = row.dataset.parentId || '';
|
||||
const rowPid = rowParentId === '' ? null : parseInt(rowParentId);
|
||||
return rowPid === oldPid;
|
||||
});
|
||||
|
||||
@@ -154,6 +405,7 @@ function initMenuSortable() {
|
||||
sort_order: index + 1
|
||||
}));
|
||||
|
||||
console.log('Same level reorder:', items);
|
||||
saveMenuOrder(items);
|
||||
}
|
||||
}
|
||||
@@ -162,6 +414,13 @@ function initMenuSortable() {
|
||||
|
||||
// 메뉴 이동 API 호출 (계층 변경)
|
||||
function moveMenu(menuId, newParentId, sortOrder) {
|
||||
const payload = {
|
||||
menu_id: menuId,
|
||||
new_parent_id: newParentId,
|
||||
sort_order: sortOrder
|
||||
};
|
||||
console.log('moveMenu API Request:', payload);
|
||||
|
||||
fetch('/api/admin/menus/move', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -169,23 +428,25 @@ function moveMenu(menuId, newParentId, sortOrder) {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
menu_id: menuId,
|
||||
new_parent_id: newParentId,
|
||||
sort_order: sortOrder
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(response => {
|
||||
console.log('moveMenu API Response status:', response.status);
|
||||
return response.json();
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('moveMenu API Response data:', data);
|
||||
if (data.success) {
|
||||
console.log('메뉴 이동 성공!');
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
} else {
|
||||
console.error('메뉴 이동 실패:', data.message);
|
||||
alert('메뉴 이동 실패: ' + (data.message || ''));
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
console.error('moveMenu API Error:', error);
|
||||
alert('메뉴 이동 중 오류 발생');
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user