feat(pop-designer): POP 디자이너 v2.0 - 4가지 디바이스 모드 및 캔버스 UX 개선
- v2 레이아웃 데이터 구조 도입 (4모드별 별도 레이아웃 + 공유 컴포넌트 정의)
- tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait
- sections/components를 Record<string, Definition> 객체로 관리
- v1 → v2 자동 마이그레이션 지원
- 캔버스 UX 개선
- 줌 기능 (30%~150%, 마우스 휠 + 버튼)
- 패닝 기능 (중앙 마우스, Space+드래그, 배경 드래그)
- 2개 캔버스 동시 표시 (가로/세로 모드)
- Delete 키로 섹션/컴포넌트 삭제 기능 추가
- layout.sections 순회하여 componentIds에서 부모 섹션 찾는 방식
- 미리보기 v2 레이아웃 호환성 수정
- Object.keys(layout.sections).length 체크로 변경
수정 파일: PopDesigner.tsx, PopCanvas.tsx, SectionGridV2.tsx(신규),
types/pop-layout.ts, PopPanel.tsx, PopScreenPreview.tsx,
PopCategoryTree.tsx, screenManagementService.ts
This commit is contained in:
parent
d9b7ef9ad4
commit
368d641ae8
|
|
@ -4710,12 +4710,89 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// POP 레이아웃 관리 (모바일/태블릿)
|
// POP 레이아웃 관리 (모바일/태블릿)
|
||||||
|
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP v1 → v2 마이그레이션 (백엔드)
|
||||||
|
* - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components
|
||||||
|
*/
|
||||||
|
private migratePopV1ToV2(v1Data: any): any {
|
||||||
|
console.log("POP v1 → v2 마이그레이션 시작");
|
||||||
|
|
||||||
|
// 기본 v2 구조
|
||||||
|
const v2Data: any = {
|
||||||
|
version: "pop-2.0",
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
},
|
||||||
|
sections: {},
|
||||||
|
components: {},
|
||||||
|
dataFlow: {
|
||||||
|
sectionConnections: [],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
touchTargetMin: 48,
|
||||||
|
mode: "normal",
|
||||||
|
canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 },
|
||||||
|
},
|
||||||
|
metadata: v1Data.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
// v1 섹션 배열 처리
|
||||||
|
const sections = v1Data.sections || [];
|
||||||
|
const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"];
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
// 섹션 정의 생성
|
||||||
|
v2Data.sections[section.id] = {
|
||||||
|
id: section.id,
|
||||||
|
label: section.label,
|
||||||
|
componentIds: (section.components || []).map((c: any) => c.id),
|
||||||
|
innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 },
|
||||||
|
style: section.style,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 섹션 위치 복사 (4모드 모두 동일)
|
||||||
|
const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트별 처리
|
||||||
|
for (const comp of section.components || []) {
|
||||||
|
// 컴포넌트 정의 생성
|
||||||
|
v2Data.components[comp.id] = {
|
||||||
|
id: comp.id,
|
||||||
|
type: comp.type,
|
||||||
|
label: comp.label,
|
||||||
|
dataBinding: comp.dataBinding,
|
||||||
|
style: comp.style,
|
||||||
|
config: comp.config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 위치 복사 (4모드 모두 동일)
|
||||||
|
const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionCount = Object.keys(v2Data.sections).length;
|
||||||
|
const componentCount = Object.keys(v2Data.components).length;
|
||||||
|
console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
|
||||||
|
return v2Data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POP 레이아웃 조회
|
* POP 레이아웃 조회
|
||||||
* - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회
|
* - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회
|
||||||
* - V2와 동일한 로직, 테이블명만 다름
|
* - v1 데이터는 자동으로 v2로 마이그레이션하여 반환
|
||||||
*/
|
*/
|
||||||
async getLayoutPop(
|
async getLayoutPop(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
@ -4792,16 +4869,32 @@ export class ScreenManagementService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
const layoutData = layout.layout_data;
|
||||||
`POP 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
|
|
||||||
);
|
// v1 → v2 자동 마이그레이션
|
||||||
return layout.layout_data;
|
if (layoutData && layoutData.version === "pop-1.0") {
|
||||||
|
console.log("POP v1 레이아웃 감지, v2로 마이그레이션");
|
||||||
|
return this.migratePopV1ToV2(layoutData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인)
|
||||||
|
if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) {
|
||||||
|
console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션");
|
||||||
|
return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 레이아웃 그대로 반환
|
||||||
|
const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0;
|
||||||
|
const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0;
|
||||||
|
console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
|
||||||
|
return layoutData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POP 레이아웃 저장
|
* POP 레이아웃 저장
|
||||||
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
|
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
|
||||||
* - V2와 동일한 로직, 테이블명만 다름
|
* - v2 형식으로 저장 (version: "pop-2.0")
|
||||||
*/
|
*/
|
||||||
async saveLayoutPop(
|
async saveLayoutPop(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
@ -4811,7 +4904,18 @@ export class ScreenManagementService {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(`=== POP 레이아웃 저장 시작 ===`);
|
console.log(`=== POP 레이아웃 저장 시작 ===`);
|
||||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
|
||||||
|
// v2 구조 확인
|
||||||
|
const isV2 = layoutData.version === "pop-2.0" ||
|
||||||
|
(layoutData.layouts && layoutData.sections && layoutData.components);
|
||||||
|
|
||||||
|
if (isV2) {
|
||||||
|
const sectionCount = Object.keys(layoutData.sections || {}).length;
|
||||||
|
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||||
|
console.log(`v2 레이아웃: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
|
} else {
|
||||||
|
console.log(`v1 레이아웃 (섹션 수: ${layoutData.sections?.length || 0})`);
|
||||||
|
}
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
const screens = await query<{ company_code: string | null }>(
|
const screens = await query<{ company_code: string | null }>(
|
||||||
|
|
@ -4829,11 +4933,20 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버전 정보 추가 (프론트엔드 pop-1.0과 통일)
|
// 버전 정보 보장 (v2 우선, v1은 프론트엔드에서 마이그레이션 후 저장 권장)
|
||||||
const dataToSave = {
|
let dataToSave: any;
|
||||||
version: "pop-1.0",
|
if (isV2) {
|
||||||
...layoutData
|
dataToSave = {
|
||||||
|
...layoutData,
|
||||||
|
version: "pop-2.0",
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
// v1 형식으로 저장 (하위 호환)
|
||||||
|
dataToSave = {
|
||||||
|
version: "pop-1.0",
|
||||||
|
...layoutData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||||
await query(
|
await query(
|
||||||
|
|
@ -4844,7 +4957,7 @@ export class ScreenManagementService {
|
||||||
[screenId, companyCode, JSON.stringify(dataToSave), userId || null],
|
[screenId, companyCode, JSON.stringify(dataToSave), userId || null],
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`POP 레이아웃 저장 완료`);
|
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,283 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import GridLayout, { Layout } from "react-grid-layout";
|
import GridLayout, { Layout } from "react-grid-layout";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PopLayoutData,
|
PopLayoutDataV2,
|
||||||
PopSectionData,
|
PopLayoutModeKey,
|
||||||
PopComponentData,
|
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
GridPosition,
|
GridPosition,
|
||||||
|
MODE_RESOLUTIONS,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { DND_ITEM_TYPES, DragItemSection, DragItemComponent } from "./panels/PopPanel";
|
import { DND_ITEM_TYPES, DragItemSection } from "./panels/PopPanel";
|
||||||
import { GripVertical, Trash2 } from "lucide-react";
|
import { GripVertical, Trash2, ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SectionGrid } from "./SectionGrid";
|
import { SectionGridV2 } from "./SectionGridV2";
|
||||||
|
|
||||||
import "react-grid-layout/css/styles.css";
|
import "react-grid-layout/css/styles.css";
|
||||||
import "react-resizable/css/styles.css";
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 정의
|
||||||
|
// ========================================
|
||||||
type DeviceType = "mobile" | "tablet";
|
type DeviceType = "mobile" | "tablet";
|
||||||
|
|
||||||
// 디바이스별 캔버스 크기 (dp)
|
// 모드별 라벨
|
||||||
const DEVICE_SIZES = {
|
const MODE_LABELS: Record<PopLayoutModeKey, string> = {
|
||||||
mobile: {
|
tablet_landscape: "태블릿 가로",
|
||||||
portrait: { width: 360, height: 640 },
|
tablet_portrait: "태블릿 세로",
|
||||||
landscape: { width: 640, height: 360 },
|
mobile_landscape: "모바일 가로",
|
||||||
},
|
mobile_portrait: "모바일 세로",
|
||||||
tablet: {
|
};
|
||||||
portrait: { width: 768, height: 1024 },
|
|
||||||
landscape: { width: 1024, height: 768 },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
interface PopCanvasProps {
|
interface PopCanvasProps {
|
||||||
layout: PopLayoutData;
|
layout: PopLayoutDataV2;
|
||||||
activeDevice: DeviceType;
|
activeDevice: DeviceType;
|
||||||
showBothDevices: boolean;
|
activeModeKey: PopLayoutModeKey;
|
||||||
isLandscape: boolean;
|
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
|
||||||
selectedSectionId: string | null;
|
selectedSectionId: string | null;
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
onSelectSection: (id: string | null) => void;
|
onSelectSection: (id: string | null) => void;
|
||||||
onSelectComponent: (id: string | null) => void;
|
onSelectComponent: (id: string | null) => void;
|
||||||
onUpdateSection: (id: string, updates: Partial<PopSectionData>) => void;
|
onUpdateSectionPosition: (sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
||||||
|
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
||||||
onDeleteSection: (id: string) => void;
|
onDeleteSection: (id: string) => void;
|
||||||
onLayoutChange: (sections: PopSectionData[]) => void;
|
|
||||||
onDropSection: (gridPosition: GridPosition) => void;
|
onDropSection: (gridPosition: GridPosition) => void;
|
||||||
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
||||||
onUpdateComponent: (sectionId: string, componentId: string, updates: Partial<PopComponentData>) => void;
|
|
||||||
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
export function PopCanvas({
|
export function PopCanvas({
|
||||||
layout,
|
layout,
|
||||||
activeDevice,
|
activeDevice,
|
||||||
showBothDevices,
|
activeModeKey,
|
||||||
isLandscape,
|
onModeKeyChange,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
onSelectSection,
|
onSelectSection,
|
||||||
onSelectComponent,
|
onSelectComponent,
|
||||||
onUpdateSection,
|
onUpdateSectionPosition,
|
||||||
|
onUpdateComponentPosition,
|
||||||
onDeleteSection,
|
onDeleteSection,
|
||||||
onLayoutChange,
|
|
||||||
onDropSection,
|
onDropSection,
|
||||||
onDropComponent,
|
onDropComponent,
|
||||||
onUpdateComponent,
|
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
}: PopCanvasProps) {
|
}: PopCanvasProps) {
|
||||||
const { canvasGrid, sections } = layout;
|
const { settings, sections, components, layouts } = layout;
|
||||||
|
const canvasGrid = settings.canvasGrid;
|
||||||
|
|
||||||
// GridLayout용 레이아웃 변환
|
// 줌 상태 (0.3 ~ 1.0 범위)
|
||||||
const gridLayoutItems: Layout[] = useMemo(() => {
|
const [canvasScale, setCanvasScale] = useState(0.6);
|
||||||
return sections.map((section) => ({
|
|
||||||
i: section.id,
|
|
||||||
x: section.grid.col - 1,
|
|
||||||
y: section.grid.row - 1,
|
|
||||||
w: section.grid.colSpan,
|
|
||||||
h: section.grid.rowSpan,
|
|
||||||
minW: 2, // 최소 너비 2칸
|
|
||||||
minH: 1, // 최소 높이 1행 (20px) - 헤더만 보임
|
|
||||||
}));
|
|
||||||
}, [sections]);
|
|
||||||
|
|
||||||
// 드래그/리사이즈 완료 핸들러 (onDragStop, onResizeStop 사용)
|
// 패닝 상태
|
||||||
const handleDragResizeStop = useCallback(
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||||
const section = sections.find((s) => s.id === newItem.i);
|
const [isSpacePressed, setIsSpacePressed] = useState(false); // Space 키 눌림 상태
|
||||||
if (!section) return;
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const newGrid: GridPosition = {
|
// 줌 인 (최대 1.5로 증가)
|
||||||
col: newItem.x + 1,
|
const handleZoomIn = () => {
|
||||||
row: newItem.y + 1,
|
setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
||||||
colSpan: newItem.w,
|
|
||||||
rowSpan: newItem.h,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 변경된 경우에만 업데이트
|
// 줌 아웃 (최소 0.3)
|
||||||
if (
|
const handleZoomOut = () => {
|
||||||
section.grid.col !== newGrid.col ||
|
setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
||||||
section.grid.row !== newGrid.row ||
|
};
|
||||||
section.grid.colSpan !== newGrid.colSpan ||
|
|
||||||
section.grid.rowSpan !== newGrid.rowSpan
|
|
||||||
) {
|
|
||||||
const updatedSections = sections.map((s) =>
|
|
||||||
s.id === newItem.i ? { ...s, grid: newGrid } : s
|
|
||||||
);
|
|
||||||
onLayoutChange(updatedSections);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sections, onLayoutChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 디바이스 프레임 렌더링
|
// 맞춤 (1.0)
|
||||||
const renderDeviceFrame = (device: DeviceType) => {
|
const handleZoomFit = () => {
|
||||||
const orientation = isLandscape ? "landscape" : "portrait";
|
setCanvasScale(1.0);
|
||||||
const size = DEVICE_SIZES[device][orientation];
|
};
|
||||||
const isActive = device === activeDevice;
|
|
||||||
|
// 패닝 시작 (중앙 마우스 버튼 또는 배경 영역 드래그)
|
||||||
|
const handlePanStart = (e: React.MouseEvent) => {
|
||||||
|
// 중앙 마우스 버튼(휠 버튼, button === 1) 또는 Space 키 누른 상태
|
||||||
|
// 또는 내부 컨테이너(스크롤 영역) 직접 클릭 시
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 마우스 휠 줌 (0.3 ~ 1.5 범위)
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 초기 로드 시 캔버스를 중앙으로 스크롤
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const container = containerRef.current;
|
||||||
|
// 약간의 딜레이 후 중앙으로 스크롤 (DOM이 완전히 렌더링된 후)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const scrollX = (container.scrollWidth - container.clientWidth) / 2;
|
||||||
|
const scrollY = (container.scrollHeight - container.clientHeight) / 2;
|
||||||
|
container.scrollTo(scrollX, scrollY);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [activeDevice]); // 디바이스 변경 시 재중앙화
|
||||||
|
|
||||||
|
// 현재 디바이스의 가로/세로 모드 키
|
||||||
|
const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
||||||
|
? "tablet_landscape"
|
||||||
|
: "mobile_landscape";
|
||||||
|
const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
||||||
|
? "tablet_portrait"
|
||||||
|
: "mobile_portrait";
|
||||||
|
|
||||||
|
// 단일 캔버스 프레임 렌더링
|
||||||
|
const renderDeviceFrame = (modeKey: PopLayoutModeKey) => {
|
||||||
|
const resolution = MODE_RESOLUTIONS[modeKey];
|
||||||
|
const isActive = modeKey === activeModeKey;
|
||||||
|
const modeLayout = layouts[modeKey];
|
||||||
|
|
||||||
|
// 이 모드의 섹션 위치 목록
|
||||||
|
const sectionPositions = modeLayout.sectionPositions;
|
||||||
|
const sectionIds = Object.keys(sectionPositions);
|
||||||
|
|
||||||
|
// GridLayout용 레이아웃 아이템 생성
|
||||||
|
const gridLayoutItems: Layout[] = sectionIds.map((sectionId) => {
|
||||||
|
const pos = sectionPositions[sectionId];
|
||||||
|
return {
|
||||||
|
i: sectionId,
|
||||||
|
x: pos.col - 1,
|
||||||
|
y: pos.row - 1,
|
||||||
|
w: pos.colSpan,
|
||||||
|
h: pos.rowSpan,
|
||||||
|
minW: 2,
|
||||||
|
minH: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const cols = canvasGrid.columns;
|
const cols = canvasGrid.columns;
|
||||||
const rowHeight = canvasGrid.rowHeight;
|
const rowHeight = canvasGrid.rowHeight;
|
||||||
const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap];
|
const margin: [number, number] = [canvasGrid.gap, canvasGrid.gap];
|
||||||
|
|
||||||
const sizeLabel = `${size.width}x${size.height}`;
|
const sizeLabel = `${resolution.width}x${resolution.height}`;
|
||||||
const deviceLabel =
|
const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`;
|
||||||
device === "mobile" ? `모바일 (${sizeLabel})` : `태블릿 (${sizeLabel})`;
|
|
||||||
|
// 드래그/리사이즈 완료 핸들러
|
||||||
|
const handleDragResizeStop = (
|
||||||
|
layoutItems: Layout[],
|
||||||
|
oldItem: Layout,
|
||||||
|
newItem: Layout
|
||||||
|
) => {
|
||||||
|
const newPos: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
onUpdateSectionPosition(newItem.i, newPos, modeKey);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={modeKey}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-[2rem] border-4 bg-white shadow-xl transition-all",
|
"relative shrink-0 cursor-pointer rounded-lg border-4 bg-white shadow-xl transition-all",
|
||||||
isActive ? "border-primary" : "border-gray-300",
|
isActive
|
||||||
device === "mobile" ? "rounded-[1.5rem]" : "rounded-[2rem]"
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-300 hover:border-gray-400"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: size.width,
|
width: resolution.width * canvasScale,
|
||||||
height: size.height,
|
height: resolution.height * canvasScale,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) {
|
||||||
|
onModeKeyChange(modeKey);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 디바이스 라벨 */}
|
{/* 모드 라벨 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
|
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
|
||||||
isActive ? "text-primary" : "text-muted-foreground"
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{deviceLabel}
|
{modeLabel}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 활성 표시 배지 */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-bold text-white shadow-lg">
|
||||||
|
편집 중
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 드롭 영역 */}
|
{/* 드롭 영역 */}
|
||||||
<CanvasDropZone
|
<CanvasDropZone
|
||||||
device={device}
|
modeKey={modeKey}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
size={size}
|
resolution={resolution}
|
||||||
|
scale={canvasScale}
|
||||||
cols={cols}
|
cols={cols}
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
margin={margin}
|
margin={margin}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
|
components={components}
|
||||||
|
sectionPositions={sectionPositions}
|
||||||
|
componentPositions={modeLayout.componentPositions}
|
||||||
gridLayoutItems={gridLayoutItems}
|
gridLayoutItems={gridLayoutItems}
|
||||||
selectedSectionId={selectedSectionId}
|
selectedSectionId={selectedSectionId}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
|
|
@ -165,8 +286,8 @@ export function PopCanvas({
|
||||||
onDragResizeStop={handleDragResizeStop}
|
onDragResizeStop={handleDragResizeStop}
|
||||||
onDropSection={onDropSection}
|
onDropSection={onDropSection}
|
||||||
onDropComponent={onDropComponent}
|
onDropComponent={onDropComponent}
|
||||||
|
onUpdateComponentPosition={(compId, pos) => onUpdateComponentPosition(compId, pos, modeKey)}
|
||||||
onDeleteSection={onDeleteSection}
|
onDeleteSection={onDeleteSection}
|
||||||
onUpdateComponent={onUpdateComponent}
|
|
||||||
onDeleteComponent={onDeleteComponent}
|
onDeleteComponent={onDeleteComponent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,28 +295,91 @@ export function PopCanvas({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center gap-8 overflow-auto bg-gray-50 p-8">
|
<div className="relative flex h-full flex-col bg-gray-50">
|
||||||
{showBothDevices ? (
|
{/* 줌 컨트롤 바 */}
|
||||||
<>
|
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-4 py-2">
|
||||||
{renderDeviceFrame("tablet")}
|
<span className="text-xs text-muted-foreground">
|
||||||
{renderDeviceFrame("mobile")}
|
줌: {Math.round(canvasScale * 100)}%
|
||||||
</>
|
</span>
|
||||||
) : (
|
<Button
|
||||||
renderDeviceFrame(activeDevice)
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
title="줌 아웃"
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
title="줌 인"
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleZoomFit}
|
||||||
|
title="맞춤 (100%)"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</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 gap-16"
|
||||||
|
style={{
|
||||||
|
// 캔버스 주변에 충분한 여백 확보 (상하좌우 500px씩)
|
||||||
|
padding: "500px",
|
||||||
|
minWidth: "fit-content",
|
||||||
|
minHeight: "fit-content",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 가로 모드 캔버스 */}
|
||||||
|
{renderDeviceFrame(landscapeModeKey)}
|
||||||
|
|
||||||
|
{/* 세로 모드 캔버스 */}
|
||||||
|
{renderDeviceFrame(portraitModeKey)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 캔버스 드롭 영역
|
// ========================================
|
||||||
|
// 캔버스 드롭 영역 컴포넌트
|
||||||
|
// ========================================
|
||||||
interface CanvasDropZoneProps {
|
interface CanvasDropZoneProps {
|
||||||
device: DeviceType;
|
modeKey: PopLayoutModeKey;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
size: { width: number; height: number };
|
resolution: { width: number; height: number };
|
||||||
|
scale: number;
|
||||||
cols: number;
|
cols: number;
|
||||||
rowHeight: number;
|
rowHeight: number;
|
||||||
margin: [number, number];
|
margin: [number, number];
|
||||||
sections: PopSectionData[];
|
sections: PopLayoutDataV2["sections"];
|
||||||
|
components: PopLayoutDataV2["components"];
|
||||||
|
sectionPositions: Record<string, GridPosition>;
|
||||||
|
componentPositions: Record<string, GridPosition>;
|
||||||
gridLayoutItems: Layout[];
|
gridLayoutItems: Layout[];
|
||||||
selectedSectionId: string | null;
|
selectedSectionId: string | null;
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
|
|
@ -204,19 +388,23 @@ interface CanvasDropZoneProps {
|
||||||
onDragResizeStop: (layout: Layout[], oldItem: Layout, newItem: Layout) => void;
|
onDragResizeStop: (layout: Layout[], oldItem: Layout, newItem: Layout) => void;
|
||||||
onDropSection: (gridPosition: GridPosition) => void;
|
onDropSection: (gridPosition: GridPosition) => void;
|
||||||
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
||||||
|
onUpdateComponentPosition: (componentId: string, position: GridPosition) => void;
|
||||||
onDeleteSection: (id: string) => void;
|
onDeleteSection: (id: string) => void;
|
||||||
onUpdateComponent: (sectionId: string, componentId: string, updates: Partial<PopComponentData>) => void;
|
|
||||||
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CanvasDropZone({
|
function CanvasDropZone({
|
||||||
device,
|
modeKey,
|
||||||
isActive,
|
isActive,
|
||||||
size,
|
resolution,
|
||||||
|
scale,
|
||||||
cols,
|
cols,
|
||||||
rowHeight,
|
rowHeight,
|
||||||
margin,
|
margin,
|
||||||
sections,
|
sections,
|
||||||
|
components,
|
||||||
|
sectionPositions,
|
||||||
|
componentPositions,
|
||||||
gridLayoutItems,
|
gridLayoutItems,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
|
|
@ -225,36 +413,41 @@ function CanvasDropZone({
|
||||||
onDragResizeStop,
|
onDragResizeStop,
|
||||||
onDropSection,
|
onDropSection,
|
||||||
onDropComponent,
|
onDropComponent,
|
||||||
|
onUpdateComponentPosition,
|
||||||
onDeleteSection,
|
onDeleteSection,
|
||||||
onUpdateComponent,
|
|
||||||
onDeleteComponent,
|
onDeleteComponent,
|
||||||
}: CanvasDropZoneProps) {
|
}: CanvasDropZoneProps) {
|
||||||
const dropRef = useRef<HTMLDivElement>(null);
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 스케일 적용된 크기
|
||||||
|
const scaledWidth = resolution.width * scale;
|
||||||
|
const scaledHeight = resolution.height * scale;
|
||||||
|
|
||||||
// 섹션 드롭 핸들러
|
// 섹션 드롭 핸들러
|
||||||
const [{ isOver, canDrop }, drop] = useDrop(() => ({
|
const [{ isOver, canDrop }, drop] = useDrop(
|
||||||
|
() => ({
|
||||||
accept: DND_ITEM_TYPES.SECTION,
|
accept: DND_ITEM_TYPES.SECTION,
|
||||||
drop: (item: DragItemSection, monitor) => {
|
drop: (item: DragItemSection, monitor) => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
|
|
||||||
// 드롭 위치 계산
|
|
||||||
const clientOffset = monitor.getClientOffset();
|
const clientOffset = monitor.getClientOffset();
|
||||||
if (!clientOffset || !dropRef.current) return;
|
if (!clientOffset || !dropRef.current) return;
|
||||||
|
|
||||||
const dropRect = dropRef.current.getBoundingClientRect();
|
const dropRect = dropRef.current.getBoundingClientRect();
|
||||||
const x = clientOffset.x - dropRect.left;
|
// 스케일 보정
|
||||||
const y = clientOffset.y - dropRect.top;
|
const x = (clientOffset.x - dropRect.left) / scale;
|
||||||
|
const y = (clientOffset.y - dropRect.top) / scale;
|
||||||
|
|
||||||
// 그리드 위치 계산
|
// 그리드 위치 계산
|
||||||
const colWidth = (size.width - 16) / cols;
|
const colWidth = (resolution.width - 16) / cols;
|
||||||
const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1));
|
const col = Math.max(1, Math.min(cols, Math.floor(x / colWidth) + 1));
|
||||||
const row = Math.max(1, Math.floor(y / rowHeight) + 1);
|
const row = Math.max(1, Math.floor(y / (rowHeight * scale)) + 1);
|
||||||
|
|
||||||
onDropSection({
|
onDropSection({
|
||||||
col,
|
col,
|
||||||
row,
|
row,
|
||||||
colSpan: 3, // 기본 너비
|
colSpan: 3,
|
||||||
rowSpan: 4, // 기본 높이 (20px * 4 = 80px)
|
rowSpan: 4,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
canDrop: () => isActive,
|
canDrop: () => isActive,
|
||||||
|
|
@ -262,18 +455,28 @@ function CanvasDropZone({
|
||||||
isOver: monitor.isOver(),
|
isOver: monitor.isOver(),
|
||||||
canDrop: monitor.canDrop(),
|
canDrop: monitor.canDrop(),
|
||||||
}),
|
}),
|
||||||
}), [isActive, size, cols, rowHeight, onDropSection]);
|
}),
|
||||||
|
[isActive, resolution, scale, cols, rowHeight, onDropSection]
|
||||||
|
);
|
||||||
|
|
||||||
// ref 결합
|
|
||||||
drop(dropRef);
|
drop(dropRef);
|
||||||
|
|
||||||
|
const sectionIds = Object.keys(sectionPositions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={dropRef}
|
ref={dropRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full w-full overflow-auto rounded-[1.5rem] bg-gray-100 p-2 transition-colors",
|
"h-full w-full overflow-hidden rounded-md bg-gray-100 p-1 transition-colors",
|
||||||
isOver && canDrop && "bg-primary/10 ring-2 ring-primary ring-inset"
|
isOver && canDrop && "bg-primary/10 ring-2 ring-primary ring-inset"
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
// 내부 컨텐츠를 스케일 조정
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
width: resolution.width,
|
||||||
|
height: resolution.height,
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
onSelectSection(null);
|
onSelectSection(null);
|
||||||
|
|
@ -281,13 +484,13 @@ function CanvasDropZone({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sections.length > 0 ? (
|
{sectionIds.length > 0 ? (
|
||||||
<GridLayout
|
<GridLayout
|
||||||
className="layout"
|
className="layout"
|
||||||
layout={gridLayoutItems}
|
layout={gridLayoutItems}
|
||||||
cols={cols}
|
cols={cols}
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
width={size.width - 16}
|
width={resolution.width - 8}
|
||||||
margin={margin}
|
margin={margin}
|
||||||
containerPadding={[0, 0]}
|
containerPadding={[0, 0]}
|
||||||
onDragStop={onDragResizeStop}
|
onDragStop={onDragResizeStop}
|
||||||
|
|
@ -299,43 +502,45 @@ function CanvasDropZone({
|
||||||
useCSSTransforms={true}
|
useCSSTransforms={true}
|
||||||
draggableHandle=".section-drag-handle"
|
draggableHandle=".section-drag-handle"
|
||||||
>
|
>
|
||||||
{sections.map((section) => (
|
{sectionIds.map((sectionId) => {
|
||||||
|
const sectionDef = sections[sectionId];
|
||||||
|
if (!sectionDef) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={section.id}
|
key={sectionId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex flex-col rounded-lg border-2 bg-white transition-all overflow-hidden",
|
"group relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
|
||||||
selectedSectionId === section.id
|
selectedSectionId === sectionId
|
||||||
? "border-primary ring-2 ring-primary/30"
|
? "border-primary ring-2 ring-primary/30"
|
||||||
: "border-gray-200 hover:border-gray-400"
|
: "border-gray-200 hover:border-gray-400"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelectSection(section.id);
|
onSelectSection(sectionId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 섹션 헤더 - 고정 높이 */}
|
{/* 섹션 헤더 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"section-drag-handle flex h-7 shrink-0 cursor-move items-center justify-between border-b px-2",
|
"section-drag-handle flex h-7 shrink-0 cursor-move items-center justify-between border-b px-2",
|
||||||
selectedSectionId === section.id
|
selectedSectionId === sectionId ? "bg-primary/10" : "bg-gray-50"
|
||||||
? "bg-primary/10"
|
|
||||||
: "bg-gray-50"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<GripVertical className="h-3 w-3 text-gray-400" />
|
<GripVertical className="h-3 w-3 text-gray-400" />
|
||||||
<span className="text-xs font-medium text-gray-600">
|
<span className="text-xs font-medium text-gray-600">
|
||||||
{section.label || `섹션`}
|
{sectionDef.label || "섹션"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{selectedSectionId === section.id && (
|
{selectedSectionId === sectionId && isActive && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-5 w-5 text-destructive hover:bg-destructive/10"
|
className="h-5 w-5 text-destructive hover:bg-destructive/10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteSection(section.id);
|
onDeleteSection(sectionId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
|
|
@ -343,20 +548,24 @@ function CanvasDropZone({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 섹션 내부 - 나머지 영역 전부 차지 */}
|
{/* 섹션 내부 - 컴포넌트들 */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<SectionGrid
|
<SectionGridV2
|
||||||
section={section}
|
sectionId={sectionId}
|
||||||
|
sectionDef={sectionDef}
|
||||||
|
components={components}
|
||||||
|
componentPositions={componentPositions}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
onSelectComponent={onSelectComponent}
|
onSelectComponent={onSelectComponent}
|
||||||
onDropComponent={onDropComponent}
|
onDropComponent={onDropComponent}
|
||||||
onUpdateComponent={onUpdateComponent}
|
onUpdateComponentPosition={onUpdateComponentPosition}
|
||||||
onDeleteComponent={onDeleteComponent}
|
onDeleteComponent={onDeleteComponent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</GridLayout>
|
</GridLayout>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
@ -369,10 +578,11 @@ function CanvasDropZone({
|
||||||
>
|
>
|
||||||
{isOver && canDrop
|
{isOver && canDrop
|
||||||
? "여기에 섹션을 놓으세요"
|
? "여기에 섹션을 놓으세요"
|
||||||
: "왼쪽 패널에서 섹션을 드래그하세요"}
|
: isActive
|
||||||
|
? "왼쪽 패널에서 섹션을 드래그하세요"
|
||||||
|
: "클릭하여 편집 모드로 전환"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { DndProvider } from "react-dnd";
|
import { DndProvider } from "react-dnd";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
import { ArrowLeft, Save, Smartphone, Tablet, Columns2, RotateCcw } from "lucide-react";
|
import { ArrowLeft, Save, Smartphone, Tablet } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,84 +16,130 @@ import { toast } from "sonner";
|
||||||
import { PopCanvas } from "./PopCanvas";
|
import { PopCanvas } from "./PopCanvas";
|
||||||
import { PopPanel } from "./panels/PopPanel";
|
import { PopPanel } from "./panels/PopPanel";
|
||||||
import {
|
import {
|
||||||
PopLayoutData,
|
PopLayoutDataV2,
|
||||||
PopSectionData,
|
PopLayoutModeKey,
|
||||||
PopComponentData,
|
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
createEmptyPopLayout,
|
|
||||||
createPopSection,
|
|
||||||
createPopComponent,
|
|
||||||
GridPosition,
|
GridPosition,
|
||||||
|
PopSectionDefinition,
|
||||||
|
createEmptyPopLayoutV2,
|
||||||
|
createSectionDefinition,
|
||||||
|
createComponentDefinition,
|
||||||
|
ensureV2Layout,
|
||||||
|
addSectionToV2Layout,
|
||||||
|
addComponentToV2Layout,
|
||||||
|
removeSectionFromV2Layout,
|
||||||
|
removeComponentFromV2Layout,
|
||||||
|
updateSectionPositionInMode,
|
||||||
|
updateComponentPositionInMode,
|
||||||
|
isV2Layout,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
// 디바이스 타입
|
// 디바이스 타입
|
||||||
|
// ========================================
|
||||||
type DeviceType = "mobile" | "tablet";
|
type DeviceType = "mobile" | "tablet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디바이스 + 방향 → 모드 키 변환
|
||||||
|
*/
|
||||||
|
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
|
||||||
|
if (device === "tablet") {
|
||||||
|
return isLandscape ? "tablet_landscape" : "tablet_portrait";
|
||||||
|
}
|
||||||
|
return isLandscape ? "mobile_landscape" : "mobile_portrait";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
interface PopDesignerProps {
|
interface PopDesignerProps {
|
||||||
selectedScreen: ScreenDefinition;
|
selectedScreen: ScreenDefinition;
|
||||||
onBackToList: () => void;
|
onBackToList: () => void;
|
||||||
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
export default function PopDesigner({
|
export default function PopDesigner({
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
onBackToList,
|
onBackToList,
|
||||||
onScreenUpdate,
|
onScreenUpdate,
|
||||||
}: PopDesignerProps) {
|
}: PopDesignerProps) {
|
||||||
// 레이아웃 상태
|
// ========================================
|
||||||
const [layout, setLayout] = useState<PopLayoutData>(createEmptyPopLayout());
|
// 레이아웃 상태 (v2)
|
||||||
|
// ========================================
|
||||||
|
const [layout, setLayout] = useState<PopLayoutDataV2>(createEmptyPopLayoutV2());
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
// 디바이스 프리뷰 상태
|
// ========================================
|
||||||
|
// 디바이스/모드 상태
|
||||||
|
// ========================================
|
||||||
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
||||||
const [showBothDevices, setShowBothDevices] = useState(false);
|
|
||||||
const [isLandscape, setIsLandscape] = useState(true);
|
|
||||||
|
|
||||||
// 선택된 섹션/컴포넌트
|
// 활성 모드 키 (가로/세로 중 현재 포커스된 캔버스)
|
||||||
|
// 기본값: 태블릿 가로
|
||||||
|
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 선택 상태
|
||||||
|
// ========================================
|
||||||
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
|
const [selectedSectionId, setSelectedSectionId] = useState<string | null>(null);
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 선택된 섹션 객체
|
// ========================================
|
||||||
const selectedSection = selectedSectionId
|
// 파생 상태
|
||||||
? layout.sections.find((s) => s.id === selectedSectionId) || null
|
// ========================================
|
||||||
: null;
|
|
||||||
|
|
||||||
|
// 선택된 섹션 정의
|
||||||
|
const selectedSection: PopSectionDefinition | null = useMemo(() => {
|
||||||
|
if (!selectedSectionId) return null;
|
||||||
|
return layout.sections[selectedSectionId] || null;
|
||||||
|
}, [layout.sections, selectedSectionId]);
|
||||||
|
|
||||||
|
// 현재 활성 모드의 섹션 ID 목록
|
||||||
|
const activeSectionIds = useMemo(() => {
|
||||||
|
return Object.keys(layout.layouts[activeModeKey].sectionPositions);
|
||||||
|
}, [layout.layouts, activeModeKey]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
// 레이아웃 로드
|
// 레이아웃 로드
|
||||||
// API는 이미 언래핑된 layout_data를 반환하므로 response 자체가 레이아웃 데이터
|
// ========================================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLayout = async () => {
|
const loadLayout = async () => {
|
||||||
if (!selectedScreen?.screenId) return;
|
if (!selectedScreen?.screenId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// API가 layout_data 내용을 직접 반환함 (언래핑된 상태)
|
// API가 layout_data 내용을 직접 반환 (언래핑 상태)
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (loadedLayout && loadedLayout.version === "pop-1.0") {
|
if (loadedLayout) {
|
||||||
// 유효한 POP 레이아웃
|
// v1 또는 v2 → v2로 변환
|
||||||
setLayout(loadedLayout as PopLayoutData);
|
const v2Layout = ensureV2Layout(loadedLayout);
|
||||||
console.log("POP 레이아웃 로드 성공:", loadedLayout.sections?.length || 0, "개 섹션");
|
setLayout(v2Layout);
|
||||||
} else if (loadedLayout && loadedLayout.sections) {
|
|
||||||
// 버전 태그 없지만 sections 구조가 있으면 사용
|
const sectionCount = Object.keys(v2Layout.sections).length;
|
||||||
console.warn("버전 태그 없음, sections 구조 감지하여 사용");
|
const componentCount = Object.keys(v2Layout.components).length;
|
||||||
setLayout({
|
console.log(`POP v2 레이아웃 로드 성공: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||||
...createEmptyPopLayout(),
|
|
||||||
...loadedLayout,
|
// v1에서 마이그레이션된 경우 알림
|
||||||
version: "pop-1.0",
|
if (!isV2Layout(loadedLayout)) {
|
||||||
} as PopLayoutData);
|
console.log("v1 → v2 자동 마이그레이션 완료");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 레이아웃 없음 - 빈 레이아웃 생성
|
// 레이아웃 없음 - 빈 v2 레이아웃 생성
|
||||||
console.log("POP 레이아웃 없음, 빈 레이아웃 생성");
|
console.log("POP 레이아웃 없음, 빈 v2 레이아웃 생성");
|
||||||
setLayout(createEmptyPopLayout());
|
setLayout(createEmptyPopLayoutV2());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
console.error("레이아웃 로드 실패:", error);
|
||||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||||
setLayout(createEmptyPopLayout());
|
setLayout(createEmptyPopLayoutV2());
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +148,9 @@ export default function PopDesigner({
|
||||||
loadLayout();
|
loadLayout();
|
||||||
}, [selectedScreen?.screenId]);
|
}, [selectedScreen?.screenId]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
// 저장
|
// 저장
|
||||||
|
// ========================================
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!selectedScreen?.screenId) return;
|
if (!selectedScreen?.screenId) return;
|
||||||
|
|
||||||
|
|
@ -119,111 +167,116 @@ export default function PopDesigner({
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId, layout]);
|
}, [selectedScreen?.screenId, layout]);
|
||||||
|
|
||||||
// 섹션 드롭 (팔레트 → 캔버스)
|
// ========================================
|
||||||
|
// 섹션 추가 (4모드 동기화)
|
||||||
|
// ========================================
|
||||||
const handleDropSection = useCallback((gridPosition: GridPosition) => {
|
const handleDropSection = useCallback((gridPosition: GridPosition) => {
|
||||||
const newId = `section-${Date.now()}`;
|
const newId = `section-${Date.now()}`;
|
||||||
const newSection = createPopSection(newId, gridPosition);
|
|
||||||
|
|
||||||
setLayout((prev) => ({
|
setLayout((prev) => addSectionToV2Layout(prev, newId, gridPosition));
|
||||||
...prev,
|
|
||||||
sections: [...prev.sections, newSection],
|
|
||||||
}));
|
|
||||||
setSelectedSectionId(newId);
|
setSelectedSectionId(newId);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 컴포넌트 드롭 (팔레트 → 섹션)
|
// ========================================
|
||||||
|
// 컴포넌트 추가 (4모드 동기화)
|
||||||
|
// ========================================
|
||||||
const handleDropComponent = useCallback(
|
const handleDropComponent = useCallback(
|
||||||
(sectionId: string, type: PopComponentType, gridPosition: GridPosition) => {
|
(sectionId: string, type: PopComponentType, gridPosition: GridPosition) => {
|
||||||
const newId = `${type}-${Date.now()}`;
|
const newId = `${type}-${Date.now()}`;
|
||||||
const newComponent = createPopComponent(newId, type, gridPosition);
|
|
||||||
|
|
||||||
setLayout((prev) => ({
|
setLayout((prev) => addComponentToV2Layout(prev, sectionId, newId, type, gridPosition));
|
||||||
...prev,
|
|
||||||
sections: prev.sections.map((s) =>
|
|
||||||
s.id === sectionId
|
|
||||||
? { ...s, components: [...s.components, newComponent] }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
setSelectedComponentId(newId);
|
setSelectedComponentId(newId);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 섹션 업데이트
|
// ========================================
|
||||||
const handleUpdateSection = useCallback(
|
// 섹션 정의 업데이트 (공유)
|
||||||
(id: string, updates: Partial<PopSectionData>) => {
|
// ========================================
|
||||||
|
const handleUpdateSectionDefinition = useCallback(
|
||||||
|
(sectionId: string, updates: Partial<PopSectionDefinition>) => {
|
||||||
setLayout((prev) => ({
|
setLayout((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
sections: prev.sections.map((s) =>
|
sections: {
|
||||||
s.id === id ? { ...s, ...updates } : s
|
...prev.sections,
|
||||||
),
|
[sectionId]: {
|
||||||
|
...prev.sections[sectionId],
|
||||||
|
...updates,
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 섹션 삭제
|
// ========================================
|
||||||
const handleDeleteSection = useCallback((id: string) => {
|
// 섹션 위치 업데이트 (현재 모드만)
|
||||||
setLayout((prev) => ({
|
// ========================================
|
||||||
...prev,
|
const handleUpdateSectionPosition = useCallback(
|
||||||
sections: prev.sections.filter((s) => s.id !== id),
|
(sectionId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
||||||
}));
|
const targetMode = modeKey || activeModeKey;
|
||||||
|
setLayout((prev) => updateSectionPositionInMode(prev, targetMode, sectionId, position));
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[activeModeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 위치 업데이트 (현재 모드만)
|
||||||
|
// ========================================
|
||||||
|
const handleUpdateComponentPosition = useCallback(
|
||||||
|
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
||||||
|
const targetMode = modeKey || activeModeKey;
|
||||||
|
setLayout((prev) => updateComponentPositionInMode(prev, targetMode, componentId, position));
|
||||||
|
setHasChanges(true);
|
||||||
|
},
|
||||||
|
[activeModeKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 섹션 삭제 (4모드 동기화)
|
||||||
|
// ========================================
|
||||||
|
const handleDeleteSection = useCallback((sectionId: string) => {
|
||||||
|
setLayout((prev) => removeSectionFromV2Layout(prev, sectionId));
|
||||||
setSelectedSectionId(null);
|
setSelectedSectionId(null);
|
||||||
|
setSelectedComponentId(null);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 레이아웃 변경 (드래그/리사이즈)
|
// ========================================
|
||||||
const handleLayoutChange = useCallback((sections: PopSectionData[]) => {
|
// 컴포넌트 삭제 (4모드 동기화)
|
||||||
setLayout((prev) => ({
|
// ========================================
|
||||||
...prev,
|
|
||||||
sections,
|
|
||||||
}));
|
|
||||||
setHasChanges(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컴포넌트 업데이트
|
|
||||||
const handleUpdateComponent = useCallback(
|
|
||||||
(sectionId: string, componentId: string, updates: Partial<PopComponentData>) => {
|
|
||||||
setLayout((prev) => ({
|
|
||||||
...prev,
|
|
||||||
sections: prev.sections.map((s) =>
|
|
||||||
s.id === sectionId
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
components: s.components.map((c) =>
|
|
||||||
c.id === componentId ? { ...c, ...updates } : c
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
setHasChanges(true);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 컴포넌트 삭제
|
|
||||||
const handleDeleteComponent = useCallback(
|
const handleDeleteComponent = useCallback(
|
||||||
(sectionId: string, componentId: string) => {
|
(sectionId: string, componentId: string) => {
|
||||||
setLayout((prev) => ({
|
setLayout((prev) => removeComponentFromV2Layout(prev, sectionId, componentId));
|
||||||
...prev,
|
|
||||||
sections: prev.sections.map((s) =>
|
|
||||||
s.id === sectionId
|
|
||||||
? { ...s, components: s.components.filter((c) => c.id !== componentId) }
|
|
||||||
: s
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
setSelectedComponentId(null);
|
setSelectedComponentId(null);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 디바이스 전환
|
||||||
|
// ========================================
|
||||||
|
const handleDeviceChange = useCallback((device: DeviceType) => {
|
||||||
|
setActiveDevice(device);
|
||||||
|
// 기본 모드 키 설정 (가로)
|
||||||
|
setActiveModeKey(device === "tablet" ? "tablet_landscape" : "mobile_landscape");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모드 키 전환 (캔버스 포커스)
|
||||||
|
// ========================================
|
||||||
|
const handleModeKeyChange = useCallback((modeKey: PopLayoutModeKey) => {
|
||||||
|
setActiveModeKey(modeKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
// 뒤로가기
|
// 뒤로가기
|
||||||
|
// ========================================
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) {
|
if (confirm("저장하지 않은 변경사항이 있습니다. 나가시겠습니까?")) {
|
||||||
|
|
@ -234,6 +287,63 @@ export default function PopDesigner({
|
||||||
}
|
}
|
||||||
}, [hasChanges, onBackToList]);
|
}, [hasChanges, onBackToList]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Delete 키 삭제 기능
|
||||||
|
// ========================================
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// input/textarea 포커스 시 제외
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 또는 Backspace 키
|
||||||
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
|
e.preventDefault(); // 브라우저 뒤로가기 방지
|
||||||
|
|
||||||
|
// 컴포넌트가 선택되어 있으면 컴포넌트 삭제
|
||||||
|
if (selectedComponentId) {
|
||||||
|
// v2 구조: 컴포넌트가 속한 섹션을 sections의 componentIds에서 찾기
|
||||||
|
// (PopComponentDefinition에는 sectionId가 없으므로 섹션을 순회하여 찾음)
|
||||||
|
let foundSectionId: string | null = null;
|
||||||
|
for (const [sectionId, sectionDef] of Object.entries(layout.sections)) {
|
||||||
|
if (sectionDef.componentIds.includes(selectedComponentId)) {
|
||||||
|
foundSectionId = sectionId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundSectionId) {
|
||||||
|
handleDeleteComponent(foundSectionId, selectedComponentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 컴포넌트가 선택되지 않았고 섹션이 선택되어 있으면 섹션 삭제
|
||||||
|
else if (selectedSectionId) {
|
||||||
|
handleDeleteSection(selectedSectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
selectedComponentId,
|
||||||
|
selectedSectionId,
|
||||||
|
layout.sections,
|
||||||
|
handleDeleteComponent,
|
||||||
|
handleDeleteSection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 로딩 상태
|
||||||
|
// ========================================
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
|
@ -242,6 +352,9 @@ export default function PopDesigner({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 렌더링
|
||||||
|
// ========================================
|
||||||
return (
|
return (
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="flex h-screen flex-col bg-background">
|
<div className="flex h-screen flex-col bg-background">
|
||||||
|
|
@ -261,11 +374,11 @@ export default function PopDesigner({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 디바이스 전환 */}
|
{/* 중앙: 디바이스 전환 (가로/세로 전환 버튼 제거 - 캔버스 2개 동시 표시) */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeDevice}
|
value={activeDevice}
|
||||||
onValueChange={(v) => setActiveDevice(v as DeviceType)}
|
onValueChange={(v) => handleDeviceChange(v as DeviceType)}
|
||||||
>
|
>
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
||||||
|
|
@ -278,24 +391,6 @@ export default function PopDesigner({
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsLandscape(!isLandscape)}
|
|
||||||
title={isLandscape ? "세로 모드로 전환" : "가로 모드로 전환"}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={showBothDevices ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowBothDevices(!showBothDevices)}
|
|
||||||
title="나란히 보기"
|
|
||||||
>
|
|
||||||
<Columns2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 저장 */}
|
{/* 오른쪽: 저장 */}
|
||||||
|
|
@ -322,9 +417,10 @@ export default function PopDesigner({
|
||||||
>
|
>
|
||||||
<PopPanel
|
<PopPanel
|
||||||
layout={layout}
|
layout={layout}
|
||||||
|
activeModeKey={activeModeKey}
|
||||||
selectedSectionId={selectedSectionId}
|
selectedSectionId={selectedSectionId}
|
||||||
selectedSection={selectedSection}
|
selectedSection={selectedSection}
|
||||||
onUpdateSection={handleUpdateSection}
|
onUpdateSectionDefinition={handleUpdateSectionDefinition}
|
||||||
onDeleteSection={handleDeleteSection}
|
onDeleteSection={handleDeleteSection}
|
||||||
activeDevice={activeDevice}
|
activeDevice={activeDevice}
|
||||||
/>
|
/>
|
||||||
|
|
@ -332,23 +428,22 @@ export default function PopDesigner({
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
{/* 오른쪽: 캔버스 */}
|
{/* 오른쪽: 캔버스 (가로+세로 2개 동시 표시) */}
|
||||||
<ResizablePanel defaultSize={80}>
|
<ResizablePanel defaultSize={80}>
|
||||||
<PopCanvas
|
<PopCanvas
|
||||||
layout={layout}
|
layout={layout}
|
||||||
activeDevice={activeDevice}
|
activeDevice={activeDevice}
|
||||||
showBothDevices={showBothDevices}
|
activeModeKey={activeModeKey}
|
||||||
isLandscape={isLandscape}
|
onModeKeyChange={handleModeKeyChange}
|
||||||
selectedSectionId={selectedSectionId}
|
selectedSectionId={selectedSectionId}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
onSelectSection={setSelectedSectionId}
|
onSelectSection={setSelectedSectionId}
|
||||||
onSelectComponent={setSelectedComponentId}
|
onSelectComponent={setSelectedComponentId}
|
||||||
onUpdateSection={handleUpdateSection}
|
onUpdateSectionPosition={handleUpdateSectionPosition}
|
||||||
|
onUpdateComponentPosition={handleUpdateComponentPosition}
|
||||||
onDeleteSection={handleDeleteSection}
|
onDeleteSection={handleDeleteSection}
|
||||||
onLayoutChange={handleLayoutChange}
|
|
||||||
onDropSection={handleDropSection}
|
onDropSection={handleDropSection}
|
||||||
onDropComponent={handleDropComponent}
|
onDropComponent={handleDropComponent}
|
||||||
onUpdateComponent={handleUpdateComponent}
|
|
||||||
onDeleteComponent={handleDeleteComponent}
|
onDeleteComponent={handleDeleteComponent}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
|
import { useDrop } from "react-dnd";
|
||||||
|
import GridLayout, { Layout } from "react-grid-layout";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopSectionDefinition,
|
||||||
|
PopComponentDefinition,
|
||||||
|
PopComponentType,
|
||||||
|
GridPosition,
|
||||||
|
} from "./types/pop-layout";
|
||||||
|
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
||||||
|
import { Trash2, Move } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
import "react-grid-layout/css/styles.css";
|
||||||
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
interface SectionGridV2Props {
|
||||||
|
sectionId: string;
|
||||||
|
sectionDef: PopSectionDefinition;
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
componentPositions: Record<string, GridPosition>;
|
||||||
|
isActive: boolean;
|
||||||
|
selectedComponentId: string | null;
|
||||||
|
onSelectComponent: (id: string | null) => void;
|
||||||
|
onDropComponent: (sectionId: string, type: PopComponentType, gridPosition: GridPosition) => void;
|
||||||
|
onUpdateComponentPosition: (componentId: string, position: GridPosition) => void;
|
||||||
|
onDeleteComponent: (sectionId: string, componentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
|
export function SectionGridV2({
|
||||||
|
sectionId,
|
||||||
|
sectionDef,
|
||||||
|
components,
|
||||||
|
componentPositions,
|
||||||
|
isActive,
|
||||||
|
selectedComponentId,
|
||||||
|
onSelectComponent,
|
||||||
|
onDropComponent,
|
||||||
|
onUpdateComponentPosition,
|
||||||
|
onDeleteComponent,
|
||||||
|
}: SectionGridV2Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 이 섹션에 포함된 컴포넌트 ID 목록
|
||||||
|
const componentIds = sectionDef.componentIds || [];
|
||||||
|
|
||||||
|
// 컨테이너 크기 측정
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 300, height: 200 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateSize = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setContainerSize({
|
||||||
|
width: containerRef.current.offsetWidth,
|
||||||
|
height: containerRef.current.offsetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSize();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateSize);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 셀 크기 계산 - 고정 셀 크기 기반으로 자동 계산
|
||||||
|
const padding = 8; // p-2 = 8px
|
||||||
|
const gap = 4; // 고정 간격
|
||||||
|
const availableWidth = containerSize.width - padding * 2;
|
||||||
|
const availableHeight = containerSize.height - padding * 2;
|
||||||
|
|
||||||
|
// 고정 셀 크기 (40px) 기반으로 열/행 수 자동 계산
|
||||||
|
const CELL_SIZE = 40;
|
||||||
|
const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap)));
|
||||||
|
const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap)));
|
||||||
|
const cellHeight = CELL_SIZE;
|
||||||
|
|
||||||
|
// GridLayout용 레이아웃 변환
|
||||||
|
const gridLayoutItems: Layout[] = useMemo(() => {
|
||||||
|
return componentIds
|
||||||
|
.map((compId) => {
|
||||||
|
const pos = componentPositions[compId];
|
||||||
|
if (!pos) return null;
|
||||||
|
|
||||||
|
// 위치가 그리드 범위를 벗어나지 않도록 조정
|
||||||
|
const x = Math.min(Math.max(0, pos.col - 1), Math.max(0, cols - 1));
|
||||||
|
const y = Math.min(Math.max(0, pos.row - 1), Math.max(0, rows - 1));
|
||||||
|
const w = Math.min(Math.max(1, pos.colSpan), Math.max(1, cols - x));
|
||||||
|
const h = Math.min(Math.max(1, pos.rowSpan), Math.max(1, rows - y));
|
||||||
|
|
||||||
|
return {
|
||||||
|
i: compId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
minW: 1,
|
||||||
|
minH: 1,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is Layout => item !== null);
|
||||||
|
}, [componentIds, componentPositions, cols, rows]);
|
||||||
|
|
||||||
|
// 드래그 완료 핸들러
|
||||||
|
const handleDragStop = useCallback(
|
||||||
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||||||
|
const newPos: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
|
||||||
|
const oldPos = componentPositions[newItem.i];
|
||||||
|
if (!oldPos || oldPos.col !== newPos.col || oldPos.row !== newPos.row) {
|
||||||
|
onUpdateComponentPosition(newItem.i, newPos);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[componentPositions, onUpdateComponentPosition]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리사이즈 완료 핸들러
|
||||||
|
const handleResizeStop = useCallback(
|
||||||
|
(layout: Layout[], oldItem: Layout, newItem: Layout) => {
|
||||||
|
const newPos: GridPosition = {
|
||||||
|
col: newItem.x + 1,
|
||||||
|
row: newItem.y + 1,
|
||||||
|
colSpan: newItem.w,
|
||||||
|
rowSpan: newItem.h,
|
||||||
|
};
|
||||||
|
|
||||||
|
const oldPos = componentPositions[newItem.i];
|
||||||
|
if (!oldPos || oldPos.colSpan !== newPos.colSpan || oldPos.rowSpan !== newPos.rowSpan) {
|
||||||
|
onUpdateComponentPosition(newItem.i, newPos);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[componentPositions, onUpdateComponentPosition]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 빈 셀 찾기 (드롭 위치용)
|
||||||
|
const findEmptyCell = useCallback((): GridPosition => {
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
|
||||||
|
componentIds.forEach((compId) => {
|
||||||
|
const pos = componentPositions[compId];
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
for (let c = pos.col; c < pos.col + pos.colSpan; c++) {
|
||||||
|
for (let r = pos.row; r < pos.row + pos.rowSpan; r++) {
|
||||||
|
occupied.add(`${c}-${r}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 빈 셀 찾기
|
||||||
|
for (let r = 1; r <= rows; r++) {
|
||||||
|
for (let c = 1; c <= cols; c++) {
|
||||||
|
if (!occupied.has(`${c}-${r}`)) {
|
||||||
|
return { col: c, row: r, colSpan: 1, rowSpan: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빈 셀 없으면 첫 번째 위치
|
||||||
|
return { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
||||||
|
}, [componentIds, componentPositions, cols, rows]);
|
||||||
|
|
||||||
|
// 컴포넌트 드롭 핸들러
|
||||||
|
const [{ isOver, canDrop }, drop] = useDrop(
|
||||||
|
() => ({
|
||||||
|
accept: DND_ITEM_TYPES.COMPONENT,
|
||||||
|
drop: (item: DragItemComponent) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
const emptyCell = findEmptyCell();
|
||||||
|
onDropComponent(sectionId, item.componentType, emptyCell);
|
||||||
|
return { dropped: true };
|
||||||
|
},
|
||||||
|
canDrop: () => isActive,
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
canDrop: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[isActive, sectionId, findEmptyCell, onDropComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
containerRef.current = node;
|
||||||
|
drop(node);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"relative h-full w-full p-2 transition-colors",
|
||||||
|
isOver && canDrop && "bg-blue-50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectComponent(null);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 빈 상태 안내 텍스트 */}
|
||||||
|
{componentIds.length === 0 && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded bg-white/80 px-2 py-1 text-xs",
|
||||||
|
isOver && canDrop ? "font-medium text-primary" : "text-gray-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOver && canDrop ? "여기에 놓으세요" : "컴포넌트를 드래그하세요"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컴포넌트 GridLayout */}
|
||||||
|
{componentIds.length > 0 && availableWidth > 0 && cols > 0 && (
|
||||||
|
<GridLayout
|
||||||
|
className="layout relative z-10"
|
||||||
|
layout={gridLayoutItems}
|
||||||
|
cols={cols}
|
||||||
|
rowHeight={cellHeight}
|
||||||
|
width={availableWidth}
|
||||||
|
margin={[gap, gap]}
|
||||||
|
containerPadding={[0, 0]}
|
||||||
|
onDragStop={handleDragStop}
|
||||||
|
onResizeStop={handleResizeStop}
|
||||||
|
isDraggable={isActive}
|
||||||
|
isResizable={isActive}
|
||||||
|
compactType={null}
|
||||||
|
preventCollision={false}
|
||||||
|
useCSSTransforms={true}
|
||||||
|
draggableHandle=".component-drag-handle"
|
||||||
|
resizeHandles={["se", "e", "s"]}
|
||||||
|
>
|
||||||
|
{componentIds.map((compId) => {
|
||||||
|
const compDef = components[compId];
|
||||||
|
if (!compDef) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={compId}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col overflow-hidden rounded border bg-white text-xs transition-all",
|
||||||
|
selectedComponentId === compId
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200 hover:border-gray-400"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectComponent(compId);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 바 */}
|
||||||
|
<div className="component-drag-handle flex h-5 cursor-move items-center justify-center border-b bg-gray-50 opacity-60 transition-opacity hover:opacity-100">
|
||||||
|
<Move className="h-3 w-3 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컴포넌트 내용 */}
|
||||||
|
<div className="flex flex-1 items-center justify-center p-1">
|
||||||
|
<ComponentPreviewV2 component={compDef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
{selectedComponentId === compId && isActive && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -right-2 -top-2 z-10 h-5 w-5 rounded-full bg-white text-destructive shadow hover:bg-destructive/10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteComponent(sectionId, compId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</GridLayout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 미리보기
|
||||||
|
// ========================================
|
||||||
|
interface ComponentPreviewV2Props {
|
||||||
|
component: PopComponentDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentPreviewV2({ component }: ComponentPreviewV2Props) {
|
||||||
|
const { type, label } = component;
|
||||||
|
|
||||||
|
const renderPreview = () => {
|
||||||
|
switch (type) {
|
||||||
|
case "pop-field":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "필드"}</span>
|
||||||
|
<div className="h-6 w-full rounded border border-gray-200 bg-gray-50" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-button":
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 w-full items-center justify-center rounded bg-primary/10 font-medium text-primary">
|
||||||
|
{label || "버튼"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-list":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "리스트"}</span>
|
||||||
|
<div className="h-3 w-full rounded bg-gray-100" />
|
||||||
|
<div className="h-3 w-3/4 rounded bg-gray-100" />
|
||||||
|
<div className="h-3 w-full rounded bg-gray-100" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-indicator":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center gap-1">
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
|
||||||
|
<span className="text-lg font-bold text-primary">0</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-scanner":
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col items-center gap-1">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded border-2 border-dashed border-gray-300">
|
||||||
|
<span className="text-[8px] text-gray-400">QR</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pop-numpad":
|
||||||
|
return (
|
||||||
|
<div className="grid w-full grid-cols-3 gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex h-4 items-center justify-center rounded bg-gray-100 text-[8px]"
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="text-gray-500">{label || type}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="w-full overflow-hidden">{renderPreview()}</div>;
|
||||||
|
}
|
||||||
|
|
@ -34,18 +34,21 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PopLayoutData,
|
PopLayoutDataV2,
|
||||||
PopSectionData,
|
PopLayoutModeKey,
|
||||||
|
PopSectionDefinition,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
|
MODE_RESOLUTIONS,
|
||||||
} from "../types/pop-layout";
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
// 드래그 아이템 타입
|
// 드래그 아이템 타입
|
||||||
|
// ========================================
|
||||||
export const DND_ITEM_TYPES = {
|
export const DND_ITEM_TYPES = {
|
||||||
SECTION: "section",
|
SECTION: "section",
|
||||||
COMPONENT: "component",
|
COMPONENT: "component",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 드래그 아이템 데이터
|
|
||||||
export interface DragItemSection {
|
export interface DragItemSection {
|
||||||
type: typeof DND_ITEM_TYPES.SECTION;
|
type: typeof DND_ITEM_TYPES.SECTION;
|
||||||
}
|
}
|
||||||
|
|
@ -55,16 +58,9 @@ export interface DragItemComponent {
|
||||||
componentType: PopComponentType;
|
componentType: PopComponentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopPanelProps {
|
// ========================================
|
||||||
layout: PopLayoutData;
|
|
||||||
selectedSectionId: string | null;
|
|
||||||
selectedSection: PopSectionData | null;
|
|
||||||
onUpdateSection: (id: string, updates: Partial<PopSectionData>) => void;
|
|
||||||
onDeleteSection: (id: string) => void;
|
|
||||||
activeDevice: "mobile" | "tablet";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 팔레트 정의
|
// 컴포넌트 팔레트 정의
|
||||||
|
// ========================================
|
||||||
const COMPONENT_PALETTE: {
|
const COMPONENT_PALETTE: {
|
||||||
type: PopComponentType;
|
type: PopComponentType;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -109,16 +105,39 @@ const COMPONENT_PALETTE: {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
interface PopPanelProps {
|
||||||
|
layout: PopLayoutDataV2;
|
||||||
|
activeModeKey: PopLayoutModeKey;
|
||||||
|
selectedSectionId: string | null;
|
||||||
|
selectedSection: PopSectionDefinition | null;
|
||||||
|
onUpdateSectionDefinition: (id: string, updates: Partial<PopSectionDefinition>) => void;
|
||||||
|
onDeleteSection: (id: string) => void;
|
||||||
|
activeDevice: "mobile" | "tablet";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 메인 컴포넌트
|
||||||
|
// ========================================
|
||||||
export function PopPanel({
|
export function PopPanel({
|
||||||
layout,
|
layout,
|
||||||
|
activeModeKey,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
selectedSection,
|
selectedSection,
|
||||||
onUpdateSection,
|
onUpdateSectionDefinition,
|
||||||
onDeleteSection,
|
onDeleteSection,
|
||||||
activeDevice,
|
activeDevice,
|
||||||
}: PopPanelProps) {
|
}: PopPanelProps) {
|
||||||
const [activeTab, setActiveTab] = useState<string>("components");
|
const [activeTab, setActiveTab] = useState<string>("components");
|
||||||
|
|
||||||
|
// 현재 모드의 섹션 위치
|
||||||
|
const currentModeLayout = layout.layouts[activeModeKey];
|
||||||
|
const selectedSectionPosition = selectedSectionId
|
||||||
|
? currentModeLayout.sectionPositions[selectedSectionId]
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|
@ -140,6 +159,16 @@ export function PopPanel({
|
||||||
{/* 컴포넌트 탭 */}
|
{/* 컴포넌트 탭 */}
|
||||||
<TabsContent value="components" className="flex-1 overflow-auto p-2">
|
<TabsContent value="components" className="flex-1 overflow-auto p-2">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* 현재 모드 표시 */}
|
||||||
|
<div className="rounded-lg bg-muted p-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
편집 중: {getModeLabel(activeModeKey)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{MODE_RESOLUTIONS[activeModeKey].width} x {MODE_RESOLUTIONS[activeModeKey].height}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 섹션 드래그 아이템 */}
|
{/* 섹션 드래그 아이템 */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
|
@ -176,12 +205,15 @@ export function PopPanel({
|
||||||
|
|
||||||
{/* 편집 탭 */}
|
{/* 편집 탭 */}
|
||||||
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
|
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
|
||||||
{selectedSection ? (
|
{selectedSection && selectedSectionPosition ? (
|
||||||
<SectionEditor
|
<SectionEditorV2
|
||||||
section={selectedSection}
|
section={selectedSection}
|
||||||
onUpdate={(updates) => onUpdateSection(selectedSection.id, updates)}
|
position={selectedSectionPosition}
|
||||||
|
activeModeKey={activeModeKey}
|
||||||
|
onUpdateDefinition={(updates) =>
|
||||||
|
onUpdateSectionDefinition(selectedSection.id, updates)
|
||||||
|
}
|
||||||
onDelete={() => onDeleteSection(selectedSection.id)}
|
onDelete={() => onDeleteSection(selectedSection.id)}
|
||||||
activeDevice={activeDevice}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
|
@ -194,7 +226,22 @@ export function PopPanel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모드 라벨 헬퍼
|
||||||
|
// ========================================
|
||||||
|
function getModeLabel(modeKey: PopLayoutModeKey): string {
|
||||||
|
const labels: Record<PopLayoutModeKey, string> = {
|
||||||
|
tablet_landscape: "태블릿 가로",
|
||||||
|
tablet_portrait: "태블릿 세로",
|
||||||
|
mobile_landscape: "모바일 가로",
|
||||||
|
mobile_portrait: "모바일 세로",
|
||||||
|
};
|
||||||
|
return labels[modeKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
// 드래그 가능한 섹션 아이템
|
// 드래그 가능한 섹션 아이템
|
||||||
|
// ========================================
|
||||||
function DraggableSectionItem() {
|
function DraggableSectionItem() {
|
||||||
const [{ isDragging }, drag] = useDrag(() => ({
|
const [{ isDragging }, drag] = useDrag(() => ({
|
||||||
type: DND_ITEM_TYPES.SECTION,
|
type: DND_ITEM_TYPES.SECTION,
|
||||||
|
|
@ -223,7 +270,9 @@ function DraggableSectionItem() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
// ========================================
|
||||||
interface DraggableComponentItemProps {
|
interface DraggableComponentItemProps {
|
||||||
type: PopComponentType;
|
type: PopComponentType;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -264,28 +313,31 @@ function DraggableComponentItem({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 섹션 편집기
|
// ========================================
|
||||||
interface SectionEditorProps {
|
// v2 섹션 편집기
|
||||||
section: PopSectionData;
|
// ========================================
|
||||||
onUpdate: (updates: Partial<PopSectionData>) => void;
|
interface SectionEditorV2Props {
|
||||||
|
section: PopSectionDefinition;
|
||||||
|
position: { col: number; row: number; colSpan: number; rowSpan: number };
|
||||||
|
activeModeKey: PopLayoutModeKey;
|
||||||
|
onUpdateDefinition: (updates: Partial<PopSectionDefinition>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
activeDevice: "mobile" | "tablet";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionEditor({
|
function SectionEditorV2({
|
||||||
section,
|
section,
|
||||||
onUpdate,
|
position,
|
||||||
|
activeModeKey,
|
||||||
|
onUpdateDefinition,
|
||||||
onDelete,
|
onDelete,
|
||||||
activeDevice,
|
}: SectionEditorV2Props) {
|
||||||
}: SectionEditorProps) {
|
|
||||||
const [isGridOpen, setIsGridOpen] = useState(true);
|
const [isGridOpen, setIsGridOpen] = useState(true);
|
||||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 섹션 기본 정보 */}
|
{/* 섹션 기본 정보 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">섹션</span>
|
<span className="text-sm font-medium">섹션 설정</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -298,19 +350,22 @@ function SectionEditor({
|
||||||
|
|
||||||
{/* 라벨 */}
|
{/* 라벨 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">라벨</Label>
|
<Label className="text-xs">라벨 (공유)</Label>
|
||||||
<Input
|
<Input
|
||||||
value={section.label || ""}
|
value={section.label || ""}
|
||||||
onChange={(e) => onUpdate({ label: e.target.value })}
|
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
|
||||||
placeholder="섹션 이름"
|
placeholder="섹션 이름"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
라벨은 4개 모드에서 공유됩니다
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 그리드 위치/크기 */}
|
{/* 현재 모드 위치 (읽기 전용 - 드래그로 조정) */}
|
||||||
<Collapsible open={isGridOpen} onOpenChange={setIsGridOpen}>
|
<Collapsible open={isGridOpen} onOpenChange={setIsGridOpen}>
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
|
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
|
||||||
그리드 위치
|
현재 모드 위치
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-transform",
|
"h-4 w-4 transition-transform",
|
||||||
|
|
@ -319,87 +374,44 @@ function SectionEditor({
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="space-y-3 pt-2">
|
<CollapsibleContent className="space-y-3 pt-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="rounded-lg bg-muted p-3">
|
||||||
<div className="space-y-1">
|
<p className="mb-2 text-xs font-medium">{getModeLabel(activeModeKey)}</p>
|
||||||
<Label className="text-xs">시작 열</Label>
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<Input
|
<div className="flex justify-between">
|
||||||
type="number"
|
<span className="text-muted-foreground">시작 열:</span>
|
||||||
min={1}
|
<span className="font-medium">{position.col}</span>
|
||||||
max={24}
|
|
||||||
value={section.grid.col}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate({
|
|
||||||
grid: { ...section.grid, col: parseInt(e.target.value) || 1 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex justify-between">
|
||||||
<Label className="text-xs">시작 행</Label>
|
<span className="text-muted-foreground">시작 행:</span>
|
||||||
<Input
|
<span className="font-medium">{position.row}</span>
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={section.grid.row}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate({
|
|
||||||
grid: { ...section.grid, row: parseInt(e.target.value) || 1 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex justify-between">
|
||||||
<Label className="text-xs">열 크기</Label>
|
<span className="text-muted-foreground">열 크기:</span>
|
||||||
<Input
|
<span className="font-medium">{position.colSpan}</span>
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={24}
|
|
||||||
value={section.grid.colSpan}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate({
|
|
||||||
grid: {
|
|
||||||
...section.grid,
|
|
||||||
colSpan: parseInt(e.target.value) || 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex justify-between">
|
||||||
<Label className="text-xs">행 크기</Label>
|
<span className="text-muted-foreground">행 크기:</span>
|
||||||
<Input
|
<span className="font-medium">{position.rowSpan}</span>
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={section.grid.rowSpan}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate({
|
|
||||||
grid: {
|
|
||||||
...section.grid,
|
|
||||||
rowSpan: parseInt(e.target.value) || 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
캔버스는 24열 그리드입니다
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
위치/크기는 캔버스에서 드래그하여 조정하세요.
|
||||||
|
각 모드(가로/세로)별로 별도 저장됩니다.
|
||||||
</p>
|
</p>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
{/* 내부 그리드 설정 */}
|
{/* 내부 그리드 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">내부 그리드 (공유)</h4>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">내부 열 수</Label>
|
<Label className="text-xs">내부 열 수</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(section.innerGrid.columns)}
|
value={String(section.innerGrid.columns)}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
onUpdate({
|
onUpdateDefinition({
|
||||||
innerGrid: { ...section.innerGrid, columns: parseInt(v) },
|
innerGrid: { ...section.innerGrid, columns: parseInt(v) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +433,7 @@ function SectionEditor({
|
||||||
<Select
|
<Select
|
||||||
value={String(section.innerGrid.rows)}
|
value={String(section.innerGrid.rows)}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
onUpdate({
|
onUpdateDefinition({
|
||||||
innerGrid: { ...section.innerGrid, rows: parseInt(v) },
|
innerGrid: { ...section.innerGrid, rows: parseInt(v) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -440,70 +452,33 @@ function SectionEditor({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
섹션 내부에서 컴포넌트를 배치할 그리드 (점으로 표시)
|
내부 그리드 설정은 4개 모드에서 공유됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모바일 전용 설정 */}
|
{/* 컴포넌트 목록 */}
|
||||||
<Collapsible open={isMobileOpen} onOpenChange={setIsMobileOpen}>
|
<div className="space-y-2">
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
|
<h4 className="text-sm font-medium">
|
||||||
모바일 전용 설정
|
포함된 컴포넌트 ({section.componentIds.length}개)
|
||||||
<ChevronDown
|
</h4>
|
||||||
className={cn(
|
{section.componentIds.length > 0 ? (
|
||||||
"h-4 w-4 transition-transform",
|
|
||||||
isMobileOpen && "rotate-180"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-3 pt-2">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">모바일 열 크기</Label>
|
{section.componentIds.map((compId) => (
|
||||||
<Input
|
<div
|
||||||
type="number"
|
key={compId}
|
||||||
min={1}
|
className="rounded border bg-muted/50 px-2 py-1 text-xs"
|
||||||
max={4}
|
>
|
||||||
value={section.mobileGrid?.colSpan || section.grid.colSpan}
|
{compId}
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate({
|
|
||||||
mobileGrid: {
|
|
||||||
col: section.mobileGrid?.col || 1,
|
|
||||||
row: section.mobileGrid?.row || section.grid.row,
|
|
||||||
colSpan: parseInt(e.target.value) || 4,
|
|
||||||
rowSpan:
|
|
||||||
section.mobileGrid?.rowSpan || section.grid.rowSpan,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">모바일 행 크기</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={section.mobileGrid?.rowSpan || section.grid.rowSpan}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate({
|
|
||||||
mobileGrid: {
|
|
||||||
col: section.mobileGrid?.col || 1,
|
|
||||||
row: section.mobileGrid?.row || section.grid.row,
|
|
||||||
colSpan: section.mobileGrid?.colSpan || 4,
|
|
||||||
rowSpan: parseInt(e.target.value) || 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
모바일에서는 4열 그리드로 자동 변환됩니다
|
아직 컴포넌트가 없습니다
|
||||||
</p>
|
</p>
|
||||||
</CollapsibleContent>
|
)}
|
||||||
</Collapsible>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,209 @@
|
||||||
// POP 디자이너 레이아웃 타입 정의
|
// POP 디자이너 레이아웃 타입 정의
|
||||||
// 그리드 기반 반응형 레이아웃 (픽셀 좌표 없음, 그리드 셀 기반)
|
// 그리드 기반 반응형 레이아웃 (픽셀 좌표 없음, 그리드 셀 기반)
|
||||||
|
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 레이아웃 모드 키 (4가지)
|
||||||
|
// ========================================
|
||||||
|
export type PopLayoutModeKey =
|
||||||
|
| "tablet_landscape" // 태블릿 가로 (1024x768)
|
||||||
|
| "tablet_portrait" // 태블릿 세로 (768x1024)
|
||||||
|
| "mobile_landscape" // 모바일 가로 (667x375)
|
||||||
|
| "mobile_portrait"; // 모바일 세로 (375x667)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POP 레이아웃 전체 데이터
|
* 모드별 해상도 상수
|
||||||
* - 캔버스는 12열 그리드
|
|
||||||
* - 섹션은 그리드 셀 위치/크기로 배치
|
|
||||||
* - 컴포넌트는 섹션 내부 그리드에 배치
|
|
||||||
*/
|
*/
|
||||||
export interface PopLayoutData {
|
export const MODE_RESOLUTIONS: Record<PopLayoutModeKey, { width: number; height: number }> = {
|
||||||
|
tablet_landscape: { width: 1024, height: 768 },
|
||||||
|
tablet_portrait: { width: 768, height: 1024 },
|
||||||
|
mobile_landscape: { width: 667, height: 375 },
|
||||||
|
mobile_portrait: { width: 375, height: 667 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v1.0 레이아웃 (기존 - 하위 호환)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 v1.0 (기존 구조)
|
||||||
|
* - 단일 sections 배열로 모든 모드 공유
|
||||||
|
* @deprecated v2.0 사용 권장
|
||||||
|
*/
|
||||||
|
export interface PopLayoutDataV1 {
|
||||||
version: "pop-1.0";
|
version: "pop-1.0";
|
||||||
layoutMode: "grid"; // 그리드 기반 (반응형)
|
layoutMode: "grid";
|
||||||
deviceTarget: PopDeviceTarget;
|
deviceTarget: PopDeviceTarget;
|
||||||
canvasGrid: PopCanvasGrid; // 캔버스 그리드 설정
|
canvasGrid: PopCanvasGrid;
|
||||||
sections: PopSectionData[]; // 섹션 목록
|
sections: PopSectionDataV1[];
|
||||||
metadata?: PopLayoutMetadata;
|
metadata?: PopLayoutMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2.0 레이아웃 (신규)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 v2.0
|
||||||
|
* - 4개 모드별 섹션/컴포넌트 위치 분리
|
||||||
|
* - 섹션/컴포넌트 정의는 공유
|
||||||
|
* - 3단계 데이터 흐름 지원
|
||||||
|
*/
|
||||||
|
export interface PopLayoutDataV2 {
|
||||||
|
version: "pop-2.0";
|
||||||
|
|
||||||
|
// 4개 모드별 레이아웃 (위치/크기만)
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: PopModeLayout;
|
||||||
|
tablet_portrait: PopModeLayout;
|
||||||
|
mobile_landscape: PopModeLayout;
|
||||||
|
mobile_portrait: PopModeLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공유 섹션 정의 (ID → 정의)
|
||||||
|
sections: Record<string, PopSectionDefinition>;
|
||||||
|
|
||||||
|
// 공유 컴포넌트 정의 (ID → 정의)
|
||||||
|
components: Record<string, PopComponentDefinition>;
|
||||||
|
|
||||||
|
// 3단계 데이터 흐름
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
|
||||||
|
// 전역 설정
|
||||||
|
settings: PopGlobalSettings;
|
||||||
|
|
||||||
|
// 메타데이터
|
||||||
|
metadata?: PopLayoutMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모드별 레이아웃 (위치/크기만 저장)
|
||||||
|
*/
|
||||||
|
export interface PopModeLayout {
|
||||||
|
// 섹션별 위치 (섹션 ID → 위치)
|
||||||
|
sectionPositions: Record<string, GridPosition>;
|
||||||
|
// 컴포넌트별 위치 (컴포넌트 ID → 위치) - 섹션 내부 그리드 기준
|
||||||
|
componentPositions: Record<string, GridPosition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 섹션 정의 (위치 제외)
|
||||||
|
*/
|
||||||
|
export interface PopSectionDefinition {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
// 이 섹션에 포함된 컴포넌트 ID 목록
|
||||||
|
componentIds: string[];
|
||||||
|
// 내부 그리드 설정
|
||||||
|
innerGrid: PopInnerGrid;
|
||||||
|
// 데이터 소스 (섹션 레벨)
|
||||||
|
dataSource?: PopDataSource;
|
||||||
|
// 섹션 내 컴포넌트 간 연결 (Level 1)
|
||||||
|
connections?: PopConnection[];
|
||||||
|
// 스타일
|
||||||
|
style?: PopSectionStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 컴포넌트 정의 (위치 제외)
|
||||||
|
*/
|
||||||
|
export interface PopComponentDefinition {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
// 데이터 바인딩
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
// 스타일 프리셋
|
||||||
|
style?: PopStylePreset;
|
||||||
|
// 컴포넌트별 설정
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 소스 설정
|
||||||
|
*/
|
||||||
|
export interface PopDataSource {
|
||||||
|
type: "api" | "static" | "parent";
|
||||||
|
endpoint?: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
staticData?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트/섹션 간 연결
|
||||||
|
*/
|
||||||
|
export interface PopConnection {
|
||||||
|
from: string; // 소스 ID (컴포넌트 또는 섹션)
|
||||||
|
to: string; // 타겟 ID
|
||||||
|
trigger: PopTrigger; // 트리거 이벤트
|
||||||
|
action: PopAction; // 수행할 액션
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PopTrigger =
|
||||||
|
| "onChange" // 값 변경 시
|
||||||
|
| "onSubmit" // 제출 시
|
||||||
|
| "onClick" // 클릭 시
|
||||||
|
| "onScan" // 스캔 완료 시
|
||||||
|
| "onSelect"; // 선택 시
|
||||||
|
|
||||||
|
export type PopAction =
|
||||||
|
| { type: "setValue"; targetField: string } // 값 설정
|
||||||
|
| { type: "filter"; filterField: string } // 필터링
|
||||||
|
| { type: "refresh" } // 새로고침
|
||||||
|
| { type: "navigate"; screenId: number } // 화면 이동
|
||||||
|
| { type: "api"; endpoint: string; method: string }; // API 호출
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3단계 데이터 흐름
|
||||||
|
*/
|
||||||
|
export interface PopDataFlow {
|
||||||
|
// Level 2: 섹션 간 연결
|
||||||
|
sectionConnections: PopConnection[];
|
||||||
|
|
||||||
|
// Level 3: 화면 로드 시 파라미터 수신
|
||||||
|
onScreenLoad?: {
|
||||||
|
paramMapping: Record<string, string>; // URL 파라미터 → 컴포넌트 ID
|
||||||
|
};
|
||||||
|
|
||||||
|
// Level 3: 다음 화면으로 데이터 전달
|
||||||
|
navigationOutput?: {
|
||||||
|
screenId: number;
|
||||||
|
paramMapping: Record<string, string>; // 컴포넌트 ID → URL 파라미터명
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 설정
|
||||||
|
*/
|
||||||
|
export interface PopGlobalSettings {
|
||||||
|
// 최소 터치 타겟 크기 (px)
|
||||||
|
touchTargetMin: number; // 기본 48px, 산업용 60px
|
||||||
|
// 모드 (일반/산업용)
|
||||||
|
mode: "normal" | "industrial";
|
||||||
|
// 캔버스 그리드 설정
|
||||||
|
canvasGrid: PopCanvasGrid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 통합 타입 (v1 또는 v2)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 레이아웃 데이터 (v1 또는 v2)
|
||||||
|
*/
|
||||||
|
export type PopLayoutData = PopLayoutDataV1 | PopLayoutDataV2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버전 체크 타입 가드
|
||||||
|
*/
|
||||||
|
export function isV2Layout(data: PopLayoutData): data is PopLayoutDataV2 {
|
||||||
|
return data.version === "pop-2.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isV1Layout(data: PopLayoutData): data is PopLayoutDataV1 {
|
||||||
|
return data.version === "pop-1.0";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캔버스 그리드 설정
|
* 캔버스 그리드 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -43,6 +231,8 @@ export interface PopLayoutMetadata {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그리드 위치/크기
|
* 그리드 위치/크기
|
||||||
|
* - col/row: 1-based 시작 위치
|
||||||
|
* - colSpan/rowSpan: 차지하는 칸 수
|
||||||
*/
|
*/
|
||||||
export interface GridPosition {
|
export interface GridPosition {
|
||||||
col: number; // 시작 열 (1-based)
|
col: number; // 시작 열 (1-based)
|
||||||
|
|
@ -51,26 +241,43 @@ export interface GridPosition {
|
||||||
rowSpan: number; // 행 개수
|
rowSpan: number; // 행 개수
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v1 섹션/컴포넌트 타입 (기존 구조)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 섹션 데이터
|
* 섹션 데이터 v1 (기존 구조 - 위치 포함)
|
||||||
* - 캔버스 그리드 위에 배치
|
* @deprecated v2에서는 PopSectionDefinition 사용
|
||||||
* - 내부에 컴포넌트들을 가짐
|
|
||||||
*/
|
*/
|
||||||
export interface PopSectionData {
|
export interface PopSectionDataV1 {
|
||||||
id: string;
|
id: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
// 그리드 위치 (12열 캔버스 기준)
|
|
||||||
grid: GridPosition;
|
grid: GridPosition;
|
||||||
// 모바일용 그리드 위치 (선택, 없으면 자동 조정)
|
|
||||||
mobileGrid?: GridPosition;
|
mobileGrid?: GridPosition;
|
||||||
// 내부 그리드 설정
|
|
||||||
innerGrid: PopInnerGrid;
|
innerGrid: PopInnerGrid;
|
||||||
// 섹션 내 컴포넌트들
|
components: PopComponentDataV1[];
|
||||||
components: PopComponentData[];
|
|
||||||
// 스타일
|
|
||||||
style?: PopSectionStyle;
|
style?: PopSectionStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 데이터 v1 (기존 구조 - 위치 포함)
|
||||||
|
* @deprecated v2에서는 PopComponentDefinition 사용
|
||||||
|
*/
|
||||||
|
export interface PopComponentDataV1 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
grid: GridPosition;
|
||||||
|
mobileGrid?: GridPosition;
|
||||||
|
label?: string;
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
style?: PopStylePreset;
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하위 호환을 위한 alias
|
||||||
|
export type PopSectionData = PopSectionDataV1;
|
||||||
|
export type PopComponentData = PopComponentDataV1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 섹션 내부 그리드 설정
|
* 섹션 내부 그리드 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -311,10 +518,15 @@ export const DEFAULT_INNER_GRID: PopInnerGrid = {
|
||||||
gap: 4,
|
gap: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v1 생성 함수 (기존 - 하위 호환)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 레이아웃 생성
|
* 빈 v1 레이아웃 생성
|
||||||
|
* @deprecated createEmptyPopLayoutV2 사용 권장
|
||||||
*/
|
*/
|
||||||
export const createEmptyPopLayout = (): PopLayoutData => ({
|
export const createEmptyPopLayoutV1 = (): PopLayoutDataV1 => ({
|
||||||
version: "pop-1.0",
|
version: "pop-1.0",
|
||||||
layoutMode: "grid",
|
layoutMode: "grid",
|
||||||
deviceTarget: "both",
|
deviceTarget: "both",
|
||||||
|
|
@ -322,13 +534,16 @@ export const createEmptyPopLayout = (): PopLayoutData => ({
|
||||||
sections: [],
|
sections: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 하위 호환을 위한 alias
|
||||||
|
export const createEmptyPopLayout = createEmptyPopLayoutV1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 새 섹션 생성
|
* 새 섹션 생성 (v1)
|
||||||
*/
|
*/
|
||||||
export const createPopSection = (
|
export const createPopSection = (
|
||||||
id: string,
|
id: string,
|
||||||
grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 }
|
grid: GridPosition = { col: 1, row: 1, colSpan: 3, rowSpan: 4 }
|
||||||
): PopSectionData => ({
|
): PopSectionDataV1 => ({
|
||||||
id,
|
id,
|
||||||
grid,
|
grid,
|
||||||
innerGrid: { ...DEFAULT_INNER_GRID },
|
innerGrid: { ...DEFAULT_INNER_GRID },
|
||||||
|
|
@ -340,24 +555,398 @@ export const createPopSection = (
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 새 컴포넌트 생성
|
* 새 컴포넌트 생성 (v1)
|
||||||
*/
|
*/
|
||||||
export const createPopComponent = (
|
export const createPopComponent = (
|
||||||
id: string,
|
id: string,
|
||||||
type: PopComponentType,
|
type: PopComponentType,
|
||||||
grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 },
|
grid: GridPosition = { col: 1, row: 1, colSpan: 1, rowSpan: 1 },
|
||||||
label?: string
|
label?: string
|
||||||
): PopComponentData => ({
|
): PopComponentDataV1 => ({
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
grid,
|
grid,
|
||||||
label,
|
label,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 타입 가드 =====
|
// ========================================
|
||||||
|
// v2 생성 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
export const isPopField = (comp: PopComponentData): boolean =>
|
/**
|
||||||
|
* 빈 v2 레이아웃 생성
|
||||||
|
*/
|
||||||
|
export const createEmptyPopLayoutV2 = (): PopLayoutDataV2 => ({
|
||||||
|
version: "pop-2.0",
|
||||||
|
layouts: {
|
||||||
|
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||||
|
},
|
||||||
|
sections: {},
|
||||||
|
components: {},
|
||||||
|
dataFlow: {
|
||||||
|
sectionConnections: [],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
touchTargetMin: 48,
|
||||||
|
mode: "normal",
|
||||||
|
canvasGrid: { ...DEFAULT_CANVAS_GRID },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 섹션 정의 생성
|
||||||
|
*/
|
||||||
|
export const createSectionDefinition = (
|
||||||
|
id: string,
|
||||||
|
label?: string
|
||||||
|
): PopSectionDefinition => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
componentIds: [],
|
||||||
|
innerGrid: { ...DEFAULT_INNER_GRID },
|
||||||
|
style: {
|
||||||
|
showBorder: true,
|
||||||
|
padding: "small",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 컴포넌트 정의 생성
|
||||||
|
*/
|
||||||
|
export const createComponentDefinition = (
|
||||||
|
id: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
label?: string
|
||||||
|
): PopComponentDefinition => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 마이그레이션 함수 (v1 → v2)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 레이아웃을 v2로 마이그레이션
|
||||||
|
* - 기존 섹션/컴포넌트를 tablet_landscape 기준으로 4모드에 복제
|
||||||
|
* - 정의와 위치를 분리
|
||||||
|
*/
|
||||||
|
export const migrateV1ToV2 = (v1: PopLayoutDataV1): PopLayoutDataV2 => {
|
||||||
|
const v2 = createEmptyPopLayoutV2();
|
||||||
|
|
||||||
|
// 캔버스 그리드 설정 복사
|
||||||
|
v2.settings.canvasGrid = { ...v1.canvasGrid };
|
||||||
|
|
||||||
|
// 메타데이터 복사
|
||||||
|
if (v1.metadata) {
|
||||||
|
v2.metadata = { ...v1.metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 섹션별 마이그레이션
|
||||||
|
for (const section of v1.sections) {
|
||||||
|
// 1. 섹션 정의 생성
|
||||||
|
const sectionDef: PopSectionDefinition = {
|
||||||
|
id: section.id,
|
||||||
|
label: section.label,
|
||||||
|
componentIds: section.components.map(c => c.id),
|
||||||
|
innerGrid: { ...section.innerGrid },
|
||||||
|
style: section.style ? { ...section.style } : undefined,
|
||||||
|
};
|
||||||
|
v2.sections[section.id] = sectionDef;
|
||||||
|
|
||||||
|
// 2. 섹션 위치 복사 (4모드 모두 동일하게)
|
||||||
|
const sectionPos: GridPosition = { ...section.grid };
|
||||||
|
v2.layouts.tablet_landscape.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
v2.layouts.tablet_portrait.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
v2.layouts.mobile_landscape.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
v2.layouts.mobile_portrait.sectionPositions[section.id] = { ...sectionPos };
|
||||||
|
|
||||||
|
// 3. 컴포넌트별 마이그레이션
|
||||||
|
for (const comp of section.components) {
|
||||||
|
// 컴포넌트 정의 생성
|
||||||
|
const compDef: PopComponentDefinition = {
|
||||||
|
id: comp.id,
|
||||||
|
type: comp.type,
|
||||||
|
label: comp.label,
|
||||||
|
dataBinding: comp.dataBinding ? { ...comp.dataBinding } : undefined,
|
||||||
|
style: comp.style ? { ...comp.style } : undefined,
|
||||||
|
config: comp.config,
|
||||||
|
};
|
||||||
|
v2.components[comp.id] = compDef;
|
||||||
|
|
||||||
|
// 컴포넌트 위치 복사 (4모드 모두 동일하게)
|
||||||
|
const compPos: GridPosition = { ...comp.grid };
|
||||||
|
v2.layouts.tablet_landscape.componentPositions[comp.id] = { ...compPos };
|
||||||
|
v2.layouts.tablet_portrait.componentPositions[comp.id] = { ...compPos };
|
||||||
|
v2.layouts.mobile_landscape.componentPositions[comp.id] = { ...compPos };
|
||||||
|
v2.layouts.mobile_portrait.componentPositions[comp.id] = { ...compPos };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return v2;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 데이터를 v2로 보장 (필요시 마이그레이션)
|
||||||
|
*/
|
||||||
|
export const ensureV2Layout = (data: PopLayoutData): PopLayoutDataV2 => {
|
||||||
|
if (isV2Layout(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (isV1Layout(data)) {
|
||||||
|
return migrateV1ToV2(data);
|
||||||
|
}
|
||||||
|
// 알 수 없는 버전 - 빈 v2 반환
|
||||||
|
console.warn("알 수 없는 레이아웃 버전, 빈 v2 레이아웃 생성");
|
||||||
|
return createEmptyPopLayoutV2();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2 헬퍼 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에 섹션 추가 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const addSectionToV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string,
|
||||||
|
position: GridPosition,
|
||||||
|
label?: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 섹션 정의 추가
|
||||||
|
newLayout.sections = {
|
||||||
|
...newLayout.sections,
|
||||||
|
[sectionId]: createSectionDefinition(sectionId, label),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4모드 모두에 위치 추가
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
newLayouts[mode] = {
|
||||||
|
...newLayouts[mode],
|
||||||
|
sectionPositions: {
|
||||||
|
...newLayouts[mode].sectionPositions,
|
||||||
|
[sectionId]: { ...position },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에 컴포넌트 추가 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const addComponentToV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string,
|
||||||
|
componentId: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
position: GridPosition,
|
||||||
|
label?: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 컴포넌트 정의 추가
|
||||||
|
newLayout.components = {
|
||||||
|
...newLayout.components,
|
||||||
|
[componentId]: createComponentDefinition(componentId, type, label),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 섹션의 componentIds에 추가
|
||||||
|
const section = newLayout.sections[sectionId];
|
||||||
|
if (section) {
|
||||||
|
newLayout.sections = {
|
||||||
|
...newLayout.sections,
|
||||||
|
[sectionId]: {
|
||||||
|
...section,
|
||||||
|
componentIds: [...section.componentIds, componentId],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4모드 모두에 위치 추가
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
newLayouts[mode] = {
|
||||||
|
...newLayouts[mode],
|
||||||
|
componentPositions: {
|
||||||
|
...newLayouts[mode].componentPositions,
|
||||||
|
[componentId]: { ...position },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 섹션 삭제 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const removeSectionFromV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 섹션에 포함된 컴포넌트 ID 가져오기
|
||||||
|
const section = newLayout.sections[sectionId];
|
||||||
|
const componentIds = section?.componentIds || [];
|
||||||
|
|
||||||
|
// 섹션 정의 삭제
|
||||||
|
const { [sectionId]: _, ...remainingSections } = newLayout.sections;
|
||||||
|
newLayout.sections = remainingSections;
|
||||||
|
|
||||||
|
// 컴포넌트 정의 삭제
|
||||||
|
let remainingComponents = { ...newLayout.components };
|
||||||
|
for (const compId of componentIds) {
|
||||||
|
const { [compId]: __, ...rest } = remainingComponents;
|
||||||
|
remainingComponents = rest;
|
||||||
|
}
|
||||||
|
newLayout.components = remainingComponents;
|
||||||
|
|
||||||
|
// 4모드 모두에서 위치 삭제
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
const { [sectionId]: ___, ...remainingSecPos } = newLayouts[mode].sectionPositions;
|
||||||
|
let remainingCompPos = { ...newLayouts[mode].componentPositions };
|
||||||
|
for (const compId of componentIds) {
|
||||||
|
const { [compId]: ____, ...rest } = remainingCompPos;
|
||||||
|
remainingCompPos = rest;
|
||||||
|
}
|
||||||
|
newLayouts[mode] = {
|
||||||
|
sectionPositions: remainingSecPos,
|
||||||
|
componentPositions: remainingCompPos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 컴포넌트 삭제 (4모드 동기화)
|
||||||
|
*/
|
||||||
|
export const removeComponentFromV2Layout = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
sectionId: string,
|
||||||
|
componentId: string
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
const newLayout = { ...layout };
|
||||||
|
|
||||||
|
// 컴포넌트 정의 삭제
|
||||||
|
const { [componentId]: _, ...remainingComponents } = newLayout.components;
|
||||||
|
newLayout.components = remainingComponents;
|
||||||
|
|
||||||
|
// 섹션의 componentIds에서 제거
|
||||||
|
const section = newLayout.sections[sectionId];
|
||||||
|
if (section) {
|
||||||
|
newLayout.sections = {
|
||||||
|
...newLayout.sections,
|
||||||
|
[sectionId]: {
|
||||||
|
...section,
|
||||||
|
componentIds: section.componentIds.filter(id => id !== componentId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4모드 모두에서 위치 삭제
|
||||||
|
const modeKeys: PopLayoutModeKey[] = [
|
||||||
|
"tablet_landscape", "tablet_portrait",
|
||||||
|
"mobile_landscape", "mobile_portrait"
|
||||||
|
];
|
||||||
|
|
||||||
|
const newLayouts = { ...newLayout.layouts };
|
||||||
|
for (const mode of modeKeys) {
|
||||||
|
const { [componentId]: __, ...remainingCompPos } = newLayouts[mode].componentPositions;
|
||||||
|
newLayouts[mode] = {
|
||||||
|
...newLayouts[mode],
|
||||||
|
componentPositions: remainingCompPos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
newLayout.layouts = newLayouts;
|
||||||
|
|
||||||
|
return newLayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 특정 모드의 섹션 위치 업데이트
|
||||||
|
*/
|
||||||
|
export const updateSectionPositionInMode = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
modeKey: PopLayoutModeKey,
|
||||||
|
sectionId: string,
|
||||||
|
position: GridPosition
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
layouts: {
|
||||||
|
...layout.layouts,
|
||||||
|
[modeKey]: {
|
||||||
|
...layout.layouts[modeKey],
|
||||||
|
sectionPositions: {
|
||||||
|
...layout.layouts[modeKey].sectionPositions,
|
||||||
|
[sectionId]: position,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 레이아웃에서 특정 모드의 컴포넌트 위치 업데이트
|
||||||
|
*/
|
||||||
|
export const updateComponentPositionInMode = (
|
||||||
|
layout: PopLayoutDataV2,
|
||||||
|
modeKey: PopLayoutModeKey,
|
||||||
|
componentId: string,
|
||||||
|
position: GridPosition
|
||||||
|
): PopLayoutDataV2 => {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
layouts: {
|
||||||
|
...layout.layouts,
|
||||||
|
[modeKey]: {
|
||||||
|
...layout.layouts[modeKey],
|
||||||
|
componentPositions: {
|
||||||
|
...layout.layouts[modeKey].componentPositions,
|
||||||
|
[componentId]: position,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 가드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export const isPopField = (comp: PopComponentDataV1 | PopComponentDefinition): boolean =>
|
||||||
comp.type === "pop-field";
|
comp.type === "pop-field";
|
||||||
|
|
||||||
export const isPopButton = (comp: PopComponentData): boolean =>
|
export const isPopButton = (comp: PopComponentDataV1 | PopComponentDefinition): boolean =>
|
||||||
comp.type === "pop-button";
|
comp.type === "pop-button";
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,7 @@ function TreeNode({
|
||||||
<span className="text-muted-foreground/50 text-xs mr-1">ㄴ</span>
|
<span className="text-muted-foreground/50 text-xs mr-1">ㄴ</span>
|
||||||
<Monitor className="h-4 w-4 text-blue-500 shrink-0" />
|
<Monitor className="h-4 w-4 text-blue-500 shrink-0" />
|
||||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||||
|
|
||||||
{/* 더보기 메뉴 (폴더와 동일한 스타일) */}
|
{/* 더보기 메뉴 (폴더와 동일한 스타일) */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -880,6 +881,7 @@ export function PopCategoryTree({
|
||||||
>
|
>
|
||||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||||
|
|
||||||
{/* 더보기 메뉴 */}
|
{/* 더보기 메뉴 */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ interface PopScreenPreviewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 디바이스 프레임 크기
|
// 디바이스 프레임 크기
|
||||||
|
// 모바일: 세로(portrait), 태블릿: 가로(landscape) 디폴트
|
||||||
const DEVICE_SIZES = {
|
const DEVICE_SIZES = {
|
||||||
mobile: { width: 375, height: 667 }, // iPhone SE 기준
|
mobile: { width: 375, height: 667 }, // iPhone SE 기준 (세로)
|
||||||
tablet: { width: 768, height: 1024 }, // iPad 기준
|
tablet: { width: 1024, height: 768 }, // iPad 기준 (가로)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -46,7 +47,19 @@ export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const layout = await screenApi.getLayoutPop(screen.screenId);
|
const layout = await screenApi.getLayoutPop(screen.screenId);
|
||||||
setHasLayout(layout && layout.sections && layout.sections.length > 0);
|
|
||||||
|
// v2 레이아웃: sections는 객체 (Record<string, PopSectionDefinition>)
|
||||||
|
// v1 레이아웃: sections는 배열
|
||||||
|
if (layout) {
|
||||||
|
const isV2 = layout.version === "pop-2.0";
|
||||||
|
const hasSections = isV2
|
||||||
|
? layout.sections && Object.keys(layout.sections).length > 0
|
||||||
|
: layout.sections && Array.isArray(layout.sections) && layout.sections.length > 0;
|
||||||
|
|
||||||
|
setHasLayout(hasSections);
|
||||||
|
} else {
|
||||||
|
setHasLayout(false);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setHasLayout(false);
|
setHasLayout(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -94,11 +107,13 @@ export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
|
||||||
{/* 디바이스 선택 */}
|
{/* 디바이스 선택 */}
|
||||||
<Tabs value={deviceType} onValueChange={(v) => setDeviceType(v as DeviceType)}>
|
<Tabs value={deviceType} onValueChange={(v) => setDeviceType(v as DeviceType)}>
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="mobile" className="h-7 px-2">
|
<TabsTrigger value="mobile" className="h-7 px-3 gap-1.5" title="모바일 (375x667)">
|
||||||
<Smartphone className="h-3.5 w-3.5" />
|
<Smartphone className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs">모바일</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tablet" className="h-7 px-2">
|
<TabsTrigger value="tablet" className="h-7 px-3 gap-1.5" title="태블릿 (1024x768 가로)">
|
||||||
<Tablet className="h-3.5 w-3.5" />
|
<Tablet className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-xs">태블릿</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -152,27 +167,9 @@ export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 디바이스 프레임 + iframe
|
// 디바이스 프레임 + iframe (심플한 테두리)
|
||||||
<div
|
<div
|
||||||
className="relative bg-gray-900 rounded-[2rem] p-2 shadow-xl"
|
className="relative border-2 border-gray-300 rounded-lg shadow-lg overflow-hidden"
|
||||||
style={{
|
|
||||||
width: deviceSize.width * scale + 16,
|
|
||||||
height: deviceSize.height * scale + 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 디바이스 노치 (모바일) */}
|
|
||||||
{deviceType === "mobile" && (
|
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-24 h-6 bg-gray-900 rounded-b-xl z-10" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 디바이스 홈 버튼 (태블릿) */}
|
|
||||||
{deviceType === "tablet" && (
|
|
||||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-8 h-8 bg-gray-800 rounded-full" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* iframe 컨테이너 */}
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-[1.5rem] overflow-hidden"
|
|
||||||
style={{
|
style={{
|
||||||
width: deviceSize.width * scale,
|
width: deviceSize.width * scale,
|
||||||
height: deviceSize.height * scale,
|
height: deviceSize.height * scale,
|
||||||
|
|
@ -191,7 +188,6 @@ export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
|
||||||
title="POP Screen Preview"
|
title="POP Screen Preview"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue