feat: 메뉴 관리 하위메뉴 다른 상위로 드래그 이동 + 가장자리 자동 스크롤

- 하위 메뉴를 다른 상위 메뉴 아래로 드래그해 재부모(상위 변경) 지원
- 순서+상위+깊이를 함께 저장하는 MenuModel::setStructure() 추가(자식수 재계산)
- 드래그 중 화면 상/하단 근처에서 자동 스크롤

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-07-06 18:25:29 +09:00
parent b3518f6777
commit ace0ce6296
3 changed files with 164 additions and 16 deletions

View File

@@ -72,6 +72,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>">
<?= csrf_field() ?>
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
<input type="hidden" name="menu_tree" id="menu-tree-payload" value=""/>
<table class="data-table w-full">
<thead>
<tr>
@@ -92,7 +93,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
?>
<tr class="menu-row" data-mm-idx="<?= (int) $row->mm_idx ?>" data-mm-pidx="<?= (int) $row->mm_pidx ?>" data-mm-dep="<?= (int) $row->mm_dep ?>">
<td class="text-center align-middle">
<span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서 변경하세요">↕</span>
<span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서 변경 · 하위 메뉴는 다른 상위 메뉴 아래로 이동 가능">↕</span>
</td>
<td class="text-center">
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
@@ -444,11 +445,43 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
}
};
// ── 드래그 중 화면 상/하단 가장자리 근처에서 자동 스크롤 ──────────────
// 스크롤되는 컨테이너(.main-content-area)가 있으면 그걸, 없으면 window를 스크롤.
let autoScrollRaf = null;
const getScrollContainer = function() {
const main = tbody.closest('.main-content-area');
if (main && main.scrollHeight > main.clientHeight + 2) return main;
return null; // null이면 window 스크롤
};
const autoScrollTick = function() {
if (!draggingActive) { autoScrollRaf = null; return; }
const EDGE = 70; // 가장자리 감지 영역(px)
const STEP_MAX = 20; // 프레임당 최대 스크롤(px)
const vh = window.innerHeight;
let dy = 0;
if (lastClientY < EDGE) {
dy = -Math.ceil((EDGE - lastClientY) / EDGE * STEP_MAX);
} else if (lastClientY > vh - EDGE) {
dy = Math.ceil((lastClientY - (vh - EDGE)) / EDGE * STEP_MAX);
}
if (dy !== 0) {
const sc = getScrollContainer();
if (sc) { sc.scrollTop += dy; } else { window.scrollBy(0, dy); }
// 스크롤로 행 위치가 바뀌었으니 placeholder 위치 재계산
if (!rafId) rafId = window.requestAnimationFrame(updatePlaceholderPosition);
}
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
};
const clearDragState = function() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
if (autoScrollRaf) {
window.cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
document.body.classList.remove('menu-row-dragging');
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
@@ -463,41 +496,76 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
draggingActive = false;
};
// 드래그된 행(하위 메뉴)의 새 상위 메뉴를 결정한다.
// 평면 목록에서 바로 위쪽으로 올라가며 dep이 (자신-1)인 가장 가까운 행을 상위로 삼는다.
const resolveNewParentIdx = function(row, draggedDep) {
var prev = row.previousElementSibling;
while (prev) {
if (prev.classList.contains('menu-row')) {
var pdep = parseInt(prev.dataset.mmDep || '0', 10);
if (pdep === draggedDep - 1) {
return parseInt(prev.dataset.mmIdx || '0', 10);
}
if (pdep < draggedDep - 1) {
return 0; // 유효한 상위가 없음
}
}
prev = prev.previousElementSibling;
}
return 0;
};
// 현재 화면 순서 그대로 구조(순서/상위/깊이) 페이로드를 만든다.
const buildTreePayload = function() {
var arr = [];
tbody.querySelectorAll('tr.menu-row').forEach(function(row){
var idInput = row.querySelector('input[name="mm_idx[]"]');
if (!idInput) return;
arr.push({
idx: parseInt(idInput.value, 10),
pidx: parseInt(row.dataset.mmPidx || '0', 10),
dep: parseInt(row.dataset.mmDep || '0', 10)
});
});
return arr;
};
const payloadInput = document.getElementById('menu-tree-payload');
// 수동 "순서 적용" 버튼 제출 시에도 구조가 반영되도록 payload 채움
moveForm.addEventListener('submit', function(){
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
});
var onMouseUp = function() {
if (!draggingActive || !draggingRow || !placeholderRow) {
clearDragState();
return;
}
var prevRow = placeholderRow.previousElementSibling;
tbody.insertBefore(draggingRow, placeholderRow);
refreshOrderNos();
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
var draggedPidx = parseInt(draggingRow.dataset.mmPidx || '0', 10);
if (draggedDep > 0) {
var valid = false;
if (prevRow && prevRow.classList.contains('menu-row')) {
var prevIdx = parseInt(prevRow.dataset.mmIdx || '0', 10);
var prevPidx = parseInt(prevRow.dataset.mmPidx || '0', 10);
if (prevRow === draggingRow) {
valid = true;
} else if (prevIdx === draggedPidx || prevPidx === draggedPidx) {
valid = true;
}
}
if (!valid) {
alert('하위 메뉴는 다른 상위 메뉴에는 들어갈 수 없습니다.');
// 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
var newPidx = resolveNewParentIdx(draggingRow, draggedDep);
if (newPidx <= 0) {
alert('하위 메뉴는 상위 메뉴 아래에만 놓을 수 있습니다.');
restoreOrderByIds(originalOrderIds);
clearDragState();
return;
}
draggingRow.dataset.mmPidx = String(newPidx);
}
const approved = window.confirm('변경한 순서를 적용할까요?');
if (!approved) {
// 취소 시 원래 순서·상위로 되돌림
if (typeof draggingRow.dataset.mmPidxOrig !== 'undefined') {
draggingRow.dataset.mmPidx = draggingRow.dataset.mmPidxOrig;
}
restoreOrderByIds(originalOrderIds);
} else {
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
moveForm.submit();
}
clearDragState();
@@ -511,6 +579,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
e.preventDefault();
originalOrderIds = collectCurrentOrderIds();
draggingRow = row;
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
placeholderRow = makePlaceholder();
draggingActive = true;
row.classList.add('dragging');
@@ -518,6 +588,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
tbody.insertBefore(placeholderRow, row.nextSibling);
lastClientY = e.clientY;
updatePlaceholderPosition();
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mouseup', onMouseUp);
});