feat: [dev] DevToolbar 드래그 이동 + 위치 프리셋 기능 추가
- 접힌 Dev 버튼: 마우스 드래그로 자유 이동 - 펼친 툴바: 좌측 그립 아이콘으로 드래그 이동 - 3x3 위치 프리셋 그리드 (좌상/우상/중앙/좌하/우하) - localStorage에 위치 저장 (새로고침 후에도 유지)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user