);
}
+
+// ========================================
+// 모달 사이즈 설정 패널
+// ========================================
+
+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
+
+
+
+ );
+}
diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx
index 16fa0da8..e3623f1b 100644
--- a/frontend/components/pop/designer/PopDesigner.tsx
+++ b/frontend/components/pop/designer/PopDesigner.tsx
@@ -28,11 +28,14 @@ import {
createEmptyPopLayoutV5,
isV5Layout,
addComponentToV5Layout,
+ createComponentDefinitionV5,
GRID_BREAKPOINTS,
+ PopModalDefinition,
} from "./types/pop-layout";
import { getAllEffectivePositions } from "./utils/gridUtils";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
+import { PopDesignerContext } from "./PopDesignerContext";
// ========================================
// Props
@@ -75,10 +78,18 @@ export default function PopDesigner({
// 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState("tablet_landscape");
- // 선택된 컴포넌트
- const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
- ? layout.components[selectedComponentId] || null
- : null;
+ // 모달 캔버스 활성 상태 ("main" 또는 모달 ID)
+ const [activeCanvasId, setActiveCanvasId] = useState("main");
+
+ // 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회)
+ const selectedComponent: PopComponentDefinitionV5 | null = (() => {
+ if (!selectedComponentId) return null;
+ if (activeCanvasId === "main") {
+ return layout.components[selectedComponentId] || null;
+ }
+ const modal = layout.modals?.find(m => m.id === activeCanvasId);
+ return modal?.components[selectedComponentId] || null;
+ })();
// ========================================
// 히스토리 관리
@@ -209,56 +220,104 @@ export default function PopDesigner({
(type: PopComponentType, position: PopGridPosition) => {
const componentId = `comp_${idCounter}`;
setIdCounter((prev) => prev + 1);
- const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
- setLayout(newLayout);
- saveToHistory(newLayout);
+
+ if (activeCanvasId === "main") {
+ // 메인 캔버스
+ const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
+ setLayout(newLayout);
+ saveToHistory(newLayout);
+ } else {
+ // 모달 캔버스
+ setLayout(prev => {
+ const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`);
+ const newLayout = {
+ ...prev,
+ modals: (prev.modals || []).map(m => {
+ if (m.id !== activeCanvasId) return m;
+ return { ...m, components: { ...m.components, [componentId]: comp } };
+ }),
+ };
+ saveToHistory(newLayout);
+ return newLayout;
+ });
+ }
setSelectedComponentId(componentId);
setHasChanges(true);
},
- [idCounter, layout, saveToHistory]
+ [idCounter, layout, saveToHistory, activeCanvasId]
);
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial) => {
// 함수적 업데이트로 stale closure 방지
setLayout((prev) => {
- const existingComponent = prev.components[componentId];
- if (!existingComponent) return prev;
+ if (activeCanvasId === "main") {
+ // 메인 캔버스
+ const existingComponent = prev.components[componentId];
+ if (!existingComponent) return prev;
- const newComponent = {
- ...existingComponent,
- ...updates,
- };
- const newLayout = {
- ...prev,
- components: {
- ...prev.components,
- [componentId]: newComponent,
- },
- };
- saveToHistory(newLayout);
- return newLayout;
+ const newLayout = {
+ ...prev,
+ components: {
+ ...prev.components,
+ [componentId]: { ...existingComponent, ...updates },
+ },
+ };
+ saveToHistory(newLayout);
+ return newLayout;
+ } else {
+ // 모달 캔버스
+ const newLayout = {
+ ...prev,
+ modals: (prev.modals || []).map(m => {
+ if (m.id !== activeCanvasId) return m;
+ const existing = m.components[componentId];
+ if (!existing) return m;
+ return {
+ ...m,
+ components: {
+ ...m.components,
+ [componentId]: { ...existing, ...updates },
+ },
+ };
+ }),
+ };
+ saveToHistory(newLayout);
+ return newLayout;
+ }
});
setHasChanges(true);
},
- [saveToHistory]
+ [saveToHistory, activeCanvasId]
);
const handleDeleteComponent = useCallback(
(componentId: string) => {
- const newComponents = { ...layout.components };
- delete newComponents[componentId];
-
- const newLayout = {
- ...layout,
- components: newComponents,
- };
- setLayout(newLayout);
- saveToHistory(newLayout);
+ setLayout(prev => {
+ if (activeCanvasId === "main") {
+ const newComponents = { ...prev.components };
+ delete newComponents[componentId];
+ const newLayout = { ...prev, components: newComponents };
+ saveToHistory(newLayout);
+ return newLayout;
+ } else {
+ const newLayout = {
+ ...prev,
+ modals: (prev.modals || []).map(m => {
+ if (m.id !== activeCanvasId) return m;
+ const newComps = { ...m.components };
+ delete newComps[componentId];
+ return { ...m, components: newComps };
+ }),
+ };
+ saveToHistory(newLayout);
+ return newLayout;
+ }
+ });
setSelectedComponentId(null);
setHasChanges(true);
},
- [layout, saveToHistory]
+ [saveToHistory, activeCanvasId]
);
const handleMoveComponent = useCallback(
@@ -478,6 +537,59 @@ export default function PopDesigner({
setHasChanges(true);
}, [layout, currentMode, saveToHistory]);
+ // ========================================
+ // 모달 캔버스 관리
+ // ========================================
+
+ /** 모달 ID 자동 생성 (계층적: modal-1, modal-1-1, modal-1-1-1) */
+ const generateModalId = useCallback((parentCanvasId: string): string => {
+ const modals = layout.modals || [];
+ if (parentCanvasId === "main") {
+ const rootModals = modals.filter(m => !m.parentId);
+ return `modal-${rootModals.length + 1}`;
+ }
+ const prefix = parentCanvasId.replace("modal-", "");
+ const children = modals.filter(m => m.parentId === parentCanvasId);
+ return `modal-${prefix}-${children.length + 1}`;
+ }, [layout.modals]);
+
+ /** 모달 캔버스 생성하고 해당 탭으로 전환 */
+ const createModalCanvas = useCallback((buttonComponentId: string, title: string): string => {
+ const modalId = generateModalId(activeCanvasId);
+ const newModal: PopModalDefinition = {
+ id: modalId,
+ parentId: activeCanvasId === "main" ? undefined : activeCanvasId,
+ title: title || "새 모달",
+ sourceButtonId: buttonComponentId,
+ gridConfig: { ...layout.gridConfig },
+ components: {},
+ };
+ setLayout(prev => ({
+ ...prev,
+ modals: [...(prev.modals || []), newModal],
+ }));
+ setHasChanges(true);
+ setActiveCanvasId(modalId);
+ return modalId;
+ }, [generateModalId, activeCanvasId, layout.gridConfig]);
+
+ /** 모달 정의 업데이트 (제목, sizeConfig 등) */
+ const handleUpdateModal = useCallback((modalId: string, updates: Partial) => {
+ setLayout(prev => ({
+ ...prev,
+ modals: (prev.modals || []).map(m =>
+ m.id === modalId ? { ...m, ...updates } : m
+ ),
+ }));
+ setHasChanges(true);
+ }, []);
+
+ /** 특정 캔버스로 전환 */
+ const navigateToCanvas = useCallback((canvasId: string) => {
+ setActiveCanvasId(canvasId);
+ setSelectedComponentId(null);
+ }, []);
+
// ========================================
// 뒤로가기
// ========================================
@@ -560,6 +672,14 @@ export default function PopDesigner({
// 렌더링
// ========================================
return (
+
{/* 헤더 */}
@@ -645,6 +765,9 @@ export default function PopDesigner({
onResetOverride={handleResetOverride}
onChangeGapPreset={handleChangeGapPreset}
previewPageIndex={previewPageIndex}
+ activeCanvasId={activeCanvasId}
+ onActiveCanvasChange={navigateToCanvas}
+ onUpdateModal={handleUpdateModal}
/>
@@ -670,5 +793,6 @@ export default function PopDesigner({
+
);
}
diff --git a/frontend/components/pop/designer/PopDesignerContext.tsx b/frontend/components/pop/designer/PopDesignerContext.tsx
new file mode 100644
index 00000000..8af42d64
--- /dev/null
+++ b/frontend/components/pop/designer/PopDesignerContext.tsx
@@ -0,0 +1,35 @@
+/**
+ * PopDesignerContext - 디자이너 전역 컨텍스트
+ *
+ * ConfigPanel 등 하위 컴포넌트에서 디자이너 레벨 동작을 트리거하기 위한 컨텍스트.
+ * 예: pop-button 설정 패널에서 "모달 캔버스 생성" 버튼 클릭 시
+ * 디자이너의 activeCanvasId를 변경하고 새 모달을 생성.
+ *
+ * Provider: PopDesigner.tsx
+ * Consumer: pop-button ConfigPanel (ModalCanvasButton)
+ */
+
+"use client";
+
+import { createContext, useContext } from "react";
+
+export interface PopDesignerContextType {
+ /** 새 모달 캔버스 생성하고 해당 탭으로 전환 (모달 ID 반환) */
+ createModalCanvas: (buttonComponentId: string, title: string) => string;
+ /** 특정 캔버스(메인 또는 모달)로 전환 */
+ navigateToCanvas: (canvasId: string) => void;
+ /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */
+ activeCanvasId: string;
+ /** 현재 선택된 컴포넌트 ID */
+ selectedComponentId: string | null;
+}
+
+export const PopDesignerContext = createContext(null);
+
+/**
+ * 디자이너 컨텍스트 사용 훅
+ * 뷰어 모드에서는 null 반환 (Provider 없음)
+ */
+export function usePopDesignerContext(): PopDesignerContextType | null {
+ return useContext(PopDesignerContext);
+}
diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx
index d4933443..0620b783 100644
--- a/frontend/components/pop/designer/renderers/PopRenderer.tsx
+++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx
@@ -277,6 +277,7 @@ export default function PopRenderer({
effectivePosition={position}
isDesignMode={false}
isSelected={false}
+ screenId={String(currentScreenId || "")}
/>
);
@@ -362,6 +363,7 @@ function DraggableComponent({
isDesignMode={isDesignMode}
isSelected={isSelected}
previewPageIndex={previewPageIndex}
+ screenId=""
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
@@ -513,9 +515,11 @@ interface ComponentContentProps {
isDesignMode: boolean;
isSelected: boolean;
previewPageIndex?: number;
+ /** 화면 ID (이벤트 버스/액션 실행용) */
+ screenId?: string;
}
-function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex }: ComponentContentProps) {
+function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, screenId }: ComponentContentProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
@@ -541,6 +545,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
label={component.label}
isDesignMode={isDesignMode}
previewPageIndex={previewPageIndex}
+ screenId={screenId}
/>
);
@@ -561,14 +566,14 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
}
// 실제 모드: 컴포넌트 렌더링
- return renderActualComponent(component);
+ return renderActualComponent(component, screenId);
}
// ========================================
// 실제 컴포넌트 렌더링 (뷰어 모드)
// ========================================
-function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
+function renderActualComponent(component: PopComponentDefinitionV5, screenId?: string): React.ReactNode {
// 레지스트리에서 등록된 실제 컴포넌트 조회
const registeredComp = PopComponentRegistry.getComponent(component.type);
const ActualComp = registeredComp?.component;
@@ -576,7 +581,7 @@ function renderActualComponent(component: PopComponentDefinitionV5): React.React
if (ActualComp) {
return (