ERP-node/frontend/components/pop/designer/PopCanvasV4.tsx

392 lines
15 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useRef, useState, useEffect } from "react";
import { useDrop } from "react-dnd";
import { cn } from "@/lib/utils";
import {
PopLayoutDataV4,
PopContainerV4,
PopComponentDefinitionV4,
PopComponentType,
PopSizeConstraintV4,
} from "./types/pop-layout";
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, RotateCcw, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PopFlexRenderer } from "./renderers/PopFlexRenderer";
// ========================================
// 프리셋 해상도 (4개 모드)
// ========================================
const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕", width: 375, height: 667, icon: Smartphone, isLandscape: false },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔", width: 667, height: 375, icon: Smartphone, isLandscape: true },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕", width: 768, height: 1024, icon: Tablet, isLandscape: false },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔", width: 1024, height: 768, icon: Tablet, isLandscape: true },
] as const;
type ViewportPreset = (typeof VIEWPORT_PRESETS)[number]["id"];
// 기본 프리셋 (태블릿 가로)
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
// ========================================
// Props
// ========================================
interface PopCanvasV4Props {
layout: PopLayoutDataV4;
selectedComponentId: string | null;
selectedContainerId: string | null;
currentMode: ViewportPreset; // 현재 모드
tempLayout?: PopContainerV4 | null; // 임시 레이아웃 (고정 전 미리보기)
onModeChange: (mode: ViewportPreset) => void; // 모드 변경
onSelectComponent: (id: string | null) => void;
onSelectContainer: (id: string | null) => void;
onDropComponent: (type: PopComponentType, containerId: string) => void;
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV4>) => void;
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
onDeleteComponent: (componentId: string) => void;
onResizeComponent?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
onLockLayout?: () => void; // 배치 고정
onResetOverride?: (mode: ViewportPreset) => void; // 오버라이드 초기화
}
// ========================================
// v4 캔버스
//
// 핵심: 단일 캔버스 + 뷰포트 프리뷰
// - 가로/세로 모드 따로 없음
// - 다양한 뷰포트 크기로 미리보기
// ========================================
export function PopCanvasV4({
layout,
selectedComponentId,
selectedContainerId,
currentMode,
tempLayout,
onModeChange,
onSelectComponent,
onSelectContainer,
onDropComponent,
onUpdateComponent,
onUpdateContainer,
onDeleteComponent,
onResizeComponent,
onReorderComponent,
onLockLayout,
onResetOverride,
}: PopCanvasV4Props) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
// 커스텀 뷰포트 크기 (슬라이더)
const [customWidth, setCustomWidth] = useState(1024);
const [customHeight, setCustomHeight] = useState(768);
// 패닝 상태
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const [isSpacePressed, setIsSpacePressed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
// 현재 뷰포트 해상도
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const viewportWidth = customWidth;
const viewportHeight = customHeight;
// 줌 컨트롤
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
const handleZoomFit = () => setCanvasScale(1.0);
// 뷰포트 프리셋 변경
const handleViewportChange = (preset: ViewportPreset) => {
onModeChange(preset); // 부모에게 알림
const presetData = VIEWPORT_PRESETS.find((p) => p.id === preset)!;
setCustomWidth(presetData.width);
setCustomHeight(presetData.height);
};
// 슬라이더로 너비 변경 시 높이도 비율에 맞게 조정
const handleWidthChange = (newWidth: number) => {
setCustomWidth(newWidth);
// 현재 프리셋의 가로세로 비율 유지
const ratio = currentPreset.height / currentPreset.width;
setCustomHeight(Math.round(newWidth * ratio));
};
// 패닝
const handlePanStart = (e: React.MouseEvent) => {
const isMiddleButton = e.button === 1;
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area");
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
setIsPanning(true);
setPanStart({ x: e.clientX, y: e.clientY });
e.preventDefault();
}
};
const handlePanMove = (e: React.MouseEvent) => {
if (!isPanning || !containerRef.current) return;
const deltaX = e.clientX - panStart.x;
const deltaY = e.clientY - panStart.y;
containerRef.current.scrollLeft -= deltaX;
containerRef.current.scrollTop -= deltaY;
setPanStart({ x: e.clientX, y: e.clientY });
};
const handlePanEnd = () => setIsPanning(false);
// 마우스 휠 줌
const handleWheel = useCallback((e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
}
}, []);
// Space 키 감지 (패닝용)
// 참고: Delete/Backspace 키는 PopDesigner에서 처리 (히스토리 지원)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === "Space") setIsSpacePressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [isSpacePressed]);
// 컴포넌트 드롭
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: DND_ITEM_TYPES.COMPONENT,
drop: (item: DragItemComponent) => {
// 루트 컨테이너에 추가
onDropComponent(item.componentType, "root");
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}),
[onDropComponent]
);
drop(dropRef);
// 오버라이드 상태 확인
const hasOverride = (mode: ViewportPreset): boolean => {
if (mode === DEFAULT_PRESET) return false; // 기본 모드는 오버라이드 없음
const override = layout.overrides?.[mode as keyof typeof layout.overrides];
if (!override) return false;
// 컴포넌트 또는 컨테이너 오버라이드가 있으면 true
const hasComponentOverrides = override.components && Object.keys(override.components).length > 0;
const hasContainerOverrides = override.containers && Object.keys(override.containers).length > 0;
return !!(hasComponentOverrides || hasContainerOverrides);
};
return (
<div className="relative flex h-full flex-col bg-gray-100">
{/* 툴바 */}
<div className="flex shrink-0 items-center justify-between border-b bg-white px-4 py-2">
{/* 뷰포트 프리셋 (4개 모드) */}
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground mr-2">:</span>
{VIEWPORT_PRESETS.map((preset) => {
const Icon = preset.icon;
const isActive = currentMode === preset.id;
const isDefault = preset.id === DEFAULT_PRESET;
const isEdited = hasOverride(preset.id);
return (
<Button
key={preset.id}
variant={isActive ? "default" : isEdited ? "secondary" : "outline"}
size="sm"
className={cn(
"h-8 gap-1 text-xs",
isDefault && !isActive && "border-primary/50",
isEdited && !isActive && "border-yellow-500 bg-yellow-50 hover:bg-yellow-100"
)}
onClick={() => handleViewportChange(preset.id)}
title={preset.label}
>
<Icon className={cn("h-4 w-4", preset.isLandscape && "rotate-90")} />
<span className="hidden lg:inline">{preset.shortLabel}</span>
{isDefault && (
<span className="text-[10px] text-muted-foreground ml-1">()</span>
)}
{isEdited && (
<span className="text-[10px] text-yellow-700 ml-1 font-medium">()</span>
)}
</Button>
);
})}
</div>
{/* 고정 버튼 (기본 모드가 아닐 때 표시) */}
{currentMode !== DEFAULT_PRESET && onLockLayout && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs"
onClick={onLockLayout}
title="현재 배치를 이 모드 전용으로 고정합니다"
>
<Lock className="h-3 w-3" />
<span></span>
</Button>
)}
{/* 오버라이드 초기화 버튼 (편집된 모드에만 표시) */}
{hasOverride(currentMode) && onResetOverride && (
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-xs text-yellow-700 border-yellow-500 hover:bg-yellow-50"
onClick={() => onResetOverride(currentMode)}
title="이 모드의 편집 내용을 삭제하고 기본 규칙으로 되돌립니다"
>
<RotateCcw className="h-3 w-3" />
<span> </span>
</Button>
)}
{/* 줌 컨트롤 */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{Math.round(canvasScale * 100)}%
</span>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit}>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 뷰포트 크기 슬라이더 */}
<div className="flex shrink-0 items-center gap-4 border-b bg-gray-50 px-4 py-2">
<span className="text-xs text-muted-foreground">:</span>
<input
type="range"
min={320}
max={1200}
value={customWidth}
onChange={(e) => handleWidthChange(Number(e.target.value))}
className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-xs font-mono w-24 text-right">
{customWidth} x {viewportHeight}
</span>
</div>
{/* 캔버스 영역 */}
<div
ref={containerRef}
className={cn(
"relative flex-1 overflow-auto",
isPanning && "cursor-grabbing",
isSpacePressed && "cursor-grab"
)}
onMouseDown={handlePanStart}
onMouseMove={handlePanMove}
onMouseUp={handlePanEnd}
onMouseLeave={handlePanEnd}
onWheel={handleWheel}
>
<div
className="canvas-scroll-area flex items-center justify-center"
style={{ padding: "100px", minWidth: "fit-content", minHeight: "fit-content" }}
>
{/* 디바이스 프레임 */}
<div
ref={dropRef}
className={cn(
"relative rounded-xl bg-white shadow-lg transition-all",
"ring-2 ring-primary ring-offset-2",
isOver && canDrop && "ring-4 ring-green-500 bg-green-50"
)}
style={{
width: viewportWidth * canvasScale,
height: viewportHeight * canvasScale,
overflow: "auto", // 컴포넌트가 넘치면 스크롤 가능
}}
>
{/* 뷰포트 라벨 */}
<div className="absolute -top-6 left-0 text-xs font-medium text-muted-foreground">
{currentPreset.label} ({viewportWidth}x{viewportHeight})
</div>
{/* Flexbox 렌더러 - 최소 높이는 뷰포트 높이, 컨텐츠에 따라 늘어남 */}
<div
className="origin-top-left"
style={{
transform: `scale(${canvasScale})`,
width: viewportWidth,
minHeight: viewportHeight, // height → minHeight로 변경
}}
>
<PopFlexRenderer
layout={layout}
viewportWidth={viewportWidth}
currentMode={currentMode}
tempLayout={tempLayout}
isDesignMode={true}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onContainerClick={onSelectContainer}
onBackgroundClick={() => {
onSelectComponent(null);
onSelectContainer(null);
}}
onComponentResize={onResizeComponent}
onReorderComponent={onReorderComponent}
/>
</div>
{/* 드롭 안내 (빈 상태) */}
{layout.root.children.length === 0 && (
<div
className={cn(
"absolute inset-0 flex items-center justify-center",
isOver && canDrop ? "text-green-600" : "text-gray-400"
)}
>
<div className="text-center">
<p className="text-sm font-medium">
{isOver && canDrop
? "여기에 놓으세요"
: "컴포넌트를 드래그하세요"}
</p>
<p className="text-xs mt-1">
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default PopCanvasV4;