feat: 메뉴 관리 하위메뉴 다른 상위로 드래그 이동 + 가장자리 자동 스크롤
- 하위 메뉴를 다른 상위 메뉴 아래로 드래그해 재부모(상위 변경) 지원 - 순서+상위+깊이를 함께 저장하는 MenuModel::setStructure() 추가(자식수 재계산) - 드래그 중 화면 상/하단 근처에서 자동 스크롤 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user