feat: [dev] DevToolbar 드래그 이동 + 위치 프리셋 기능 추가

- 접힌 Dev 버튼: 마우스 드래그로 자유 이동
- 펼친 툴바: 좌측 그립 아이콘으로 드래그 이동
- 3x3 위치 프리셋 그리드 (좌상/우상/중앙/좌하/우하)
- localStorage에 위치 저장 (새로고침 후에도 유지)
This commit is contained in:
김보곤
2026-03-23 09:33:55 +09:00
parent 2e36c2ba88
commit 21d00bbe54

View File

@@ -14,7 +14,7 @@
* NEXT_PUBLIC_DEV_TOOLBAR_ENABLED=true
*/
import { useState } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
FileText, // 견적
@@ -39,6 +39,7 @@ import {
PackageCheck, // 입고
// Dev 도구 아이콘
Layers, // 컴포넌트 레지스트리
GripVertical, // 드래그 핸들
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -102,6 +103,46 @@ const MATERIAL_STEPS: { type: DevFillPageType; label: string; icon: typeof FileT
{ type: 'receiving', label: '입고', icon: PackageCheck, path: '/material/receiving-management/new', fillEnabled: true },
];
// ===== 위치 관리 헬퍼 =====
const POS_KEY_MINI = 'dev-toolbar-pos-mini';
const POS_KEY_FULL = 'dev-toolbar-pos-full';
function loadPos(key: string): { x: number; y: number } | null {
if (typeof window === 'undefined') return null;
try {
const s = localStorage.getItem(key);
return s ? JSON.parse(s) : null;
} catch { return null; }
}
function savePos(key: string, p: { x: number; y: number }) {
try { localStorage.setItem(key, JSON.stringify(p)); } catch { /* noop */ }
}
function PositionGrid({ onSelect }: { onSelect: (id: string) => void }) {
const spots: [string, boolean, string][] = [
['tl', true, '좌상단'], ['', false, ''], ['tr', true, '우상단'],
['', false, ''], ['c', true, '중앙'], ['', false, ''],
['bl', true, '좌하단'], ['', false, ''], ['br', true, '우하단'],
];
return (
<div className="inline-grid grid-cols-3 gap-[3px] p-1 rounded bg-yellow-200/60" title="위치 프리셋">
{spots.map(([id, active, title], i) =>
active ? (
<button
key={id}
className="w-[7px] h-[7px] rounded-full bg-yellow-700 hover:bg-yellow-900 hover:scale-150 transition-all"
onClick={(e) => { e.stopPropagation(); onSelect(id); }}
title={title}
/>
) : (
<div key={`e${i}`} className="w-[7px] h-[7px] rounded-full bg-yellow-400/30" />
)
)}
</div>
);
}
export function DevToolbar() {
const pathname = usePathname();
const router = useRouter();
@@ -120,6 +161,67 @@ export function DevToolbar() {
const [isExpanded, setIsExpanded] = useState(true);
const [isLoading, setIsLoading] = useState<DevFillPageType | null>(null);
// ===== 드래그 & 위치 관리 =====
const containerRef = useRef<HTMLDivElement>(null);
const [collapsedPos, setCollapsedPos] = useState<{x: number; y: number} | null>(null);
const [expandedPos, setExpandedPos] = useState<{x: number; y: number} | null>(null);
const dragRef = useRef({ active: false, moved: false, sx: 0, sy: 0, px: 0, py: 0, target: '' as 'mini' | 'full' });
useEffect(() => {
setCollapsedPos(loadPos(POS_KEY_MINI) || { x: window.innerWidth - 100, y: window.innerHeight - 96 });
setExpandedPos(loadPos(POS_KEY_FULL) || { x: Math.max(8, (window.innerWidth - 700) / 2), y: window.innerHeight - 250 });
}, []);
const startDrag = useCallback((e: React.MouseEvent, target: 'mini' | 'full', pos: {x: number; y: number} | null) => {
if (e.button !== 0) return;
e.preventDefault();
const d = dragRef.current;
d.active = true; d.moved = false;
d.sx = e.clientX; d.sy = e.clientY;
d.px = pos?.x || 0; d.py = pos?.y || 0;
d.target = target;
}, []);
useEffect(() => {
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d.active) return;
const dx = e.clientX - d.sx, dy = e.clientY - d.sy;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) d.moved = true;
if (!d.moved) return;
const p = { x: d.px + dx, y: d.py + dy };
if (d.target === 'mini') setCollapsedPos(p); else setExpandedPos(p);
};
const onUp = (e: MouseEvent) => {
const d = dragRef.current;
if (!d.active) return;
d.active = false;
if (d.moved) {
const p = { x: d.px + (e.clientX - d.sx), y: d.py + (e.clientY - d.sy) };
savePos(d.target === 'mini' ? POS_KEY_MINI : POS_KEY_FULL, p);
}
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
}, []);
const snapTo = useCallback((preset: string) => {
const el = containerRef.current;
const w = el?.offsetWidth || 700, h = el?.offsetHeight || 200;
const vw = window.innerWidth, vh = window.innerHeight, m = 16;
let p: {x: number; y: number};
switch (preset) {
case 'tl': p = { x: m, y: m }; break;
case 'tr': p = { x: vw - w - m, y: m }; break;
case 'c': p = { x: (vw - w) / 2, y: (vh - h) / 2 }; break;
case 'bl': p = { x: m, y: vh - h - m }; break;
default: p = { x: vw - w - m, y: vh - h - m }; break;
}
setExpandedPos(p);
savePos(POS_KEY_FULL, p);
}, []);
// 현재 locale 추출 (유효한 locale만 인식)
const VALID_LOCALES = ['ko', 'en'];
const firstSegment = pathname.split('/')[1];
@@ -128,18 +230,19 @@ export function DevToolbar() {
// mode=new 쿼리 파라미터 확인
const isNewMode = searchParams.get('mode') === 'new';
// 비활성화 시 렌더링하지 않음
if (!isEnabled) return null;
// 비활성화 또는 위치 미초기화 시 렌더링하지 않음
if (!isEnabled || !collapsedPos || !expandedPos) return null;
// 숨김 상태일 때 작은 버튼만 표시
// 숨김 상태일 때 작은 버튼만 표시 (드래그 이동 가능)
if (!isVisible) {
return (
<div className="fixed bottom-20 right-4 z-[9999]">
<div className="fixed z-[9999]" style={{ left: collapsedPos.x, top: collapsedPos.y }}>
<Button
size="sm"
variant="outline"
className="bg-yellow-100 border-yellow-400 text-yellow-800 hover:bg-yellow-200 shadow-lg"
onClick={() => setIsVisible(true)}
className="bg-yellow-100 border-yellow-400 text-yellow-800 hover:bg-yellow-200 shadow-lg cursor-grab active:cursor-grabbing select-none"
onMouseDown={(e) => startDrag(e, 'mini', collapsedPos)}
onClick={() => { if (!dragRef.current.moved) setIsVisible(true); }}
>
<Play className="w-4 h-4 mr-1" />
Dev
@@ -187,11 +290,19 @@ export function DevToolbar() {
const hasFlowData = flowData.quoteId || flowData.orderId || flowData.workOrderId || flowData.lotNo;
return (
<div className="fixed bottom-12 left-1/2 -translate-x-1/2 z-[9999] w-[calc(100vw-1rem)] sm:w-auto sm:max-w-[calc(100vw-1rem)]">
<div ref={containerRef} className="fixed z-[9999] max-w-[calc(100vw-1rem)]" style={{ left: expandedPos.x, top: expandedPos.y }}>
<div className="bg-yellow-50 border-2 border-yellow-400 rounded-lg shadow-2xl overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between px-2 sm:px-3 py-1.5 sm:py-2 bg-yellow-100 border-b border-yellow-300">
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
<div
className="cursor-grab active:cursor-grabbing select-none p-0.5 rounded hover:bg-yellow-200 shrink-0"
onMouseDown={(e) => startDrag(e, 'full', expandedPos)}
title="드래그하여 이동"
>
<GripVertical className="w-4 h-4 text-yellow-600" />
</div>
<Badge variant="outline" className="bg-yellow-200 border-yellow-500 text-yellow-800">
DEV MODE
</Badge>
@@ -236,6 +347,7 @@ export function DevToolbar() {
)}
</div>
<div className="flex items-center gap-1">
<PositionGrid onSelect={snapTo} />
{hasFlowData && (
<Button
size="icon"