392 lines
15 KiB
TypeScript
392 lines
15 KiB
TypeScript
"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;
|