캔버스 이동 및 줌기능

This commit is contained in:
kjs 2025-10-15 10:44:05 +09:00
parent 4cb967fea6
commit 5a8efa51af
3 changed files with 165 additions and 14 deletions

View File

@ -68,9 +68,13 @@ export default function ScreenManagementPage() {
// 현재 단계가 마지막 단계인지 확인
const isLastStep = currentStep === "template";
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
if (isDesignMode) {
return <ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />;
return (
<div className="fixed inset-0 z-50 bg-white">
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
);
}
return (

View File

@ -187,6 +187,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용
});
// Pan 모드 상태 (스페이스바 + 드래그)
const [isPanMode, setIsPanMode] = useState(false);
const [panState, setPanState] = useState({
isPanning: false,
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
});
const canvasContainerRef = useRef<HTMLDivElement>(null);
// Zoom 상태
const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100%
const MIN_ZOOM = 0.1; // 10%
const MAX_ZOOM = 3; // 300%
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
@ -973,6 +989,77 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, [selectedScreen?.screenId]);
// 스페이스바 키 이벤트 처리 (Pan 모드)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 입력 필드에서는 스페이스바 무시
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.code === "Space") {
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
if (!isPanMode) {
setIsPanMode(true);
// 커서 변경
if (canvasContainerRef.current) {
canvasContainerRef.current.style.cursor = "grab";
}
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") {
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
setIsPanMode(false);
setPanState((prev) => ({ ...prev, isPanning: false }));
// 커서 복원
if (canvasContainerRef.current) {
canvasContainerRef.current.style.cursor = "default";
}
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [isPanMode]);
// 마우스 휠로 줌 제어
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
// 캔버스 컨테이너 내에서만 동작
if (canvasContainerRef.current && canvasContainerRef.current.contains(e.target as Node)) {
// Shift 키를 누르지 않은 경우에만 줌 (Shift + 휠은 수평 스크롤용)
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
// 기본 스크롤 동작 방지
e.preventDefault();
const delta = e.deltaY;
const zoomFactor = 0.001; // 줌 속도 조절
setZoomLevel((prevZoom) => {
const newZoom = prevZoom - delta * zoomFactor;
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
});
}
}
};
// passive: false로 설정하여 preventDefault() 가능하게 함
canvasContainerRef.current?.addEventListener("wheel", handleWheel, { passive: false });
const containerRef = canvasContainerRef.current;
return () => {
containerRef?.removeEventListener("wheel", handleWheel);
};
}, [MIN_ZOOM, MAX_ZOOM]);
// 격자 설정 업데이트 및 컴포넌트 자동 스냅
const updateGridSettings = useCallback(
(newGridSettings: GridSettings) => {
@ -3538,11 +3625,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
return (
<div className="flex h-screen w-full flex-col bg-gradient-to-br from-gray-50 to-slate-100">
<div className="flex h-full w-full flex-col bg-gradient-to-br from-gray-50 to-slate-100">
{/* 상단 슬림 툴바 */}
<SlimToolbar
screenName={selectedScreen?.screenName}
tableName={selectedScreen?.tableName}
screenResolution={screenResolution}
onBack={onBackToList}
onSave={handleSave}
onUndo={undo}
@ -3666,36 +3754,78 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)}
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
<div className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6">
{/* 해상도 정보 표시 - 적당한 여백 */}
<div className="mb-4 flex items-center justify-center">
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
<span className="text-sm font-medium text-gray-700">
{screenResolution.name} ({screenResolution.width} × {screenResolution.height})
</span>
<div
ref={canvasContainerRef}
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
style={{ cursor: isPanMode ? (panState.isPanning ? "grabbing" : "grab") : "default" }}
onMouseDown={(e) => {
if (isPanMode && canvasContainerRef.current) {
e.preventDefault();
setPanState({
isPanning: true,
startX: e.pageX,
startY: e.pageY,
scrollLeft: canvasContainerRef.current.scrollLeft,
scrollTop: canvasContainerRef.current.scrollTop,
});
}
}}
onMouseMove={(e) => {
if (isPanMode && panState.isPanning && canvasContainerRef.current) {
e.preventDefault();
const dx = e.pageX - panState.startX;
const dy = e.pageY - panState.startY;
canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx;
canvasContainerRef.current.scrollTop = panState.scrollTop - dy;
}
}}
onMouseUp={() => {
if (isPanMode) {
setPanState((prev) => ({ ...prev, isPanning: false }));
}
}}
onMouseLeave={() => {
if (isPanMode) {
setPanState((prev) => ({ ...prev, isPanning: false }));
}
}}
>
{/* Pan 모드 안내 */}
{isPanMode && (
<div className="pointer-events-none fixed top-20 left-1/2 z-50 -translate-x-1/2 transform rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-lg">
🖐 Pan -
</div>
)}
{/* 줌 레벨 표시 */}
<div className="pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-lg ring-1 ring-gray-200">
🔍 {Math.round(zoomLevel * 100)}%
</div>
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 */}
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */}
<div
className="mx-auto bg-white shadow-lg"
style={{
width: screenResolution.width,
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
minHeight: screenResolution.height,
transform: `scale(${zoomLevel})`, // 줌 레벨에 따라 시각적으로 확대/축소
transformOrigin: "top center",
transition: "transform 0.1s ease-out",
}}
>
<div
ref={canvasRef}
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20" // 미묘한 그라데이션 배경
onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
// Pan 모드가 아닐 때만 다중 선택 시작
if (e.target === e.currentTarget && !isPanMode) {
startSelectionDrag(e);
}
}}

View File

@ -2,12 +2,14 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Database, ArrowLeft, Undo, Redo, Play, Save } from "lucide-react";
import { Database, ArrowLeft, Undo, Redo, Play, Save, Monitor } from "lucide-react";
import { cn } from "@/lib/utils";
import { ScreenResolution } from "@/types/screen";
interface SlimToolbarProps {
screenName?: string;
tableName?: string;
screenResolution?: ScreenResolution;
onBack: () => void;
onSave: () => void;
onUndo: () => void;
@ -21,6 +23,7 @@ interface SlimToolbarProps {
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
screenName,
tableName,
screenResolution,
onBack,
onSave,
onUndo,
@ -52,6 +55,20 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
)}
</div>
</div>
{/* 해상도 정보 표시 */}
{screenResolution && (
<>
<div className="h-6 w-px bg-gray-300" />
<div className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5">
<Monitor className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
<span className="text-xs text-blue-600">
({screenResolution.width} × {screenResolution.height})
</span>
</div>
</>
)}
</div>
{/* 우측: 액션 버튼들 */}