"use client"; import { useCallback, useRef, useState, useEffect, useMemo } from "react"; import { useDrop } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopLayoutDataV5, PopComponentDefinitionV5, PopComponentType, PopGridPosition, GridMode, GapPreset, GAP_PRESETS, GRID_BREAKPOINTS, DEFAULT_COMPONENT_GRID_SIZE, PopModalDefinition, ModalSizePreset, MODAL_SIZE_PRESETS, resolveModalWidth, } from "./types/pop-layout"; import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useDrag } from "react-dnd"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; import PopRenderer from "./renderers/PopRenderer"; import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils"; import { DND_ITEM_TYPES } from "./constants"; /** * 캔버스 내 상대 좌표 → 그리드 좌표 변환 * @param relX 캔버스 내 X 좌표 (패딩 포함) * @param relY 캔버스 내 Y 좌표 (패딩 포함) */ function calcGridPosition( relX: number, relY: number, canvasWidth: number, columns: number, rowHeight: number, gap: number, padding: number ): { col: number; row: number } { // 패딩 제외한 좌표 const x = relX - padding; const y = relY - padding; // 사용 가능한 너비 (패딩과 gap 제외) const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1); const colWidth = availableWidth / columns; // 셀+gap 단위로 계산 const cellStride = colWidth + gap; const rowStride = rowHeight + gap; // 그리드 좌표 (1부터 시작) const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1)); const row = Math.max(1, Math.floor(y / rowStride) + 1); return { col, row }; } // 드래그 아이템 타입 정의 interface DragItemComponent { type: typeof DND_ITEM_TYPES.COMPONENT; componentType: PopComponentType; } interface DragItemMoveComponent { componentId: string; originalPosition: PopGridPosition; } // ======================================== // 프리셋 해상도 (4개 모드) - 너비만 정의 // ======================================== const VIEWPORT_PRESETS = [ { id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone }, { id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone }, { id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet }, { id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet }, ] as const; type ViewportPreset = GridMode; // 기본 프리셋 (태블릿 가로) const DEFAULT_PRESET: ViewportPreset = "tablet_landscape"; // 캔버스 세로 자동 확장 설정 const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px) const CANVAS_EXTRA_ROWS = 3; // 여유 행 수 // ======================================== // Props // ======================================== interface PopCanvasProps { layout: PopLayoutDataV5; selectedComponentId: string | null; currentMode: GridMode; onModeChange: (mode: GridMode) => void; onSelectComponent: (id: string | null) => void; onDropComponent: (type: PopComponentType, position: PopGridPosition) => void; onUpdateComponent: (componentId: string, updates: Partial) => void; onDeleteComponent: (componentId: string) => void; onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void; onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void; onResizeEnd?: (componentId: string) => void; onHideComponent?: (componentId: string) => void; onUnhideComponent?: (componentId: string) => void; onLockLayout?: () => void; onResetOverride?: (mode: GridMode) => void; onChangeGapPreset?: (preset: GapPreset) => void; /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ previewPageIndex?: number; /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ activeCanvasId?: string; /** 캔버스 전환 콜백 */ onActiveCanvasChange?: (canvasId: string) => void; /** 모달 정의 업데이트 콜백 */ onUpdateModal?: (modalId: string, updates: Partial) => void; } // ======================================== // PopCanvas: 그리드 캔버스 // ======================================== export default function PopCanvas({ layout, selectedComponentId, currentMode, onModeChange, onSelectComponent, onDropComponent, onUpdateComponent, onDeleteComponent, onMoveComponent, onResizeComponent, onResizeEnd, onHideComponent, onUnhideComponent, onLockLayout, onResetOverride, onChangeGapPreset, previewPageIndex, activeCanvasId = "main", onActiveCanvasChange, onUpdateModal, }: PopCanvasProps) { // 모달 탭 데이터 const modalTabs = useMemo(() => { const tabs: { id: string; label: string }[] = [{ id: "main", label: "메인화면" }]; if (layout.modals?.length) { for (const modal of layout.modals) { const numbering = modal.id.replace("modal-", ""); tabs.push({ id: modal.id, label: `모달화면 ${numbering}` }); } } return tabs; }, [layout.modals]); // activeCanvasId에 따라 렌더링할 layout 분기 const activeLayout = useMemo((): PopLayoutDataV5 => { if (activeCanvasId === "main") return layout; const modal = layout.modals?.find(m => m.id === activeCanvasId); if (!modal) return layout; // fallback return { ...layout, gridConfig: modal.gridConfig, components: modal.components, overrides: modal.overrides, }; }, [layout, activeCanvasId]); // 현재 활성 모달 정의 (모달 캔버스일 때만) const activeModal = useMemo(() => { if (activeCanvasId === "main") return null; return layout.modals?.find(m => m.id === activeCanvasId) || null; }, [layout.modals, activeCanvasId]); // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); // 커스텀 뷰포트 너비 const [customWidth, setCustomWidth] = useState(1024); // 그리드 가이드 표시 여부 const [showGridGuide, setShowGridGuide] = useState(true); // 패닝 상태 const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [isSpacePressed, setIsSpacePressed] = useState(false); const containerRef = useRef(null); const canvasRef = useRef(null); // 현재 뷰포트 해상도 const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; const breakpoint = GRID_BREAKPOINTS[currentMode]; // Gap 프리셋 적용 const currentGapPreset = layout.settings.gapPreset || "medium"; const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); // 숨김 컴포넌트 ID 목록 (activeLayout 기반) const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; // 동적 캔버스 높이 계산 (컴포넌트 배치 기반) const dynamicCanvasHeight = useMemo(() => { const visibleComps = Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT; // 최대 row + rowSpan 찾기 const maxRowEnd = visibleComps.reduce((max, comp) => { const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id]; const pos = overridePos ? { ...comp.position, ...overridePos } : comp.position; const rowEnd = pos.row + pos.rowSpan; return Math.max(max, rowEnd); }, 1); // 높이 계산: (행 수 + 여유) * (행높이 + gap) + padding const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS; const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2; return Math.max(MIN_CANVAS_HEIGHT, height); }, [activeLayout.components, activeLayout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); // 그리드 라벨 계산 (동적 행 수) const gridLabels = useMemo(() => { const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1); // 동적 행 수 계산 const rowCount = Math.ceil(dynamicCanvasHeight / (breakpoint.rowHeight + adjustedGap)); const rowLabels = Array.from({ length: rowCount }, (_, i) => i + 1); return { columnLabels, rowLabels }; }, [breakpoint.columns, breakpoint.rowHeight, dynamicCanvasHeight, adjustedGap]); // 줌 컨트롤 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 = (mode: GridMode) => { onModeChange(mode); const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!; setCustomWidth(presetData.width); // customHeight는 dynamicCanvasHeight로 자동 계산됨 }; // 패닝 const handlePanStart = (e: React.MouseEvent) => { const isMiddleButton = e.button === 1; if (isMiddleButton || isSpacePressed) { 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); // Ctrl + 휠로 줌 조정 const handleWheel = (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 키 감지 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, DND_ITEM_TYPES.MOVE_COMPONENT], drop: (item: DragItemComponent | DragItemMoveComponent, monitor) => { if (!canvasRef.current) return; const canvasRect = canvasRef.current.getBoundingClientRect(); const itemType = monitor.getItemType(); // 팔레트에서 새 컴포넌트 추가 - 마우스 위치 기준 if (itemType === DND_ITEM_TYPES.COMPONENT) { const offset = monitor.getClientOffset(); if (!offset) return; // 캔버스 내 상대 좌표 (스케일 보정) // canvasRect는 scale 적용된 크기이므로, 상대 좌표를 scale로 나눠야 실제 좌표 const relX = (offset.x - canvasRect.left) / canvasScale; const relY = (offset.y - canvasRect.top) / canvasScale; // 그리드 좌표 계산 const gridPos = calcGridPosition( relX, relY, customWidth, breakpoint.columns, breakpoint.rowHeight, adjustedGap, adjustedPadding ); const dragItem = item as DragItemComponent; const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[dragItem.componentType]; const candidatePosition: PopGridPosition = { col: gridPos.col, row: gridPos.row, colSpan: defaultSize.colSpan, rowSpan: defaultSize.rowSpan, }; // 현재 모드에서의 유효 위치들로 중첩 검사 const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); const existingPositions = Array.from(effectivePositions.values()); const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePosition, pos) ); let finalPosition: PopGridPosition; if (hasOverlap) { finalPosition = findNextEmptyPosition( existingPositions, defaultSize.colSpan, defaultSize.rowSpan, breakpoint.columns ); toast.info("겹치는 위치입니다. 빈 위치로 자동 배치됩니다."); } else { finalPosition = candidatePosition; } onDropComponent(dragItem.componentType, finalPosition); } // 기존 컴포넌트 이동 - 마우스 위치 기준 if (itemType === DND_ITEM_TYPES.MOVE_COMPONENT) { const offset = monitor.getClientOffset(); if (!offset) return; // 캔버스 내 상대 좌표 (스케일 보정) const relX = (offset.x - canvasRect.left) / canvasScale; const relY = (offset.y - canvasRect.top) / canvasScale; const gridPos = calcGridPosition( relX, relY, customWidth, breakpoint.columns, breakpoint.rowHeight, adjustedGap, adjustedPadding ); const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean }; // 현재 모드에서의 유효 위치들 가져오기 const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 const currentEffectivePos = effectivePositions.get(dragItem.componentId); const componentData = layout.components[dragItem.componentId]; if (!currentEffectivePos && !componentData) return; const sourcePosition = currentEffectivePos || componentData.position; // colSpan이 현재 모드의 columns를 초과하면 제한 const adjustedColSpan = Math.min(sourcePosition.colSpan, breakpoint.columns); // 드롭 위치 + 크기가 범위를 초과하면 드롭 위치를 자동 조정 let adjustedCol = gridPos.col; if (adjustedCol + adjustedColSpan - 1 > breakpoint.columns) { adjustedCol = Math.max(1, breakpoint.columns - adjustedColSpan + 1); } const newPosition: PopGridPosition = { col: adjustedCol, row: gridPos.row, colSpan: adjustedColSpan, rowSpan: sourcePosition.rowSpan, }; // 자기 자신 제외한 다른 컴포넌트들의 유효 위치와 겹침 체크 const hasOverlap = Array.from(effectivePositions.entries()).some(([id, pos]) => { if (id === dragItem.componentId) return false; // 자기 자신 제외 return isOverlapping(newPosition, pos); }); if (hasOverlap) { toast.error("이 위치로 이동할 수 없습니다 (다른 컴포넌트와 겹침)"); return; } // 이동 처리 (숨김 컴포넌트의 경우 handleMoveComponent에서 숨김 해제도 함께 처리됨) onMoveComponent?.(dragItem.componentId, newPosition); // 숨김 패널에서 드래그한 경우 안내 메시지 if (dragItem.fromHidden) { toast.info("컴포넌트가 다시 표시됩니다"); } } }, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, activeLayout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] ); drop(canvasRef); // 빈 상태 체크 (activeLayout 기반) const isEmpty = Object.keys(activeLayout.components).length === 0; // 숨김 처리된 컴포넌트 객체 목록 const hiddenComponents = useMemo(() => { return hiddenComponentIds .map(id => activeLayout.components[id]) .filter(Boolean); }, [hiddenComponentIds, activeLayout.components]); // 표시되는 컴포넌트 목록 (숨김 제외) const visibleComponents = useMemo(() => { return Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); }, [activeLayout.components, hiddenComponentIds]); // 검토 필요 컴포넌트 목록 const reviewComponents = useMemo(() => { return visibleComponents.filter(comp => { const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id]; return needsReview(currentMode, hasOverride); }); }, [visibleComponents, activeLayout.overrides, currentMode]); // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; // 12칸 모드가 아닐 때만 패널 표시 // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 const hasGridComponents = Object.keys(activeLayout.components).length > 0; const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); const showRightPanel = showReviewPanel || showHiddenPanel; return (
{/* 상단 컨트롤 */}
{/* 모드 프리셋 버튼 */}
{VIEWPORT_PRESETS.map((preset) => { const Icon = preset.icon; const isActive = currentMode === preset.id; const isDefault = preset.id === DEFAULT_PRESET; return ( ); })}
{/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */} {currentMode !== DEFAULT_PRESET && ( <> {layout.overrides?.[currentMode] && ( )} )}
{/* 해상도 표시 */}
{customWidth} × {Math.round(dynamicCanvasHeight)}
{/* Gap 프리셋 선택 */}
간격:
{/* 줌 컨트롤 */}
{Math.round(canvasScale * 100)}%
{/* 그리드 가이드 토글 */}
{/* 모달 탭 바 (모달이 1개 이상 있을 때만 표시) */} {modalTabs.length > 1 && (
{modalTabs.map(tab => ( ))}
)} {/* 모달 사이즈 설정 패널 (모달 캔버스 활성 시) */} {activeModal && ( onUpdateModal?.(activeModal.id, updates)} /> )} {/* 캔버스 영역 */}
{/* 그리드 + 라벨 영역 */}
{/* 그리드 라벨 영역 */} {showGridGuide && ( <> {/* 열 라벨 (상단) */}
{gridLabels.columnLabels.map((num) => (
{num}
))}
{/* 행 라벨 (좌측) */}
{gridLabels.rowLabels.map((num) => (
{num}
))}
)} {/* 디바이스 스크린 */}
{isEmpty ? ( // 빈 상태
컴포넌트를 드래그하여 배치하세요
{breakpoint.label} - {breakpoint.columns}칸 그리드
) : ( // 그리드 렌더러 onSelectComponent(null)} onComponentMove={onMoveComponent} onComponentResize={onResizeComponent} onComponentResizeEnd={onResizeEnd} overrideGap={adjustedGap} overridePadding={adjustedPadding} previewPageIndex={previewPageIndex} /> )}
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */} {showRightPanel && (
{/* 검토 필요 패널 */} {showReviewPanel && ( )} {/* 숨김 컴포넌트 패널 */} {showHiddenPanel && ( )}
)}
{/* 하단 정보 */}
{breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px)
Space + 드래그: 패닝 | Ctrl + 휠: 줌
); } // ======================================== // 검토 필요 영역 (오른쪽 패널) // ======================================== interface ReviewPanelProps { components: PopComponentDefinitionV5[]; selectedComponentId: string | null; onSelectComponent: (id: string | null) => void; } function ReviewPanel({ components, selectedComponentId, onSelectComponent, }: ReviewPanelProps) { return (
{/* 헤더 */}
검토 필요 ({components.length}개)
{/* 컴포넌트 목록 */}
{components.map((comp) => ( onSelectComponent(comp.id)} /> ))}
{/* 안내 문구 */}

자동 배치됨. 클릭하여 확인 후 편집 가능

); } // ======================================== // 검토 필요 아이템 (ReviewPanel 내부) // ======================================== interface ReviewItemProps { component: PopComponentDefinitionV5; isSelected: boolean; onSelect: () => void; } function ReviewItem({ component, isSelected, onSelect, }: ReviewItemProps) { return (
{ e.stopPropagation(); onSelect(); }} > {component.label || component.id} 자동 배치됨
); } // ======================================== // 숨김 컴포넌트 영역 (오른쪽 패널) // ======================================== interface HiddenPanelProps { components: PopComponentDefinitionV5[]; selectedComponentId: string | null; onSelectComponent: (id: string | null) => void; onHideComponent?: (componentId: string) => void; } function HiddenPanel({ components, selectedComponentId, onSelectComponent, onHideComponent, }: HiddenPanelProps) { // 그리드에서 컴포넌트를 드래그하여 이 패널에 드롭하면 숨김 처리 const [{ isOver, canDrop }, drop] = useDrop( () => ({ accept: DND_ITEM_TYPES.MOVE_COMPONENT, drop: (item: { componentId: string; fromHidden?: boolean }) => { // 이미 숨김 패널에서 온 아이템은 무시 if (item.fromHidden) return; // 숨김 처리 onHideComponent?.(item.componentId); toast.info("컴포넌트가 숨김 처리되었습니다"); }, canDrop: (item: { componentId: string; fromHidden?: boolean }) => { // 숨김 패널에서 온 아이템은 드롭 불가 return !item.fromHidden; }, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), [onHideComponent] ); return (
{/* 헤더 */}
숨김 ({components.length}개)
{/* 컴포넌트 목록 */}
{components.map((comp) => ( onSelectComponent(comp.id)} /> ))}
{/* 안내 문구 */}

그리드로 드래그하여 다시 표시

); } // ======================================== // 숨김 컴포넌트 아이템 (드래그 가능) // ======================================== interface HiddenItemProps { component: PopComponentDefinitionV5; isSelected: boolean; onSelect: () => void; } function HiddenItem({ component, isSelected, onSelect, }: HiddenItemProps) { const [{ isDragging }, drag] = useDrag( () => ({ type: DND_ITEM_TYPES.MOVE_COMPONENT, item: { componentId: component.id, originalPosition: component.position, fromHidden: true, // 숨김 패널에서 왔음을 표시 }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }), [component.id, component.position] ); return (
{/* 컴포넌트 이름 */}
{component.label || component.type}
{/* 원본 위치 정보 */}
원본: {component.position.col}열, {component.position.row}행
); } // ======================================== // 모달 사이즈 설정 패널 // ======================================== const SIZE_PRESET_ORDER: ModalSizePreset[] = ["sm", "md", "lg", "xl", "full"]; const MODE_LABELS: { mode: GridMode; label: string; icon: typeof Smartphone; width: number }[] = [ { mode: "mobile_portrait", label: "모바일 세로", icon: Smartphone, width: 375 }, { mode: "mobile_landscape", label: "모바일 가로", icon: Smartphone, width: 667 }, { mode: "tablet_portrait", label: "태블릿 세로", icon: Tablet, width: 768 }, { mode: "tablet_landscape", label: "태블릿 가로", icon: Monitor, width: 1024 }, ]; function ModalSizeSettingsPanel({ modal, currentMode, onUpdate, }: { modal: PopModalDefinition; currentMode: GridMode; onUpdate: (updates: Partial) => void; }) { const [isExpanded, setIsExpanded] = useState(false); const sizeConfig = modal.sizeConfig || { default: "md" }; const usePerMode = !!sizeConfig.modeOverrides && Object.keys(sizeConfig.modeOverrides).length > 0; const currentModeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; const currentModeWidth = currentModeInfo.width; const currentModalWidth = resolveModalWidth( { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, currentMode, currentModeWidth, ); const handleDefaultChange = (preset: ModalSizePreset) => { onUpdate({ sizeConfig: { ...sizeConfig, default: preset, }, }); }; const handleTogglePerMode = () => { if (usePerMode) { onUpdate({ sizeConfig: { default: sizeConfig.default, }, }); } else { onUpdate({ sizeConfig: { ...sizeConfig, modeOverrides: { mobile_portrait: sizeConfig.default, mobile_landscape: sizeConfig.default, tablet_portrait: sizeConfig.default, tablet_landscape: sizeConfig.default, }, }, }); } }; const handleModeChange = (mode: GridMode, preset: ModalSizePreset) => { onUpdate({ sizeConfig: { ...sizeConfig, modeOverrides: { ...sizeConfig.modeOverrides, [mode]: preset, }, }, }); }; return (
{/* 헤더 (항상 표시) */} {/* 펼침 영역 */} {isExpanded && (
{/* 기본 사이즈 선택 */}
모달 사이즈
{SIZE_PRESET_ORDER.map(preset => { const info = MODAL_SIZE_PRESETS[preset]; return ( ); })}
{/* 모드별 개별 설정 토글 */}
모드별 개별 사이즈
{/* 모드별 설정 */} {usePerMode && (
{MODE_LABELS.map(({ mode, label, icon: Icon }) => { const modePreset = sizeConfig.modeOverrides?.[mode] ?? sizeConfig.default; return (
{label}
{SIZE_PRESET_ORDER.map(preset => ( ))}
); })}
)} {/* 캔버스 축소판 미리보기 */}
)}
); } // ======================================== // 모달 사이즈 썸네일 미리보기 (캔버스 축소판 + 모달 오버레이) // ======================================== function ModalThumbnailPreview({ sizeConfig, currentMode, }: { sizeConfig: { default: ModalSizePreset; modeOverrides?: Partial> }; currentMode: GridMode; }) { const PREVIEW_WIDTH = 260; const ASPECT_RATIO = 0.65; const modeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; const modeWidth = modeInfo.width; const modeHeight = modeWidth * ASPECT_RATIO; const scale = PREVIEW_WIDTH / modeWidth; const previewHeight = Math.round(modeHeight * scale); const modalWidth = resolveModalWidth( { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, currentMode, modeWidth, ); const scaledModalWidth = Math.min(Math.round(modalWidth * scale), PREVIEW_WIDTH); const isFull = modalWidth >= modeWidth; const scaledModalHeight = isFull ? previewHeight : Math.round(previewHeight * 0.75); const Icon = modeInfo.icon; return (
미리보기
{modeInfo.label}
{/* 반투명 배경 오버레이 (모달이 열렸을 때의 딤 효과) */}
{/* 모달 영역 (가운데 정렬, FULL이면 전체 채움) */}
모달
{/* 하단 수치 표시 */}
{isFull ? "FULL" : `${modalWidth}px`} / {modeWidth}px
); }