POP 디자이너 v5 그리드 시스템 통합 및 그리드 가이드 재설계
레거시 v1~v4 시스템 제거 (6,634줄 순감) GridGuide SVG → PopRenderer CSS Grid 기반으로 전환 행/열 라벨 추가로 배치 위치 명확화 컴포넌트 타입 pop-sample로 단순화 문서 정리 (ARCHITECTURE, SPEC, CHANGELOG, ADR)
This commit is contained in:
parent
5f23c13490
commit
9ebc8c4219
File diff suppressed because it is too large
Load Diff
|
|
@ -4903,24 +4903,16 @@ export class ScreenManagementService {
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(`=== POP 레이아웃 저장 시작 ===`);
|
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
|
||||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||||
|
|
||||||
// 버전 감지
|
// v5 그리드 레이아웃만 지원
|
||||||
const isV3 = layoutData.version === "pop-3.0" ||
|
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||||
(layoutData.layouts && layoutData.components && !layoutData.sections);
|
console.log(`컴포넌트: ${componentCount}개`);
|
||||||
const isV2 = layoutData.version === "pop-2.0" ||
|
|
||||||
(layoutData.layouts && layoutData.sections && layoutData.components);
|
|
||||||
|
|
||||||
if (isV3) {
|
// v5 형식 검증
|
||||||
const componentCount = Object.keys(layoutData.components || {}).length;
|
if (layoutData.version && layoutData.version !== "pop-5.0") {
|
||||||
console.log(`v3 레이아웃: ${componentCount}개 컴포넌트 (섹션 없음)`);
|
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
|
||||||
} else 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})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
|
|
@ -4946,50 +4938,12 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
|
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
|
||||||
|
|
||||||
// 버전 정보 보장
|
// v5 그리드 레이아웃으로 저장 (단일 버전)
|
||||||
let dataToSave: any;
|
const dataToSave = {
|
||||||
if (isV3) {
|
|
||||||
dataToSave = {
|
|
||||||
...layoutData,
|
...layoutData,
|
||||||
version: "pop-3.0",
|
version: "pop-5.0",
|
||||||
};
|
};
|
||||||
|
console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`)
|
||||||
// canvasGrid.rows 검증 및 보정
|
|
||||||
if (dataToSave.settings?.canvasGrid) {
|
|
||||||
if (!dataToSave.settings.canvasGrid.rows) {
|
|
||||||
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
|
||||||
dataToSave.settings.canvasGrid.rows = 24;
|
|
||||||
}
|
|
||||||
// 구버전 rowHeight 필드 제거
|
|
||||||
if (dataToSave.settings.canvasGrid.rowHeight) {
|
|
||||||
console.warn("구버전 rowHeight 필드 제거");
|
|
||||||
delete dataToSave.settings.canvasGrid.rowHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isV2) {
|
|
||||||
dataToSave = {
|
|
||||||
...layoutData,
|
|
||||||
version: "pop-2.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
// canvasGrid.rows 검증 및 보정
|
|
||||||
if (dataToSave.settings?.canvasGrid) {
|
|
||||||
if (!dataToSave.settings.canvasGrid.rows) {
|
|
||||||
console.warn("canvasGrid.rows 없음, 기본값 24로 설정");
|
|
||||||
dataToSave.settings.canvasGrid.rows = 24;
|
|
||||||
}
|
|
||||||
if (dataToSave.settings.canvasGrid.rowHeight) {
|
|
||||||
console.warn("구버전 rowHeight 필드 제거");
|
|
||||||
delete dataToSave.settings.canvasGrid.rowHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// v1 형식으로 저장 (하위 호환)
|
|
||||||
dataToSave = {
|
|
||||||
version: "pop-1.0",
|
|
||||||
...layoutData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||||
await query(
|
await query(
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,9 @@ import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
||||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
|
|
@ -17,23 +15,14 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei
|
||||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext";
|
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV3,
|
PopLayoutDataV5,
|
||||||
PopLayoutDataV4,
|
GridMode,
|
||||||
PopLayoutModeKey,
|
isV5Layout,
|
||||||
ensureV3Layout,
|
createEmptyPopLayoutV5,
|
||||||
isV3Layout,
|
|
||||||
isV4Layout,
|
|
||||||
} from "@/components/pop/designer/types/pop-layout";
|
} from "@/components/pop/designer/types/pop-layout";
|
||||||
|
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
|
||||||
import {
|
import {
|
||||||
PopLayoutRenderer,
|
|
||||||
hasBaseLayout,
|
|
||||||
getEffectiveModeLayout,
|
|
||||||
} from "@/components/pop/designer/renderers";
|
|
||||||
import { PopFlexRenderer } from "@/components/pop/designer/renderers/PopFlexRenderer";
|
|
||||||
import {
|
|
||||||
useResponsiveMode,
|
|
||||||
useResponsiveModeWithOverride,
|
useResponsiveModeWithOverride,
|
||||||
type DeviceType,
|
type DeviceType,
|
||||||
} from "@/hooks/useDeviceOrientation";
|
} from "@/hooks/useDeviceOrientation";
|
||||||
|
|
@ -50,39 +39,16 @@ const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width:
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// 모드 키 변환
|
||||||
// 헬퍼 함수
|
const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => {
|
||||||
// ========================================
|
|
||||||
|
|
||||||
const getModeKey = (device: DeviceType, isLandscape: boolean): PopLayoutModeKey => {
|
|
||||||
if (device === "tablet") {
|
if (device === "tablet") {
|
||||||
return isLandscape ? "tablet_landscape" : "tablet_portrait";
|
return isLandscape ? "tablet_landscape" : "tablet_portrait";
|
||||||
}
|
}
|
||||||
return isLandscape ? "mobile_landscape" : "mobile_portrait";
|
return isLandscape ? "mobile_landscape" : "mobile_portrait";
|
||||||
};
|
};
|
||||||
|
|
||||||
// v3.0 레이아웃인지 확인
|
|
||||||
const isPopLayoutV3 = (layout: any): layout is PopLayoutDataV3 => {
|
|
||||||
return layout && layout.version === "pop-3.0" && layout.layouts && layout.components;
|
|
||||||
};
|
|
||||||
|
|
||||||
// v4.0 레이아웃인지 확인
|
|
||||||
const isPopLayoutV4 = (layout: any): layout is PopLayoutDataV4 => {
|
|
||||||
return layout && layout.version === "pop-4.0" && layout.root && layout.components;
|
|
||||||
};
|
|
||||||
|
|
||||||
// v1/v2/v3/v4 레이아웃인지 확인
|
|
||||||
const isPopLayout = (layout: any): boolean => {
|
|
||||||
return layout && (
|
|
||||||
layout.version === "pop-1.0" ||
|
|
||||||
layout.version === "pop-2.0" ||
|
|
||||||
layout.version === "pop-3.0" ||
|
|
||||||
layout.version === "pop-4.0"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 메인 컴포넌트
|
// 메인 컴포넌트 (v5 그리드 시스템 전용)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function PopScreenViewPage() {
|
function PopScreenViewPage() {
|
||||||
|
|
@ -103,21 +69,15 @@ function PopScreenViewPage() {
|
||||||
// 현재 모드 정보
|
// 현재 모드 정보
|
||||||
const deviceType = mode.device;
|
const deviceType = mode.device;
|
||||||
const isLandscape = mode.isLandscape;
|
const isLandscape = mode.isLandscape;
|
||||||
const currentModeKey = mode.modeKey;
|
const currentModeKey = getModeKey(deviceType, isLandscape);
|
||||||
|
|
||||||
const { user, userName, companyCode } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||||
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
|
|
||||||
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
||||||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
|
||||||
const [tableRefreshKey, setTableRefreshKey] = useState(0);
|
|
||||||
|
|
||||||
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
|
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
|
||||||
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
|
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
|
||||||
|
|
||||||
|
|
@ -131,18 +91,6 @@ function PopScreenViewPage() {
|
||||||
return () => window.removeEventListener("resize", updateViewportWidth);
|
return () => window.removeEventListener("resize", updateViewportWidth);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 컴포넌트 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
const initComponents = async () => {
|
|
||||||
try {
|
|
||||||
await initializeComponents();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("POP 화면 컴포넌트 초기화 실패:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
initComponents();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 화면 및 POP 레이아웃 로드
|
// 화면 및 POP 레이아웃 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadScreen = async () => {
|
const loadScreen = async () => {
|
||||||
|
|
@ -156,39 +104,22 @@ function PopScreenViewPage() {
|
||||||
try {
|
try {
|
||||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||||
|
|
||||||
if (popLayout && isPopLayoutV4(popLayout)) {
|
if (popLayout && isV5Layout(popLayout)) {
|
||||||
// v4 레이아웃
|
// v5 레이아웃 로드
|
||||||
setPopLayoutV4(popLayout);
|
setLayout(popLayout);
|
||||||
setPopLayoutV3(null);
|
|
||||||
const componentCount = Object.keys(popLayout.components).length;
|
const componentCount = Object.keys(popLayout.components).length;
|
||||||
console.log(`[POP] v4 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||||
} else if (popLayout && isPopLayout(popLayout)) {
|
} else if (popLayout) {
|
||||||
// v1/v2/v3 → v3로 변환
|
// 다른 버전 레이아웃은 빈 v5로 처리
|
||||||
const v3Layout = ensureV3Layout(popLayout);
|
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
|
||||||
setPopLayoutV3(v3Layout);
|
setLayout(createEmptyPopLayoutV5());
|
||||||
setPopLayoutV4(null);
|
|
||||||
|
|
||||||
const componentCount = Object.keys(v3Layout.components).length;
|
|
||||||
console.log(`[POP] v3 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
|
||||||
|
|
||||||
if (!isV3Layout(popLayout)) {
|
|
||||||
console.log("[POP] v1/v2 → v3 자동 마이그레이션 완료");
|
|
||||||
}
|
|
||||||
} else if (popLayout && popLayout.components && Array.isArray(popLayout.components) && popLayout.components.length > 0) {
|
|
||||||
// 이전 형식 (레거시 components 구조)
|
|
||||||
console.log("[POP] 레거시 레이아웃 로드:", popLayout.components.length, "개 컴포넌트");
|
|
||||||
setLayout(popLayout as LayoutData);
|
|
||||||
} else {
|
} else {
|
||||||
console.log("[POP] 레이아웃 없음");
|
console.log("[POP] 레이아웃 없음");
|
||||||
setPopLayoutV3(null);
|
setLayout(createEmptyPopLayoutV5());
|
||||||
setPopLayoutV4(null);
|
|
||||||
setLayout(null);
|
|
||||||
}
|
}
|
||||||
} catch (layoutError) {
|
} catch (layoutError) {
|
||||||
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
||||||
setPopLayoutV3(null);
|
setLayout(createEmptyPopLayoutV5());
|
||||||
setPopLayoutV4(null);
|
|
||||||
setLayout(null);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[POP] 화면 로드 실패:", error);
|
console.error("[POP] 화면 로드 실패:", error);
|
||||||
|
|
@ -205,6 +136,7 @@ function PopScreenViewPage() {
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
||||||
|
const hasComponents = Object.keys(layout.components).length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -336,70 +268,19 @@ function PopScreenViewPage() {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* POP 레이아웃 v4.0 렌더링 */}
|
{/* v5 그리드 렌더러 */}
|
||||||
{popLayoutV4 ? (
|
{hasComponents ? (
|
||||||
<div
|
<div
|
||||||
className="mx-auto h-full"
|
className="mx-auto h-full"
|
||||||
style={{ maxWidth: 1366 }}
|
style={{ maxWidth: 1366 }}
|
||||||
>
|
>
|
||||||
<PopFlexRenderer
|
<PopRenderer
|
||||||
layout={popLayoutV4}
|
layout={layout}
|
||||||
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
||||||
|
currentMode={currentModeKey}
|
||||||
isDesignMode={false}
|
isDesignMode={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : popLayoutV3 ? (
|
|
||||||
/* POP 레이아웃 v3.0 렌더링 */
|
|
||||||
<PopLayoutV3Renderer
|
|
||||||
layout={popLayoutV3}
|
|
||||||
modeKey={currentModeKey}
|
|
||||||
/>
|
|
||||||
) : layout && layout.components && layout.components.length > 0 ? (
|
|
||||||
// 레거시 형식 (components 구조) - 호환성 유지
|
|
||||||
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
|
||||||
<div className="relative w-full min-h-full p-4">
|
|
||||||
{layout.components
|
|
||||||
.filter((component) => !component.parentId)
|
|
||||||
.map((component) => (
|
|
||||||
<div
|
|
||||||
key={component.id}
|
|
||||||
style={{
|
|
||||||
position: component.position ? "absolute" : "relative",
|
|
||||||
left: component.position?.x || 0,
|
|
||||||
top: component.position?.y || 0,
|
|
||||||
width: component.size?.width || "100%",
|
|
||||||
height: component.size?.height || "auto",
|
|
||||||
zIndex: component.position?.z || 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DynamicComponentRenderer
|
|
||||||
component={component}
|
|
||||||
isDesignMode={false}
|
|
||||||
isInteractive={true}
|
|
||||||
formData={formData}
|
|
||||||
onDataflowComplete={() => { }}
|
|
||||||
screenId={screenId}
|
|
||||||
tableName={screen?.tableName}
|
|
||||||
userId={user?.userId}
|
|
||||||
userName={userName}
|
|
||||||
companyCode={companyCode}
|
|
||||||
selectedRowsData={selectedRowsData}
|
|
||||||
onSelectedRowsChange={(_, selectedData) => {
|
|
||||||
setSelectedRowsData(selectedData);
|
|
||||||
}}
|
|
||||||
refreshKey={tableRefreshKey}
|
|
||||||
onRefresh={() => {
|
|
||||||
setTableRefreshKey((prev) => prev + 1);
|
|
||||||
setSelectedRowsData([]);
|
|
||||||
}}
|
|
||||||
onFormDataChange={(fieldName, value) => {
|
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScreenMultiLangProvider>
|
|
||||||
) : (
|
) : (
|
||||||
// 빈 화면
|
// 빈 화면
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
||||||
|
|
@ -423,55 +304,6 @@ function PopScreenViewPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// POP 레이아웃 v3.0 렌더러
|
|
||||||
// ========================================
|
|
||||||
interface PopLayoutV3RendererProps {
|
|
||||||
layout: PopLayoutDataV3;
|
|
||||||
modeKey: PopLayoutModeKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopLayoutV3Renderer({ layout, modeKey }: PopLayoutV3RendererProps) {
|
|
||||||
// 태블릿 가로 모드가 기준으로 설정되어 있는지 확인
|
|
||||||
if (!hasBaseLayout(layout)) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
|
|
||||||
<span className="text-2xl">!</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
|
||||||
화면이 설정되지 않았습니다
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 max-w-xs">
|
|
||||||
POP 화면 디자이너에서 태블릿 가로 모드 레이아웃을 먼저 설정해주세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 모드에 맞는 레이아웃 가져오기
|
|
||||||
const { modeLayout, isConverted, sourceModeKey } = getEffectiveModeLayout(layout, modeKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex flex-col">
|
|
||||||
{isConverted && (
|
|
||||||
<div className="mx-2 mt-2 px-2 py-1 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-700 shrink-0">
|
|
||||||
{sourceModeKey} 기준 자동 변환됨
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PopLayoutRenderer
|
|
||||||
layout={layout}
|
|
||||||
modeKey={modeKey}
|
|
||||||
customModeLayout={isConverted ? modeLayout : undefined}
|
|
||||||
isDesignMode={false}
|
|
||||||
className="flex-1"
|
|
||||||
style={{ height: "100%" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider 래퍼
|
// Provider 래퍼
|
||||||
export default function PopScreenViewPageWrapper() {
|
export default function PopScreenViewPageWrapper() {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
|
||||||
import { DndProvider } from "react-dnd";
|
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
|
||||||
import {
|
|
||||||
PopLayoutDataV4,
|
|
||||||
createEmptyPopLayoutV4,
|
|
||||||
addComponentToV4Layout,
|
|
||||||
removeComponentFromV4Layout,
|
|
||||||
updateComponentInV4Layout,
|
|
||||||
updateContainerV4,
|
|
||||||
findContainerV4,
|
|
||||||
PopComponentType,
|
|
||||||
PopComponentDefinitionV4,
|
|
||||||
PopContainerV4,
|
|
||||||
} from "@/components/pop/designer/types/pop-layout";
|
|
||||||
import { PopCanvasV4 } from "@/components/pop/designer/PopCanvasV4";
|
|
||||||
import { PopPanel } from "@/components/pop/designer/panels/PopPanel";
|
|
||||||
import { ComponentEditorPanelV4 } from "@/components/pop/designer/panels/ComponentEditorPanelV4";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4 테스트 페이지
|
|
||||||
//
|
|
||||||
// 목적: v4 렌더러, 캔버스, 속성 패널 테스트
|
|
||||||
// 경로: /pop/test-v4
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export default function TestV4Page() {
|
|
||||||
// 레이아웃 상태
|
|
||||||
const [layout, setLayout] = useState<PopLayoutDataV4>(() => {
|
|
||||||
// 초기 테스트 데이터
|
|
||||||
const initial = createEmptyPopLayoutV4();
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선택 상태
|
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
|
||||||
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 컴포넌트 ID 카운터
|
|
||||||
const [idCounter, setIdCounter] = useState(1);
|
|
||||||
|
|
||||||
// 선택된 컴포넌트/컨테이너 가져오기
|
|
||||||
const selectedComponent = selectedComponentId
|
|
||||||
? layout.components[selectedComponentId]
|
|
||||||
: null;
|
|
||||||
const selectedContainer = selectedContainerId
|
|
||||||
? findContainerV4(layout.root, selectedContainerId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 컴포넌트 드롭
|
|
||||||
const handleDropComponent = useCallback(
|
|
||||||
(type: PopComponentType, containerId: string) => {
|
|
||||||
const componentId = `comp_${idCounter}`;
|
|
||||||
setIdCounter((prev) => prev + 1);
|
|
||||||
|
|
||||||
setLayout((prev) =>
|
|
||||||
addComponentToV4Layout(prev, componentId, type, containerId, `${type} ${idCounter}`)
|
|
||||||
);
|
|
||||||
setSelectedComponentId(componentId);
|
|
||||||
setSelectedContainerId(null);
|
|
||||||
},
|
|
||||||
[idCounter]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 컴포넌트 삭제
|
|
||||||
const handleDeleteComponent = useCallback((componentId: string) => {
|
|
||||||
setLayout((prev) => removeComponentFromV4Layout(prev, componentId));
|
|
||||||
setSelectedComponentId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컴포넌트 업데이트
|
|
||||||
const handleUpdateComponent = useCallback(
|
|
||||||
(componentId: string, updates: Partial<PopComponentDefinitionV4>) => {
|
|
||||||
setLayout((prev) => updateComponentInV4Layout(prev, componentId, updates));
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 컨테이너 업데이트
|
|
||||||
const handleUpdateContainer = useCallback(
|
|
||||||
(containerId: string, updates: Partial<PopContainerV4>) => {
|
|
||||||
setLayout((prev) => ({
|
|
||||||
...prev,
|
|
||||||
root: updateContainerV4(prev.root, containerId, updates),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 선택
|
|
||||||
const handleSelectComponent = useCallback((id: string | null) => {
|
|
||||||
setSelectedComponentId(id);
|
|
||||||
if (id) setSelectedContainerId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectContainer = useCallback((id: string | null) => {
|
|
||||||
setSelectedContainerId(id);
|
|
||||||
if (id) setSelectedComponentId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndProvider backend={HTML5Backend}>
|
|
||||||
<div className="flex h-screen w-screen overflow-hidden bg-gray-100">
|
|
||||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
|
||||||
<div className="w-64 shrink-0 border-r bg-white">
|
|
||||||
<div className="border-b px-4 py-3">
|
|
||||||
<h2 className="text-sm font-semibold">v4 테스트</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">컴포넌트를 드래그하세요</p>
|
|
||||||
</div>
|
|
||||||
<PopPanel />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 중앙: 캔버스 */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<PopCanvasV4
|
|
||||||
layout={layout}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
selectedContainerId={selectedContainerId}
|
|
||||||
onSelectComponent={handleSelectComponent}
|
|
||||||
onSelectContainer={handleSelectContainer}
|
|
||||||
onDropComponent={handleDropComponent}
|
|
||||||
onUpdateComponent={handleUpdateComponent}
|
|
||||||
onUpdateContainer={handleUpdateContainer}
|
|
||||||
onDeleteComponent={handleDeleteComponent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽: 속성 패널 */}
|
|
||||||
<div className="w-72 shrink-0 border-l bg-white">
|
|
||||||
<ComponentEditorPanelV4
|
|
||||||
component={selectedComponent}
|
|
||||||
container={selectedContainer}
|
|
||||||
onUpdateComponent={
|
|
||||||
selectedComponentId
|
|
||||||
? (updates) => handleUpdateComponent(selectedComponentId, updates)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onUpdateContainer={
|
|
||||||
selectedContainerId
|
|
||||||
? (updates) => handleUpdateContainer(selectedContainerId, updates)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DndProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +1,130 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useRef, useState, useEffect } from "react";
|
import { useCallback, useRef, useState, useEffect, useMemo } from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV3,
|
PopLayoutDataV5,
|
||||||
PopLayoutModeKey,
|
PopComponentDefinitionV5,
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
GridPosition,
|
PopGridPosition,
|
||||||
MODE_RESOLUTIONS,
|
GridMode,
|
||||||
|
GRID_BREAKPOINTS,
|
||||||
|
DEFAULT_COMPONENT_GRID_SIZE,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet } from "lucide-react";
|
||||||
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import PopRenderer from "./renderers/PopRenderer";
|
||||||
|
import { mouseToGridPosition, findNextEmptyPosition } from "./utils/gridUtils";
|
||||||
|
|
||||||
|
// DnD 타입 상수 (인라인)
|
||||||
|
const DND_ITEM_TYPES = {
|
||||||
|
COMPONENT: "component",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface DragItemComponent {
|
||||||
|
type: typeof DND_ITEM_TYPES.COMPONENT;
|
||||||
|
componentType: PopComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 타입 정의
|
// 프리셋 해상도 (4개 모드)
|
||||||
// ========================================
|
// ========================================
|
||||||
type DeviceType = "mobile" | "tablet";
|
const VIEWPORT_PRESETS = [
|
||||||
|
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, height: 667, icon: Smartphone },
|
||||||
|
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 667, height: 375, icon: Smartphone },
|
||||||
|
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 768, height: 1024, icon: Tablet },
|
||||||
|
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, height: 768, icon: Tablet },
|
||||||
|
] as const;
|
||||||
|
|
||||||
// 모드별 라벨
|
type ViewportPreset = GridMode;
|
||||||
const MODE_LABELS: Record<PopLayoutModeKey, string> = {
|
|
||||||
tablet_landscape: "태블릿 가로",
|
|
||||||
tablet_portrait: "태블릿 세로",
|
|
||||||
mobile_landscape: "모바일 가로",
|
|
||||||
mobile_portrait: "모바일 세로",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 타입별 라벨
|
// 기본 프리셋 (태블릿 가로)
|
||||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
|
||||||
"pop-field": "필드",
|
|
||||||
"pop-button": "버튼",
|
|
||||||
"pop-list": "리스트",
|
|
||||||
"pop-indicator": "인디케이터",
|
|
||||||
"pop-scanner": "스캐너",
|
|
||||||
"pop-numpad": "숫자패드",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
// ========================================
|
// ========================================
|
||||||
interface PopCanvasProps {
|
interface PopCanvasProps {
|
||||||
layout: PopLayoutDataV3;
|
layout: PopLayoutDataV5;
|
||||||
activeDevice: DeviceType;
|
|
||||||
activeModeKey: PopLayoutModeKey;
|
|
||||||
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
|
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
|
currentMode: GridMode;
|
||||||
|
onModeChange: (mode: GridMode) => void;
|
||||||
onSelectComponent: (id: string | null) => void;
|
onSelectComponent: (id: string | null) => void;
|
||||||
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
||||||
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void;
|
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
onDeleteComponent: (componentId: string) => void;
|
onDeleteComponent: (componentId: string) => void;
|
||||||
|
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
|
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 메인 컴포넌트
|
// PopCanvas: 그리드 캔버스
|
||||||
// ========================================
|
// ========================================
|
||||||
export function PopCanvas({
|
|
||||||
layout,
|
|
||||||
activeDevice,
|
|
||||||
activeModeKey,
|
|
||||||
onModeKeyChange,
|
|
||||||
selectedComponentId,
|
|
||||||
onSelectComponent,
|
|
||||||
onUpdateComponentPosition,
|
|
||||||
onDropComponent,
|
|
||||||
onDeleteComponent,
|
|
||||||
}: PopCanvasProps) {
|
|
||||||
const { settings, components, layouts } = layout;
|
|
||||||
const canvasGrid = settings.canvasGrid;
|
|
||||||
|
|
||||||
// 줌 상태 (0.3 ~ 1.5 범위)
|
export default function PopCanvas({
|
||||||
const [canvasScale, setCanvasScale] = useState(0.6);
|
layout,
|
||||||
|
selectedComponentId,
|
||||||
|
currentMode,
|
||||||
|
onModeChange,
|
||||||
|
onSelectComponent,
|
||||||
|
onDropComponent,
|
||||||
|
onUpdateComponent,
|
||||||
|
onDeleteComponent,
|
||||||
|
onMoveComponent,
|
||||||
|
onResizeComponent,
|
||||||
|
}: PopCanvasProps) {
|
||||||
|
// 줌 상태
|
||||||
|
const [canvasScale, setCanvasScale] = useState(0.8);
|
||||||
|
|
||||||
|
// 커스텀 뷰포트 크기
|
||||||
|
const [customWidth, setCustomWidth] = useState(1024);
|
||||||
|
const [customHeight, setCustomHeight] = useState(768);
|
||||||
|
|
||||||
|
// 그리드 가이드 표시 여부
|
||||||
|
const [showGridGuide, setShowGridGuide] = useState(true);
|
||||||
|
|
||||||
// 패닝 상태
|
// 패닝 상태
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 드래그 상태
|
||||||
|
const [isDraggingComponent, setIsDraggingComponent] = useState(false);
|
||||||
|
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
|
||||||
|
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [dragPreviewPos, setDragPreviewPos] = useState<PopGridPosition | null>(null);
|
||||||
|
|
||||||
|
// 현재 뷰포트 해상도
|
||||||
|
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
|
||||||
|
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||||
|
|
||||||
|
// 그리드 라벨 계산
|
||||||
|
const gridLabels = useMemo(() => {
|
||||||
|
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
|
||||||
|
const rowLabels = Array.from({ length: 20 }, (_, i) => i + 1);
|
||||||
|
return { columnLabels, rowLabels };
|
||||||
|
}, [breakpoint.columns]);
|
||||||
|
|
||||||
// 줌 컨트롤
|
// 줌 컨트롤
|
||||||
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
||||||
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
||||||
const handleZoomFit = () => setCanvasScale(1.0);
|
const handleZoomFit = () => setCanvasScale(1.0);
|
||||||
|
|
||||||
|
// 모드 변경
|
||||||
|
const handleViewportChange = (mode: GridMode) => {
|
||||||
|
onModeChange(mode);
|
||||||
|
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
|
||||||
|
setCustomWidth(presetData.width);
|
||||||
|
setCustomHeight(presetData.height);
|
||||||
|
};
|
||||||
|
|
||||||
// 패닝
|
// 패닝
|
||||||
const handlePanStart = (e: React.MouseEvent) => {
|
const handlePanStart = (e: React.MouseEvent) => {
|
||||||
const isMiddleButton = e.button === 1;
|
const isMiddleButton = e.button === 1;
|
||||||
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area");
|
if (isMiddleButton || isSpacePressed) {
|
||||||
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
|
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
setPanStart({ x: e.clientX, y: e.clientY });
|
setPanStart({ x: e.clientX, y: e.clientY });
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -105,12 +142,14 @@ export function PopCanvas({
|
||||||
|
|
||||||
const handlePanEnd = () => setIsPanning(false);
|
const handlePanEnd = () => setIsPanning(false);
|
||||||
|
|
||||||
// 마우스 휠 줌
|
// Ctrl + 휠로 줌 조정
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
||||||
}, []);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Space 키 감지
|
// Space 키 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -128,42 +167,139 @@ export function PopCanvas({
|
||||||
};
|
};
|
||||||
}, [isSpacePressed]);
|
}, [isSpacePressed]);
|
||||||
|
|
||||||
// 초기 로드 시 캔버스 중앙 스크롤
|
// 컴포넌트 드롭 (팔레트에서)
|
||||||
useEffect(() => {
|
const [{ isOver, canDrop }, drop] = useDrop(
|
||||||
if (containerRef.current) {
|
() => ({
|
||||||
const container = containerRef.current;
|
accept: DND_ITEM_TYPES.COMPONENT,
|
||||||
const timer = setTimeout(() => {
|
drop: (item: DragItemComponent, monitor) => {
|
||||||
const scrollX = (container.scrollWidth - container.clientWidth) / 2;
|
if (!canvasRef.current) return;
|
||||||
const scrollY = (container.scrollHeight - container.clientHeight) / 2;
|
|
||||||
container.scrollTo(scrollX, scrollY);
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [activeDevice]);
|
|
||||||
|
|
||||||
// 현재 디바이스의 가로/세로 모드 키
|
const offset = monitor.getClientOffset();
|
||||||
const landscapeModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
if (!offset) return;
|
||||||
? "tablet_landscape"
|
|
||||||
: "mobile_landscape";
|
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||||
const portraitModeKey: PopLayoutModeKey = activeDevice === "tablet"
|
|
||||||
? "tablet_portrait"
|
// 마우스 위치 → 그리드 좌표 변환
|
||||||
: "mobile_portrait";
|
const gridPos = mouseToGridPosition(
|
||||||
|
offset.x,
|
||||||
|
offset.y,
|
||||||
|
canvasRect,
|
||||||
|
breakpoint.columns,
|
||||||
|
breakpoint.rowHeight,
|
||||||
|
breakpoint.gap,
|
||||||
|
breakpoint.padding
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컴포넌트 기본 크기
|
||||||
|
const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[item.componentType];
|
||||||
|
|
||||||
|
// 다음 빈 위치 찾기
|
||||||
|
const existingPositions = Object.values(layout.components).map(c => c.position);
|
||||||
|
const position = findNextEmptyPosition(
|
||||||
|
existingPositions,
|
||||||
|
defaultSize.colSpan,
|
||||||
|
defaultSize.rowSpan,
|
||||||
|
breakpoint.columns
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컴포넌트 추가
|
||||||
|
onDropComponent(item.componentType, position);
|
||||||
|
},
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isOver: monitor.isOver(),
|
||||||
|
canDrop: monitor.canDrop(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[onDropComponent, breakpoint, layout.components]
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(canvasRef);
|
||||||
|
|
||||||
|
// 빈 상태 체크
|
||||||
|
const isEmpty = Object.keys(layout.components).length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col bg-gray-50">
|
<div className="flex h-full flex-col bg-gray-50">
|
||||||
{/* 줌 컨트롤 바 */}
|
{/* 상단 컨트롤 */}
|
||||||
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-4 py-2">
|
<div className="flex items-center gap-2 border-b bg-white px-4 py-2">
|
||||||
|
{/* 모드 프리셋 버튼 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{VIEWPORT_PRESETS.map((preset) => {
|
||||||
|
const Icon = preset.icon;
|
||||||
|
const isActive = currentMode === preset.id;
|
||||||
|
const isDefault = preset.id === DEFAULT_PRESET;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={preset.id}
|
||||||
|
variant={isActive ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewportChange(preset.id as GridMode)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1 text-xs",
|
||||||
|
isActive && "shadow-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{preset.shortLabel}
|
||||||
|
{isDefault && " (기본)"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-gray-300" />
|
||||||
|
|
||||||
|
{/* 해상도 표시 */}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{customWidth} × {customHeight}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* 줌 컨트롤 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
줌: {Math.round(canvasScale * 100)}%
|
{Math.round(canvasScale * 100)}%
|
||||||
</span>
|
</span>
|
||||||
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut} title="줌 아웃">
|
<Button
|
||||||
<ZoomOut className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={canvasScale <= 0.3}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn} title="줌 인">
|
<Button
|
||||||
<ZoomIn className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleZoomFit}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit} title="맞춤 (100%)">
|
<Button
|
||||||
<Maximize2 className="h-4 w-4" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={canvasScale >= 1.5}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-gray-300" />
|
||||||
|
|
||||||
|
{/* 그리드 가이드 토글 */}
|
||||||
|
<Button
|
||||||
|
variant={showGridGuide ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowGridGuide(!showGridGuide)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
그리드 {showGridGuide ? "ON" : "OFF"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -171,9 +307,9 @@ export function PopCanvas({
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-1 overflow-auto",
|
"canvas-scroll-area relative flex-1 overflow-auto bg-gray-100",
|
||||||
isPanning && "cursor-grabbing",
|
isSpacePressed && "cursor-grab",
|
||||||
isSpacePressed && "cursor-grab"
|
isPanning && "cursor-grabbing"
|
||||||
)}
|
)}
|
||||||
onMouseDown={handlePanStart}
|
onMouseDown={handlePanStart}
|
||||||
onMouseMove={handlePanMove}
|
onMouseMove={handlePanMove}
|
||||||
|
|
@ -182,381 +318,114 @@ export function PopCanvas({
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="canvas-scroll-area flex items-center justify-center gap-16"
|
className="relative mx-auto my-8 origin-top"
|
||||||
style={{ padding: "500px", minWidth: "fit-content", minHeight: "fit-content" }}
|
|
||||||
>
|
|
||||||
{/* 가로 모드 */}
|
|
||||||
<DeviceFrame
|
|
||||||
modeKey={landscapeModeKey}
|
|
||||||
isActive={landscapeModeKey === activeModeKey}
|
|
||||||
scale={canvasScale}
|
|
||||||
canvasGrid={canvasGrid}
|
|
||||||
layout={layout}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onModeKeyChange={onModeKeyChange}
|
|
||||||
onSelectComponent={onSelectComponent}
|
|
||||||
onUpdateComponentPosition={onUpdateComponentPosition}
|
|
||||||
onDropComponent={onDropComponent}
|
|
||||||
onDeleteComponent={onDeleteComponent}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 세로 모드 */}
|
|
||||||
<DeviceFrame
|
|
||||||
modeKey={portraitModeKey}
|
|
||||||
isActive={portraitModeKey === activeModeKey}
|
|
||||||
scale={canvasScale}
|
|
||||||
canvasGrid={canvasGrid}
|
|
||||||
layout={layout}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onModeKeyChange={onModeKeyChange}
|
|
||||||
onSelectComponent={onSelectComponent}
|
|
||||||
onUpdateComponentPosition={onUpdateComponentPosition}
|
|
||||||
onDropComponent={onDropComponent}
|
|
||||||
onDeleteComponent={onDeleteComponent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// CSS Grid 기반 디바이스 프레임 (v3: 컴포넌트 직접 배치)
|
|
||||||
// ========================================
|
|
||||||
interface DeviceFrameProps {
|
|
||||||
modeKey: PopLayoutModeKey;
|
|
||||||
isActive: boolean;
|
|
||||||
scale: number;
|
|
||||||
canvasGrid: { columns: number; rows: number; gap: number };
|
|
||||||
layout: PopLayoutDataV3;
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
onModeKeyChange: (modeKey: PopLayoutModeKey) => void;
|
|
||||||
onSelectComponent: (id: string | null) => void;
|
|
||||||
onUpdateComponentPosition: (componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => void;
|
|
||||||
onDropComponent: (type: PopComponentType, gridPosition: GridPosition) => void;
|
|
||||||
onDeleteComponent: (componentId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceFrame({
|
|
||||||
modeKey,
|
|
||||||
isActive,
|
|
||||||
scale,
|
|
||||||
canvasGrid,
|
|
||||||
layout,
|
|
||||||
selectedComponentId,
|
|
||||||
onModeKeyChange,
|
|
||||||
onSelectComponent,
|
|
||||||
onUpdateComponentPosition,
|
|
||||||
onDropComponent,
|
|
||||||
onDeleteComponent,
|
|
||||||
}: DeviceFrameProps) {
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
|
||||||
const dropRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const { components, layouts } = layout;
|
|
||||||
const resolution = MODE_RESOLUTIONS[modeKey];
|
|
||||||
const modeLayout = layouts[modeKey];
|
|
||||||
const componentPositions = modeLayout.componentPositions;
|
|
||||||
const componentIds = Object.keys(componentPositions);
|
|
||||||
|
|
||||||
const cols = canvasGrid.columns;
|
|
||||||
const rows = canvasGrid.rows || 24;
|
|
||||||
const gap = canvasGrid.gap;
|
|
||||||
|
|
||||||
// 드래그 상태
|
|
||||||
const [dragState, setDragState] = useState<{
|
|
||||||
componentId: string;
|
|
||||||
startPos: GridPosition;
|
|
||||||
currentPos: GridPosition;
|
|
||||||
isDragging: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// 리사이즈 상태
|
|
||||||
const [resizeState, setResizeState] = useState<{
|
|
||||||
componentId: string;
|
|
||||||
startPos: GridPosition;
|
|
||||||
currentPos: GridPosition;
|
|
||||||
handle: "se" | "sw" | "ne" | "nw" | "e" | "w" | "n" | "s";
|
|
||||||
isResizing: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// 라벨
|
|
||||||
const sizeLabel = `${resolution.width}x${resolution.height}`;
|
|
||||||
const modeLabel = `${MODE_LABELS[modeKey]} (${sizeLabel})`;
|
|
||||||
|
|
||||||
// 마우스 → 그리드 좌표 변환
|
|
||||||
const getGridPosition = useCallback((clientX: number, clientY: number): { col: number; row: number } => {
|
|
||||||
if (!gridRef.current) return { col: 1, row: 1 };
|
|
||||||
const rect = gridRef.current.getBoundingClientRect();
|
|
||||||
const x = (clientX - rect.left) / scale;
|
|
||||||
const y = (clientY - rect.top) / scale;
|
|
||||||
const cellWidth = (resolution.width - gap * (cols + 1)) / cols;
|
|
||||||
const cellHeight = (resolution.height - gap * (rows + 1)) / rows;
|
|
||||||
const col = Math.max(1, Math.min(cols, Math.floor((x - gap) / (cellWidth + gap)) + 1));
|
|
||||||
const row = Math.max(1, Math.min(rows, Math.floor((y - gap) / (cellHeight + gap)) + 1));
|
|
||||||
return { col, row };
|
|
||||||
}, [scale, resolution, cols, rows, gap]);
|
|
||||||
|
|
||||||
// 드래그 시작
|
|
||||||
const handleDragStart = useCallback((e: React.MouseEvent, componentId: string) => {
|
|
||||||
if (!isActive) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const pos = componentPositions[componentId];
|
|
||||||
setDragState({
|
|
||||||
componentId,
|
|
||||||
startPos: { ...pos },
|
|
||||||
currentPos: { ...pos },
|
|
||||||
isDragging: true,
|
|
||||||
});
|
|
||||||
}, [isActive, componentPositions]);
|
|
||||||
|
|
||||||
// 마우스 이동
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
||||||
if (dragState?.isDragging && gridRef.current) {
|
|
||||||
const { col, row } = getGridPosition(e.clientX, e.clientY);
|
|
||||||
const newCol = Math.max(1, Math.min(cols - dragState.startPos.colSpan + 1, col));
|
|
||||||
const newRow = Math.max(1, Math.min(rows - dragState.startPos.rowSpan + 1, row));
|
|
||||||
setDragState(prev => prev ? {
|
|
||||||
...prev,
|
|
||||||
currentPos: { ...prev.startPos, col: newCol, row: newRow }
|
|
||||||
} : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resizeState?.isResizing && gridRef.current) {
|
|
||||||
const { col, row } = getGridPosition(e.clientX, e.clientY);
|
|
||||||
const startPos = resizeState.startPos;
|
|
||||||
let newPos = { ...startPos };
|
|
||||||
|
|
||||||
switch (resizeState.handle) {
|
|
||||||
case "se":
|
|
||||||
newPos.colSpan = Math.max(2, col - startPos.col + 1);
|
|
||||||
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
|
|
||||||
break;
|
|
||||||
case "e":
|
|
||||||
newPos.colSpan = Math.max(2, col - startPos.col + 1);
|
|
||||||
break;
|
|
||||||
case "s":
|
|
||||||
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
|
|
||||||
break;
|
|
||||||
case "sw":
|
|
||||||
const newColSW = Math.min(col, startPos.col + startPos.colSpan - 2);
|
|
||||||
newPos.col = newColSW;
|
|
||||||
newPos.colSpan = startPos.col + startPos.colSpan - newColSW;
|
|
||||||
newPos.rowSpan = Math.max(2, row - startPos.row + 1);
|
|
||||||
break;
|
|
||||||
case "w":
|
|
||||||
const newColW = Math.min(col, startPos.col + startPos.colSpan - 2);
|
|
||||||
newPos.col = newColW;
|
|
||||||
newPos.colSpan = startPos.col + startPos.colSpan - newColW;
|
|
||||||
break;
|
|
||||||
case "ne":
|
|
||||||
const newRowNE = Math.min(row, startPos.row + startPos.rowSpan - 2);
|
|
||||||
newPos.row = newRowNE;
|
|
||||||
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNE;
|
|
||||||
newPos.colSpan = Math.max(2, col - startPos.col + 1);
|
|
||||||
break;
|
|
||||||
case "n":
|
|
||||||
const newRowN = Math.min(row, startPos.row + startPos.rowSpan - 2);
|
|
||||||
newPos.row = newRowN;
|
|
||||||
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowN;
|
|
||||||
break;
|
|
||||||
case "nw":
|
|
||||||
const newColNW = Math.min(col, startPos.col + startPos.colSpan - 2);
|
|
||||||
const newRowNW = Math.min(row, startPos.row + startPos.rowSpan - 2);
|
|
||||||
newPos.col = newColNW;
|
|
||||||
newPos.row = newRowNW;
|
|
||||||
newPos.colSpan = startPos.col + startPos.colSpan - newColNW;
|
|
||||||
newPos.rowSpan = startPos.row + startPos.rowSpan - newRowNW;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
newPos.col = Math.max(1, newPos.col);
|
|
||||||
newPos.row = Math.max(1, newPos.row);
|
|
||||||
newPos.colSpan = Math.min(cols - newPos.col + 1, newPos.colSpan);
|
|
||||||
newPos.rowSpan = Math.min(rows - newPos.row + 1, newPos.rowSpan);
|
|
||||||
|
|
||||||
setResizeState(prev => prev ? { ...prev, currentPos: newPos } : null);
|
|
||||||
}
|
|
||||||
}, [dragState, resizeState, getGridPosition, cols, rows]);
|
|
||||||
|
|
||||||
// 드래그/리사이즈 종료
|
|
||||||
const handleMouseUp = useCallback(() => {
|
|
||||||
if (dragState?.isDragging) {
|
|
||||||
onUpdateComponentPosition(dragState.componentId, dragState.currentPos, modeKey);
|
|
||||||
setDragState(null);
|
|
||||||
}
|
|
||||||
if (resizeState?.isResizing) {
|
|
||||||
onUpdateComponentPosition(resizeState.componentId, resizeState.currentPos, modeKey);
|
|
||||||
setResizeState(null);
|
|
||||||
}
|
|
||||||
}, [dragState, resizeState, onUpdateComponentPosition, modeKey]);
|
|
||||||
|
|
||||||
// 리사이즈 시작
|
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent, componentId: string, handle: string) => {
|
|
||||||
if (!isActive) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const pos = componentPositions[componentId];
|
|
||||||
setResizeState({
|
|
||||||
componentId,
|
|
||||||
startPos: { ...pos },
|
|
||||||
currentPos: { ...pos },
|
|
||||||
handle: handle as any,
|
|
||||||
isResizing: true,
|
|
||||||
});
|
|
||||||
}, [isActive, componentPositions]);
|
|
||||||
|
|
||||||
// 컴포넌트 드롭
|
|
||||||
const [{ isOver, canDrop }, drop] = useDrop(
|
|
||||||
() => ({
|
|
||||||
accept: DND_ITEM_TYPES.COMPONENT,
|
|
||||||
drop: (item: DragItemComponent, monitor) => {
|
|
||||||
if (!isActive) return;
|
|
||||||
const clientOffset = monitor.getClientOffset();
|
|
||||||
if (!clientOffset || !gridRef.current) return;
|
|
||||||
const { col, row } = getGridPosition(clientOffset.x, clientOffset.y);
|
|
||||||
onDropComponent(item.componentType, { col, row, colSpan: 4, rowSpan: 3 });
|
|
||||||
},
|
|
||||||
canDrop: () => isActive,
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOver: monitor.isOver(),
|
|
||||||
canDrop: monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[isActive, getGridPosition, onDropComponent]
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(dropRef);
|
|
||||||
|
|
||||||
// 현재 표시할 위치
|
|
||||||
const getDisplayPosition = (componentId: string): GridPosition => {
|
|
||||||
if (dragState?.componentId === componentId && dragState.isDragging) {
|
|
||||||
return dragState.currentPos;
|
|
||||||
}
|
|
||||||
if (resizeState?.componentId === componentId && resizeState.isResizing) {
|
|
||||||
return resizeState.currentPos;
|
|
||||||
}
|
|
||||||
return componentPositions[componentId];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
{/* 모드 라벨 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs font-medium",
|
|
||||||
isActive ? "text-primary" : "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{modeLabel}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 디바이스 프레임 */}
|
|
||||||
<div
|
|
||||||
ref={dropRef}
|
|
||||||
className={cn(
|
|
||||||
"relative cursor-pointer overflow-hidden rounded-xl bg-white shadow-lg transition-all",
|
|
||||||
isActive ? "ring-2 ring-primary ring-offset-2" : "ring-1 ring-gray-200 hover:ring-gray-300",
|
|
||||||
isOver && canDrop && "ring-2 ring-primary bg-primary/5"
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
width: resolution.width * scale,
|
width: `${customWidth + 32}px`, // 라벨 공간 추가
|
||||||
height: resolution.height * scale,
|
minHeight: `${customHeight + 32}px`,
|
||||||
}}
|
transform: `scale(${canvasScale})`,
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
if (!isActive) onModeKeyChange(modeKey);
|
|
||||||
else onSelectComponent(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseLeave={handleMouseUp}
|
|
||||||
>
|
|
||||||
{/* CSS Grid (뷰어와 동일) */}
|
|
||||||
<div
|
|
||||||
ref={gridRef}
|
|
||||||
className="origin-top-left"
|
|
||||||
style={{
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
width: resolution.width,
|
|
||||||
height: resolution.height,
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
|
||||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
|
||||||
gap: `${gap}px`,
|
|
||||||
padding: `${gap}px`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{componentIds.length > 0 ? (
|
{/* 그리드 라벨 영역 */}
|
||||||
componentIds.map((componentId) => {
|
{showGridGuide && (
|
||||||
const compDef = components[componentId];
|
|
||||||
if (!compDef) return null;
|
|
||||||
|
|
||||||
const pos = getDisplayPosition(componentId);
|
|
||||||
const isSelected = selectedComponentId === componentId;
|
|
||||||
const isDragging = dragState?.componentId === componentId && dragState.isDragging;
|
|
||||||
const isResizing = resizeState?.componentId === componentId && resizeState.isResizing;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={componentId}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex cursor-move items-center justify-center overflow-hidden rounded-lg border-2 bg-white transition-all",
|
|
||||||
isSelected
|
|
||||||
? "border-primary ring-2 ring-primary/30"
|
|
||||||
: "border-gray-200 hover:border-gray-300",
|
|
||||||
(isDragging || isResizing) && "opacity-80 shadow-xl z-50"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
gridColumn: `${pos.col} / span ${pos.colSpan}`,
|
|
||||||
gridRow: `${pos.row} / span ${pos.rowSpan}`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!isActive) onModeKeyChange(modeKey);
|
|
||||||
onSelectComponent(componentId);
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => handleDragStart(e, componentId)}
|
|
||||||
>
|
|
||||||
{/* 컴포넌트 라벨 */}
|
|
||||||
<span className="text-xs text-gray-500 select-none">
|
|
||||||
{compDef.label || COMPONENT_TYPE_LABELS[compDef.type]}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 리사이즈 핸들 */}
|
|
||||||
{isActive && isSelected && (
|
|
||||||
<>
|
<>
|
||||||
<div className="absolute -right-1 -bottom-1 h-3 w-3 cursor-se-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "se")} />
|
{/* 열 라벨 (상단) */}
|
||||||
<div className="absolute -left-1 -bottom-1 h-3 w-3 cursor-sw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "sw")} />
|
<div
|
||||||
<div className="absolute -right-1 -top-1 h-3 w-3 cursor-ne-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "ne")} />
|
className="flex absolute top-0 left-8"
|
||||||
<div className="absolute -left-1 -top-1 h-3 w-3 cursor-nw-resize rounded-full bg-primary" onMouseDown={(e) => handleResizeStart(e, componentId, "nw")} />
|
style={{
|
||||||
<div className="absolute right-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-e-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "e")} />
|
gap: `${breakpoint.gap}px`,
|
||||||
<div className="absolute left-0 top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-w-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "w")} />
|
paddingLeft: `${breakpoint.padding}px`,
|
||||||
<div className="absolute bottom-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-s-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "s")} />
|
}}
|
||||||
<div className="absolute top-0 left-1/2 h-1.5 w-6 -translate-x-1/2 cursor-n-resize rounded bg-primary/50" onMouseDown={(e) => handleResizeStart(e, componentId, "n")} />
|
>
|
||||||
|
{gridLabels.columnLabels.map((num) => (
|
||||||
|
<div
|
||||||
|
key={`col-${num}`}
|
||||||
|
className="flex items-center justify-center text-xs font-semibold text-blue-500"
|
||||||
|
style={{
|
||||||
|
width: `calc((${customWidth}px - ${breakpoint.padding * 2}px - ${breakpoint.gap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
|
||||||
|
height: "24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 라벨 (좌측) */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col absolute top-8 left-0"
|
||||||
|
style={{
|
||||||
|
gap: `${breakpoint.gap}px`,
|
||||||
|
paddingTop: `${breakpoint.padding}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gridLabels.rowLabels.map((num) => (
|
||||||
|
<div
|
||||||
|
key={`row-${num}`}
|
||||||
|
className="flex items-center justify-center text-xs font-semibold text-blue-500"
|
||||||
|
style={{
|
||||||
|
width: "24px",
|
||||||
|
height: `${breakpoint.rowHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
{/* 디바이스 스크린 */}
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-span-full row-span-full flex items-center justify-center text-sm",
|
"relative rounded-lg border-2 bg-white shadow-xl overflow-hidden",
|
||||||
isOver && canDrop ? "text-primary" : "text-gray-400"
|
canDrop && isOver && "ring-4 ring-primary/20"
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${customWidth}px`,
|
||||||
|
minHeight: `${customHeight}px`,
|
||||||
|
marginLeft: "32px",
|
||||||
|
marginTop: "32px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isOver && canDrop
|
{isEmpty ? (
|
||||||
? "여기에 컴포넌트를 놓으세요"
|
// 빈 상태
|
||||||
: isActive
|
<div className="flex h-full items-center justify-center p-8">
|
||||||
? "왼쪽 패널에서 컴포넌트를 드래그하세요"
|
<div className="text-center">
|
||||||
: "클릭하여 편집"}
|
<div className="mb-2 text-sm font-medium text-gray-500">
|
||||||
|
컴포넌트를 드래그하여 배치하세요
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{breakpoint.label} - {breakpoint.columns}칸 그리드
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 그리드 렌더러
|
||||||
|
<PopRenderer
|
||||||
|
layout={layout}
|
||||||
|
viewportWidth={customWidth}
|
||||||
|
currentMode={currentMode}
|
||||||
|
isDesignMode={true}
|
||||||
|
showGridGuide={showGridGuide}
|
||||||
|
selectedComponentId={selectedComponentId}
|
||||||
|
onComponentClick={onSelectComponent}
|
||||||
|
onBackgroundClick={() => onSelectComponent(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 정보 */}
|
||||||
|
<div className="flex items-center justify-between border-t bg-white px-4 py-2">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px)
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Space + 드래그: 패닝 | Ctrl + 휠: 줌
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState, useEffect } from "react";
|
|
||||||
import { useDrop } from "react-dnd";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopLayoutDataV4,
|
|
||||||
PopContainerV4,
|
|
||||||
PopComponentDefinitionV4,
|
|
||||||
PopComponentType,
|
|
||||||
PopSizeConstraintV4,
|
|
||||||
} from "./types/pop-layout";
|
|
||||||
import { DND_ITEM_TYPES, DragItemComponent } from "./panels/PopPanel";
|
|
||||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, RotateCcw, Lock } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { PopFlexRenderer } from "./renderers/PopFlexRenderer";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 프리셋 해상도 (4개 모드)
|
|
||||||
// ========================================
|
|
||||||
const VIEWPORT_PRESETS = [
|
|
||||||
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕", width: 375, height: 667, icon: Smartphone, isLandscape: false },
|
|
||||||
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔", width: 667, height: 375, icon: Smartphone, isLandscape: true },
|
|
||||||
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕", width: 768, height: 1024, icon: Tablet, isLandscape: false },
|
|
||||||
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔", width: 1024, height: 768, icon: Tablet, isLandscape: true },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type ViewportPreset = (typeof VIEWPORT_PRESETS)[number]["id"];
|
|
||||||
|
|
||||||
// 기본 프리셋 (태블릿 가로)
|
|
||||||
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Props
|
|
||||||
// ========================================
|
|
||||||
interface PopCanvasV4Props {
|
|
||||||
layout: PopLayoutDataV4;
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
selectedContainerId: string | null;
|
|
||||||
currentMode: ViewportPreset; // 현재 모드
|
|
||||||
tempLayout?: PopContainerV4 | null; // 임시 레이아웃 (고정 전 미리보기)
|
|
||||||
onModeChange: (mode: ViewportPreset) => void; // 모드 변경
|
|
||||||
onSelectComponent: (id: string | null) => void;
|
|
||||||
onSelectContainer: (id: string | null) => void;
|
|
||||||
onDropComponent: (type: PopComponentType, containerId: string) => void;
|
|
||||||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV4>) => void;
|
|
||||||
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
|
|
||||||
onDeleteComponent: (componentId: string) => void;
|
|
||||||
onResizeComponent?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
|
||||||
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
|
||||||
onLockLayout?: () => void; // 배치 고정
|
|
||||||
onResetOverride?: (mode: ViewportPreset) => void; // 오버라이드 초기화
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4 캔버스
|
|
||||||
//
|
|
||||||
// 핵심: 단일 캔버스 + 뷰포트 프리뷰
|
|
||||||
// - 가로/세로 모드 따로 없음
|
|
||||||
// - 다양한 뷰포트 크기로 미리보기
|
|
||||||
// ========================================
|
|
||||||
export function PopCanvasV4({
|
|
||||||
layout,
|
|
||||||
selectedComponentId,
|
|
||||||
selectedContainerId,
|
|
||||||
currentMode,
|
|
||||||
tempLayout,
|
|
||||||
onModeChange,
|
|
||||||
onSelectComponent,
|
|
||||||
onSelectContainer,
|
|
||||||
onDropComponent,
|
|
||||||
onUpdateComponent,
|
|
||||||
onUpdateContainer,
|
|
||||||
onDeleteComponent,
|
|
||||||
onResizeComponent,
|
|
||||||
onReorderComponent,
|
|
||||||
onLockLayout,
|
|
||||||
onResetOverride,
|
|
||||||
}: PopCanvasV4Props) {
|
|
||||||
// 줌 상태
|
|
||||||
const [canvasScale, setCanvasScale] = useState(0.8);
|
|
||||||
|
|
||||||
// 커스텀 뷰포트 크기 (슬라이더)
|
|
||||||
const [customWidth, setCustomWidth] = useState(1024);
|
|
||||||
const [customHeight, setCustomHeight] = useState(768);
|
|
||||||
|
|
||||||
// 패닝 상태
|
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
|
||||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
|
||||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const dropRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 현재 뷰포트 해상도
|
|
||||||
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
|
|
||||||
const viewportWidth = customWidth;
|
|
||||||
const viewportHeight = customHeight;
|
|
||||||
|
|
||||||
// 줌 컨트롤
|
|
||||||
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
|
||||||
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
|
||||||
const handleZoomFit = () => setCanvasScale(1.0);
|
|
||||||
|
|
||||||
// 뷰포트 프리셋 변경
|
|
||||||
const handleViewportChange = (preset: ViewportPreset) => {
|
|
||||||
onModeChange(preset); // 부모에게 알림
|
|
||||||
const presetData = VIEWPORT_PRESETS.find((p) => p.id === preset)!;
|
|
||||||
setCustomWidth(presetData.width);
|
|
||||||
setCustomHeight(presetData.height);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 슬라이더로 너비 변경 시 높이도 비율에 맞게 조정
|
|
||||||
const handleWidthChange = (newWidth: number) => {
|
|
||||||
setCustomWidth(newWidth);
|
|
||||||
// 현재 프리셋의 가로세로 비율 유지
|
|
||||||
const ratio = currentPreset.height / currentPreset.width;
|
|
||||||
setCustomHeight(Math.round(newWidth * ratio));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 패닝
|
|
||||||
const handlePanStart = (e: React.MouseEvent) => {
|
|
||||||
const isMiddleButton = e.button === 1;
|
|
||||||
const isScrollAreaClick = (e.target as HTMLElement).classList.contains("canvas-scroll-area");
|
|
||||||
if (isMiddleButton || isSpacePressed || isScrollAreaClick) {
|
|
||||||
setIsPanning(true);
|
|
||||||
setPanStart({ x: e.clientX, y: e.clientY });
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePanMove = (e: React.MouseEvent) => {
|
|
||||||
if (!isPanning || !containerRef.current) return;
|
|
||||||
const deltaX = e.clientX - panStart.x;
|
|
||||||
const deltaY = e.clientY - panStart.y;
|
|
||||||
containerRef.current.scrollLeft -= deltaX;
|
|
||||||
containerRef.current.scrollTop -= deltaY;
|
|
||||||
setPanStart({ x: e.clientX, y: e.clientY });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePanEnd = () => setIsPanning(false);
|
|
||||||
|
|
||||||
// 마우스 휠 줌
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
|
||||||
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Space 키 감지 (패닝용)
|
|
||||||
// 참고: Delete/Backspace 키는 PopDesigner에서 처리 (히스토리 지원)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
|
|
||||||
};
|
|
||||||
const handleKeyUp = (e: KeyboardEvent) => {
|
|
||||||
if (e.code === "Space") setIsSpacePressed(false);
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
|
||||||
};
|
|
||||||
}, [isSpacePressed]);
|
|
||||||
|
|
||||||
// 컴포넌트 드롭
|
|
||||||
const [{ isOver, canDrop }, drop] = useDrop(
|
|
||||||
() => ({
|
|
||||||
accept: DND_ITEM_TYPES.COMPONENT,
|
|
||||||
drop: (item: DragItemComponent) => {
|
|
||||||
// 루트 컨테이너에 추가
|
|
||||||
onDropComponent(item.componentType, "root");
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOver: monitor.isOver(),
|
|
||||||
canDrop: monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[onDropComponent]
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(dropRef);
|
|
||||||
|
|
||||||
// 오버라이드 상태 확인
|
|
||||||
const hasOverride = (mode: ViewportPreset): boolean => {
|
|
||||||
if (mode === DEFAULT_PRESET) return false; // 기본 모드는 오버라이드 없음
|
|
||||||
|
|
||||||
const override = layout.overrides?.[mode as keyof typeof layout.overrides];
|
|
||||||
if (!override) return false;
|
|
||||||
|
|
||||||
// 컴포넌트 또는 컨테이너 오버라이드가 있으면 true
|
|
||||||
const hasComponentOverrides = override.components && Object.keys(override.components).length > 0;
|
|
||||||
const hasContainerOverrides = override.containers && Object.keys(override.containers).length > 0;
|
|
||||||
|
|
||||||
return !!(hasComponentOverrides || hasContainerOverrides);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-full flex-col bg-gray-100">
|
|
||||||
{/* 툴바 */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b bg-white px-4 py-2">
|
|
||||||
{/* 뷰포트 프리셋 (4개 모드) */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-xs text-muted-foreground mr-2">미리보기:</span>
|
|
||||||
{VIEWPORT_PRESETS.map((preset) => {
|
|
||||||
const Icon = preset.icon;
|
|
||||||
const isActive = currentMode === preset.id;
|
|
||||||
const isDefault = preset.id === DEFAULT_PRESET;
|
|
||||||
const isEdited = hasOverride(preset.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={preset.id}
|
|
||||||
variant={isActive ? "default" : isEdited ? "secondary" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"h-8 gap-1 text-xs",
|
|
||||||
isDefault && !isActive && "border-primary/50",
|
|
||||||
isEdited && !isActive && "border-yellow-500 bg-yellow-50 hover:bg-yellow-100"
|
|
||||||
)}
|
|
||||||
onClick={() => handleViewportChange(preset.id)}
|
|
||||||
title={preset.label}
|
|
||||||
>
|
|
||||||
<Icon className={cn("h-4 w-4", preset.isLandscape && "rotate-90")} />
|
|
||||||
<span className="hidden lg:inline">{preset.shortLabel}</span>
|
|
||||||
{isDefault && (
|
|
||||||
<span className="text-[10px] text-muted-foreground ml-1">(기본)</span>
|
|
||||||
)}
|
|
||||||
{isEdited && (
|
|
||||||
<span className="text-[10px] text-yellow-700 ml-1 font-medium">(편집)</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 고정 버튼 (기본 모드가 아닐 때 표시) */}
|
|
||||||
{currentMode !== DEFAULT_PRESET && onLockLayout && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1 text-xs"
|
|
||||||
onClick={onLockLayout}
|
|
||||||
title="현재 배치를 이 모드 전용으로 고정합니다"
|
|
||||||
>
|
|
||||||
<Lock className="h-3 w-3" />
|
|
||||||
<span>고정</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 오버라이드 초기화 버튼 (편집된 모드에만 표시) */}
|
|
||||||
{hasOverride(currentMode) && onResetOverride && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1 text-xs text-yellow-700 border-yellow-500 hover:bg-yellow-50"
|
|
||||||
onClick={() => onResetOverride(currentMode)}
|
|
||||||
title="이 모드의 편집 내용을 삭제하고 기본 규칙으로 되돌립니다"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3 w-3" />
|
|
||||||
<span>자동으로 되돌리기</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 줌 컨트롤 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{Math.round(canvasScale * 100)}%
|
|
||||||
</span>
|
|
||||||
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomOut}>
|
|
||||||
<ZoomOut className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomIn}>
|
|
||||||
<ZoomIn className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="icon" className="h-7 w-7" onClick={handleZoomFit}>
|
|
||||||
<Maximize2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 뷰포트 크기 슬라이더 */}
|
|
||||||
<div className="flex shrink-0 items-center gap-4 border-b bg-gray-50 px-4 py-2">
|
|
||||||
<span className="text-xs text-muted-foreground">너비:</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={320}
|
|
||||||
max={1200}
|
|
||||||
value={customWidth}
|
|
||||||
onChange={(e) => handleWidthChange(Number(e.target.value))}
|
|
||||||
className="flex-1 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-xs font-mono w-24 text-right">
|
|
||||||
{customWidth} x {viewportHeight}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 캔버스 영역 */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={cn(
|
|
||||||
"relative flex-1 overflow-auto",
|
|
||||||
isPanning && "cursor-grabbing",
|
|
||||||
isSpacePressed && "cursor-grab"
|
|
||||||
)}
|
|
||||||
onMouseDown={handlePanStart}
|
|
||||||
onMouseMove={handlePanMove}
|
|
||||||
onMouseUp={handlePanEnd}
|
|
||||||
onMouseLeave={handlePanEnd}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="canvas-scroll-area flex items-center justify-center"
|
|
||||||
style={{ padding: "100px", minWidth: "fit-content", minHeight: "fit-content" }}
|
|
||||||
>
|
|
||||||
{/* 디바이스 프레임 */}
|
|
||||||
<div
|
|
||||||
ref={dropRef}
|
|
||||||
className={cn(
|
|
||||||
"relative rounded-xl bg-white shadow-lg transition-all",
|
|
||||||
"ring-2 ring-primary ring-offset-2",
|
|
||||||
isOver && canDrop && "ring-4 ring-green-500 bg-green-50"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
width: viewportWidth * canvasScale,
|
|
||||||
height: viewportHeight * canvasScale,
|
|
||||||
overflow: "auto", // 컴포넌트가 넘치면 스크롤 가능
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 뷰포트 라벨 */}
|
|
||||||
<div className="absolute -top-6 left-0 text-xs font-medium text-muted-foreground">
|
|
||||||
{currentPreset.label} ({viewportWidth}x{viewportHeight})
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flexbox 렌더러 - 최소 높이는 뷰포트 높이, 컨텐츠에 따라 늘어남 */}
|
|
||||||
<div
|
|
||||||
className="origin-top-left"
|
|
||||||
style={{
|
|
||||||
transform: `scale(${canvasScale})`,
|
|
||||||
width: viewportWidth,
|
|
||||||
minHeight: viewportHeight, // height → minHeight로 변경
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopFlexRenderer
|
|
||||||
layout={layout}
|
|
||||||
viewportWidth={viewportWidth}
|
|
||||||
currentMode={currentMode}
|
|
||||||
tempLayout={tempLayout}
|
|
||||||
isDesignMode={true}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onComponentClick={onSelectComponent}
|
|
||||||
onContainerClick={onSelectContainer}
|
|
||||||
onBackgroundClick={() => {
|
|
||||||
onSelectComponent(null);
|
|
||||||
onSelectContainer(null);
|
|
||||||
}}
|
|
||||||
onComponentResize={onResizeComponent}
|
|
||||||
onReorderComponent={onReorderComponent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 드롭 안내 (빈 상태) */}
|
|
||||||
{layout.root.children.length === 0 && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 flex items-center justify-center",
|
|
||||||
isOver && canDrop ? "text-green-600" : "text-gray-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{isOver && canDrop
|
|
||||||
? "여기에 놓으세요"
|
|
||||||
: "컴포넌트를 드래그하세요"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-1">
|
|
||||||
왼쪽 패널에서 컴포넌트를 드래그하여 추가
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PopCanvasV4;
|
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } 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, Undo2, Redo2 } from "lucide-react";
|
import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
|
|
@ -13,44 +12,22 @@ import {
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { PopCanvas } from "./PopCanvas";
|
import PopCanvas from "./PopCanvas";
|
||||||
import { PopCanvasV4 } from "./PopCanvasV4";
|
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
|
||||||
import { PopPanel } from "./panels/PopPanel";
|
import ComponentPalette from "./panels/ComponentPalette";
|
||||||
import { ComponentPaletteV4 } from "./panels/ComponentPaletteV4";
|
|
||||||
import { ComponentEditorPanelV4 } from "./panels/ComponentEditorPanelV4";
|
|
||||||
import {
|
import {
|
||||||
PopLayoutDataV3,
|
PopLayoutDataV5,
|
||||||
PopLayoutDataV4,
|
|
||||||
PopLayoutModeKey,
|
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
GridPosition,
|
PopComponentDefinitionV5,
|
||||||
PopComponentDefinition,
|
PopGridPosition,
|
||||||
PopComponentDefinitionV4,
|
GridMode,
|
||||||
PopContainerV4,
|
createEmptyPopLayoutV5,
|
||||||
PopSizeConstraintV4,
|
isV5Layout,
|
||||||
createEmptyPopLayoutV3,
|
addComponentToV5Layout,
|
||||||
createEmptyPopLayoutV4,
|
|
||||||
ensureV3Layout,
|
|
||||||
addComponentToV3Layout,
|
|
||||||
removeComponentFromV3Layout,
|
|
||||||
updateComponentPositionInModeV3,
|
|
||||||
addComponentToV4Layout,
|
|
||||||
removeComponentFromV4Layout,
|
|
||||||
updateComponentInV4Layout,
|
|
||||||
updateContainerV4,
|
|
||||||
findContainerV4,
|
|
||||||
isV3Layout,
|
|
||||||
isV4Layout,
|
|
||||||
} 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 LayoutMode = "v3" | "v4";
|
|
||||||
type DeviceType = "mobile" | "tablet";
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -61,9 +38,7 @@ interface PopDesignerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 메인 컴포넌트 (v3/v4 통합)
|
// 메인 컴포넌트 (v5 그리드 시스템 전용)
|
||||||
// - 새 화면: v4로 시작
|
|
||||||
// - 기존 v3 화면: v3로 로드 (하위 호환)
|
|
||||||
// ========================================
|
// ========================================
|
||||||
export default function PopDesigner({
|
export default function PopDesigner({
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
|
|
@ -71,140 +46,77 @@ export default function PopDesigner({
|
||||||
onScreenUpdate,
|
onScreenUpdate,
|
||||||
}: PopDesignerProps) {
|
}: PopDesignerProps) {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 모드 (데이터에 따라 자동 결정)
|
// 레이아웃 상태
|
||||||
// ========================================
|
// ========================================
|
||||||
const [layoutMode, setLayoutMode] = useState<LayoutMode>("v4");
|
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||||
|
|
||||||
// ========================================
|
// 히스토리
|
||||||
// 레이아웃 상태 (데스크탑 모드와 동일한 방식)
|
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
|
||||||
// ========================================
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
|
|
||||||
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
|
|
||||||
|
|
||||||
// 히스토리 (v4용)
|
|
||||||
const [historyV4, setHistoryV4] = useState<PopLayoutDataV4[]>([]);
|
|
||||||
const [historyIndexV4, setHistoryIndexV4] = useState(-1);
|
|
||||||
|
|
||||||
// 히스토리 (v3용)
|
|
||||||
const [historyV3, setHistoryV3] = useState<PopLayoutDataV3[]>([]);
|
|
||||||
const [historyIndexV3, setHistoryIndexV3] = useState(-1);
|
|
||||||
|
|
||||||
const [idCounter, setIdCounter] = useState(1);
|
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
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 [idCounter, setIdCounter] = useState(1);
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 히스토리 저장 함수
|
|
||||||
// ========================================
|
|
||||||
const saveToHistoryV4 = useCallback((newLayout: PopLayoutDataV4) => {
|
|
||||||
setHistoryV4((prev) => {
|
|
||||||
const newHistory = prev.slice(0, historyIndexV4 + 1);
|
|
||||||
newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사
|
|
||||||
return newHistory.slice(-50); // 최대 50개
|
|
||||||
});
|
|
||||||
setHistoryIndexV4((prev) => Math.min(prev + 1, 49));
|
|
||||||
}, [historyIndexV4]);
|
|
||||||
|
|
||||||
const saveToHistoryV3 = useCallback((newLayout: PopLayoutDataV3) => {
|
|
||||||
setHistoryV3((prev) => {
|
|
||||||
const newHistory = prev.slice(0, historyIndexV3 + 1);
|
|
||||||
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
|
|
||||||
return newHistory.slice(-50);
|
|
||||||
});
|
|
||||||
setHistoryIndexV3((prev) => Math.min(prev + 1, 49));
|
|
||||||
}, [historyIndexV3]);
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Undo/Redo 함수
|
|
||||||
// ========================================
|
|
||||||
const undoV4 = useCallback(() => {
|
|
||||||
if (historyIndexV4 > 0) {
|
|
||||||
const newIndex = historyIndexV4 - 1;
|
|
||||||
const previousLayout = historyV4[newIndex];
|
|
||||||
if (previousLayout) {
|
|
||||||
setLayoutV4(JSON.parse(JSON.stringify(previousLayout)));
|
|
||||||
setHistoryIndexV4(newIndex);
|
|
||||||
console.log("[Undo V4] 복원됨, index:", newIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [historyIndexV4, historyV4]);
|
|
||||||
|
|
||||||
const redoV4 = useCallback(() => {
|
|
||||||
if (historyIndexV4 < historyV4.length - 1) {
|
|
||||||
const newIndex = historyIndexV4 + 1;
|
|
||||||
const nextLayout = historyV4[newIndex];
|
|
||||||
if (nextLayout) {
|
|
||||||
setLayoutV4(JSON.parse(JSON.stringify(nextLayout)));
|
|
||||||
setHistoryIndexV4(newIndex);
|
|
||||||
console.log("[Redo V4] 복원됨, index:", newIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [historyIndexV4, historyV4]);
|
|
||||||
|
|
||||||
const undoV3 = useCallback(() => {
|
|
||||||
if (historyIndexV3 > 0) {
|
|
||||||
const newIndex = historyIndexV3 - 1;
|
|
||||||
const previousLayout = historyV3[newIndex];
|
|
||||||
if (previousLayout) {
|
|
||||||
setLayoutV3(JSON.parse(JSON.stringify(previousLayout)));
|
|
||||||
setHistoryIndexV3(newIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [historyIndexV3, historyV3]);
|
|
||||||
|
|
||||||
const redoV3 = useCallback(() => {
|
|
||||||
if (historyIndexV3 < historyV3.length - 1) {
|
|
||||||
const newIndex = historyIndexV3 + 1;
|
|
||||||
const nextLayout = historyV3[newIndex];
|
|
||||||
if (nextLayout) {
|
|
||||||
setLayoutV3(JSON.parse(JSON.stringify(nextLayout)));
|
|
||||||
setHistoryIndexV3(newIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [historyIndexV3, historyV3]);
|
|
||||||
|
|
||||||
// 현재 모드의 Undo/Redo
|
|
||||||
const canUndo = layoutMode === "v4" ? historyIndexV4 > 0 : historyIndexV3 > 0;
|
|
||||||
const canRedo = layoutMode === "v4"
|
|
||||||
? historyIndexV4 < historyV4.length - 1
|
|
||||||
: historyIndexV3 < historyV3.length - 1;
|
|
||||||
const handleUndo = layoutMode === "v4" ? undoV4 : undoV3;
|
|
||||||
const handleRedo = layoutMode === "v4" ? redoV4 : redoV3;
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v3용 디바이스/모드 상태
|
|
||||||
// ========================================
|
|
||||||
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
|
||||||
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4용 뷰포트 모드 상태
|
|
||||||
// ========================================
|
|
||||||
type ViewportMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
|
||||||
const [currentViewportMode, setCurrentViewportMode] = useState<ViewportMode>("tablet_landscape");
|
|
||||||
|
|
||||||
// v4: 임시 레이아웃 (고정 전 배치) - 다른 모드에서만 사용
|
|
||||||
const [tempLayout, setTempLayout] = useState<PopContainerV4 | null>(null);
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 선택 상태
|
// 선택 상태
|
||||||
// ========================================
|
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 선택된 컴포넌트/컨테이너
|
// 그리드 모드 (4개 프리셋)
|
||||||
const selectedComponentV3: PopComponentDefinition | null = selectedComponentId
|
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||||
? layoutV3.components[selectedComponentId] || null
|
|
||||||
: null;
|
// 선택된 컴포넌트
|
||||||
const selectedComponentV4: PopComponentDefinitionV4 | null = selectedComponentId
|
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
|
||||||
? layoutV4.components[selectedComponentId] || null
|
? layout.components[selectedComponentId] || null
|
||||||
: null;
|
|
||||||
const selectedContainer: PopContainerV4 | null = selectedContainerId
|
|
||||||
? findContainerV4(layoutV4.root, selectedContainerId)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 히스토리 관리
|
||||||
|
// ========================================
|
||||||
|
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
const newHistory = prev.slice(0, historyIndex + 1);
|
||||||
|
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
|
||||||
|
// 최대 50개 유지
|
||||||
|
if (newHistory.length > 50) {
|
||||||
|
newHistory.shift();
|
||||||
|
return newHistory;
|
||||||
|
}
|
||||||
|
return newHistory;
|
||||||
|
});
|
||||||
|
setHistoryIndex((prev) => Math.min(prev + 1, 49));
|
||||||
|
}, [historyIndex]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
const newIndex = historyIndex - 1;
|
||||||
|
const previousLayout = history[newIndex];
|
||||||
|
if (previousLayout) {
|
||||||
|
setLayout(JSON.parse(JSON.stringify(previousLayout)));
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
setHasChanges(true);
|
||||||
|
toast.success("실행 취소됨");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyIndex, history]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
const newIndex = historyIndex + 1;
|
||||||
|
const nextLayout = history[newIndex];
|
||||||
|
if (nextLayout) {
|
||||||
|
setLayout(JSON.parse(JSON.stringify(nextLayout)));
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
setHasChanges(true);
|
||||||
|
toast.success("다시 실행됨");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [historyIndex, history]);
|
||||||
|
|
||||||
|
const canUndo = historyIndex > 0;
|
||||||
|
const canRedo = historyIndex < history.length - 1;
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 레이아웃 로드
|
// 레이아웃 로드
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -216,45 +128,27 @@ export default function PopDesigner({
|
||||||
try {
|
try {
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
// 유효한 레이아웃인지 확인:
|
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
|
||||||
// 1. version 필드 필수
|
// v5 레이아웃 로드
|
||||||
// 2. 컴포넌트가 있어야 함 (빈 레이아웃은 새 화면 취급)
|
setLayout(loadedLayout);
|
||||||
const hasValidLayout = loadedLayout && loadedLayout.version;
|
setHistory([loadedLayout]);
|
||||||
const hasComponents = loadedLayout?.components && Object.keys(loadedLayout.components).length > 0;
|
setHistoryIndex(0);
|
||||||
|
console.log(`POP 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
|
||||||
if (hasValidLayout && hasComponents) {
|
|
||||||
if (isV4Layout(loadedLayout)) {
|
|
||||||
// v4 레이아웃
|
|
||||||
setLayoutV4(loadedLayout);
|
|
||||||
setHistoryV4([loadedLayout]);
|
|
||||||
setHistoryIndexV4(0);
|
|
||||||
setLayoutMode("v4");
|
|
||||||
console.log(`POP v4 레이아웃 로드: ${Object.keys(loadedLayout.components).length}개 컴포넌트`);
|
|
||||||
} else {
|
} else {
|
||||||
// v1/v2/v3 → v3로 변환
|
// 새 화면 또는 빈 레이아웃
|
||||||
const v3Layout = ensureV3Layout(loadedLayout);
|
const emptyLayout = createEmptyPopLayoutV5();
|
||||||
setLayoutV3(v3Layout);
|
setLayout(emptyLayout);
|
||||||
setHistoryV3([v3Layout]);
|
setHistory([emptyLayout]);
|
||||||
setHistoryIndexV3(0);
|
setHistoryIndex(0);
|
||||||
setLayoutMode("v3");
|
console.log("새 POP 화면 생성 (v5 그리드)");
|
||||||
console.log(`POP v3 레이아웃 로드: ${Object.keys(v3Layout.components).length}개 컴포넌트`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 새 화면 또는 빈 레이아웃 → v4로 시작
|
|
||||||
const emptyLayout = createEmptyPopLayoutV4();
|
|
||||||
setLayoutV4(emptyLayout);
|
|
||||||
setHistoryV4([emptyLayout]);
|
|
||||||
setHistoryIndexV4(0);
|
|
||||||
setLayoutMode("v4");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
console.error("레이아웃 로드 실패:", error);
|
||||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||||
const emptyLayout = createEmptyPopLayoutV4();
|
const emptyLayout = createEmptyPopLayoutV5();
|
||||||
setLayoutV4(emptyLayout);
|
setLayout(emptyLayout);
|
||||||
setHistoryV4([emptyLayout]);
|
setHistory([emptyLayout]);
|
||||||
setHistoryIndexV4(0);
|
setHistoryIndex(0);
|
||||||
setLayoutMode("v4");
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -271,8 +165,7 @@ export default function PopDesigner({
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const layoutToSave = layoutMode === "v3" ? layoutV3 : layoutV4;
|
await screenApi.saveLayoutPop(selectedScreen.screenId, layout);
|
||||||
await screenApi.saveLayoutPop(selectedScreen.screenId, layoutToSave);
|
|
||||||
toast.success("저장되었습니다");
|
toast.success("저장되었습니다");
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -281,271 +174,69 @@ export default function PopDesigner({
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [selectedScreen?.screenId, layoutMode, layoutV3, layoutV4]);
|
}, [selectedScreen?.screenId, layout]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// v3: 컴포넌트 핸들러
|
// 컴포넌트 핸들러
|
||||||
// ========================================
|
// ========================================
|
||||||
const handleDropComponentV3 = useCallback(
|
const handleDropComponent = useCallback(
|
||||||
(type: PopComponentType, gridPosition: GridPosition) => {
|
(type: PopComponentType, position: PopGridPosition) => {
|
||||||
const newId = `${type}-${Date.now()}`;
|
|
||||||
const newLayout = addComponentToV3Layout(layoutV3, newId, type, gridPosition);
|
|
||||||
setLayoutV3(newLayout);
|
|
||||||
saveToHistoryV3(newLayout);
|
|
||||||
setSelectedComponentId(newId);
|
|
||||||
setHasChanges(true);
|
|
||||||
},
|
|
||||||
[layoutV3, saveToHistoryV3]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdateComponentDefinitionV3 = useCallback(
|
|
||||||
(componentId: string, updates: Partial<PopComponentDefinition>) => {
|
|
||||||
const newLayout = {
|
|
||||||
...layoutV3,
|
|
||||||
components: {
|
|
||||||
...layoutV3.components,
|
|
||||||
[componentId]: { ...layoutV3.components[componentId], ...updates },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setLayoutV3(newLayout);
|
|
||||||
saveToHistoryV3(newLayout);
|
|
||||||
setHasChanges(true);
|
|
||||||
},
|
|
||||||
[layoutV3, saveToHistoryV3]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdateComponentPositionV3 = useCallback(
|
|
||||||
(componentId: string, position: GridPosition, modeKey?: PopLayoutModeKey) => {
|
|
||||||
const targetMode = modeKey || activeModeKey;
|
|
||||||
const newLayout = updateComponentPositionInModeV3(layoutV3, targetMode, componentId, position);
|
|
||||||
setLayoutV3(newLayout);
|
|
||||||
saveToHistoryV3(newLayout);
|
|
||||||
setHasChanges(true);
|
|
||||||
},
|
|
||||||
[layoutV3, activeModeKey, saveToHistoryV3]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteComponentV3 = useCallback((componentId: string) => {
|
|
||||||
const newLayout = removeComponentFromV3Layout(layoutV3, componentId);
|
|
||||||
setLayoutV3(newLayout);
|
|
||||||
saveToHistoryV3(newLayout);
|
|
||||||
setSelectedComponentId(null);
|
|
||||||
setHasChanges(true);
|
|
||||||
}, [layoutV3, saveToHistoryV3]);
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4: 컴포넌트 핸들러
|
|
||||||
// ========================================
|
|
||||||
const handleDropComponentV4 = useCallback(
|
|
||||||
(type: PopComponentType, containerId: string) => {
|
|
||||||
const componentId = `comp_${idCounter}`;
|
const componentId = `comp_${idCounter}`;
|
||||||
setIdCounter((prev) => prev + 1);
|
setIdCounter((prev) => prev + 1);
|
||||||
const newLayout = addComponentToV4Layout(layoutV4, componentId, type, containerId, `${type} ${idCounter}`);
|
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||||
setLayoutV4(newLayout);
|
setLayout(newLayout);
|
||||||
saveToHistoryV4(newLayout);
|
saveToHistory(newLayout);
|
||||||
setSelectedComponentId(componentId);
|
setSelectedComponentId(componentId);
|
||||||
setSelectedContainerId(null);
|
|
||||||
setHasChanges(true);
|
|
||||||
console.log("[V4] 컴포넌트 추가, 히스토리 저장됨");
|
|
||||||
},
|
|
||||||
[idCounter, layoutV4, saveToHistoryV4]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpdateComponentV4 = useCallback(
|
|
||||||
(componentId: string, updates: Partial<PopComponentDefinitionV4>) => {
|
|
||||||
const newLayout = updateComponentInV4Layout(layoutV4, componentId, updates);
|
|
||||||
setLayoutV4(newLayout);
|
|
||||||
saveToHistoryV4(newLayout);
|
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layoutV4, saveToHistoryV4]
|
[idCounter, layout, saveToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateContainerV4 = useCallback(
|
const handleUpdateComponent = useCallback(
|
||||||
(containerId: string, updates: Partial<PopContainerV4>) => {
|
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
||||||
if (currentViewportMode === "tablet_landscape") {
|
const existingComponent = layout.components[componentId];
|
||||||
// 기본 모드 (태블릿 가로) → root 직접 수정 ✅
|
|
||||||
const newLayout = {
|
|
||||||
...layoutV4,
|
|
||||||
root: updateContainerV4(layoutV4.root, containerId, updates),
|
|
||||||
};
|
|
||||||
setLayoutV4(newLayout);
|
|
||||||
saveToHistoryV4(newLayout);
|
|
||||||
setHasChanges(true);
|
|
||||||
console.log("[기본 모드] root 컨테이너 수정");
|
|
||||||
} else {
|
|
||||||
// 다른 모드 → 속성 패널에서 수정 차단됨 (UI에서 비활성화)
|
|
||||||
toast.warning("기본 모드(태블릿 가로)에서만 속성을 변경할 수 있습니다");
|
|
||||||
console.log("[다른 모드] 속성 수정 차단");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[layoutV4, currentViewportMode, saveToHistoryV4]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteComponentV4 = useCallback((componentId: string) => {
|
|
||||||
const newLayout = removeComponentFromV4Layout(layoutV4, componentId);
|
|
||||||
setLayoutV4(newLayout);
|
|
||||||
saveToHistoryV4(newLayout);
|
|
||||||
setSelectedComponentId(null);
|
|
||||||
setHasChanges(true);
|
|
||||||
console.log("[V4] 컴포넌트 삭제, 히스토리 저장됨");
|
|
||||||
}, [layoutV4, saveToHistoryV4]);
|
|
||||||
|
|
||||||
// v4: 현재 모드 배치 고정 (오버라이드 저장) 🔥
|
|
||||||
const handleLockLayoutV4 = useCallback(() => {
|
|
||||||
if (currentViewportMode === "tablet_landscape") {
|
|
||||||
toast.info("기본 모드는 고정할 필요가 없습니다");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tempLayout) {
|
|
||||||
toast.info("변경사항이 없습니다");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 임시 레이아웃을 오버라이드에 저장 ✅
|
|
||||||
const newLayout = {
|
|
||||||
...layoutV4,
|
|
||||||
overrides: {
|
|
||||||
...layoutV4.overrides,
|
|
||||||
[currentViewportMode]: {
|
|
||||||
...layoutV4.overrides?.[currentViewportMode as keyof typeof layoutV4.overrides],
|
|
||||||
containers: {
|
|
||||||
root: {
|
|
||||||
direction: tempLayout.direction,
|
|
||||||
wrap: tempLayout.wrap,
|
|
||||||
gap: tempLayout.gap,
|
|
||||||
alignItems: tempLayout.alignItems,
|
|
||||||
justifyContent: tempLayout.justifyContent,
|
|
||||||
padding: tempLayout.padding,
|
|
||||||
children: tempLayout.children, // 순서 고정
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setLayoutV4(newLayout);
|
|
||||||
saveToHistoryV4(newLayout);
|
|
||||||
setTempLayout(null); // 임시 레이아웃 초기화
|
|
||||||
setHasChanges(true);
|
|
||||||
toast.success(`${currentViewportMode} 모드 배치가 고정되었습니다`);
|
|
||||||
console.log(`[V4] ${currentViewportMode} 배치 고정됨 (tempLayout → overrides)`);
|
|
||||||
}, [layoutV4, currentViewportMode, tempLayout, saveToHistoryV4]);
|
|
||||||
|
|
||||||
// v4: 오버라이드 초기화 (자동 계산으로 되돌리기)
|
|
||||||
const handleResetOverrideV4 = useCallback((mode: ViewportMode) => {
|
|
||||||
if (mode === "tablet_landscape") {
|
|
||||||
toast.info("기본 모드는 초기화할 수 없습니다");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOverrides = { ...layoutV4.overrides };
|
|
||||||
delete newOverrides[mode as keyof typeof newOverrides];
|
|
||||||
|
|
||||||
const newLayout = {
|
|
||||||
...layoutV4,
|
|
||||||
overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
setLayoutV4(newLayout);
|
|
||||||
saveToHistoryV4(newLayout);
|
|
||||||
setHasChanges(true);
|
|
||||||
toast.success(`${mode} 모드 오버라이드가 초기화되었습니다`);
|
|
||||||
console.log(`[V4] ${mode} 오버라이드 초기화됨`);
|
|
||||||
}, [layoutV4, saveToHistoryV4]);
|
|
||||||
|
|
||||||
// v4: 컴포넌트 크기 조정 (드래그) - 리사이즈 중에는 히스토리 저장 안 함
|
|
||||||
// 리사이즈 완료 시 별도로 저장해야 함 (TODO: 드래그 종료 시 저장)
|
|
||||||
const handleResizeComponentV4 = useCallback(
|
|
||||||
(componentId: string, sizeUpdates: Partial<PopSizeConstraintV4>) => {
|
|
||||||
const existingComponent = layoutV4.components[componentId];
|
|
||||||
if (!existingComponent) return;
|
if (!existingComponent) return;
|
||||||
|
|
||||||
const newLayout = {
|
const newLayout = {
|
||||||
...layoutV4,
|
...layout,
|
||||||
components: {
|
components: {
|
||||||
...layoutV4.components,
|
...layout.components,
|
||||||
[componentId]: {
|
[componentId]: {
|
||||||
...existingComponent,
|
...existingComponent,
|
||||||
size: {
|
...updates,
|
||||||
...existingComponent.size,
|
|
||||||
...sizeUpdates,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setLayoutV4(newLayout);
|
setLayout(newLayout);
|
||||||
// 리사이즈 중에는 히스토리 저장 안 함 (너무 많아짐)
|
saveToHistory(newLayout);
|
||||||
// saveToHistoryV4(newLayout);
|
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[layoutV4]
|
[layout, saveToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// v4: 컴포넌트 순서 변경 (드래그 앤 드롭)
|
const handleDeleteComponent = useCallback(
|
||||||
const handleReorderComponentV4 = useCallback(
|
(componentId: string) => {
|
||||||
(containerId: string, fromIndex: number, toIndex: number) => {
|
const newComponents = { ...layout.components };
|
||||||
// 컨테이너 찾기 (재귀)
|
delete newComponents[componentId];
|
||||||
const reorderInContainer = (container: PopContainerV4): PopContainerV4 => {
|
|
||||||
if (container.id === containerId) {
|
|
||||||
const newChildren = [...container.children];
|
|
||||||
const [movedItem] = newChildren.splice(fromIndex, 1);
|
|
||||||
newChildren.splice(toIndex, 0, movedItem);
|
|
||||||
return { ...container, children: newChildren };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자식 컨테이너에서도 찾기
|
|
||||||
return {
|
|
||||||
...container,
|
|
||||||
children: container.children.map(child => {
|
|
||||||
if (typeof child === "object") {
|
|
||||||
return reorderInContainer(child);
|
|
||||||
}
|
|
||||||
return child;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentViewportMode === "tablet_landscape") {
|
|
||||||
// 기본 모드 → root 직접 수정 ✅
|
|
||||||
const newLayout = {
|
const newLayout = {
|
||||||
...layoutV4,
|
...layout,
|
||||||
root: reorderInContainer(layoutV4.root),
|
components: newComponents,
|
||||||
};
|
};
|
||||||
setLayoutV4(newLayout);
|
setLayout(newLayout);
|
||||||
saveToHistoryV4(newLayout);
|
saveToHistory(newLayout);
|
||||||
|
setSelectedComponentId(null);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
console.log("[기본 모드] 컴포넌트 순서 변경 (root 저장)");
|
|
||||||
} else {
|
|
||||||
// 다른 모드 → 임시 레이아웃에만 저장 (화면에만 표시, layoutV4는 안 건드림) 🔥
|
|
||||||
const reorderedRoot = reorderInContainer(layoutV4.root);
|
|
||||||
setTempLayout(reorderedRoot);
|
|
||||||
console.log(`[${currentViewportMode}] 컴포넌트 순서 변경 (임시, 고정 필요)`);
|
|
||||||
toast.info("배치 변경됨. '고정' 버튼을 클릭하여 저장하세요", { duration: 2000 });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[layoutV4, currentViewportMode, saveToHistoryV4]
|
[layout, saveToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v3: 디바이스/모드 전환
|
|
||||||
// ========================================
|
|
||||||
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("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) {
|
||||||
onBackToList();
|
onBackToList();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -554,7 +245,7 @@ export default function PopDesigner({
|
||||||
}, [hasChanges, onBackToList]);
|
}, [hasChanges, onBackToList]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 단축키 처리 (Delete, Undo, Redo)
|
// 단축키 처리
|
||||||
// ========================================
|
// ========================================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
@ -570,53 +261,35 @@ export default function PopDesigner({
|
||||||
if (e.key === "Delete" || e.key === "Backspace") {
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (selectedComponentId) {
|
if (selectedComponentId) {
|
||||||
layoutMode === "v3" ? handleDeleteComponentV3(selectedComponentId) : handleDeleteComponentV4(selectedComponentId);
|
handleDeleteComponent(selectedComponentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Z / Cmd+Z: Undo (Shift 안 눌림)
|
// Ctrl+Z: Undo
|
||||||
if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
|
if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("Undo 시도:", { canUndo, layoutMode });
|
if (canUndo) undo();
|
||||||
if (canUndo) {
|
|
||||||
handleUndo();
|
|
||||||
setHasChanges(true);
|
|
||||||
toast.success("실행 취소됨");
|
|
||||||
} else {
|
|
||||||
toast.info("실행 취소할 내용이 없습니다");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Shift+Z / Cmd+Shift+Z: Redo
|
// Ctrl+Shift+Z or Ctrl+Y: Redo
|
||||||
if (isCtrlOrCmd && key === "z" && e.shiftKey) {
|
if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
console.log("Redo 시도:", { canRedo, layoutMode });
|
if (canRedo) redo();
|
||||||
if (canRedo) {
|
|
||||||
handleRedo();
|
|
||||||
setHasChanges(true);
|
|
||||||
toast.success("다시 실행됨");
|
|
||||||
} else {
|
|
||||||
toast.info("다시 실행할 내용이 없습니다");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+Y / Cmd+Y: Redo (대체)
|
// Ctrl+S: 저장
|
||||||
if (isCtrlOrCmd && key === "y") {
|
if (isCtrlOrCmd && key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canRedo) {
|
handleSave();
|
||||||
handleRedo();
|
|
||||||
setHasChanges(true);
|
|
||||||
toast.success("다시 실행됨");
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [selectedComponentId, layoutMode, handleDeleteComponentV3, handleDeleteComponentV4, canUndo, canRedo, handleUndo, handleRedo]);
|
}, [selectedComponentId, handleDeleteComponent, canUndo, canRedo, undo, redo, handleSave]);
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 로딩
|
// 로딩
|
||||||
|
|
@ -634,41 +307,25 @@ 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">
|
||||||
{/* 툴바 */}
|
{/* 헤더 */}
|
||||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
|
||||||
{/* 왼쪽: 뒤로가기 + 화면명 */}
|
{/* 왼쪽: 뒤로가기 + 화면명 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="sm" onClick={handleBack}>
|
<Button
|
||||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
variant="ghost"
|
||||||
목록
|
size="icon"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium">
|
<div>
|
||||||
{selectedScreen?.screenName || "POP 화면"}
|
<h2 className="text-sm font-medium">{selectedScreen?.screenName}</h2>
|
||||||
</span>
|
<p className="text-xs text-muted-foreground">
|
||||||
{hasChanges && <span className="text-xs text-orange-500">*변경됨</span>}
|
그리드 레이아웃 (v5)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 레이아웃 버전 + v3 디바이스 전환 */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{layoutMode === "v4" ? "자동 레이아웃 (v4)" : "4모드 레이아웃 (v3)"}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{layoutMode === "v3" && (
|
|
||||||
<Tabs value={activeDevice} onValueChange={(v) => handleDeviceChange(v as DeviceType)}>
|
|
||||||
<TabsList className="h-8">
|
|
||||||
<TabsTrigger value="tablet" className="h-7 px-3 text-xs">
|
|
||||||
<Tablet className="mr-1 h-3.5 w-3.5" />
|
|
||||||
태블릿
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="mobile" className="h-7 px-3 text-xs">
|
|
||||||
<Smartphone className="mr-1 h-3.5 w-3.5" />
|
|
||||||
모바일
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: Undo/Redo + 저장 */}
|
{/* 오른쪽: Undo/Redo + 저장 */}
|
||||||
|
|
@ -679,11 +336,7 @@ export default function PopDesigner({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => {
|
onClick={undo}
|
||||||
handleUndo();
|
|
||||||
setHasChanges(true);
|
|
||||||
toast.success("실행 취소됨");
|
|
||||||
}}
|
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
title="실행 취소 (Ctrl+Z)"
|
title="실행 취소 (Ctrl+Z)"
|
||||||
>
|
>
|
||||||
|
|
@ -693,11 +346,7 @@ export default function PopDesigner({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => {
|
onClick={redo}
|
||||||
handleRedo();
|
|
||||||
setHasChanges(true);
|
|
||||||
toast.success("다시 실행됨");
|
|
||||||
}}
|
|
||||||
disabled={!canRedo}
|
disabled={!canRedo}
|
||||||
title="다시 실행 (Ctrl+Shift+Z)"
|
title="다시 실행 (Ctrl+Shift+Z)"
|
||||||
>
|
>
|
||||||
|
|
@ -715,84 +364,41 @@ export default function PopDesigner({
|
||||||
|
|
||||||
{/* 메인 영역 */}
|
{/* 메인 영역 */}
|
||||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||||
{/* 왼쪽: 컴포넌트 패널 */}
|
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="border-r">
|
<ResizablePanel defaultSize={15} minSize={12} maxSize={20}>
|
||||||
{layoutMode === "v3" ? (
|
<ComponentPalette />
|
||||||
<PopPanel
|
|
||||||
layout={layoutV3}
|
|
||||||
activeModeKey={activeModeKey}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
selectedComponent={selectedComponentV3}
|
|
||||||
onUpdateComponentDefinition={handleUpdateComponentDefinitionV3}
|
|
||||||
onDeleteComponent={handleDeleteComponentV3}
|
|
||||||
activeDevice={activeDevice}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ComponentPaletteV4 />
|
|
||||||
)}
|
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
{/* 중앙: 캔버스 */}
|
{/* 중앙: 캔버스 */}
|
||||||
<ResizablePanel defaultSize={layoutMode === "v3" ? 80 : 60}>
|
<ResizablePanel defaultSize={65}>
|
||||||
{layoutMode === "v3" ? (
|
|
||||||
<PopCanvas
|
<PopCanvas
|
||||||
layout={layoutV3}
|
layout={layout}
|
||||||
activeDevice={activeDevice}
|
|
||||||
activeModeKey={activeModeKey}
|
|
||||||
onModeKeyChange={handleModeKeyChange}
|
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
|
currentMode={currentMode}
|
||||||
|
onModeChange={setCurrentMode}
|
||||||
onSelectComponent={setSelectedComponentId}
|
onSelectComponent={setSelectedComponentId}
|
||||||
onUpdateComponentPosition={handleUpdateComponentPositionV3}
|
onDropComponent={handleDropComponent}
|
||||||
onDropComponent={handleDropComponentV3}
|
onUpdateComponent={handleUpdateComponent}
|
||||||
onDeleteComponent={handleDeleteComponentV3}
|
onDeleteComponent={handleDeleteComponent}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<PopCanvasV4
|
|
||||||
layout={layoutV4}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
selectedContainerId={selectedContainerId}
|
|
||||||
currentMode={currentViewportMode}
|
|
||||||
tempLayout={tempLayout}
|
|
||||||
onModeChange={setCurrentViewportMode}
|
|
||||||
onSelectComponent={setSelectedComponentId}
|
|
||||||
onSelectContainer={setSelectedContainerId}
|
|
||||||
onDropComponent={handleDropComponentV4}
|
|
||||||
onUpdateComponent={handleUpdateComponentV4}
|
|
||||||
onUpdateContainer={handleUpdateContainerV4}
|
|
||||||
onDeleteComponent={handleDeleteComponentV4}
|
|
||||||
onResizeComponent={handleResizeComponentV4}
|
|
||||||
onReorderComponent={handleReorderComponentV4}
|
|
||||||
onLockLayout={handleLockLayoutV4}
|
|
||||||
onResetOverride={handleResetOverrideV4}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
{/* 오른쪽: 속성 패널 (v4만) */}
|
|
||||||
{layoutMode === "v4" && (
|
|
||||||
<>
|
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 오른쪽: 속성 패널 */}
|
||||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
|
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
|
||||||
<ComponentEditorPanelV4
|
<ComponentEditorPanel
|
||||||
component={selectedComponentV4}
|
component={selectedComponent}
|
||||||
container={selectedContainer}
|
currentMode={currentMode}
|
||||||
currentViewportMode={currentViewportMode}
|
|
||||||
onUpdateComponent={
|
onUpdateComponent={
|
||||||
selectedComponentId
|
selectedComponentId
|
||||||
? (updates) => handleUpdateComponentV4(selectedComponentId, updates)
|
? (updates) => handleUpdateComponent(selectedComponentId, updates)
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onUpdateContainer={
|
|
||||||
selectedContainerId
|
|
||||||
? (updates) => handleUpdateContainerV4(selectedContainerId, updates)
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// POP 디자이너 컴포넌트 export
|
// POP 디자이너 컴포넌트 export (v5 그리드 시스템)
|
||||||
|
|
||||||
// 타입
|
// 타입
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
|
@ -7,18 +7,25 @@ export * from "./types";
|
||||||
export { default as PopDesigner } from "./PopDesigner";
|
export { default as PopDesigner } from "./PopDesigner";
|
||||||
|
|
||||||
// 캔버스
|
// 캔버스
|
||||||
export { PopCanvas } from "./PopCanvas";
|
export { default as PopCanvas } from "./PopCanvas";
|
||||||
|
|
||||||
// 패널
|
// 패널
|
||||||
export { PopPanel } from "./panels/PopPanel";
|
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
|
||||||
|
|
||||||
// 타입 재export (편의)
|
// 렌더러
|
||||||
|
export { default as PopRenderer } from "./renderers/PopRenderer";
|
||||||
|
|
||||||
|
// 유틸리티
|
||||||
|
export * from "./utils/gridUtils";
|
||||||
|
|
||||||
|
// 핵심 타입 재export (편의)
|
||||||
export type {
|
export type {
|
||||||
PopLayoutData,
|
PopLayoutDataV5,
|
||||||
PopSectionData,
|
PopComponentDefinitionV5,
|
||||||
PopComponentData,
|
|
||||||
PopComponentType,
|
PopComponentType,
|
||||||
GridPosition,
|
PopGridPosition,
|
||||||
PopCanvasGrid,
|
GridMode,
|
||||||
PopInnerGrid,
|
PopGridConfig,
|
||||||
|
PopDataBinding,
|
||||||
|
PopDataFlow,
|
||||||
} from "./types/pop-layout";
|
} from "./types/pop-layout";
|
||||||
|
|
|
||||||
|
|
@ -2,50 +2,73 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentDefinition, PopComponentConfig } from "../types/pop-layout";
|
import {
|
||||||
import { Settings, Database, Link2 } from "lucide-react";
|
PopComponentDefinitionV5,
|
||||||
|
PopGridPosition,
|
||||||
|
GridMode,
|
||||||
|
GRID_BREAKPOINTS,
|
||||||
|
PopComponentType,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Database,
|
||||||
|
Eye,
|
||||||
|
Grid3x3,
|
||||||
|
MoveHorizontal,
|
||||||
|
MoveVertical,
|
||||||
|
} from "lucide-react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props 정의
|
// Props
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ComponentEditorPanelProps {
|
interface ComponentEditorPanelProps {
|
||||||
/** 선택된 컴포넌트 (없으면 null) */
|
/** 선택된 컴포넌트 */
|
||||||
component: PopComponentDefinition | null;
|
component: PopComponentDefinitionV5 | null;
|
||||||
/** 컴포넌트 설정 변경 시 호출 */
|
/** 현재 모드 */
|
||||||
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
|
currentMode: GridMode;
|
||||||
/** 컴포넌트 라벨 변경 시 호출 */
|
/** 컴포넌트 업데이트 */
|
||||||
onLabelChange?: (label: string) => void;
|
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
/** 추가 className */
|
/** 추가 className */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 컴포넌트 편집 패널
|
// 컴포넌트 타입별 라벨
|
||||||
//
|
// ========================================
|
||||||
// 역할:
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
// - 선택된 컴포넌트의 설정을 편집
|
"pop-field": "필드",
|
||||||
// - 3개 탭: 기본 설정 / 데이터 바인딩 / 데이터 연결
|
"pop-button": "버튼",
|
||||||
//
|
"pop-list": "리스트",
|
||||||
// TODO:
|
"pop-indicator": "인디케이터",
|
||||||
// - 타입별 상세 설정 UI 구현
|
"pop-scanner": "스캐너",
|
||||||
// - 데이터 바인딩 UI 구현
|
"pop-numpad": "숫자패드",
|
||||||
// - 데이터 플로우 UI 구현
|
"pop-spacer": "스페이서",
|
||||||
|
"pop-break": "줄바꿈",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 편집 패널 (v5 그리드 시스템)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
export function ComponentEditorPanel({
|
export default function ComponentEditorPanel({
|
||||||
component,
|
component,
|
||||||
onConfigChange,
|
currentMode,
|
||||||
onLabelChange,
|
onUpdateComponent,
|
||||||
className,
|
className,
|
||||||
}: ComponentEditorPanelProps) {
|
}: ComponentEditorPanelProps) {
|
||||||
// 컴포넌트가 선택되지 않은 경우
|
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||||
|
|
||||||
|
// 선택된 컴포넌트 없음
|
||||||
if (!component) {
|
if (!component) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex h-full flex-col", className)}>
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b px-4 py-3">
|
||||||
<h3 className="text-sm font-medium">컴포넌트 편집</h3>
|
<h3 className="text-sm font-medium">속성</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
||||||
컴포넌트를 선택하세요
|
컴포넌트를 선택하세요
|
||||||
|
|
@ -54,50 +77,75 @@ export function ComponentEditorPanel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 기본 모드 여부
|
||||||
|
const isDefaultMode = currentMode === "tablet_landscape";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex h-full flex-col", className)}>
|
<div className={cn("flex h-full flex-col bg-white", className)}>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="border-b px-4 py-3">
|
<div className="border-b px-4 py-3">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">
|
||||||
{component.label || getComponentTypeLabel(component.type)}
|
{component.label || COMPONENT_TYPE_LABELS[component.type]}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">{component.type}</p>
|
<p className="text-xs text-muted-foreground">{component.type}</p>
|
||||||
|
{!isDefaultMode && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1">
|
||||||
|
기본 모드(태블릿 가로)에서만 위치 편집 가능
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
{/* 탭 */}
|
||||||
<Tabs defaultValue="settings" className="flex-1">
|
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
||||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||||
|
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||||
|
<Grid3x3 className="h-3 w-3" />
|
||||||
|
위치
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="settings" className="gap-1 text-xs">
|
<TabsTrigger value="settings" className="gap-1 text-xs">
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-3 w-3" />
|
||||||
설정
|
설정
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="visibility" className="gap-1 text-xs">
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
표시
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="data" className="gap-1 text-xs">
|
<TabsTrigger value="data" className="gap-1 text-xs">
|
||||||
<Database className="h-3 w-3" />
|
<Database className="h-3 w-3" />
|
||||||
데이터
|
데이터
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="flow" className="gap-1 text-xs">
|
|
||||||
<Link2 className="h-3 w-3" />
|
|
||||||
연결
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 기본 설정 탭 */}
|
{/* 위치 탭 */}
|
||||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
<TabsContent value="position" className="flex-1 overflow-auto p-4">
|
||||||
<ComponentSettingsForm
|
<PositionForm
|
||||||
component={component}
|
component={component}
|
||||||
onConfigChange={onConfigChange}
|
currentMode={currentMode}
|
||||||
onLabelChange={onLabelChange}
|
isDefaultMode={isDefaultMode}
|
||||||
|
columns={breakpoint.columns}
|
||||||
|
onUpdate={onUpdateComponent}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 데이터 바인딩 탭 (뼈대) */}
|
{/* 설정 탭 */}
|
||||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||||
<DataBindingPlaceholder />
|
<ComponentSettingsForm
|
||||||
|
component={component}
|
||||||
|
onUpdate={onUpdateComponent}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 데이터 연결 탭 (뼈대) */}
|
{/* 표시 탭 */}
|
||||||
<TabsContent value="flow" className="flex-1 overflow-auto p-4">
|
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
||||||
<DataFlowPlaceholder />
|
<VisibilityForm
|
||||||
|
component={component}
|
||||||
|
onUpdate={onUpdateComponent}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 데이터 탭 */}
|
||||||
|
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||||
|
<DataBindingPlaceholder />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,41 +153,186 @@ export function ComponentEditorPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 컴포넌트 설정 폼
|
// 위치 편집 폼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface PositionFormProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
currentMode: GridMode;
|
||||||
|
isDefaultMode: boolean;
|
||||||
|
columns: number;
|
||||||
|
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
|
||||||
|
const { position } = component;
|
||||||
|
|
||||||
|
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
|
||||||
|
// 범위 체크
|
||||||
|
let clampedValue = Math.max(1, value);
|
||||||
|
|
||||||
|
if (field === "col" || field === "colSpan") {
|
||||||
|
clampedValue = Math.min(columns, clampedValue);
|
||||||
|
}
|
||||||
|
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
|
||||||
|
clampedValue = columns - position.col + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate?.({
|
||||||
|
position: {
|
||||||
|
...position,
|
||||||
|
[field]: clampedValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 그리드 정보 */}
|
||||||
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
<p className="text-xs font-medium text-gray-700 mb-1">
|
||||||
|
현재 그리드: {GRID_BREAKPOINTS[currentMode].label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
최대 {columns}칸 × 무제한 행
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 열 위치 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<MoveHorizontal className="h-3 w-3" />
|
||||||
|
열 위치 (Col)
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={columns}
|
||||||
|
value={position.col}
|
||||||
|
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
|
||||||
|
disabled={!isDefaultMode}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(1~{columns})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 위치 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<MoveVertical className="h-3 w-3" />
|
||||||
|
행 위치 (Row)
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={position.row}
|
||||||
|
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
|
||||||
|
disabled={!isDefaultMode}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(1~)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-200" />
|
||||||
|
|
||||||
|
{/* 열 크기 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<MoveHorizontal className="h-3 w-3" />
|
||||||
|
열 크기 (ColSpan)
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={columns}
|
||||||
|
value={position.colSpan}
|
||||||
|
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
|
||||||
|
disabled={!isDefaultMode}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
칸 (1~{columns})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{Math.round((position.colSpan / columns) * 100)}% 너비
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 크기 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<MoveVertical className="h-3 w-3" />
|
||||||
|
행 크기 (RowSpan)
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={position.rowSpan}
|
||||||
|
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
|
||||||
|
disabled={!isDefaultMode}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
행
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 비활성화 안내 */}
|
||||||
|
{!isDefaultMode && (
|
||||||
|
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
위치 편집은 기본 모드(태블릿 가로)에서만 가능합니다.
|
||||||
|
다른 모드에서는 자동으로 변환됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 설정 폼
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ComponentSettingsFormProps {
|
interface ComponentSettingsFormProps {
|
||||||
component: PopComponentDefinition;
|
component: PopComponentDefinitionV5;
|
||||||
onConfigChange?: (config: Partial<PopComponentConfig>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
onLabelChange?: (label: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentSettingsForm({
|
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
|
||||||
component,
|
|
||||||
onConfigChange,
|
|
||||||
onLabelChange,
|
|
||||||
}: ComponentSettingsFormProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 라벨 입력 */}
|
{/* 라벨 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium">라벨</label>
|
<Label className="text-xs font-medium">라벨</Label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className="h-8 w-full rounded border border-input bg-background px-2 text-sm"
|
|
||||||
value={component.label || ""}
|
value={component.label || ""}
|
||||||
onChange={(e) => onLabelChange?.(e.target.value)}
|
onChange={(e) => onUpdate?.({ label: e.target.value })}
|
||||||
placeholder="컴포넌트 라벨"
|
placeholder="컴포넌트 이름"
|
||||||
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타입별 설정 (TODO: 상세 구현) */}
|
{/* 컴포넌트 타입별 설정 (추후 구현) */}
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{getComponentTypeLabel(component.type)} 상세 설정
|
{component.type} 전용 설정은 Phase 4에서 구현 예정
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-center text-xs text-muted-foreground">
|
|
||||||
(추후 구현 예정)
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -147,69 +340,82 @@ function ComponentSettingsForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 데이터 바인딩 플레이스홀더 (뼈대)
|
// 표시/숨김 폼
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface VisibilityFormProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||||
|
const modes: Array<{ key: GridMode; label: string }> = [
|
||||||
|
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
|
||||||
|
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
|
||||||
|
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
|
||||||
|
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
|
||||||
|
onUpdate?.({
|
||||||
|
visibility: {
|
||||||
|
...component.visibility,
|
||||||
|
[mode]: visible,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-medium">모드별 표시 설정</Label>
|
||||||
|
|
||||||
|
{modes.map((mode) => {
|
||||||
|
const isVisible = component.visibility?.[mode.key] !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={mode.key} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`visibility-${mode.key}`}
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleVisibilityChange(mode.key, checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`visibility-${mode.key}`}
|
||||||
|
className="text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 데이터 바인딩 플레이스홀더
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function DataBindingPlaceholder() {
|
function DataBindingPlaceholder() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||||
<Database className="h-8 w-8 text-gray-400" />
|
<p className="text-sm font-medium text-gray-700">데이터 바인딩</p>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
데이터 바인딩 설정
|
Phase 4에서 구현 예정
|
||||||
</p>
|
</p>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
테이블 선택 → 칼럼 선택 → 조인 설정
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-center text-xs text-gray-400">
|
|
||||||
(추후 구현 예정)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 데이터 플로우 플레이스홀더 (뼈대)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function DataFlowPlaceholder() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Link2 className="h-8 w-8 text-gray-400" />
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
데이터 연결 설정
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
컴포넌트 간 / 섹션 간 / 화면 간 연결
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-center text-xs text-gray-400">
|
|
||||||
(추후 구현 예정)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 헬퍼 함수
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function getComponentTypeLabel(type: string): string {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
"pop-field": "필드",
|
|
||||||
"pop-button": "버튼",
|
|
||||||
"pop-list": "리스트",
|
|
||||||
"pop-indicator": "인디케이터",
|
|
||||||
"pop-scanner": "스캐너",
|
|
||||||
"pop-numpad": "넘패드",
|
|
||||||
};
|
|
||||||
return labels[type] || type;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ComponentEditorPanel;
|
|
||||||
|
|
|
||||||
|
|
@ -1,721 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopComponentDefinitionV4,
|
|
||||||
PopSizeConstraintV4,
|
|
||||||
PopContainerV4,
|
|
||||||
PopComponentType,
|
|
||||||
} from "../types/pop-layout";
|
|
||||||
import {
|
|
||||||
Settings,
|
|
||||||
Database,
|
|
||||||
Link2,
|
|
||||||
MoveHorizontal,
|
|
||||||
MoveVertical,
|
|
||||||
Square,
|
|
||||||
Maximize2,
|
|
||||||
AlignCenter,
|
|
||||||
Eye,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Props 정의
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ComponentEditorPanelV4Props {
|
|
||||||
/** 선택된 컴포넌트 */
|
|
||||||
component: PopComponentDefinitionV4 | null;
|
|
||||||
/** 선택된 컨테이너 */
|
|
||||||
container: PopContainerV4 | null;
|
|
||||||
/** 현재 뷰포트 모드 */
|
|
||||||
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
|
||||||
/** 컴포넌트 업데이트 */
|
|
||||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
||||||
/** 컨테이너 업데이트 */
|
|
||||||
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
|
|
||||||
/** 추가 className */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 타입별 라벨
|
|
||||||
// ========================================
|
|
||||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|
||||||
"pop-field": "필드",
|
|
||||||
"pop-button": "버튼",
|
|
||||||
"pop-list": "리스트",
|
|
||||||
"pop-indicator": "인디케이터",
|
|
||||||
"pop-scanner": "스캐너",
|
|
||||||
"pop-numpad": "숫자패드",
|
|
||||||
"pop-spacer": "스페이서",
|
|
||||||
"pop-break": "줄바꿈",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4 컴포넌트 편집 패널
|
|
||||||
//
|
|
||||||
// 핵심:
|
|
||||||
// - 크기 제약 편집 (fixed/fill/hug)
|
|
||||||
// - 반응형 숨김 설정
|
|
||||||
// - 개별 정렬 설정
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export function ComponentEditorPanelV4({
|
|
||||||
component,
|
|
||||||
container,
|
|
||||||
currentViewportMode = "tablet_landscape",
|
|
||||||
onUpdateComponent,
|
|
||||||
onUpdateContainer,
|
|
||||||
className,
|
|
||||||
}: ComponentEditorPanelV4Props) {
|
|
||||||
// 아무것도 선택되지 않은 경우
|
|
||||||
if (!component && !container) {
|
|
||||||
return (
|
|
||||||
<div className={cn("flex h-full flex-col", className)}>
|
|
||||||
<div className="border-b px-4 py-3">
|
|
||||||
<h3 className="text-sm font-medium">속성</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
|
||||||
컴포넌트 또는 컨테이너를 선택하세요
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컨테이너가 선택된 경우
|
|
||||||
if (container) {
|
|
||||||
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex h-full flex-col", className)}>
|
|
||||||
<div className="border-b px-4 py-3">
|
|
||||||
<h3 className="text-sm font-medium">컨테이너 설정</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">{container.id}</p>
|
|
||||||
{isNonDefaultMode && (
|
|
||||||
<p className="text-xs text-amber-600 mt-1">
|
|
||||||
다른 모드에서는 드래그로 배치 변경 후 '고정' 버튼을 사용하세요
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto p-4">
|
|
||||||
<ContainerSettingsForm
|
|
||||||
container={container}
|
|
||||||
currentViewportMode={currentViewportMode}
|
|
||||||
onUpdate={onUpdateContainer}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트가 선택된 경우
|
|
||||||
return (
|
|
||||||
<div className={cn("flex h-full flex-col", className)}>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="border-b px-4 py-3">
|
|
||||||
<h3 className="text-sm font-medium">
|
|
||||||
{component!.label || COMPONENT_TYPE_LABELS[component!.type]}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">{component!.type}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
|
||||||
<Tabs defaultValue="size" className="flex-1">
|
|
||||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
|
||||||
<TabsTrigger value="size" className="gap-1 text-xs">
|
|
||||||
<Maximize2 className="h-3 w-3" />
|
|
||||||
크기
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="settings" className="gap-1 text-xs">
|
|
||||||
<Settings className="h-3 w-3" />
|
|
||||||
설정
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="visibility" className="gap-1 text-xs">
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
표시
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="data" className="gap-1 text-xs">
|
|
||||||
<Database className="h-3 w-3" />
|
|
||||||
데이터
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* 크기 제약 탭 */}
|
|
||||||
<TabsContent value="size" className="flex-1 overflow-auto p-4">
|
|
||||||
<SizeConstraintForm
|
|
||||||
component={component!}
|
|
||||||
onUpdate={onUpdateComponent}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 기본 설정 탭 */}
|
|
||||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
|
||||||
<ComponentSettingsForm
|
|
||||||
component={component!}
|
|
||||||
onUpdate={onUpdateComponent}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 모드별 표시 탭 */}
|
|
||||||
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
|
||||||
<VisibilityForm
|
|
||||||
component={component!}
|
|
||||||
onUpdate={onUpdateComponent}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 데이터 바인딩 탭 */}
|
|
||||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
|
||||||
<DataBindingPlaceholder />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 크기 제약 폼
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface SizeConstraintFormProps {
|
|
||||||
component: PopComponentDefinitionV4;
|
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SizeConstraintForm({ component, onUpdate }: SizeConstraintFormProps) {
|
|
||||||
const { size } = component;
|
|
||||||
|
|
||||||
const handleSizeChange = (
|
|
||||||
field: keyof PopSizeConstraintV4,
|
|
||||||
value: string | number | undefined
|
|
||||||
) => {
|
|
||||||
onUpdate?.({
|
|
||||||
size: {
|
|
||||||
...size,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 너비 설정 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium flex items-center gap-1">
|
|
||||||
<MoveHorizontal className="h-3 w-3" />
|
|
||||||
너비
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<SizeButton
|
|
||||||
active={size.width === "fixed"}
|
|
||||||
onClick={() => handleSizeChange("width", "fixed")}
|
|
||||||
label="고정"
|
|
||||||
description="px"
|
|
||||||
/>
|
|
||||||
<SizeButton
|
|
||||||
active={size.width === "fill"}
|
|
||||||
onClick={() => handleSizeChange("width", "fill")}
|
|
||||||
label="채움"
|
|
||||||
description="flex"
|
|
||||||
/>
|
|
||||||
<SizeButton
|
|
||||||
active={size.width === "hug"}
|
|
||||||
onClick={() => handleSizeChange("width", "hug")}
|
|
||||||
label="맞춤"
|
|
||||||
description="auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 고정 너비 입력 */}
|
|
||||||
{size.width === "fixed" && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-24 text-xs"
|
|
||||||
value={size.fixedWidth || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleSizeChange(
|
|
||||||
"fixedWidth",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="너비"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">px</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 채움일 때 최소/최대 */}
|
|
||||||
{size.width === "fill" && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-20 text-xs"
|
|
||||||
value={size.minWidth || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleSizeChange(
|
|
||||||
"minWidth",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="최소"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">~</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-20 text-xs"
|
|
||||||
value={size.maxWidth || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleSizeChange(
|
|
||||||
"maxWidth",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="최대"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">px</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 높이 설정 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium flex items-center gap-1">
|
|
||||||
<MoveVertical className="h-3 w-3" />
|
|
||||||
높이
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<SizeButton
|
|
||||||
active={size.height === "fixed"}
|
|
||||||
onClick={() => handleSizeChange("height", "fixed")}
|
|
||||||
label="고정"
|
|
||||||
description="px"
|
|
||||||
/>
|
|
||||||
<SizeButton
|
|
||||||
active={size.height === "fill"}
|
|
||||||
onClick={() => handleSizeChange("height", "fill")}
|
|
||||||
label="채움"
|
|
||||||
description="flex"
|
|
||||||
/>
|
|
||||||
<SizeButton
|
|
||||||
active={size.height === "hug"}
|
|
||||||
onClick={() => handleSizeChange("height", "hug")}
|
|
||||||
label="맞춤"
|
|
||||||
description="auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 고정 높이 입력 */}
|
|
||||||
{size.height === "fixed" && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-24 text-xs"
|
|
||||||
value={size.fixedHeight || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleSizeChange(
|
|
||||||
"fixedHeight",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="높이"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">px</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 채움일 때 최소 */}
|
|
||||||
{size.height === "fill" && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-24 text-xs"
|
|
||||||
value={size.minHeight || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleSizeChange(
|
|
||||||
"minHeight",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="최소 높이"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">px</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 개별 정렬 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium flex items-center gap-1">
|
|
||||||
<AlignCenter className="h-3 w-3" />
|
|
||||||
개별 정렬
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={component.alignSelf || "none"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onUpdate?.({
|
|
||||||
alignSelf: value === "none" ? undefined : (value as any),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="컨테이너 설정 따름" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">컨테이너 설정 따름</SelectItem>
|
|
||||||
<SelectItem value="start">시작</SelectItem>
|
|
||||||
<SelectItem value="center">가운데</SelectItem>
|
|
||||||
<SelectItem value="end">끝</SelectItem>
|
|
||||||
<SelectItem value="stretch">늘이기</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 크기 버튼 컴포넌트
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface SizeButtonProps {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SizeButton({ active, onClick, label, description }: SizeButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex-1 flex flex-col items-center gap-0.5 rounded-md border p-2 text-xs transition-colors",
|
|
||||||
active
|
|
||||||
? "border-primary bg-primary/10 text-primary"
|
|
||||||
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{label}</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">{description}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컨테이너 설정 폼
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ContainerSettingsFormProps {
|
|
||||||
container: PopContainerV4;
|
|
||||||
currentViewportMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
|
||||||
onUpdate?: (updates: Partial<PopContainerV4>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContainerSettingsForm({
|
|
||||||
container,
|
|
||||||
currentViewportMode = "tablet_landscape",
|
|
||||||
onUpdate,
|
|
||||||
}: ContainerSettingsFormProps) {
|
|
||||||
const isNonDefaultMode = currentViewportMode !== "tablet_landscape";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 방향 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium">방향</Label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant={container.direction === "horizontal" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 h-8 text-xs"
|
|
||||||
onClick={() => onUpdate?.({ direction: "horizontal" })}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
>
|
|
||||||
가로
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={container.direction === "vertical" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 h-8 text-xs"
|
|
||||||
onClick={() => onUpdate?.({ direction: "vertical" })}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
>
|
|
||||||
세로
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{isNonDefaultMode && (
|
|
||||||
<p className="text-[10px] text-amber-600">
|
|
||||||
드래그로 배치 변경 후 '고정' 버튼 클릭
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 줄바꿈 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium">줄바꿈</Label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant={container.wrap ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 h-8 text-xs"
|
|
||||||
onClick={() => onUpdate?.({ wrap: true })}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
>
|
|
||||||
허용
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={!container.wrap ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 h-8 text-xs"
|
|
||||||
onClick={() => onUpdate?.({ wrap: false })}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
>
|
|
||||||
금지
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 간격 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium">간격 (gap)</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-24 text-xs"
|
|
||||||
value={container.gap}
|
|
||||||
onChange={(e) => onUpdate?.({ gap: Number(e.target.value) || 0 })}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">px</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 패딩 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium">패딩</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-24 text-xs"
|
|
||||||
value={container.padding || 0}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate?.({ padding: Number(e.target.value) || undefined })
|
|
||||||
}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">px</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정렬 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium">교차축 정렬</Label>
|
|
||||||
<Select
|
|
||||||
value={container.alignItems}
|
|
||||||
onValueChange={(value) => onUpdate?.({ alignItems: value as any })}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="start">시작</SelectItem>
|
|
||||||
<SelectItem value="center">가운데</SelectItem>
|
|
||||||
<SelectItem value="end">끝</SelectItem>
|
|
||||||
<SelectItem value="stretch">늘이기</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium">주축 정렬</Label>
|
|
||||||
<Select
|
|
||||||
value={container.justifyContent}
|
|
||||||
onValueChange={(value) => onUpdate?.({ justifyContent: value as any })}
|
|
||||||
disabled={isNonDefaultMode}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="start">시작</SelectItem>
|
|
||||||
<SelectItem value="center">가운데</SelectItem>
|
|
||||||
<SelectItem value="end">끝</SelectItem>
|
|
||||||
<SelectItem value="space-between">균등 배치</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 설정 폼
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ComponentSettingsFormProps {
|
|
||||||
component: PopComponentDefinitionV4;
|
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentSettingsForm({
|
|
||||||
component,
|
|
||||||
onUpdate,
|
|
||||||
}: ComponentSettingsFormProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 라벨 입력 */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs font-medium">라벨</Label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
value={component.label || ""}
|
|
||||||
onChange={(e) => onUpdate?.({ label: e.target.value })}
|
|
||||||
placeholder="컴포넌트 라벨"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 타입별 설정 (TODO: 상세 구현) */}
|
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
{COMPONENT_TYPE_LABELS[component.type]} 상세 설정
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-center text-xs text-muted-foreground">
|
|
||||||
(추후 구현 예정)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 모드별 표시/숨김 폼
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface VisibilityFormProps {
|
|
||||||
component: PopComponentDefinitionV4;
|
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
|
||||||
const modes = [
|
|
||||||
{ key: "tablet_landscape" as const, label: "태블릿 가로 (1024×768)" },
|
|
||||||
{ key: "tablet_portrait" as const, label: "태블릿 세로 (768×1024)" },
|
|
||||||
{ key: "mobile_landscape" as const, label: "모바일 가로 (667×375)" },
|
|
||||||
{ key: "mobile_portrait" as const, label: "모바일 세로 (375×667)" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium flex items-center gap-1">
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
모드별 표시 설정
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border p-3">
|
|
||||||
{modes.map(({ key, label }) => {
|
|
||||||
const isChecked = component.visibility?.[key] !== false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id={`visibility-${key}`}
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={(e) => {
|
|
||||||
onUpdate?.({
|
|
||||||
visibility: {
|
|
||||||
...component.visibility,
|
|
||||||
[key]: e.target.checked,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="h-4 w-4 rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`visibility-${key}`}
|
|
||||||
className="text-xs cursor-pointer flex-1"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{!isChecked && (
|
|
||||||
<span className="text-xs text-muted-foreground">(숨김)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 반응형 숨김 (픽셀 기반) */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-medium">반응형 숨김 (픽셀 기반)</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-24 text-xs"
|
|
||||||
value={component.hideBelow || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate?.({
|
|
||||||
hideBelow: e.target.value ? Number(e.target.value) : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="없음"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">px 이하에서 숨김</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
예: 500 입력 시 화면 너비가 500px 이하면 자동 숨김
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 데이터 바인딩 플레이스홀더
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function DataBindingPlaceholder() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Database className="h-8 w-8 text-gray-400" />
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
데이터 바인딩 설정
|
|
||||||
</p>
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
테이블 선택 - 칼럼 선택 - 조인 설정
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-center text-xs text-gray-400">
|
|
||||||
(추후 구현 예정)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ComponentEditorPanelV4;
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDrag } from "react-dnd";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
|
import { Square } from "lucide-react";
|
||||||
|
|
||||||
|
// DnD 타입 상수
|
||||||
|
const DND_ITEM_TYPES = {
|
||||||
|
COMPONENT: "component",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// 컴포넌트 정의
|
||||||
|
interface PaletteItem {
|
||||||
|
type: PopComponentType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
|
{
|
||||||
|
type: "pop-sample",
|
||||||
|
label: "샘플 박스",
|
||||||
|
icon: Square,
|
||||||
|
description: "크기 조정 테스트용",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
function DraggablePaletteItem({ item }: { item: PaletteItem }) {
|
||||||
|
const [{ isDragging }, drag] = useDrag(
|
||||||
|
() => ({
|
||||||
|
type: DND_ITEM_TYPES.COMPONENT,
|
||||||
|
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: item.type },
|
||||||
|
collect: (monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
[item.type]
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={drag}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-grab items-center gap-3 rounded-md border bg-white p-3",
|
||||||
|
"transition-all hover:border-primary hover:shadow-sm",
|
||||||
|
isDragging && "opacity-50 cursor-grabbing"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded bg-muted">
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium">{item.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 팔레트 패널
|
||||||
|
export default function ComponentPalette() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-gray-50">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="border-b bg-white px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">컴포넌트</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
드래그하여 캔버스에 배치
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컴포넌트 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PALETTE_ITEMS.map((item) => (
|
||||||
|
<DraggablePaletteItem key={item.type} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 안내 */}
|
||||||
|
<div className="border-t bg-white px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tip: 캔버스의 그리드 칸에 드롭하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useDrag } from "react-dnd";
|
|
||||||
import {
|
|
||||||
Type,
|
|
||||||
MousePointer,
|
|
||||||
List,
|
|
||||||
Activity,
|
|
||||||
ScanLine,
|
|
||||||
Calculator,
|
|
||||||
GripVertical,
|
|
||||||
Space,
|
|
||||||
WrapText,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
|
||||||
import { DND_ITEM_TYPES, DragItemComponent } from "./PopPanel";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 팔레트 정의
|
|
||||||
// ========================================
|
|
||||||
const COMPONENT_PALETTE: {
|
|
||||||
type: PopComponentType;
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
type: "pop-field",
|
|
||||||
label: "필드",
|
|
||||||
icon: Type,
|
|
||||||
description: "텍스트, 숫자 등 데이터 입력",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-button",
|
|
||||||
label: "버튼",
|
|
||||||
icon: MousePointer,
|
|
||||||
description: "저장, 삭제 등 액션 실행",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-list",
|
|
||||||
label: "리스트",
|
|
||||||
icon: List,
|
|
||||||
description: "데이터 목록 (카드 템플릿 지원)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-indicator",
|
|
||||||
label: "인디케이터",
|
|
||||||
icon: Activity,
|
|
||||||
description: "KPI, 상태 표시",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-scanner",
|
|
||||||
label: "스캐너",
|
|
||||||
icon: ScanLine,
|
|
||||||
description: "바코드/QR 스캔",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-numpad",
|
|
||||||
label: "숫자패드",
|
|
||||||
icon: Calculator,
|
|
||||||
description: "숫자 입력 전용",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-spacer",
|
|
||||||
label: "스페이서",
|
|
||||||
icon: Space,
|
|
||||||
description: "빈 공간 (정렬용)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-break",
|
|
||||||
label: "줄바꿈",
|
|
||||||
icon: WrapText,
|
|
||||||
description: "강제 줄바꿈 (flex-basis: 100%)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4 컴포넌트 팔레트
|
|
||||||
// ========================================
|
|
||||||
export function ComponentPaletteV4() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="border-b p-3">
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">
|
|
||||||
편집 중: v4 (자동 반응형)
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
규칙 기반 레이아웃
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컴포넌트 목록 */}
|
|
||||||
<div className="flex-1 overflow-auto p-3">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
|
||||||
컴포넌트
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{COMPONENT_PALETTE.map((item) => (
|
|
||||||
<DraggableComponentV4
|
|
||||||
key={item.type}
|
|
||||||
type={item.type}
|
|
||||||
label={item.label}
|
|
||||||
icon={item.icon}
|
|
||||||
description={item.description}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 text-xs text-muted-foreground">
|
|
||||||
캔버스로 드래그하여 배치
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
|
||||||
// ========================================
|
|
||||||
interface DraggableComponentV4Props {
|
|
||||||
type: PopComponentType;
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraggableComponentV4({ type, label, icon: Icon, description }: DraggableComponentV4Props) {
|
|
||||||
const [{ isDragging }, drag] = useDrag(
|
|
||||||
() => ({
|
|
||||||
type: DND_ITEM_TYPES.COMPONENT,
|
|
||||||
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent,
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[type]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={drag}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded-lg border p-2 cursor-grab transition-all",
|
|
||||||
"hover:bg-accent hover:border-primary/30",
|
|
||||||
isDragging && "opacity-50 cursor-grabbing"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium truncate">{label}</div>
|
|
||||||
<div className="text-xs text-muted-foreground truncate">{description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ComponentPaletteV4;
|
|
||||||
|
|
@ -1,368 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useDrag } from "react-dnd";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
Type,
|
|
||||||
MousePointer,
|
|
||||||
List,
|
|
||||||
Activity,
|
|
||||||
ScanLine,
|
|
||||||
Calculator,
|
|
||||||
Trash2,
|
|
||||||
ChevronDown,
|
|
||||||
GripVertical,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopLayoutDataV3,
|
|
||||||
PopLayoutModeKey,
|
|
||||||
PopComponentDefinition,
|
|
||||||
PopComponentType,
|
|
||||||
MODE_RESOLUTIONS,
|
|
||||||
} from "../types/pop-layout";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 드래그 아이템 타입
|
|
||||||
// ========================================
|
|
||||||
export const DND_ITEM_TYPES = {
|
|
||||||
COMPONENT: "component",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface DragItemComponent {
|
|
||||||
type: typeof DND_ITEM_TYPES.COMPONENT;
|
|
||||||
componentType: PopComponentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 팔레트 정의
|
|
||||||
// ========================================
|
|
||||||
const COMPONENT_PALETTE: {
|
|
||||||
type: PopComponentType;
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
description: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
type: "pop-field",
|
|
||||||
label: "필드",
|
|
||||||
icon: Type,
|
|
||||||
description: "텍스트, 숫자 등 데이터 입력",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-button",
|
|
||||||
label: "버튼",
|
|
||||||
icon: MousePointer,
|
|
||||||
description: "저장, 삭제 등 액션 실행",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-list",
|
|
||||||
label: "리스트",
|
|
||||||
icon: List,
|
|
||||||
description: "데이터 목록 (카드 템플릿 지원)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-indicator",
|
|
||||||
label: "인디케이터",
|
|
||||||
icon: Activity,
|
|
||||||
description: "KPI, 상태 표시",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-scanner",
|
|
||||||
label: "스캐너",
|
|
||||||
icon: ScanLine,
|
|
||||||
description: "바코드/QR 스캔",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "pop-numpad",
|
|
||||||
label: "숫자패드",
|
|
||||||
icon: Calculator,
|
|
||||||
description: "숫자 입력 전용",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Props (v3: 섹션 없음)
|
|
||||||
// ========================================
|
|
||||||
interface PopPanelProps {
|
|
||||||
layout: PopLayoutDataV3;
|
|
||||||
activeModeKey: PopLayoutModeKey;
|
|
||||||
selectedComponentId: string | null;
|
|
||||||
selectedComponent: PopComponentDefinition | null;
|
|
||||||
onUpdateComponentDefinition: (id: string, updates: Partial<PopComponentDefinition>) => void;
|
|
||||||
onDeleteComponent: (id: string) => void;
|
|
||||||
activeDevice: "mobile" | "tablet";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 메인 컴포넌트
|
|
||||||
// ========================================
|
|
||||||
export function PopPanel({
|
|
||||||
layout,
|
|
||||||
activeModeKey,
|
|
||||||
selectedComponentId,
|
|
||||||
selectedComponent,
|
|
||||||
onUpdateComponentDefinition,
|
|
||||||
onDeleteComponent,
|
|
||||||
activeDevice,
|
|
||||||
}: PopPanelProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<string>("components");
|
|
||||||
|
|
||||||
// 현재 모드의 컴포넌트 위치
|
|
||||||
const currentModeLayout = layout.layouts[activeModeKey];
|
|
||||||
const selectedComponentPosition = selectedComponentId
|
|
||||||
? currentModeLayout.componentPositions[selectedComponentId]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<Tabs
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
className="flex h-full flex-col"
|
|
||||||
>
|
|
||||||
<TabsList className="mx-2 mt-2 grid w-auto grid-cols-2">
|
|
||||||
<TabsTrigger value="components" className="text-xs">
|
|
||||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
|
||||||
컴포넌트
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="edit" className="text-xs">
|
|
||||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
|
||||||
편집
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* 컴포넌트 탭 */}
|
|
||||||
<TabsContent value="components" className="flex-1 overflow-auto p-2">
|
|
||||||
<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>
|
|
||||||
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
|
||||||
컴포넌트
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{COMPONENT_PALETTE.map((item) => (
|
|
||||||
<DraggableComponentItem
|
|
||||||
key={item.type}
|
|
||||||
type={item.type}
|
|
||||||
label={item.label}
|
|
||||||
icon={item.icon}
|
|
||||||
description={item.description}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
캔버스로 드래그하여 배치
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 편집 탭 */}
|
|
||||||
<TabsContent value="edit" className="flex-1 overflow-auto p-2">
|
|
||||||
{selectedComponent && selectedComponentPosition ? (
|
|
||||||
<ComponentEditorV3
|
|
||||||
component={selectedComponent}
|
|
||||||
position={selectedComponentPosition}
|
|
||||||
activeModeKey={activeModeKey}
|
|
||||||
onUpdateDefinition={(updates) =>
|
|
||||||
onUpdateComponentDefinition(selectedComponent.id, updates)
|
|
||||||
}
|
|
||||||
onDelete={() => onDeleteComponent(selectedComponent.id)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
|
|
||||||
컴포넌트를 선택하세요
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 모드 라벨 헬퍼
|
|
||||||
// ========================================
|
|
||||||
function getModeLabel(modeKey: PopLayoutModeKey): string {
|
|
||||||
const labels: Record<PopLayoutModeKey, string> = {
|
|
||||||
tablet_landscape: "태블릿 가로",
|
|
||||||
tablet_portrait: "태블릿 세로",
|
|
||||||
mobile_landscape: "모바일 가로",
|
|
||||||
mobile_portrait: "모바일 세로",
|
|
||||||
};
|
|
||||||
return labels[modeKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
|
||||||
// ========================================
|
|
||||||
interface DraggableComponentItemProps {
|
|
||||||
type: PopComponentType;
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraggableComponentItem({
|
|
||||||
type,
|
|
||||||
label,
|
|
||||||
icon: Icon,
|
|
||||||
description,
|
|
||||||
}: DraggableComponentItemProps) {
|
|
||||||
const [{ isDragging }, drag] = useDrag(() => ({
|
|
||||||
type: DND_ITEM_TYPES.COMPONENT,
|
|
||||||
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: type } as DragItemComponent,
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={drag}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-grab items-start gap-3 rounded-lg border p-3 transition-all",
|
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
|
||||||
isDragging && "opacity-50 ring-2 ring-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GripVertical className="mt-0.5 h-4 w-4 text-gray-400" />
|
|
||||||
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<p className="text-sm font-medium leading-none">{label}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v3 컴포넌트 편집기
|
|
||||||
// ========================================
|
|
||||||
interface ComponentEditorV3Props {
|
|
||||||
component: PopComponentDefinition;
|
|
||||||
position: { col: number; row: number; colSpan: number; rowSpan: number };
|
|
||||||
activeModeKey: PopLayoutModeKey;
|
|
||||||
onUpdateDefinition: (updates: Partial<PopComponentDefinition>) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentEditorV3({
|
|
||||||
component,
|
|
||||||
position,
|
|
||||||
activeModeKey,
|
|
||||||
onUpdateDefinition,
|
|
||||||
onDelete,
|
|
||||||
}: ComponentEditorV3Props) {
|
|
||||||
const [isPositionOpen, setIsPositionOpen] = useState(true);
|
|
||||||
|
|
||||||
// 컴포넌트 타입 라벨
|
|
||||||
const typeLabels: Record<PopComponentType, string> = {
|
|
||||||
"pop-field": "필드",
|
|
||||||
"pop-button": "버튼",
|
|
||||||
"pop-list": "리스트",
|
|
||||||
"pop-indicator": "인디케이터",
|
|
||||||
"pop-scanner": "스캐너",
|
|
||||||
"pop-numpad": "숫자패드",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 컴포넌트 기본 정보 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium">{typeLabels[component.type]}</span>
|
|
||||||
<p className="text-[10px] text-muted-foreground">{component.id}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={onDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 라벨 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">라벨</Label>
|
|
||||||
<Input
|
|
||||||
value={component.label || ""}
|
|
||||||
onChange={(e) => onUpdateDefinition({ label: e.target.value })}
|
|
||||||
placeholder="컴포넌트 이름"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 현재 모드 위치 (읽기 전용) */}
|
|
||||||
<Collapsible open={isPositionOpen} onOpenChange={setIsPositionOpen}>
|
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-2 text-sm font-medium">
|
|
||||||
현재 모드 위치
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 transition-transform",
|
|
||||||
isPositionOpen && "rotate-180"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-3 pt-2">
|
|
||||||
<div className="rounded-lg bg-muted p-3">
|
|
||||||
<p className="mb-2 text-xs font-medium">{getModeLabel(activeModeKey)}</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">시작 열:</span>
|
|
||||||
<span className="font-medium">{position.col}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">시작 행:</span>
|
|
||||||
<span className="font-medium">{position.row}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">열 크기:</span>
|
|
||||||
<span className="font-medium">{position.colSpan}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">행 크기:</span>
|
|
||||||
<span className="font-medium">{position.rowSpan}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
위치/크기는 캔버스에서 드래그하여 조정하세요.
|
|
||||||
각 모드(가로/세로)별로 별도 저장됩니다.
|
|
||||||
</p>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* TODO: 컴포넌트별 설정 (config) */}
|
|
||||||
<div className="rounded-lg border border-dashed p-3">
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
|
||||||
컴포넌트별 상세 설정은 추후 추가 예정
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// POP 디자이너 패널 export
|
// POP 디자이너 패널 export (v5 그리드 시스템)
|
||||||
export { PopPanel } from "./PopPanel";
|
export { default as ComponentEditorPanel } from "./ComponentEditorPanel";
|
||||||
export { ComponentEditorPanel } from "./ComponentEditorPanel";
|
export { default as ComponentPalette } from "./ComponentPalette";
|
||||||
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";
|
|
||||||
|
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { forwardRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopComponentDefinition,
|
|
||||||
PopComponentType,
|
|
||||||
GridPosition,
|
|
||||||
} from "../types/pop-layout";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Props 정의
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ComponentRendererProps {
|
|
||||||
/** 컴포넌트 정의 (타입, 라벨, 설정 등) */
|
|
||||||
component: PopComponentDefinition;
|
|
||||||
/** 컴포넌트의 그리드 위치 (섹션 내부 그리드 기준) */
|
|
||||||
position: GridPosition;
|
|
||||||
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
|
|
||||||
isDesignMode?: boolean;
|
|
||||||
/** 선택된 상태인지 */
|
|
||||||
isSelected?: boolean;
|
|
||||||
/** 컴포넌트 클릭 시 호출 */
|
|
||||||
onClick?: () => void;
|
|
||||||
/** 추가 className */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 렌더러
|
|
||||||
//
|
|
||||||
// 역할:
|
|
||||||
// - 관리자가 설정한 GridPosition(col, row, colSpan, rowSpan)을
|
|
||||||
// 그대로 CSS Grid에 반영
|
|
||||||
// - 디자이너/뷰어 모두에서 동일한 렌더링 보장
|
|
||||||
// - 디자인 모드에서는 선택 상태 표시
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export const ComponentRenderer = forwardRef<HTMLDivElement, ComponentRendererProps>(
|
|
||||||
function ComponentRenderer(
|
|
||||||
{
|
|
||||||
component,
|
|
||||||
position,
|
|
||||||
isDesignMode = false,
|
|
||||||
isSelected = false,
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const { type, label, config } = component;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
// 기본 스타일
|
|
||||||
"relative flex flex-col overflow-hidden rounded border bg-white transition-all",
|
|
||||||
// 디자인 모드 스타일
|
|
||||||
isDesignMode && "cursor-pointer",
|
|
||||||
// 선택 상태 스타일
|
|
||||||
isSelected
|
|
||||||
? "border-primary ring-2 ring-primary/30 z-10"
|
|
||||||
: "border-gray-200 hover:border-gray-300",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
// 관리자가 설정한 GridPosition을 그대로 반영
|
|
||||||
gridColumn: `${position.col} / span ${position.colSpan}`,
|
|
||||||
gridRow: `${position.row} / span ${position.rowSpan}`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClick?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 컴포넌트 타입별 미리보기 렌더링 */}
|
|
||||||
<ComponentPreview type={type} label={label} config={config} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 타입별 미리보기
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ComponentPreviewProps {
|
|
||||||
type: PopComponentType;
|
|
||||||
label?: string;
|
|
||||||
config?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentPreview({ type, label, config }: ComponentPreviewProps) {
|
|
||||||
switch (type) {
|
|
||||||
case "pop-field":
|
|
||||||
return <FieldPreview label={label} config={config} />;
|
|
||||||
case "pop-button":
|
|
||||||
return <ButtonPreview label={label} config={config} />;
|
|
||||||
case "pop-list":
|
|
||||||
return <ListPreview label={label} config={config} />;
|
|
||||||
case "pop-indicator":
|
|
||||||
return <IndicatorPreview label={label} config={config} />;
|
|
||||||
case "pop-scanner":
|
|
||||||
return <ScannerPreview label={label} config={config} />;
|
|
||||||
case "pop-numpad":
|
|
||||||
return <NumpadPreview label={label} config={config} />;
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center p-2 text-xs text-gray-400">
|
|
||||||
{label || type}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 개별 컴포넌트 미리보기
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function FieldPreview({ label, config }: { label?: string; config?: any }) {
|
|
||||||
const fieldType = config?.fieldType || "text";
|
|
||||||
const placeholder = config?.placeholder || "입력하세요";
|
|
||||||
const required = config?.required || false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col gap-1 p-2">
|
|
||||||
{/* 라벨 */}
|
|
||||||
<span className="text-xs font-medium text-gray-600">
|
|
||||||
{label || "필드"}
|
|
||||||
{required && <span className="ml-1 text-destructive">*</span>}
|
|
||||||
</span>
|
|
||||||
{/* 입력 필드 미리보기 */}
|
|
||||||
<div className="flex h-8 w-full items-center rounded border border-gray-200 bg-gray-50 px-2 text-xs text-gray-400">
|
|
||||||
{placeholder}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonPreview({ label, config }: { label?: string; config?: any }) {
|
|
||||||
const buttonType = config?.buttonType || "action";
|
|
||||||
const variant = buttonType === "submit" ? "bg-primary text-white" : "bg-gray-100 text-gray-700";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full items-center justify-center rounded font-medium",
|
|
||||||
variant
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label || "버튼"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ListPreview({ label, config }: { label?: string; config?: any }) {
|
|
||||||
const itemCount = config?.itemsPerPage || 5;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col gap-1 p-2">
|
|
||||||
{/* 라벨 */}
|
|
||||||
<span className="text-xs font-medium text-gray-600">{label || "리스트"}</span>
|
|
||||||
{/* 리스트 아이템 미리보기 */}
|
|
||||||
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
|
|
||||||
{Array.from({ length: Math.min(3, itemCount) }).map((_, i) => (
|
|
||||||
<div key={i} className="h-4 rounded bg-gray-100" />
|
|
||||||
))}
|
|
||||||
{itemCount > 3 && (
|
|
||||||
<div className="text-center text-[10px] text-gray-400">
|
|
||||||
+{itemCount - 3} more
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IndicatorPreview({ label, config }: { label?: string; config?: any }) {
|
|
||||||
const indicatorType = config?.indicatorType || "kpi";
|
|
||||||
const unit = config?.unit || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
|
||||||
{/* 라벨 */}
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "KPI"}</span>
|
|
||||||
{/* 값 미리보기 */}
|
|
||||||
<span className="text-xl font-bold text-primary">
|
|
||||||
0{unit && <span className="text-sm font-normal text-gray-500">{unit}</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScannerPreview({ label, config }: { label?: string; config?: any }) {
|
|
||||||
const scannerType = config?.scannerType || "camera";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
|
||||||
{/* QR 아이콘 */}
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded border-2 border-dashed border-gray-300">
|
|
||||||
<span className="text-xs text-gray-400">QR</span>
|
|
||||||
</div>
|
|
||||||
{/* 라벨 */}
|
|
||||||
<span className="text-[10px] text-gray-500">{label || "스캐너"}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumpadPreview({ label, config }: { label?: string; config?: any }) {
|
|
||||||
const keys = [1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col gap-1 p-2">
|
|
||||||
{/* 라벨 */}
|
|
||||||
{label && (
|
|
||||||
<span className="text-[10px] text-gray-500">{label}</span>
|
|
||||||
)}
|
|
||||||
{/* 넘패드 미리보기 */}
|
|
||||||
<div className="grid flex-1 grid-cols-3 gap-0.5">
|
|
||||||
{keys.map((key) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="flex items-center justify-center rounded bg-gray-100 text-[8px]"
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ComponentRenderer;
|
|
||||||
|
|
@ -1,912 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useMemo, useState, useCallback, useRef } from "react";
|
|
||||||
import { useDrag, useDrop } from "react-dnd";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopLayoutDataV4,
|
|
||||||
PopContainerV4,
|
|
||||||
PopComponentDefinitionV4,
|
|
||||||
PopResponsiveRuleV4,
|
|
||||||
PopSizeConstraintV4,
|
|
||||||
PopComponentType,
|
|
||||||
} from "../types/pop-layout";
|
|
||||||
|
|
||||||
// 드래그 아이템 타입
|
|
||||||
const DND_COMPONENT_REORDER = "POP_COMPONENT_REORDER";
|
|
||||||
|
|
||||||
interface DragItem {
|
|
||||||
type: string;
|
|
||||||
componentId: string;
|
|
||||||
containerId: string;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Props 정의
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface PopFlexRendererProps {
|
|
||||||
/** v4 레이아웃 데이터 */
|
|
||||||
layout: PopLayoutDataV4;
|
|
||||||
/** 현재 뷰포트 너비 (반응형 규칙 적용용) */
|
|
||||||
viewportWidth: number;
|
|
||||||
/** 현재 뷰포트 모드 (오버라이드 병합용) */
|
|
||||||
currentMode?: "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
|
||||||
/** 임시 레이아웃 (고정 전 미리보기) */
|
|
||||||
tempLayout?: PopContainerV4 | null;
|
|
||||||
/** 디자인 모드 여부 */
|
|
||||||
isDesignMode?: boolean;
|
|
||||||
/** 선택된 컴포넌트 ID */
|
|
||||||
selectedComponentId?: string | null;
|
|
||||||
/** 컴포넌트 클릭 */
|
|
||||||
onComponentClick?: (componentId: string) => void;
|
|
||||||
/** 컨테이너 클릭 */
|
|
||||||
onContainerClick?: (containerId: string) => void;
|
|
||||||
/** 배경 클릭 */
|
|
||||||
onBackgroundClick?: () => void;
|
|
||||||
/** 컴포넌트 크기 변경 */
|
|
||||||
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
|
||||||
/** 컴포넌트 순서 변경 */
|
|
||||||
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
|
||||||
/** 추가 className */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 타입별 라벨
|
|
||||||
// ========================================
|
|
||||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|
||||||
"pop-field": "필드",
|
|
||||||
"pop-button": "버튼",
|
|
||||||
"pop-list": "리스트",
|
|
||||||
"pop-indicator": "인디케이터",
|
|
||||||
"pop-scanner": "스캐너",
|
|
||||||
"pop-numpad": "숫자패드",
|
|
||||||
"pop-spacer": "스페이서",
|
|
||||||
"pop-break": "줄바꿈",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4 Flexbox 렌더러
|
|
||||||
//
|
|
||||||
// 핵심 역할:
|
|
||||||
// - v4 레이아웃을 Flexbox CSS로 렌더링
|
|
||||||
// - 제약조건(fill/fixed/hug) 기반 크기 계산
|
|
||||||
// - 반응형 규칙(breakpoint) 자동 적용
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export function PopFlexRenderer({
|
|
||||||
layout,
|
|
||||||
viewportWidth,
|
|
||||||
currentMode = "tablet_landscape",
|
|
||||||
tempLayout,
|
|
||||||
isDesignMode = false,
|
|
||||||
selectedComponentId,
|
|
||||||
onComponentClick,
|
|
||||||
onContainerClick,
|
|
||||||
onBackgroundClick,
|
|
||||||
onComponentResize,
|
|
||||||
onReorderComponent,
|
|
||||||
className,
|
|
||||||
}: PopFlexRendererProps) {
|
|
||||||
const { root, components, settings, overrides } = layout;
|
|
||||||
|
|
||||||
// 오버라이드 병합 로직 (컨테이너) 🔥
|
|
||||||
const getMergedRoot = (): PopContainerV4 => {
|
|
||||||
// 1. 임시 레이아웃이 있으면 최우선 (고정 전 미리보기)
|
|
||||||
if (tempLayout) {
|
|
||||||
return tempLayout;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 기본 모드면 root 그대로 반환
|
|
||||||
if (currentMode === "tablet_landscape") {
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 다른 모드면 오버라이드 병합
|
|
||||||
const override = overrides?.[currentMode]?.containers?.root;
|
|
||||||
if (override) {
|
|
||||||
return {
|
|
||||||
...root,
|
|
||||||
...override, // 오버라이드 속성으로 덮어쓰기
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 오버라이드 없으면 기본값
|
|
||||||
return root;
|
|
||||||
};
|
|
||||||
|
|
||||||
// visibility 체크 함수 🆕
|
|
||||||
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
|
|
||||||
if (!component.visibility) return true; // 기본값: 표시
|
|
||||||
const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility];
|
|
||||||
return modeVisibility !== false; // undefined도 true로 취급
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 오버라이드 병합 🆕
|
|
||||||
const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => {
|
|
||||||
if (currentMode === "tablet_landscape") return baseComponent;
|
|
||||||
|
|
||||||
const componentOverride = overrides?.[currentMode]?.components?.[baseComponent.id];
|
|
||||||
if (!componentOverride) return baseComponent;
|
|
||||||
|
|
||||||
// 깊은 병합 (config, size)
|
|
||||||
return {
|
|
||||||
...baseComponent,
|
|
||||||
...componentOverride,
|
|
||||||
size: { ...baseComponent.size, ...componentOverride.size },
|
|
||||||
config: { ...baseComponent.config, ...componentOverride.config },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const effectiveRoot = getMergedRoot();
|
|
||||||
|
|
||||||
// 빈 상태는 PopCanvasV4에서 표시하므로 여기서는 투명 배경만 렌더링
|
|
||||||
if (effectiveRoot.children.length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("h-full w-full", className)}
|
|
||||||
onClick={onBackgroundClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("relative min-h-full w-full bg-white", className)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onBackgroundClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 루트 컨테이너 렌더링 (병합된 레이아웃 사용) */}
|
|
||||||
<ContainerRenderer
|
|
||||||
container={effectiveRoot}
|
|
||||||
components={components}
|
|
||||||
viewportWidth={viewportWidth}
|
|
||||||
settings={settings}
|
|
||||||
currentMode={currentMode}
|
|
||||||
overrides={overrides}
|
|
||||||
isDesignMode={isDesignMode}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onComponentClick={onComponentClick}
|
|
||||||
onContainerClick={onContainerClick}
|
|
||||||
onComponentResize={onComponentResize}
|
|
||||||
onReorderComponent={onReorderComponent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컨테이너 렌더러 (재귀)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ContainerRendererProps {
|
|
||||||
container: PopContainerV4;
|
|
||||||
components: Record<string, PopComponentDefinitionV4>;
|
|
||||||
viewportWidth: number;
|
|
||||||
settings: PopLayoutDataV4["settings"];
|
|
||||||
currentMode: string;
|
|
||||||
overrides: PopLayoutDataV4["overrides"];
|
|
||||||
isDesignMode?: boolean;
|
|
||||||
selectedComponentId?: string | null;
|
|
||||||
onComponentClick?: (componentId: string) => void;
|
|
||||||
onContainerClick?: (containerId: string) => void;
|
|
||||||
onComponentResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
|
||||||
onReorderComponent?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
|
||||||
depth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContainerRenderer({
|
|
||||||
container,
|
|
||||||
components,
|
|
||||||
viewportWidth,
|
|
||||||
settings,
|
|
||||||
currentMode,
|
|
||||||
overrides,
|
|
||||||
isDesignMode = false,
|
|
||||||
selectedComponentId,
|
|
||||||
onComponentClick,
|
|
||||||
onContainerClick,
|
|
||||||
onComponentResize,
|
|
||||||
onReorderComponent,
|
|
||||||
depth = 0,
|
|
||||||
}: ContainerRendererProps) {
|
|
||||||
// visibility 체크 함수
|
|
||||||
const isComponentVisible = (component: PopComponentDefinitionV4): boolean => {
|
|
||||||
if (!component.visibility) return true; // 기본값: 표시
|
|
||||||
const modeVisibility = component.visibility[currentMode as keyof typeof component.visibility];
|
|
||||||
return modeVisibility !== false; // undefined도 true로 취급
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트 오버라이드 병합
|
|
||||||
const getMergedComponent = (baseComponent: PopComponentDefinitionV4): PopComponentDefinitionV4 => {
|
|
||||||
if (currentMode === "tablet_landscape") return baseComponent;
|
|
||||||
|
|
||||||
const componentOverride = overrides?.[currentMode as keyof typeof overrides]?.components?.[baseComponent.id];
|
|
||||||
if (!componentOverride) return baseComponent;
|
|
||||||
|
|
||||||
// 깊은 병합 (config, size)
|
|
||||||
return {
|
|
||||||
...baseComponent,
|
|
||||||
...componentOverride,
|
|
||||||
size: { ...baseComponent.size, ...componentOverride.size },
|
|
||||||
config: { ...baseComponent.config, ...componentOverride.config },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// 반응형 규칙 적용
|
|
||||||
const effectiveContainer = useMemo(() => {
|
|
||||||
return applyResponsiveRules(container, viewportWidth);
|
|
||||||
}, [container, viewportWidth]);
|
|
||||||
|
|
||||||
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
|
|
||||||
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
|
|
||||||
|
|
||||||
// Flexbox 스타일 계산 (useMemo는 조건문 전에 호출해야 함)
|
|
||||||
const containerStyle = useMemo((): React.CSSProperties => {
|
|
||||||
const { direction, wrap, gap, alignItems, justifyContent, padding } = effectiveContainer;
|
|
||||||
|
|
||||||
// gap과 padding도 스케일 적용
|
|
||||||
const scaledGap = gap * scale;
|
|
||||||
const scaledPadding = padding ? padding * scale : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: direction === "horizontal" ? "row" : "column",
|
|
||||||
flexWrap: wrap ? "wrap" : "nowrap",
|
|
||||||
gap: `${scaledGap}px`,
|
|
||||||
alignItems: mapAlignment(alignItems),
|
|
||||||
justifyContent: mapJustify(justifyContent),
|
|
||||||
padding: scaledPadding ? `${scaledPadding}px` : undefined,
|
|
||||||
width: "100%",
|
|
||||||
minHeight: depth === 0 ? "100%" : undefined,
|
|
||||||
};
|
|
||||||
}, [effectiveContainer, depth, scale]);
|
|
||||||
|
|
||||||
// 숨김 처리
|
|
||||||
if (effectiveContainer.hidden) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative",
|
|
||||||
isDesignMode && depth > 0 && "border border-dashed border-gray-300 rounded"
|
|
||||||
)}
|
|
||||||
style={containerStyle}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onContainerClick?.(container.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{effectiveContainer.children.map((child, index) => {
|
|
||||||
// 중첩 컨테이너인 경우
|
|
||||||
if (typeof child === "object") {
|
|
||||||
return (
|
|
||||||
<ContainerRenderer
|
|
||||||
key={child.id}
|
|
||||||
container={child}
|
|
||||||
components={components}
|
|
||||||
viewportWidth={viewportWidth}
|
|
||||||
settings={settings}
|
|
||||||
currentMode={currentMode}
|
|
||||||
overrides={overrides}
|
|
||||||
isDesignMode={isDesignMode}
|
|
||||||
selectedComponentId={selectedComponentId}
|
|
||||||
onComponentClick={onComponentClick}
|
|
||||||
onContainerClick={onContainerClick}
|
|
||||||
onComponentResize={onComponentResize}
|
|
||||||
onReorderComponent={onReorderComponent}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 ID인 경우
|
|
||||||
const componentId = child;
|
|
||||||
const baseComponent = components[componentId];
|
|
||||||
if (!baseComponent) return null;
|
|
||||||
|
|
||||||
// visibility 체크 (모드별 숨김)
|
|
||||||
if (!isComponentVisible(baseComponent)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 반응형 숨김 처리 (픽셀 기반)
|
|
||||||
if (baseComponent.hideBelow && viewportWidth < baseComponent.hideBelow) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오버라이드 병합
|
|
||||||
const mergedComponent = getMergedComponent(baseComponent);
|
|
||||||
|
|
||||||
// pop-break 특수 처리
|
|
||||||
if (mergedComponent.type === "pop-break") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={componentId}
|
|
||||||
className={cn(
|
|
||||||
"w-full",
|
|
||||||
isDesignMode
|
|
||||||
? "h-4 border-2 border-dashed border-gray-300 rounded flex items-center justify-center cursor-pointer hover:border-gray-400"
|
|
||||||
: "h-0"
|
|
||||||
)}
|
|
||||||
style={{ flexBasis: "100%" }}
|
|
||||||
onClick={() => onComponentClick?.(componentId)}
|
|
||||||
>
|
|
||||||
{isDesignMode && (
|
|
||||||
<span className="text-xs text-gray-400">줄바꿈</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DraggableComponentWrapper
|
|
||||||
key={componentId}
|
|
||||||
componentId={componentId}
|
|
||||||
containerId={container.id}
|
|
||||||
index={index}
|
|
||||||
isDesignMode={isDesignMode}
|
|
||||||
onReorder={onReorderComponent}
|
|
||||||
>
|
|
||||||
<ComponentRendererV4
|
|
||||||
componentId={componentId}
|
|
||||||
component={mergedComponent}
|
|
||||||
settings={settings}
|
|
||||||
viewportWidth={viewportWidth}
|
|
||||||
isDesignMode={isDesignMode}
|
|
||||||
isSelected={selectedComponentId === componentId}
|
|
||||||
onClick={() => onComponentClick?.(componentId)}
|
|
||||||
onResize={onComponentResize}
|
|
||||||
/>
|
|
||||||
</DraggableComponentWrapper>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 드래그 가능한 컴포넌트 래퍼
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface DraggableComponentWrapperProps {
|
|
||||||
componentId: string;
|
|
||||||
containerId: string;
|
|
||||||
index: number;
|
|
||||||
isDesignMode: boolean;
|
|
||||||
onReorder?: (containerId: string, fromIndex: number, toIndex: number) => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DraggableComponentWrapper({
|
|
||||||
componentId,
|
|
||||||
containerId,
|
|
||||||
index,
|
|
||||||
isDesignMode,
|
|
||||||
onReorder,
|
|
||||||
children,
|
|
||||||
}: DraggableComponentWrapperProps) {
|
|
||||||
// 디자인 모드가 아니면 그냥 children 반환 (훅 호출 전에 체크)
|
|
||||||
// DndProvider가 없는 환경에서 useDrag/useDrop 훅 호출 방지
|
|
||||||
if (!isDesignMode) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디자인 모드일 때만 드래그 기능 활성화
|
|
||||||
return (
|
|
||||||
<DraggableComponentWrapperInner
|
|
||||||
componentId={componentId}
|
|
||||||
containerId={containerId}
|
|
||||||
index={index}
|
|
||||||
onReorder={onReorder}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DraggableComponentWrapperInner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디자인 모드 전용 내부 컴포넌트 (DndProvider 필요)
|
|
||||||
function DraggableComponentWrapperInner({
|
|
||||||
componentId,
|
|
||||||
containerId,
|
|
||||||
index,
|
|
||||||
onReorder,
|
|
||||||
children,
|
|
||||||
}: Omit<DraggableComponentWrapperProps, "isDesignMode">) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [{ isDragging }, drag] = useDrag({
|
|
||||||
type: DND_COMPONENT_REORDER,
|
|
||||||
item: (): DragItem => ({
|
|
||||||
type: DND_COMPONENT_REORDER,
|
|
||||||
componentId,
|
|
||||||
containerId,
|
|
||||||
index,
|
|
||||||
}),
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [{ isOver, canDrop }, drop] = useDrop({
|
|
||||||
accept: DND_COMPONENT_REORDER,
|
|
||||||
canDrop: (item: DragItem) => {
|
|
||||||
// 같은 컨테이너 내에서만 이동 가능 (일단은)
|
|
||||||
return item.containerId === containerId && item.index !== index;
|
|
||||||
},
|
|
||||||
drop: (item: DragItem) => {
|
|
||||||
if (item.index !== index && onReorder) {
|
|
||||||
onReorder(containerId, item.index, index);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOver: monitor.isOver(),
|
|
||||||
canDrop: monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// drag와 drop 합치기
|
|
||||||
drag(drop(ref));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative",
|
|
||||||
isDragging && "opacity-50",
|
|
||||||
isOver && canDrop && "ring-2 ring-blue-500 ring-offset-2"
|
|
||||||
)}
|
|
||||||
style={{ cursor: isDragging ? "grabbing" : "grab" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{/* 드롭 인디케이터 */}
|
|
||||||
{isOver && canDrop && (
|
|
||||||
<div className="absolute inset-0 bg-blue-500/10 pointer-events-none rounded" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// v4 컴포넌트 렌더러 (리사이즈 핸들 포함)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ComponentRendererV4Props {
|
|
||||||
componentId: string;
|
|
||||||
component: PopComponentDefinitionV4;
|
|
||||||
settings: PopLayoutDataV4["settings"];
|
|
||||||
viewportWidth: number;
|
|
||||||
isDesignMode?: boolean;
|
|
||||||
isSelected?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
onResize?: (componentId: string, size: Partial<PopSizeConstraintV4>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentRendererV4({
|
|
||||||
componentId,
|
|
||||||
component,
|
|
||||||
settings,
|
|
||||||
viewportWidth,
|
|
||||||
isDesignMode = false,
|
|
||||||
isSelected = false,
|
|
||||||
onClick,
|
|
||||||
onResize,
|
|
||||||
}: ComponentRendererV4Props) {
|
|
||||||
const { size, alignSelf, type, label } = component;
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 비율 스케일 계산 (디자인 모드에서는 1, 뷰어에서는 실제 비율 적용)
|
|
||||||
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
|
|
||||||
|
|
||||||
// 리사이즈 상태
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
const [resizeDirection, setResizeDirection] = useState<"width" | "height" | "both" | null>(null);
|
|
||||||
const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null);
|
|
||||||
|
|
||||||
// 크기 스타일 계산 (스케일 적용)
|
|
||||||
const sizeStyle = useMemo((): React.CSSProperties => {
|
|
||||||
return calculateSizeStyle(size, settings, scale);
|
|
||||||
}, [size, settings, scale]);
|
|
||||||
|
|
||||||
// alignSelf 스타일
|
|
||||||
const alignStyle: React.CSSProperties = alignSelf
|
|
||||||
? { alignSelf: mapAlignment(alignSelf) }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const typeLabel = COMPONENT_TYPE_LABELS[type] || type;
|
|
||||||
|
|
||||||
// 리사이즈 시작
|
|
||||||
const handleResizeStart = useCallback((
|
|
||||||
e: React.MouseEvent,
|
|
||||||
direction: "width" | "height" | "both"
|
|
||||||
) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
resizeStartRef.current = {
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
};
|
|
||||||
setIsResizing(true);
|
|
||||||
setResizeDirection(direction);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 리사이즈 중
|
|
||||||
useCallback((e: MouseEvent) => {
|
|
||||||
if (!isResizing || !resizeStartRef.current || !onResize) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - resizeStartRef.current.x;
|
|
||||||
const deltaY = e.clientY - resizeStartRef.current.y;
|
|
||||||
|
|
||||||
const updates: Partial<PopSizeConstraintV4> = {};
|
|
||||||
|
|
||||||
if (resizeDirection === "width" || resizeDirection === "both") {
|
|
||||||
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
|
|
||||||
updates.width = "fixed";
|
|
||||||
updates.fixedWidth = newWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resizeDirection === "height" || resizeDirection === "both") {
|
|
||||||
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
|
|
||||||
updates.height = "fixed";
|
|
||||||
updates.fixedHeight = newHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
onResize(componentId, updates);
|
|
||||||
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
|
|
||||||
|
|
||||||
// 리사이즈 종료 및 이벤트 등록
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isResizing) return;
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!resizeStartRef.current || !onResize) return;
|
|
||||||
|
|
||||||
const deltaX = e.clientX - resizeStartRef.current.x;
|
|
||||||
const deltaY = e.clientY - resizeStartRef.current.y;
|
|
||||||
|
|
||||||
const updates: Partial<PopSizeConstraintV4> = {};
|
|
||||||
|
|
||||||
if (resizeDirection === "width" || resizeDirection === "both") {
|
|
||||||
const newWidth = Math.max(48, Math.round(resizeStartRef.current.width + deltaX));
|
|
||||||
updates.width = "fixed";
|
|
||||||
updates.fixedWidth = newWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resizeDirection === "height" || resizeDirection === "both") {
|
|
||||||
const newHeight = Math.max(settings.touchTargetMin, Math.round(resizeStartRef.current.height + deltaY));
|
|
||||||
updates.height = "fixed";
|
|
||||||
updates.fixedHeight = newHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
onResize(componentId, updates);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
setIsResizing(false);
|
|
||||||
setResizeDirection(null);
|
|
||||||
resizeStartRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
|
||||||
window.addEventListener("mouseup", handleMouseUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
window.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
};
|
|
||||||
}, [isResizing, resizeDirection, componentId, onResize, settings.touchTargetMin]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col overflow-visible rounded-lg border-2 bg-white transition-all",
|
|
||||||
isSelected
|
|
||||||
? "border-primary ring-2 ring-primary/30 z-10"
|
|
||||||
: "border-gray-200",
|
|
||||||
isDesignMode && !isResizing && "cursor-pointer hover:border-gray-300",
|
|
||||||
isResizing && "select-none"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
...sizeStyle,
|
|
||||||
...alignStyle,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!isResizing) {
|
|
||||||
onClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
|
|
||||||
{isDesignMode && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-5 shrink-0 items-center border-b px-2",
|
|
||||||
isSelected ? "bg-primary/10" : "bg-gray-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] font-medium text-gray-600">
|
|
||||||
{label || typeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 컴포넌트 내용 */}
|
|
||||||
<div className="flex flex-1 items-center justify-center p-2 overflow-hidden">
|
|
||||||
{renderComponentContent(component, isDesignMode, settings)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 리사이즈 핸들 (디자인 모드 + 선택 시에만) */}
|
|
||||||
{isDesignMode && isSelected && onResize && (
|
|
||||||
<>
|
|
||||||
{/* 오른쪽 핸들 (너비 조정) */}
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
|
||||||
onMouseDown={(e) => handleResizeStart(e, "width")}
|
|
||||||
title="너비 조정"
|
|
||||||
>
|
|
||||||
<div className="w-0.5 h-4 bg-white rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 아래쪽 핸들 (높이 조정) */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 h-3 w-8 bg-primary rounded cursor-ns-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
|
||||||
onMouseDown={(e) => handleResizeStart(e, "height")}
|
|
||||||
title="높이 조정"
|
|
||||||
>
|
|
||||||
<div className="h-0.5 w-4 bg-white rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽 아래 핸들 (너비 + 높이 동시 조정) */}
|
|
||||||
<div
|
|
||||||
className="absolute right-0 bottom-0 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-primary rounded cursor-nwse-resize hover:bg-primary/80 flex items-center justify-center z-20"
|
|
||||||
onMouseDown={(e) => handleResizeStart(e, "both")}
|
|
||||||
title="크기 조정"
|
|
||||||
>
|
|
||||||
<div className="w-2 h-2 border-r-2 border-b-2 border-white" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 헬퍼 함수들
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 반응형 규칙 적용
|
|
||||||
*/
|
|
||||||
function applyResponsiveRules(
|
|
||||||
container: PopContainerV4,
|
|
||||||
viewportWidth: number
|
|
||||||
): PopContainerV4 & { hidden?: boolean } {
|
|
||||||
if (!container.responsive || container.responsive.length === 0) {
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 뷰포트에 적용되는 규칙 찾기 (가장 큰 breakpoint부터)
|
|
||||||
const sortedRules = [...container.responsive].sort((a, b) => b.breakpoint - a.breakpoint);
|
|
||||||
const applicableRule = sortedRules.find((rule) => viewportWidth <= rule.breakpoint);
|
|
||||||
|
|
||||||
if (!applicableRule) {
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...container,
|
|
||||||
direction: applicableRule.direction ?? container.direction,
|
|
||||||
gap: applicableRule.gap ?? container.gap,
|
|
||||||
hidden: applicableRule.hidden ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기준 뷰포트 너비 (10인치 태블릿 가로)
|
|
||||||
* 이 크기를 기준으로 컴포넌트 크기가 비율 조정됨
|
|
||||||
*/
|
|
||||||
const BASE_VIEWPORT_WIDTH = 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 크기 제약 → CSS 스타일 변환
|
|
||||||
* @param scale - 뷰포트 비율 (viewportWidth / BASE_VIEWPORT_WIDTH)
|
|
||||||
*/
|
|
||||||
function calculateSizeStyle(
|
|
||||||
size: PopSizeConstraintV4,
|
|
||||||
settings: PopLayoutDataV4["settings"],
|
|
||||||
scale: number = 1
|
|
||||||
): React.CSSProperties {
|
|
||||||
const style: React.CSSProperties = {};
|
|
||||||
|
|
||||||
// 스케일된 터치 최소 크기
|
|
||||||
const scaledTouchMin = settings.touchTargetMin * scale;
|
|
||||||
|
|
||||||
// 너비
|
|
||||||
switch (size.width) {
|
|
||||||
case "fixed":
|
|
||||||
// fixed 크기도 비율에 맞게 스케일
|
|
||||||
style.width = size.fixedWidth ? `${size.fixedWidth * scale}px` : "auto";
|
|
||||||
style.flexShrink = 0;
|
|
||||||
break;
|
|
||||||
case "fill":
|
|
||||||
style.flex = 1;
|
|
||||||
style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0;
|
|
||||||
style.maxWidth = size.maxWidth ? `${size.maxWidth * scale}px` : undefined;
|
|
||||||
break;
|
|
||||||
case "hug":
|
|
||||||
style.width = "auto";
|
|
||||||
style.flexShrink = 0;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 높이
|
|
||||||
switch (size.height) {
|
|
||||||
case "fixed":
|
|
||||||
const scaledFixedHeight = (size.fixedHeight || settings.touchTargetMin) * scale;
|
|
||||||
const minHeight = Math.max(scaledFixedHeight, scaledTouchMin);
|
|
||||||
style.height = `${minHeight}px`;
|
|
||||||
break;
|
|
||||||
case "fill":
|
|
||||||
style.flexGrow = 1;
|
|
||||||
style.minHeight = size.minHeight
|
|
||||||
? `${Math.max(size.minHeight * scale, scaledTouchMin)}px`
|
|
||||||
: `${scaledTouchMin}px`;
|
|
||||||
break;
|
|
||||||
case "hug":
|
|
||||||
style.height = "auto";
|
|
||||||
style.minHeight = `${scaledTouchMin}px`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* alignItems 값 변환
|
|
||||||
*/
|
|
||||||
function mapAlignment(value: string): React.CSSProperties["alignItems"] {
|
|
||||||
switch (value) {
|
|
||||||
case "start":
|
|
||||||
return "flex-start";
|
|
||||||
case "end":
|
|
||||||
return "flex-end";
|
|
||||||
case "center":
|
|
||||||
return "center";
|
|
||||||
case "stretch":
|
|
||||||
return "stretch";
|
|
||||||
default:
|
|
||||||
return "stretch";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* justifyContent 값 변환
|
|
||||||
*/
|
|
||||||
function mapJustify(value: string): React.CSSProperties["justifyContent"] {
|
|
||||||
switch (value) {
|
|
||||||
case "start":
|
|
||||||
return "flex-start";
|
|
||||||
case "end":
|
|
||||||
return "flex-end";
|
|
||||||
case "center":
|
|
||||||
return "center";
|
|
||||||
case "space-between":
|
|
||||||
return "space-between";
|
|
||||||
default:
|
|
||||||
return "flex-start";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컴포넌트별 내용 렌더링
|
|
||||||
*/
|
|
||||||
function renderComponentContent(
|
|
||||||
component: PopComponentDefinitionV4,
|
|
||||||
isDesignMode: boolean,
|
|
||||||
settings: PopLayoutDataV4["settings"]
|
|
||||||
): React.ReactNode {
|
|
||||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
|
||||||
|
|
||||||
// 디자인 모드에서는 플레이스홀더 표시
|
|
||||||
if (isDesignMode) {
|
|
||||||
// Spacer는 디자인 모드에서 점선 배경으로 표시
|
|
||||||
if (component.type === "pop-spacer") {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full flex items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50/50 rounded">
|
|
||||||
<span className="text-xs text-gray-400">빈 공간</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="text-xs text-gray-400 text-center">
|
|
||||||
{typeLabel}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 뷰어 모드: 실제 컴포넌트 렌더링
|
|
||||||
switch (component.type) {
|
|
||||||
case "pop-field":
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={component.label || "입력하세요"}
|
|
||||||
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-button":
|
|
||||||
return (
|
|
||||||
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
|
|
||||||
{component.label || "버튼"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-list":
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full overflow-auto p-2">
|
|
||||||
<div className="text-xs text-gray-500 text-center">
|
|
||||||
리스트 (데이터 연결 필요)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-indicator":
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-primary">0</div>
|
|
||||||
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-scanner":
|
|
||||||
return (
|
|
||||||
<div className="text-center text-gray-500">
|
|
||||||
<div className="text-xs">스캐너</div>
|
|
||||||
<div className="text-[10px]">탭하여 스캔</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-numpad":
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-1 p-1 w-full">
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-spacer":
|
|
||||||
// 실제 모드에서 Spacer는 완전히 투명 (공간만 차지)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
{typeLabel}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PopFlexRenderer;
|
|
||||||
|
|
@ -1,401 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
PopLayoutDataV3,
|
|
||||||
PopLayoutModeKey,
|
|
||||||
PopModeLayoutV3,
|
|
||||||
GridPosition,
|
|
||||||
MODE_RESOLUTIONS,
|
|
||||||
PopComponentDefinition,
|
|
||||||
} from "../types/pop-layout";
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Props 정의
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface PopLayoutRendererProps {
|
|
||||||
/** 레이아웃 데이터 (v3.0) */
|
|
||||||
layout: PopLayoutDataV3;
|
|
||||||
/** 현재 모드 키 (tablet_landscape, tablet_portrait 등) */
|
|
||||||
modeKey: PopLayoutModeKey;
|
|
||||||
/** 디자인 모드 여부 (true: 편집 가능, false: 뷰어 모드) */
|
|
||||||
isDesignMode?: boolean;
|
|
||||||
/** 선택된 컴포넌트 ID */
|
|
||||||
selectedComponentId?: string | null;
|
|
||||||
/** 컴포넌트 클릭 시 호출 */
|
|
||||||
onComponentClick?: (componentId: string) => void;
|
|
||||||
/** 배경 클릭 시 호출 (선택 해제용) */
|
|
||||||
onBackgroundClick?: () => void;
|
|
||||||
/** 커스텀 모드 레이아웃 (fallback 등에서 변환된 레이아웃 사용 시) */
|
|
||||||
customModeLayout?: PopModeLayoutV3;
|
|
||||||
/** 추가 className */
|
|
||||||
className?: string;
|
|
||||||
/** 추가 style */
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 타입별 라벨
|
|
||||||
// ========================================
|
|
||||||
const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
|
||||||
"pop-field": "필드",
|
|
||||||
"pop-button": "버튼",
|
|
||||||
"pop-list": "리스트",
|
|
||||||
"pop-indicator": "인디케이터",
|
|
||||||
"pop-scanner": "스캐너",
|
|
||||||
"pop-numpad": "숫자패드",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// POP 레이아웃 렌더러 (v3)
|
|
||||||
//
|
|
||||||
// 핵심 역할:
|
|
||||||
// - 디자이너와 뷰어에서 **동일한** 렌더링 결과 보장
|
|
||||||
// - 컴포넌트가 캔버스에 직접 배치 (섹션 없음)
|
|
||||||
// - CSS Grid + 1fr 비율 기반
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
export function PopLayoutRenderer({
|
|
||||||
layout,
|
|
||||||
modeKey,
|
|
||||||
isDesignMode = false,
|
|
||||||
selectedComponentId,
|
|
||||||
onComponentClick,
|
|
||||||
onBackgroundClick,
|
|
||||||
customModeLayout,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
}: PopLayoutRendererProps) {
|
|
||||||
const { components, layouts, settings } = layout;
|
|
||||||
const canvasGrid = settings.canvasGrid;
|
|
||||||
|
|
||||||
// 현재 모드의 레이아웃
|
|
||||||
const modeLayout = customModeLayout || layouts[modeKey];
|
|
||||||
|
|
||||||
// 컴포넌트가 없으면 빈 상태 표시
|
|
||||||
if (!modeLayout || Object.keys(modeLayout.componentPositions).length === 0) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full items-center justify-center bg-gray-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={style}
|
|
||||||
onClick={onBackgroundClick}
|
|
||||||
>
|
|
||||||
<div className="text-center text-sm text-gray-400">
|
|
||||||
<p>레이아웃이 설정되지 않았습니다</p>
|
|
||||||
{isDesignMode && <p className="mt-1">컴포넌트를 추가해주세요</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 ID 목록
|
|
||||||
const componentIds = Object.keys(modeLayout.componentPositions);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("relative w-full h-full bg-white", className)}
|
|
||||||
style={{
|
|
||||||
// CSS Grid: 디자이너와 동일
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: `repeat(${canvasGrid.columns}, 1fr)`,
|
|
||||||
gridTemplateRows: `repeat(${canvasGrid.rows || 24}, 1fr)`,
|
|
||||||
gap: `${canvasGrid.gap}px`,
|
|
||||||
padding: `${canvasGrid.gap}px`,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onBackgroundClick?.();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 컴포넌트들 직접 렌더링 */}
|
|
||||||
{componentIds.map((componentId) => {
|
|
||||||
const compDef = components[componentId];
|
|
||||||
const compPos = modeLayout.componentPositions[componentId];
|
|
||||||
|
|
||||||
if (!compDef || !compPos) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComponentRenderer
|
|
||||||
key={componentId}
|
|
||||||
componentId={componentId}
|
|
||||||
component={compDef}
|
|
||||||
position={compPos}
|
|
||||||
isDesignMode={isDesignMode}
|
|
||||||
isSelected={selectedComponentId === componentId}
|
|
||||||
onComponentClick={() => onComponentClick?.(componentId)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트 렌더러
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
interface ComponentRendererProps {
|
|
||||||
componentId: string;
|
|
||||||
component: PopComponentDefinition;
|
|
||||||
position: GridPosition;
|
|
||||||
isDesignMode?: boolean;
|
|
||||||
isSelected?: boolean;
|
|
||||||
onComponentClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentRenderer({
|
|
||||||
componentId,
|
|
||||||
component,
|
|
||||||
position,
|
|
||||||
isDesignMode = false,
|
|
||||||
isSelected = false,
|
|
||||||
onComponentClick,
|
|
||||||
}: ComponentRendererProps) {
|
|
||||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col overflow-hidden rounded-lg border-2 bg-white transition-all",
|
|
||||||
isSelected
|
|
||||||
? "border-primary ring-2 ring-primary/30 z-10"
|
|
||||||
: "border-gray-200",
|
|
||||||
isDesignMode && "cursor-pointer hover:border-gray-300"
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
gridColumn: `${position.col} / span ${position.colSpan}`,
|
|
||||||
gridRow: `${position.row} / span ${position.rowSpan}`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onComponentClick?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 컴포넌트 라벨 (디자인 모드에서만) */}
|
|
||||||
{isDesignMode && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-5 shrink-0 items-center border-b px-2",
|
|
||||||
isSelected ? "bg-primary/10" : "bg-gray-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-[10px] font-medium text-gray-600">
|
|
||||||
{component.label || typeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 컴포넌트 내용 */}
|
|
||||||
<div className="flex flex-1 items-center justify-center p-2">
|
|
||||||
{renderComponentContent(component, isDesignMode)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 컴포넌트별 렌더링
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function renderComponentContent(
|
|
||||||
component: PopComponentDefinition,
|
|
||||||
isDesignMode: boolean
|
|
||||||
): React.ReactNode {
|
|
||||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
|
||||||
|
|
||||||
// 디자인 모드에서는 플레이스홀더 표시
|
|
||||||
if (isDesignMode) {
|
|
||||||
return (
|
|
||||||
<div className="text-xs text-gray-400 text-center">
|
|
||||||
{typeLabel}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 뷰어 모드: 실제 컴포넌트 렌더링
|
|
||||||
switch (component.type) {
|
|
||||||
case "pop-field":
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={component.label || "입력하세요"}
|
|
||||||
className="w-full h-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-button":
|
|
||||||
return (
|
|
||||||
<button className="w-full h-full px-4 py-2 text-sm font-medium text-white bg-primary rounded-md hover:bg-primary/90 transition-colors">
|
|
||||||
{component.label || "버튼"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-list":
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full overflow-auto p-2">
|
|
||||||
<div className="text-xs text-gray-500 text-center">
|
|
||||||
리스트 (데이터 연결 필요)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-indicator":
|
|
||||||
return (
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-primary">0</div>
|
|
||||||
<div className="text-xs text-gray-500">{component.label || "지표"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-scanner":
|
|
||||||
return (
|
|
||||||
<div className="text-center text-gray-500">
|
|
||||||
<div className="text-xs">스캐너</div>
|
|
||||||
<div className="text-[10px]">탭하여 스캔</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pop-numpad":
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-1 p-1 w-full">
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, "C", 0, "OK"].map((key) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
className="aspect-square text-xs font-medium bg-gray-100 rounded hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
{typeLabel}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 헬퍼 함수들 (export)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 모드에 레이아웃이 설정되어 있는지 확인
|
|
||||||
*/
|
|
||||||
export function hasModeLayout(
|
|
||||||
layout: PopLayoutDataV3,
|
|
||||||
modeKey: PopLayoutModeKey
|
|
||||||
): boolean {
|
|
||||||
const modeLayout = layout.layouts[modeKey];
|
|
||||||
return modeLayout && Object.keys(modeLayout.componentPositions).length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태블릿 가로 모드(기준 모드)가 설정되어 있는지 확인
|
|
||||||
*/
|
|
||||||
export function hasBaseLayout(layout: PopLayoutDataV3): boolean {
|
|
||||||
return hasModeLayout(layout, "tablet_landscape");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태블릿 가로 모드를 기준으로 다른 모드에 맞게 자동 변환
|
|
||||||
*/
|
|
||||||
export function autoConvertLayout(
|
|
||||||
layout: PopLayoutDataV3,
|
|
||||||
targetModeKey: PopLayoutModeKey
|
|
||||||
): PopModeLayoutV3 {
|
|
||||||
const sourceKey: PopLayoutModeKey = "tablet_landscape";
|
|
||||||
const sourceLayout = layout.layouts[sourceKey];
|
|
||||||
const sourceRes = MODE_RESOLUTIONS[sourceKey];
|
|
||||||
const targetRes = MODE_RESOLUTIONS[targetModeKey];
|
|
||||||
|
|
||||||
// 비율 계산
|
|
||||||
const widthRatio = targetRes.width / sourceRes.width;
|
|
||||||
const heightRatio = targetRes.height / sourceRes.height;
|
|
||||||
|
|
||||||
// 가로 → 세로 변환인지 확인
|
|
||||||
const isOrientationChange =
|
|
||||||
sourceRes.width > sourceRes.height !== targetRes.width > targetRes.height;
|
|
||||||
|
|
||||||
// 컴포넌트 위치 변환
|
|
||||||
const convertedPositions: Record<string, GridPosition> = {};
|
|
||||||
let currentRow = 1;
|
|
||||||
|
|
||||||
// 컴포넌트를 row, col 순으로 정렬
|
|
||||||
const sortedComponentIds = Object.keys(sourceLayout.componentPositions).sort(
|
|
||||||
(a, b) => {
|
|
||||||
const posA = sourceLayout.componentPositions[a];
|
|
||||||
const posB = sourceLayout.componentPositions[b];
|
|
||||||
if (posA.row !== posB.row) return posA.row - posB.row;
|
|
||||||
return posA.col - posB.col;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const componentId of sortedComponentIds) {
|
|
||||||
const sourcePos = sourceLayout.componentPositions[componentId];
|
|
||||||
|
|
||||||
if (isOrientationChange) {
|
|
||||||
// 가로 → 세로: 세로 스택 방식
|
|
||||||
const canvasColumns = layout.settings.canvasGrid.columns;
|
|
||||||
convertedPositions[componentId] = {
|
|
||||||
col: 1,
|
|
||||||
row: currentRow,
|
|
||||||
colSpan: canvasColumns,
|
|
||||||
rowSpan: Math.max(3, Math.round(sourcePos.rowSpan * 1.5)),
|
|
||||||
};
|
|
||||||
currentRow += convertedPositions[componentId].rowSpan + 1;
|
|
||||||
} else {
|
|
||||||
// 같은 방향: 비율 변환
|
|
||||||
convertedPositions[componentId] = {
|
|
||||||
col: Math.max(1, Math.round(sourcePos.col * widthRatio)),
|
|
||||||
row: Math.max(1, Math.round(sourcePos.row * heightRatio)),
|
|
||||||
colSpan: Math.max(1, Math.round(sourcePos.colSpan * widthRatio)),
|
|
||||||
rowSpan: Math.max(1, Math.round(sourcePos.rowSpan * heightRatio)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
componentPositions: convertedPositions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 모드에 맞는 레이아웃 반환 (없으면 자동 변환)
|
|
||||||
*/
|
|
||||||
export function getEffectiveModeLayout(
|
|
||||||
layout: PopLayoutDataV3,
|
|
||||||
targetModeKey: PopLayoutModeKey
|
|
||||||
): {
|
|
||||||
modeLayout: PopModeLayoutV3;
|
|
||||||
isConverted: boolean;
|
|
||||||
sourceModeKey: PopLayoutModeKey;
|
|
||||||
} {
|
|
||||||
// 해당 모드에 레이아웃이 있으면 그대로 사용
|
|
||||||
if (hasModeLayout(layout, targetModeKey)) {
|
|
||||||
return {
|
|
||||||
modeLayout: layout.layouts[targetModeKey],
|
|
||||||
isConverted: false,
|
|
||||||
sourceModeKey: targetModeKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 없으면 태블릿 가로 모드를 기준으로 자동 변환
|
|
||||||
return {
|
|
||||||
modeLayout: autoConvertLayout(layout, targetModeKey),
|
|
||||||
isConverted: true,
|
|
||||||
sourceModeKey: "tablet_landscape",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PopLayoutRenderer;
|
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopLayoutDataV5,
|
||||||
|
PopComponentDefinitionV5,
|
||||||
|
PopGridPosition,
|
||||||
|
GridMode,
|
||||||
|
GRID_BREAKPOINTS,
|
||||||
|
detectGridMode,
|
||||||
|
PopComponentType,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Props
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface PopRendererProps {
|
||||||
|
/** v5 레이아웃 데이터 */
|
||||||
|
layout: PopLayoutDataV5;
|
||||||
|
/** 현재 뷰포트 너비 */
|
||||||
|
viewportWidth: number;
|
||||||
|
/** 현재 모드 (자동 감지 또는 수동 지정) */
|
||||||
|
currentMode?: GridMode;
|
||||||
|
/** 디자인 모드 여부 */
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
/** 그리드 가이드 표시 여부 */
|
||||||
|
showGridGuide?: boolean;
|
||||||
|
/** 선택된 컴포넌트 ID */
|
||||||
|
selectedComponentId?: string | null;
|
||||||
|
/** 컴포넌트 클릭 */
|
||||||
|
onComponentClick?: (componentId: string) => void;
|
||||||
|
/** 배경 클릭 */
|
||||||
|
onBackgroundClick?: () => void;
|
||||||
|
/** 추가 className */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 타입별 라벨
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
|
"pop-sample": "샘플",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// PopRenderer: v5 그리드 렌더러
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export default function PopRenderer({
|
||||||
|
layout,
|
||||||
|
viewportWidth,
|
||||||
|
currentMode,
|
||||||
|
isDesignMode = false,
|
||||||
|
showGridGuide = true,
|
||||||
|
selectedComponentId,
|
||||||
|
onComponentClick,
|
||||||
|
onBackgroundClick,
|
||||||
|
className,
|
||||||
|
}: PopRendererProps) {
|
||||||
|
const { gridConfig, components, overrides } = layout;
|
||||||
|
|
||||||
|
// 현재 모드 (자동 감지 또는 지정)
|
||||||
|
const mode = currentMode || detectGridMode(viewportWidth);
|
||||||
|
const breakpoint = GRID_BREAKPOINTS[mode];
|
||||||
|
|
||||||
|
// CSS Grid 스타일
|
||||||
|
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||||
|
gridAutoRows: `${breakpoint.rowHeight}px`,
|
||||||
|
gap: `${breakpoint.gap}px`,
|
||||||
|
padding: `${breakpoint.padding}px`,
|
||||||
|
minHeight: "100%",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
position: "relative",
|
||||||
|
}), [breakpoint]);
|
||||||
|
|
||||||
|
// 그리드 가이드 셀 생성
|
||||||
|
const gridCells = useMemo(() => {
|
||||||
|
if (!isDesignMode || !showGridGuide) return [];
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
const rowCount = 20; // 충분한 행 수
|
||||||
|
|
||||||
|
for (let row = 1; row <= rowCount; row++) {
|
||||||
|
for (let col = 1; col <= breakpoint.columns; col++) {
|
||||||
|
cells.push({
|
||||||
|
id: `cell-${col}-${row}`,
|
||||||
|
col,
|
||||||
|
row
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}, [isDesignMode, showGridGuide, breakpoint.columns]);
|
||||||
|
|
||||||
|
// visibility 체크
|
||||||
|
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
|
||||||
|
if (!comp.visibility) return true;
|
||||||
|
const modeVisibility = comp.visibility[mode];
|
||||||
|
return modeVisibility !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
|
||||||
|
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
|
||||||
|
const sourceColumns = 12; // 항상 12칸 기준으로 저장
|
||||||
|
const targetColumns = breakpoint.columns;
|
||||||
|
|
||||||
|
// 같은 칸 수면 그대로 사용
|
||||||
|
if (sourceColumns === targetColumns) {
|
||||||
|
return {
|
||||||
|
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||||
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비율 계산 (12칸 → 4칸, 6칸, 8칸)
|
||||||
|
const ratio = targetColumns / sourceColumns;
|
||||||
|
|
||||||
|
// 열 위치 변환
|
||||||
|
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||||
|
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||||
|
|
||||||
|
// 범위 초과 방지
|
||||||
|
if (newCol > targetColumns) {
|
||||||
|
newCol = 1;
|
||||||
|
}
|
||||||
|
if (newCol + newColSpan - 1 > targetColumns) {
|
||||||
|
newColSpan = targetColumns - newCol + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
|
||||||
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 오버라이드 적용
|
||||||
|
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
|
||||||
|
const override = overrides?.[mode]?.positions?.[comp.id];
|
||||||
|
if (override) {
|
||||||
|
return { ...comp.position, ...override };
|
||||||
|
}
|
||||||
|
return comp.position;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 오버라이드 숨김 체크
|
||||||
|
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
|
||||||
|
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative min-h-full w-full", className)}
|
||||||
|
style={gridStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onBackgroundClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 그리드 가이드 셀 (실제 DOM) */}
|
||||||
|
{gridCells.map(cell => (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className="pointer-events-none border border-dashed border-blue-300/40"
|
||||||
|
style={{
|
||||||
|
gridColumn: cell.col,
|
||||||
|
gridRow: cell.row,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 컴포넌트 렌더링 (z-index로 위에 표시) */}
|
||||||
|
{Object.values(components).map((comp) => {
|
||||||
|
// visibility 체크
|
||||||
|
if (!isVisible(comp)) return null;
|
||||||
|
|
||||||
|
// 오버라이드 숨김 체크
|
||||||
|
if (isHiddenByOverride(comp)) return null;
|
||||||
|
|
||||||
|
const position = getEffectivePosition(comp);
|
||||||
|
const positionStyle = convertPosition(position);
|
||||||
|
const isSelected = selectedComponentId === comp.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-lg border-2 bg-white transition-all overflow-hidden z-10",
|
||||||
|
isSelected
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200",
|
||||||
|
isDesignMode && "cursor-pointer hover:border-gray-300 hover:shadow-sm"
|
||||||
|
)}
|
||||||
|
style={positionStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onComponentClick?.(comp.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComponentContent
|
||||||
|
component={comp}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 컴포넌트 내용 렌더링
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ComponentContentProps {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
isDesignMode: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComponentContent({ component, isDesignMode, isSelected }: ComponentContentProps) {
|
||||||
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
|
// 디자인 모드: 플레이스홀더 표시
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-5 shrink-0 items-center border-b px-2",
|
||||||
|
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[10px] font-medium truncate",
|
||||||
|
isSelected ? "text-primary" : "text-gray-600"
|
||||||
|
)}>
|
||||||
|
{component.label || typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div className="flex flex-1 items-center justify-center p-2">
|
||||||
|
<span className="text-xs text-gray-400">{typeLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위치 정보 표시 */}
|
||||||
|
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
|
||||||
|
{component.position.col},{component.position.row}
|
||||||
|
({component.position.colSpan}×{component.position.rowSpan})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 모드: 컴포넌트 렌더링
|
||||||
|
return renderActualComponent(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 실제 컴포넌트 렌더링 (뷰어 모드)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
|
||||||
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type];
|
||||||
|
|
||||||
|
// 샘플 박스 렌더링
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-2">
|
||||||
|
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
// POP 레이아웃 렌더러 모듈 (v3)
|
// POP 레이아웃 렌더러 모듈 (v5 그리드 시스템)
|
||||||
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
|
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
|
||||||
// 섹션 제거됨, 컴포넌트 직접 배치
|
|
||||||
|
|
||||||
export { PopLayoutRenderer, default } from "./PopLayoutRenderer";
|
export { default as PopRenderer } from "./PopRenderer";
|
||||||
export {
|
|
||||||
hasModeLayout,
|
|
||||||
hasBaseLayout,
|
|
||||||
autoConvertLayout,
|
|
||||||
getEffectiveModeLayout,
|
|
||||||
} from "./PopLayoutRenderer";
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,301 @@
|
||||||
|
import {
|
||||||
|
PopGridPosition,
|
||||||
|
GridMode,
|
||||||
|
GRID_BREAKPOINTS
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 그리드 위치 변환
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 12칸 기준 위치를 다른 모드로 변환
|
||||||
|
*/
|
||||||
|
export function convertPositionToMode(
|
||||||
|
position: PopGridPosition,
|
||||||
|
targetMode: GridMode
|
||||||
|
): PopGridPosition {
|
||||||
|
const sourceColumns = 12;
|
||||||
|
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||||
|
|
||||||
|
// 같은 칸 수면 그대로 반환
|
||||||
|
if (sourceColumns === targetColumns) {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = targetColumns / sourceColumns;
|
||||||
|
|
||||||
|
// 열 위치 변환
|
||||||
|
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||||
|
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||||
|
|
||||||
|
// 범위 초과 방지
|
||||||
|
if (newCol > targetColumns) {
|
||||||
|
newCol = 1;
|
||||||
|
}
|
||||||
|
if (newCol + newColSpan - 1 > targetColumns) {
|
||||||
|
newColSpan = targetColumns - newCol + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
col: newCol,
|
||||||
|
row: position.row,
|
||||||
|
colSpan: Math.max(1, newColSpan),
|
||||||
|
rowSpan: position.rowSpan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 겹침 감지 및 해결
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 위치가 겹치는지 확인
|
||||||
|
*/
|
||||||
|
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
||||||
|
// 열 겹침 체크
|
||||||
|
const aColEnd = a.col + a.colSpan - 1;
|
||||||
|
const bColEnd = b.col + b.colSpan - 1;
|
||||||
|
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
|
||||||
|
|
||||||
|
// 행 겹침 체크
|
||||||
|
const aRowEnd = a.row + a.rowSpan - 1;
|
||||||
|
const bRowEnd = b.row + b.rowSpan - 1;
|
||||||
|
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
|
||||||
|
|
||||||
|
return colOverlap && rowOverlap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 겹침 해결 (아래로 밀기)
|
||||||
|
*/
|
||||||
|
export function resolveOverlaps(
|
||||||
|
positions: Array<{ id: string; position: PopGridPosition }>,
|
||||||
|
columns: number
|
||||||
|
): Array<{ id: string; position: PopGridPosition }> {
|
||||||
|
// row, col 순으로 정렬
|
||||||
|
const sorted = [...positions].sort((a, b) =>
|
||||||
|
a.position.row - b.position.row || a.position.col - b.position.col
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
|
||||||
|
|
||||||
|
sorted.forEach((item) => {
|
||||||
|
let { row, col, colSpan, rowSpan } = item.position;
|
||||||
|
|
||||||
|
// 열이 범위를 초과하면 조정
|
||||||
|
if (col + colSpan - 1 > columns) {
|
||||||
|
colSpan = columns - col + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 배치와 겹치면 아래로 이동
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 100;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||||
|
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
||||||
|
|
||||||
|
if (!hasOverlap) break;
|
||||||
|
|
||||||
|
row++;
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.push({
|
||||||
|
id: item.id,
|
||||||
|
position: { col, row, colSpan, rowSpan },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 좌표 변환
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마우스 좌표 → 그리드 좌표 변환
|
||||||
|
*/
|
||||||
|
export function mouseToGridPosition(
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
canvasRect: DOMRect,
|
||||||
|
columns: number,
|
||||||
|
rowHeight: number,
|
||||||
|
gap: number,
|
||||||
|
padding: number
|
||||||
|
): { col: number; row: number } {
|
||||||
|
// 캔버스 내 상대 위치
|
||||||
|
const relX = mouseX - canvasRect.left - padding;
|
||||||
|
const relY = mouseY - canvasRect.top - padding;
|
||||||
|
|
||||||
|
// 칸 너비 계산
|
||||||
|
const totalGap = gap * (columns - 1);
|
||||||
|
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
|
||||||
|
|
||||||
|
// 그리드 좌표 계산 (1부터 시작)
|
||||||
|
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
|
||||||
|
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
||||||
|
|
||||||
|
return { col, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 좌표 → 픽셀 좌표 변환
|
||||||
|
*/
|
||||||
|
export function gridToPixelPosition(
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
colSpan: number,
|
||||||
|
rowSpan: number,
|
||||||
|
canvasWidth: number,
|
||||||
|
columns: number,
|
||||||
|
rowHeight: number,
|
||||||
|
gap: number,
|
||||||
|
padding: number
|
||||||
|
): { x: number; y: number; width: number; height: number } {
|
||||||
|
const totalGap = gap * (columns - 1);
|
||||||
|
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: padding + (col - 1) * (colWidth + gap),
|
||||||
|
y: padding + (row - 1) * (rowHeight + gap),
|
||||||
|
width: colWidth * colSpan + gap * (colSpan - 1),
|
||||||
|
height: rowHeight * rowSpan + gap * (rowSpan - 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 위치 검증
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치가 그리드 범위 내에 있는지 확인
|
||||||
|
*/
|
||||||
|
export function isValidPosition(
|
||||||
|
position: PopGridPosition,
|
||||||
|
columns: number
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
position.col >= 1 &&
|
||||||
|
position.row >= 1 &&
|
||||||
|
position.colSpan >= 1 &&
|
||||||
|
position.rowSpan >= 1 &&
|
||||||
|
position.col + position.colSpan - 1 <= columns
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치를 그리드 범위 내로 조정
|
||||||
|
*/
|
||||||
|
export function clampPosition(
|
||||||
|
position: PopGridPosition,
|
||||||
|
columns: number
|
||||||
|
): PopGridPosition {
|
||||||
|
let { col, row, colSpan, rowSpan } = position;
|
||||||
|
|
||||||
|
// 최소값 보장
|
||||||
|
col = Math.max(1, col);
|
||||||
|
row = Math.max(1, row);
|
||||||
|
colSpan = Math.max(1, colSpan);
|
||||||
|
rowSpan = Math.max(1, rowSpan);
|
||||||
|
|
||||||
|
// 열 범위 초과 방지
|
||||||
|
if (col + colSpan - 1 > columns) {
|
||||||
|
if (col > columns) {
|
||||||
|
col = 1;
|
||||||
|
}
|
||||||
|
colSpan = columns - col + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { col, row, colSpan, rowSpan };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 자동 배치
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다음 빈 위치 찾기
|
||||||
|
*/
|
||||||
|
export function findNextEmptyPosition(
|
||||||
|
existingPositions: PopGridPosition[],
|
||||||
|
colSpan: number,
|
||||||
|
rowSpan: number,
|
||||||
|
columns: number
|
||||||
|
): PopGridPosition {
|
||||||
|
let row = 1;
|
||||||
|
let col = 1;
|
||||||
|
|
||||||
|
const maxAttempts = 1000;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||||
|
|
||||||
|
// 범위 체크
|
||||||
|
if (col + colSpan - 1 > columns) {
|
||||||
|
col = 1;
|
||||||
|
row++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 겹침 체크
|
||||||
|
const hasOverlap = existingPositions.some(pos =>
|
||||||
|
isOverlapping(candidatePos, pos)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasOverlap) {
|
||||||
|
return candidatePos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 위치로 이동
|
||||||
|
col++;
|
||||||
|
if (col + colSpan - 1 > columns) {
|
||||||
|
col = 1;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실패 시 마지막 행에 배치
|
||||||
|
return { col: 1, row: row + 1, colSpan, rowSpan };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트들을 자동으로 배치
|
||||||
|
*/
|
||||||
|
export function autoLayoutComponents(
|
||||||
|
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
|
||||||
|
columns: number
|
||||||
|
): Array<{ id: string; position: PopGridPosition }> {
|
||||||
|
const result: Array<{ id: string; position: PopGridPosition }> = [];
|
||||||
|
|
||||||
|
let currentRow = 1;
|
||||||
|
let currentCol = 1;
|
||||||
|
|
||||||
|
components.forEach(comp => {
|
||||||
|
// 현재 행에 공간이 부족하면 다음 행으로
|
||||||
|
if (currentCol + comp.colSpan - 1 > columns) {
|
||||||
|
currentRow++;
|
||||||
|
currentCol = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: comp.id,
|
||||||
|
position: {
|
||||||
|
col: currentCol,
|
||||||
|
row: currentRow,
|
||||||
|
colSpan: comp.colSpan,
|
||||||
|
rowSpan: comp.rowSpan,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
currentCol += comp.colSpan;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -1,588 +1,273 @@
|
||||||
# POP 화면 시스템 아키텍처
|
# POP 화면 시스템 아키텍처
|
||||||
|
|
||||||
**최종 업데이트: 2026-02-04**
|
**최종 업데이트: 2026-02-05 (v5 그리드 시스템)**
|
||||||
|
|
||||||
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
|
POP(Point of Production) 화면은 모바일/태블릿 환경에 최적화된 터치 기반 화면 시스템입니다.
|
||||||
이 문서는 POP 화면 구현에 관련된 모든 파일과 그 역할을 정리합니다.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 목차
|
## 현재 버전: v5 (CSS Grid)
|
||||||
|
|
||||||
1. [폴더 구조 개요](#1-폴더-구조-개요)
|
| 항목 | v5 (현재) |
|
||||||
2. [App 라우팅 (app/(pop))](#2-app-라우팅-apppop)
|
|------|----------|
|
||||||
3. [컴포넌트 (components/pop)](#3-컴포넌트-componentspop)
|
| 레이아웃 | CSS Grid |
|
||||||
4. [라이브러리 (lib)](#4-라이브러리-lib)
|
| 배치 방식 | 좌표 기반 (col, row, colSpan, rowSpan) |
|
||||||
5. [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템)
|
| 모드 | 4개 (mobile_portrait, mobile_landscape, tablet_portrait, tablet_landscape) |
|
||||||
6. [데이터 흐름](#6-데이터-흐름)
|
| 칸 수 | 4/6/8/12칸 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 폴더 구조 개요
|
## 폴더 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
├── app/(pop)/ # Next.js App Router - POP 라우팅
|
├── app/(pop)/ # Next.js App Router
|
||||||
│ ├── layout.tsx # POP 전용 레이아웃
|
│ ├── layout.tsx # POP 전용 레이아웃
|
||||||
│ └── pop/
|
│ └── pop/
|
||||||
│ ├── page.tsx # POP 대시보드 (메인)
|
│ ├── page.tsx # 대시보드
|
||||||
│ ├── screens/[screenId]/ # 개별 POP 화면 뷰어
|
│ ├── screens/[screenId]/ # 화면 뷰어 (v5)
|
||||||
│ ├── test-v4/ # v4 렌더러 테스트 페이지
|
|
||||||
│ └── work/ # 작업 화면
|
│ └── work/ # 작업 화면
|
||||||
│
|
│
|
||||||
├── components/pop/ # POP 컴포넌트 라이브러리
|
├── components/pop/ # POP 컴포넌트
|
||||||
│ ├── designer/ # 디자이너 모듈
|
│ ├── designer/ # 디자이너 모듈 ★
|
||||||
│ │ ├── panels/ # 편집 패널 (좌측/우측)
|
│ │ ├── PopDesigner.tsx # 메인 (레이아웃 로드/저장)
|
||||||
│ │ ├── renderers/ # 레이아웃 렌더러
|
│ │ ├── PopCanvas.tsx # 캔버스 (DnD, 줌, 모드)
|
||||||
│ │ └── types/ # 타입 정의
|
│ │ ├── panels/
|
||||||
│ ├── management/ # 화면 관리 모듈
|
│ │ │ └── ComponentEditorPanel.tsx # 속성 편집
|
||||||
│ └── dashboard/ # 대시보드 모듈
|
│ │ ├── renderers/
|
||||||
|
│ │ │ └── PopRenderer.tsx # CSS Grid 렌더링
|
||||||
|
│ │ ├── types/
|
||||||
|
│ │ │ └── pop-layout.ts # v5 타입 정의
|
||||||
|
│ │ └── utils/
|
||||||
|
│ │ └── gridUtils.ts # 위치 계산
|
||||||
|
│ ├── management/ # 화면 관리
|
||||||
|
│ └── dashboard/ # 대시보드
|
||||||
│
|
│
|
||||||
└── lib/
|
└── lib/
|
||||||
├── api/popScreenGroup.ts # POP 화면 그룹 API
|
├── api/screen.ts # 화면 API
|
||||||
├── registry/PopComponentRegistry.ts # 컴포넌트 레지스트리
|
└── registry/ # 컴포넌트 레지스트리
|
||||||
└── schemas/popComponentConfig.ts # 컴포넌트 설정 스키마
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. App 라우팅 (app/(pop))
|
## 핵심 파일
|
||||||
|
|
||||||
### `app/(pop)/layout.tsx`
|
### 1. PopDesigner.tsx (메인)
|
||||||
|
|
||||||
POP 전용 레이아웃. 데스크톱 레이아웃과 분리되어 터치 최적화 환경 제공.
|
**역할**: 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리
|
||||||
|
|
||||||
### `app/(pop)/pop/page.tsx`
|
|
||||||
|
|
||||||
**경로**: `/pop`
|
|
||||||
|
|
||||||
POP 메인 대시보드. 메뉴 그리드, KPI, 공지사항 등을 표시.
|
|
||||||
|
|
||||||
### `app/(pop)/pop/screens/[screenId]/page.tsx`
|
|
||||||
|
|
||||||
**경로**: `/pop/screens/:screenId`
|
|
||||||
|
|
||||||
**역할**: 개별 POP 화면 뷰어 (디자인 모드 X, 실행 모드)
|
|
||||||
|
|
||||||
**핵심 기능**:
|
|
||||||
- v3/v4 레이아웃 자동 감지 및 렌더링
|
|
||||||
- 반응형 모드 감지 (태블릿/모바일, 가로/세로)
|
|
||||||
- 프리뷰 모드 지원 (`?preview=true`)
|
|
||||||
- **뷰포트 감지 및 비율 스케일링**
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 뷰포트 너비 감지 (최대 1366px 제한)
|
// 상태 관리
|
||||||
const [viewportWidth, setViewportWidth] = useState(1024);
|
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateViewportWidth = () => {
|
|
||||||
setViewportWidth(Math.min(window.innerWidth, 1366));
|
|
||||||
};
|
|
||||||
updateViewportWidth();
|
|
||||||
window.addEventListener("resize", updateViewportWidth);
|
|
||||||
return () => window.removeEventListener("resize", updateViewportWidth);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 레이아웃 버전 감지 및 렌더링
|
|
||||||
if (popLayoutV4) {
|
|
||||||
// v4: PopFlexRenderer 사용 (비율 스케일링 적용)
|
|
||||||
<div className="mx-auto h-full" style={{ maxWidth: 1366 }}>
|
|
||||||
<PopFlexRenderer
|
|
||||||
layout={popLayoutV4}
|
|
||||||
viewportWidth={viewportWidth}
|
|
||||||
isDesignMode={false} // 뷰어에서 스케일 적용
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
} else if (popLayoutV3) {
|
|
||||||
// v3: PopLayoutRenderer 사용
|
|
||||||
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `app/(pop)/pop/test-v4/page.tsx`
|
|
||||||
|
|
||||||
**경로**: `/pop/test-v4`
|
|
||||||
|
|
||||||
**역할**: v4 레이아웃 시스템 테스트 페이지
|
|
||||||
|
|
||||||
**구성**:
|
|
||||||
- 왼쪽: 컴포넌트 팔레트 (PopPanel)
|
|
||||||
- 중앙: v4 캔버스 (PopCanvasV4)
|
|
||||||
- 오른쪽: 속성 패널 (ComponentEditorPanelV4)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 컴포넌트 (components/pop)
|
|
||||||
|
|
||||||
### 3.1 디자이너 모듈 (`designer/`)
|
|
||||||
|
|
||||||
#### `PopDesigner.tsx`
|
|
||||||
|
|
||||||
**역할**: POP 화면 디자이너 메인 컴포넌트
|
|
||||||
|
|
||||||
**핵심 기능**:
|
|
||||||
- v3/v4 모드 전환 (상단 탭)
|
|
||||||
- 레이아웃 로드/저장
|
|
||||||
- 컴포넌트 추가/삭제/수정
|
|
||||||
- 드래그 앤 드롭 (react-dnd)
|
|
||||||
|
|
||||||
**상태 관리**:
|
|
||||||
```typescript
|
|
||||||
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3");
|
|
||||||
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(...);
|
|
||||||
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(...);
|
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||||
|
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
|
||||||
|
|
||||||
|
// 핵심 함수
|
||||||
|
handleSave() // 레이아웃 저장
|
||||||
|
handleAddComponent() // 컴포넌트 추가
|
||||||
|
handleUpdateComponent() // 컴포넌트 수정
|
||||||
|
handleDeleteComponent() // 컴포넌트 삭제
|
||||||
|
handleUndo() / handleRedo() // 히스토리
|
||||||
```
|
```
|
||||||
|
|
||||||
**레이아웃**:
|
### 2. PopCanvas.tsx (캔버스)
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 툴바 (뒤로가기, 화면명, 모드전환, 저장) │
|
|
||||||
├──────────┬──────────────────────────┬──────────┤
|
|
||||||
│ 왼쪽 │ 중앙 캔버스 │ 오른쪽 │
|
|
||||||
│ 패널 │ │ 패널 │
|
|
||||||
│ (20%) │ (60%) │ (20%) │
|
|
||||||
│ │ │ (v4만) │
|
|
||||||
└──────────┴──────────────────────────┴──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `PopCanvas.tsx` (v3용)
|
**역할**: 그리드 캔버스, DnD, 줌, 패닝, 모드 전환
|
||||||
|
|
||||||
**역할**: v3 레이아웃용 CSS Grid 기반 캔버스
|
|
||||||
|
|
||||||
**핵심 기능**:
|
|
||||||
- 4개 모드 전환 (태블릿 가로/세로, 모바일 가로/세로)
|
|
||||||
- 그리드 기반 컴포넌트 배치
|
|
||||||
- 드래그로 위치/크기 조정
|
|
||||||
|
|
||||||
#### `PopCanvasV4.tsx` (v4용)
|
|
||||||
|
|
||||||
**역할**: v4 레이아웃용 Flexbox 기반 캔버스
|
|
||||||
|
|
||||||
**핵심 기능**:
|
|
||||||
- 단일 캔버스 + 뷰포트 프리뷰
|
|
||||||
- 3가지 프리셋 (모바일 375px, 태블릿 768px, 데스크톱 1024px)
|
|
||||||
- 너비 슬라이더로 반응형 테스트
|
|
||||||
- 줌 컨트롤 (30%~150%)
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
// DnD 설정
|
||||||
|
const DND_ITEM_TYPES = { COMPONENT: "component" };
|
||||||
|
|
||||||
|
// 뷰포트 프리셋 (4개 모드)
|
||||||
const VIEWPORT_PRESETS = [
|
const VIEWPORT_PRESETS = [
|
||||||
{ id: "mobile", label: "모바일", width: 375, height: 667 },
|
{ id: "mobile_portrait", width: 375, columns: 4 },
|
||||||
{ id: "tablet", label: "태블릿", width: 768, height: 1024 },
|
{ id: "mobile_landscape", width: 667, columns: 6 },
|
||||||
{ id: "desktop", label: "데스크톱", width: 1024, height: 768 },
|
{ id: "tablet_portrait", width: 768, columns: 8 },
|
||||||
|
{ id: "tablet_landscape", width: 1024, columns: 12 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 기능
|
||||||
|
- useDrop(): 팔레트에서 컴포넌트 드롭
|
||||||
|
- handleWheel(): 줌 (30%~150%)
|
||||||
|
- Space + 드래그: 패닝
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### 3. PopRenderer.tsx (렌더러)
|
||||||
|
|
||||||
### 3.2 패널 모듈 (`designer/panels/`)
|
**역할**: CSS Grid 기반 레이아웃 렌더링
|
||||||
|
|
||||||
#### `PopPanel.tsx`
|
|
||||||
|
|
||||||
**역할**: 왼쪽 패널 - 컴포넌트 팔레트 & 편집 탭
|
|
||||||
|
|
||||||
**탭 구성**:
|
|
||||||
1. **컴포넌트 탭**: 드래그 가능한 6개 컴포넌트
|
|
||||||
2. **편집 탭**: 선택된 컴포넌트 설정
|
|
||||||
|
|
||||||
**컴포넌트 팔레트**:
|
|
||||||
```typescript
|
```typescript
|
||||||
const COMPONENT_PALETTE = [
|
// Props
|
||||||
{ type: "pop-field", label: "필드", description: "텍스트, 숫자 등 데이터 입력" },
|
interface PopRendererProps {
|
||||||
{ type: "pop-button", label: "버튼", description: "저장, 삭제 등 액션 실행" },
|
layout: PopLayoutDataV5;
|
||||||
{ type: "pop-list", label: "리스트", description: "데이터 목록" },
|
viewportWidth: number;
|
||||||
{ type: "pop-indicator", label: "인디케이터", description: "KPI, 상태 표시" },
|
currentMode: GridMode;
|
||||||
{ type: "pop-scanner", label: "스캐너", description: "바코드/QR 스캔" },
|
isDesignMode: boolean;
|
||||||
{ type: "pop-numpad", label: "숫자패드", description: "숫자 입력 전용" },
|
selectedComponentId?: string | null;
|
||||||
];
|
onSelectComponent?: (id: string | null) => void;
|
||||||
```
|
|
||||||
|
|
||||||
**드래그 아이템 타입**:
|
|
||||||
```typescript
|
|
||||||
export const DND_ITEM_TYPES = { COMPONENT: "component" };
|
|
||||||
export interface DragItemComponent {
|
|
||||||
type: typeof DND_ITEM_TYPES.COMPONENT;
|
|
||||||
componentType: PopComponentType;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `ComponentEditorPanelV4.tsx`
|
|
||||||
|
|
||||||
**역할**: v4 오른쪽 패널 - 컴포넌트/컨테이너 속성 편집
|
|
||||||
|
|
||||||
**3개 탭**:
|
|
||||||
1. **크기 탭**: 너비/높이 제약 (fixed/fill/hug)
|
|
||||||
2. **설정 탭**: 라벨, 타입별 설정
|
|
||||||
3. **데이터 탭**: 데이터 바인딩 (미구현)
|
|
||||||
|
|
||||||
**크기 제약 편집**:
|
|
||||||
```typescript
|
|
||||||
// 너비/높이 모드
|
|
||||||
type SizeMode = "fixed" | "fill" | "hug";
|
|
||||||
|
|
||||||
// fixed: 고정 px 값
|
|
||||||
// fill: 남은 공간 채움 (flex: 1)
|
|
||||||
// hug: 내용에 맞춤 (width: auto)
|
|
||||||
```
|
|
||||||
|
|
||||||
**컨테이너 설정**:
|
|
||||||
- 방향 (horizontal/vertical)
|
|
||||||
- 줄바꿈 (wrap)
|
|
||||||
- 간격 (gap)
|
|
||||||
- 패딩 (padding)
|
|
||||||
- 정렬 (alignItems, justifyContent)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 렌더러 모듈 (`designer/renderers/`)
|
|
||||||
|
|
||||||
#### `PopLayoutRenderer.tsx` (v3용)
|
|
||||||
|
|
||||||
**역할**: v3 레이아웃을 CSS Grid로 렌더링
|
|
||||||
|
|
||||||
**입력**:
|
|
||||||
- `layout`: PopLayoutDataV3
|
|
||||||
- `modeKey`: 현재 모드 (tablet_landscape 등)
|
|
||||||
- `isDesignMode`: 디자인 모드 여부
|
|
||||||
|
|
||||||
#### `PopFlexRenderer.tsx` (v4용)
|
|
||||||
|
|
||||||
**역할**: v4 레이아웃을 Flexbox로 렌더링 + 비율 스케일링
|
|
||||||
|
|
||||||
**핵심 기능**:
|
|
||||||
- 컨테이너 재귀 렌더링
|
|
||||||
- 반응형 규칙 적용 (breakpoint)
|
|
||||||
- 크기 제약 → CSS 스타일 변환
|
|
||||||
- 컴포넌트 숨김 처리 (hideBelow)
|
|
||||||
- **비율 스케일링** (뷰어 모드)
|
|
||||||
|
|
||||||
**비율 스케일링 시스템**:
|
|
||||||
```typescript
|
|
||||||
// 기준 너비 (10인치 태블릿 가로)
|
|
||||||
const BASE_VIEWPORT_WIDTH = 1024;
|
|
||||||
|
|
||||||
// 스케일 계산 (디자인 모드: 1, 뷰어 모드: 실제 비율)
|
|
||||||
const scale = isDesignMode ? 1 : viewportWidth / BASE_VIEWPORT_WIDTH;
|
|
||||||
|
|
||||||
// 예시: 12인치(1366px) 화면
|
|
||||||
// scale = 1366 / 1024 = 1.33
|
|
||||||
// 200px 컴포넌트 → 266px
|
|
||||||
```
|
|
||||||
|
|
||||||
**크기 제약 변환 로직** (스케일 적용):
|
|
||||||
```typescript
|
|
||||||
function calculateSizeStyle(
|
|
||||||
size: PopSizeConstraintV4,
|
|
||||||
settings: PopGlobalSettingsV4,
|
|
||||||
scale: number = 1 // 스케일 파라미터 추가
|
|
||||||
): React.CSSProperties {
|
|
||||||
const style: React.CSSProperties = {};
|
|
||||||
|
|
||||||
// 너비 (스케일 적용)
|
|
||||||
switch (size.width) {
|
|
||||||
case "fixed":
|
|
||||||
style.width = `${size.fixedWidth * scale}px`;
|
|
||||||
style.flexShrink = 0;
|
|
||||||
break;
|
|
||||||
case "fill":
|
|
||||||
style.flex = 1;
|
|
||||||
style.minWidth = size.minWidth ? `${size.minWidth * scale}px` : 0;
|
|
||||||
break;
|
|
||||||
case "hug":
|
|
||||||
style.width = "auto";
|
|
||||||
style.flexShrink = 0;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 높이 (스케일 적용)
|
// CSS Grid 스타일 생성
|
||||||
switch (size.height) {
|
const gridStyle = useMemo(() => ({
|
||||||
case "fixed":
|
display: "grid",
|
||||||
style.height = `${size.fixedHeight * scale}px`;
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
break;
|
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||||
case "fill":
|
gap: `${gap}px`,
|
||||||
style.flexGrow = 1;
|
padding: `${padding}px`,
|
||||||
break;
|
}), [mode]);
|
||||||
case "hug":
|
|
||||||
style.height = "auto";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return style;
|
// 위치 변환 (12칸 → 다른 모드)
|
||||||
}
|
const convertPosition = (pos: PopGridPosition, targetMode: GridMode) => {
|
||||||
```
|
const ratio = GRID_BREAKPOINTS[targetMode].columns / 12;
|
||||||
|
return {
|
||||||
**컨테이너 스케일 적용**:
|
col: Math.max(1, Math.round(pos.col * ratio)),
|
||||||
```typescript
|
colSpan: Math.max(1, Math.round(pos.colSpan * ratio)),
|
||||||
// gap, padding도 스케일 적용
|
row: pos.row,
|
||||||
const scaledGap = gap * scale;
|
rowSpan: pos.rowSpan,
|
||||||
const scaledPadding = padding ? padding * scale : undefined;
|
};
|
||||||
```
|
|
||||||
|
|
||||||
#### `ComponentRenderer.tsx`
|
|
||||||
|
|
||||||
**역할**: 개별 컴포넌트 렌더링 (디자인 모드용 플레이스홀더)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 타입 정의 (`designer/types/`)
|
|
||||||
|
|
||||||
#### `pop-layout.ts`
|
|
||||||
|
|
||||||
**역할**: POP 레이아웃 전체 타입 시스템 정의
|
|
||||||
|
|
||||||
**파일 크기**: 1442줄 (v1~v4 모든 버전 포함)
|
|
||||||
|
|
||||||
상세 내용은 [버전별 레이아웃 시스템](#5-버전별-레이아웃-시스템) 참조.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.5 관리 모듈 (`management/`)
|
|
||||||
|
|
||||||
#### `PopCategoryTree.tsx`
|
|
||||||
|
|
||||||
POP 화면 카테고리 트리 컴포넌트
|
|
||||||
|
|
||||||
#### `PopScreenSettingModal.tsx`
|
|
||||||
|
|
||||||
POP 화면 설정 모달
|
|
||||||
|
|
||||||
#### `PopScreenPreview.tsx`
|
|
||||||
|
|
||||||
POP 화면 미리보기
|
|
||||||
|
|
||||||
#### `PopScreenFlowView.tsx`
|
|
||||||
|
|
||||||
화면 간 플로우 시각화
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.6 대시보드 모듈 (`dashboard/`)
|
|
||||||
|
|
||||||
| 파일 | 역할 |
|
|
||||||
|------|------|
|
|
||||||
| `PopDashboard.tsx` | 대시보드 메인 컴포넌트 |
|
|
||||||
| `DashboardHeader.tsx` | 상단 헤더 (로고, 시간, 사용자) |
|
|
||||||
| `DashboardFooter.tsx` | 하단 푸터 |
|
|
||||||
| `MenuGrid.tsx` | 메뉴 그리드 (앱 아이콘 형태) |
|
|
||||||
| `KpiBar.tsx` | KPI 요약 바 |
|
|
||||||
| `NoticeBanner.tsx` | 공지 배너 |
|
|
||||||
| `NoticeList.tsx` | 공지 목록 |
|
|
||||||
| `ActivityList.tsx` | 최근 활동 목록 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 라이브러리 (lib)
|
|
||||||
|
|
||||||
### `lib/api/popScreenGroup.ts`
|
|
||||||
|
|
||||||
**역할**: POP 화면 그룹 API 클라이언트
|
|
||||||
|
|
||||||
**API 함수**:
|
|
||||||
```typescript
|
|
||||||
// 조회
|
|
||||||
getPopScreenGroups(searchTerm?: string): Promise<PopScreenGroup[]>
|
|
||||||
|
|
||||||
// 생성
|
|
||||||
createPopScreenGroup(data: CreatePopScreenGroupRequest): Promise<...>
|
|
||||||
|
|
||||||
// 수정
|
|
||||||
updatePopScreenGroup(id: number, data: UpdatePopScreenGroupRequest): Promise<...>
|
|
||||||
|
|
||||||
// 삭제
|
|
||||||
deletePopScreenGroup(id: number): Promise<...>
|
|
||||||
|
|
||||||
// 루트 그룹 확보
|
|
||||||
ensurePopRootGroup(): Promise<...>
|
|
||||||
```
|
|
||||||
|
|
||||||
**트리 변환 유틸리티**:
|
|
||||||
```typescript
|
|
||||||
// 플랫 리스트 → 트리 구조
|
|
||||||
buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
|
|
||||||
```
|
|
||||||
|
|
||||||
### `lib/registry/PopComponentRegistry.ts`
|
|
||||||
|
|
||||||
**역할**: POP 컴포넌트 중앙 레지스트리
|
|
||||||
|
|
||||||
**주요 메서드**:
|
|
||||||
```typescript
|
|
||||||
class PopComponentRegistry {
|
|
||||||
static registerComponent(definition: PopComponentDefinition): void
|
|
||||||
static unregisterComponent(id: string): void
|
|
||||||
static getComponent(id: string): PopComponentDefinition | undefined
|
|
||||||
static getComponentByUrl(url: string): PopComponentDefinition | undefined
|
|
||||||
static getAllComponents(): PopComponentDefinition[]
|
|
||||||
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[]
|
|
||||||
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[]
|
|
||||||
static searchComponents(query: string): PopComponentDefinition[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**카테고리**:
|
|
||||||
```typescript
|
|
||||||
type PopComponentCategory =
|
|
||||||
| "display" // 데이터 표시 (카드, 리스트, 배지)
|
|
||||||
| "input" // 입력 (스캐너, 터치 입력)
|
|
||||||
| "action" // 액션 (버튼, 스와이프)
|
|
||||||
| "layout" // 레이아웃 (컨테이너, 그리드)
|
|
||||||
| "feedback"; // 피드백 (토스트, 로딩)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `lib/schemas/popComponentConfig.ts`
|
|
||||||
|
|
||||||
**역할**: POP 컴포넌트 설정 스키마 (Zod 기반)
|
|
||||||
|
|
||||||
**제공 내용**:
|
|
||||||
- 컴포넌트별 기본값 (`popCardListDefaults`, `popTouchButtonDefaults` 등)
|
|
||||||
- 컴포넌트별 Zod 스키마 (`popCardListOverridesSchema` 등)
|
|
||||||
- URL → 기본값/스키마 조회 함수
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 버전별 레이아웃 시스템
|
|
||||||
|
|
||||||
### v1.0 (deprecated)
|
|
||||||
|
|
||||||
- 단일 모드
|
|
||||||
- 섹션 중첩 구조
|
|
||||||
- CSS Grid
|
|
||||||
|
|
||||||
### v2.0 (deprecated)
|
|
||||||
|
|
||||||
- 4개 모드 (태블릿/모바일 x 가로/세로)
|
|
||||||
- 섹션 + 컴포넌트 분리
|
|
||||||
- CSS Grid
|
|
||||||
|
|
||||||
### v3.0 (현재 기본)
|
|
||||||
|
|
||||||
- 4개 모드
|
|
||||||
- **섹션 제거**, 컴포넌트 직접 배치
|
|
||||||
- CSS Grid
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface PopLayoutDataV3 {
|
|
||||||
version: "pop-3.0";
|
|
||||||
layouts: {
|
|
||||||
tablet_landscape: { componentPositions: Record<string, GridPosition> };
|
|
||||||
tablet_portrait: { componentPositions: Record<string, GridPosition> };
|
|
||||||
mobile_landscape: { componentPositions: Record<string, GridPosition> };
|
|
||||||
mobile_portrait: { componentPositions: Record<string, GridPosition> };
|
|
||||||
};
|
};
|
||||||
components: Record<string, PopComponentDefinition>;
|
|
||||||
dataFlow: PopDataFlow;
|
|
||||||
settings: PopGlobalSettings;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### v4.0 (신규, 권장)
|
### 4. ComponentEditorPanel.tsx (속성 패널)
|
||||||
|
|
||||||
- **단일 소스** (1번 설계 → 모든 화면 자동 적응)
|
**역할**: 선택된 컴포넌트 속성 편집
|
||||||
- **제약 기반** (fixed/fill/hug)
|
|
||||||
- **Flexbox** 렌더링
|
|
||||||
- **반응형 규칙** (breakpoint)
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface PopLayoutDataV4 {
|
// 탭 구조
|
||||||
version: "pop-4.0";
|
- grid: col, row, colSpan, rowSpan (기본 모드에서만 편집)
|
||||||
root: PopContainerV4; // 루트 컨테이너 (스택)
|
- settings: label, type 등
|
||||||
components: Record<string, PopComponentDefinitionV4>;
|
- data: 데이터 바인딩 (미구현)
|
||||||
dataFlow: PopDataFlow;
|
- visibility: 모드별 표시/숨김
|
||||||
settings: PopGlobalSettingsV4;
|
```
|
||||||
|
|
||||||
|
### 5. pop-layout.ts (타입 정의)
|
||||||
|
|
||||||
|
**역할**: v5 타입 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 그리드 모드
|
||||||
|
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
||||||
|
|
||||||
|
// 브레이크포인트 설정
|
||||||
|
const GRID_BREAKPOINTS = {
|
||||||
|
mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 },
|
||||||
|
mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 },
|
||||||
|
tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 },
|
||||||
|
tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 데이터
|
||||||
|
interface PopLayoutDataV5 {
|
||||||
|
version: "pop-5.0";
|
||||||
|
metadata: PopLayoutMetadata;
|
||||||
|
gridConfig: PopGridConfig;
|
||||||
|
components: PopComponentDefinitionV5[];
|
||||||
|
globalSettings: PopGlobalSettingsV5;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopContainerV4 {
|
// 컴포넌트 정의
|
||||||
|
interface PopComponentDefinitionV5 {
|
||||||
id: string;
|
id: string;
|
||||||
type: "stack";
|
type: PopComponentType;
|
||||||
direction: "horizontal" | "vertical";
|
label: string;
|
||||||
wrap: boolean;
|
gridPosition: PopGridPosition; // col, row, colSpan, rowSpan
|
||||||
gap: number;
|
config: PopComponentConfig;
|
||||||
alignItems: "start" | "center" | "end" | "stretch";
|
visibility: Record<GridMode, boolean>;
|
||||||
justifyContent: "start" | "center" | "end" | "space-between";
|
modeOverrides?: Record<GridMode, PopModeOverrideV5>;
|
||||||
padding?: number;
|
|
||||||
responsive?: PopResponsiveRuleV4[]; // 반응형 규칙
|
|
||||||
children: (string | PopContainerV4)[]; // 컴포넌트 ID 또는 중첩 컨테이너
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PopSizeConstraintV4 {
|
// 위치
|
||||||
width: "fixed" | "fill" | "hug";
|
interface PopGridPosition {
|
||||||
height: "fixed" | "fill" | "hug";
|
col: number; // 시작 열 (1부터)
|
||||||
fixedWidth?: number;
|
row: number; // 시작 행 (1부터)
|
||||||
fixedHeight?: number;
|
colSpan: number; // 열 크기 (1~12)
|
||||||
minWidth?: number;
|
rowSpan: number; // 행 크기 (1~)
|
||||||
maxWidth?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 버전 비교표
|
### 6. gridUtils.ts (유틸리티)
|
||||||
|
|
||||||
| 항목 | v3 | v4 |
|
**역할**: 그리드 위치 계산
|
||||||
|------|----|----|
|
|
||||||
| 설계 횟수 | 4번 (모드별) | 1번 |
|
|
||||||
| 위치 지정 | col, row, colSpan, rowSpan | 제약 (fill/fixed/hug) |
|
|
||||||
| 렌더링 | CSS Grid | Flexbox |
|
|
||||||
| 반응형 | 수동 (모드 전환) | 자동 (breakpoint 규칙) |
|
|
||||||
| 복잡도 | 높음 | 낮음 |
|
|
||||||
|
|
||||||
---
|
```typescript
|
||||||
|
// 위치 변환
|
||||||
|
convertPositionToMode(pos, targetMode)
|
||||||
|
|
||||||
## 6. 데이터 흐름
|
// 겹침 감지
|
||||||
|
isOverlapping(posA, posB)
|
||||||
|
|
||||||
### 화면 로드 흐름
|
// 빈 위치 찾기
|
||||||
|
findNextEmptyPosition(layout, mode)
|
||||||
|
|
||||||
```
|
// 마우스 → 그리드 좌표
|
||||||
[사용자 접속]
|
mouseToGridPosition(mouseX, mouseY, canvasRect, mode)
|
||||||
↓
|
|
||||||
[/pop/screens/:screenId]
|
|
||||||
↓
|
|
||||||
[screenApi.getLayoutPop(screenId)]
|
|
||||||
↓
|
|
||||||
[레이아웃 버전 감지]
|
|
||||||
├── v4 → PopFlexRenderer
|
|
||||||
├── v3 → PopLayoutRenderer
|
|
||||||
└── v1/v2 → ensureV3Layout() → v3로 변환
|
|
||||||
```
|
|
||||||
|
|
||||||
### 디자이너 저장 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
[사용자 편집]
|
|
||||||
↓
|
|
||||||
[hasChanges = true]
|
|
||||||
↓
|
|
||||||
[저장 버튼 클릭]
|
|
||||||
↓
|
|
||||||
[screenApi.saveLayoutPop(screenId, layoutV3 | layoutV4)]
|
|
||||||
↓
|
|
||||||
[hasChanges = false]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 컴포넌트 드래그 앤 드롭 흐름
|
|
||||||
|
|
||||||
```
|
|
||||||
[PopPanel의 컴포넌트 드래그]
|
|
||||||
↓
|
|
||||||
[DragItemComponent { type: "component", componentType: "pop-button" }]
|
|
||||||
↓
|
|
||||||
[캔버스 Drop 감지]
|
|
||||||
↓
|
|
||||||
[v3: handleDropComponentV3(type, gridPosition)]
|
|
||||||
[v4: handleDropComponentV4(type, containerId)]
|
|
||||||
↓
|
|
||||||
[레이아웃 상태 업데이트]
|
|
||||||
↓
|
|
||||||
[hasChanges = true]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 관련 문서
|
## 데이터 흐름
|
||||||
|
|
||||||
- [PLAN.md](./PLAN.md) - 개발 계획 및 로드맵
|
```
|
||||||
- [components-spec.md](./components-spec.md) - 컴포넌트 상세 스펙
|
[사용자 액션]
|
||||||
- [CHANGELOG.md](./CHANGELOG.md) - 변경 이력
|
↓
|
||||||
|
[PopDesigner] ← 상태 관리 (layout, selectedComponentId, history)
|
||||||
|
↓
|
||||||
|
[PopCanvas] ← DnD, 줌, 모드 전환
|
||||||
|
↓
|
||||||
|
[PopRenderer] ← CSS Grid 렌더링
|
||||||
|
↓
|
||||||
|
[컴포넌트 표시]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 저장 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[저장 버튼]
|
||||||
|
↓
|
||||||
|
PopDesigner.handleSave()
|
||||||
|
↓
|
||||||
|
screenApi.saveLayoutPop(screenId, layout)
|
||||||
|
↓
|
||||||
|
[백엔드] screenManagementService.saveLayoutPop()
|
||||||
|
↓
|
||||||
|
[DB] screen_layouts_pop 테이블
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로드 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[페이지 로드]
|
||||||
|
↓
|
||||||
|
PopDesigner useEffect
|
||||||
|
↓
|
||||||
|
screenApi.getLayoutPop(screenId)
|
||||||
|
↓
|
||||||
|
isV5Layout(data) 체크
|
||||||
|
↓
|
||||||
|
setLayout(data) 또는 createEmptyPopLayoutV5()
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*이 문서는 POP 화면 시스템의 구조를 이해하고 유지보수하기 위한 참조용으로 작성되었습니다.*
|
## API 엔드포인트
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/screen-management/layout-pop/:screenId` | 레이아웃 조회 |
|
||||||
|
| POST | `/api/screen-management/layout-pop/:screenId` | 레이아웃 저장 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 삭제된 레거시 (참고용)
|
||||||
|
|
||||||
|
| 파일 | 버전 | 이유 |
|
||||||
|
|------|------|------|
|
||||||
|
| PopCanvasV4.tsx | v4 | Flexbox 기반, v5로 대체 |
|
||||||
|
| PopFlexRenderer.tsx | v4 | Flexbox 렌더러, v5로 대체 |
|
||||||
|
| PopLayoutRenderer.tsx | v3 | 절대 좌표 기반, v5로 대체 |
|
||||||
|
| ComponentEditorPanelV4.tsx | v4 | v5 전용으로 통합 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*상세 스펙: [SPEC.md](./SPEC.md) | 파일 목록: [FILES.md](./FILES.md)*
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,105 @@
|
||||||
|
|
||||||
## [미출시]
|
## [미출시]
|
||||||
|
|
||||||
- Phase 2: 모드별 오버라이드 기능 (진행 중)
|
- Phase 4: 실제 컴포넌트 구현 (pop-field, pop-button 등)
|
||||||
- Phase 3: 컴포넌트 표시/숨김
|
- 데이터 바인딩 구현
|
||||||
- Phase 4: 순서 오버라이드
|
- 워크플로우 연동
|
||||||
- Tier 2, 3 컴포넌트
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-02-05] v5 그리드 시스템 완전 통합
|
||||||
|
|
||||||
|
### 배경 (왜 v5로 전환했는가)
|
||||||
|
|
||||||
|
**문제 상황**:
|
||||||
|
- v4 Flexbox로 반응형 구현 시도 → 배치가 예측 불가능
|
||||||
|
- 캔버스에 "그리듯이" 배치하면 화면 크기별로 깨짐
|
||||||
|
|
||||||
|
**상급자 피드백**:
|
||||||
|
> "이런 식이면 나중에 문제가 생긴다."
|
||||||
|
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
|
||||||
|
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
|
||||||
|
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
|
||||||
|
|
||||||
|
**연구 내용**:
|
||||||
|
- Softr: 블록 기반, 제약 기반 레이아웃
|
||||||
|
- Ant Design: 24열 그리드, 8px 간격
|
||||||
|
- Material Design: 4/8/12열, 반응형 브레이크포인트
|
||||||
|
|
||||||
|
**결정**: CSS Grid 기반 그리드 시스템 (v5) 채택
|
||||||
|
→ 상세: [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md)
|
||||||
|
|
||||||
|
### popdocs 문서 구조 재정비
|
||||||
|
|
||||||
|
**배경**: 문서가 AI 에이전트 진입점 역할을 못함, 컨텍스트 효율화 필요
|
||||||
|
|
||||||
|
**적용 기법**: Progressive Disclosure (점진적 공개), Token as Currency
|
||||||
|
|
||||||
|
**추가된 파일**:
|
||||||
|
- `SAVE_RULES.md`: AI 저장/조회 규칙, 템플릿
|
||||||
|
- `STATUS.md`: 현재 진행 상태, 중단점
|
||||||
|
- `PROBLEMS.md`: 문제-해결 색인
|
||||||
|
- `INDEX.md`: 기능별 색인
|
||||||
|
- `sessions/`: 날짜별 작업 기록
|
||||||
|
|
||||||
|
**문서 계층**:
|
||||||
|
- Layer 1 (진입점): README, STATUS, SAVE_RULES
|
||||||
|
- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE
|
||||||
|
- Layer 3 (심화): decisions/, sessions/, archive/
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
- **v1, v2, v3, v4 레이아웃 완전 삭제**
|
||||||
|
- 기존 POP 화면 데이터 전체 초기화 필요
|
||||||
|
- 레거시 컴포넌트 및 타입 삭제
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **CSS Grid 기반 그리드 시스템 (v5)**
|
||||||
|
- 4개 모드별 칸 수: 4/6/8/12칸
|
||||||
|
- 명시적 위치 지정 (col, row, colSpan, rowSpan)
|
||||||
|
- 모드별 오버라이드 지원
|
||||||
|
- 자동 위치 변환 (12칸 기준 → 다른 모드)
|
||||||
|
|
||||||
|
- **통합된 파일 구조**
|
||||||
|
- `PopCanvas.tsx`: 그리드 캔버스 (DnD + 줌 + 모드 전환)
|
||||||
|
- `PopRenderer.tsx`: 그리드 렌더링
|
||||||
|
- `ComponentEditorPanel.tsx`: 속성 편집
|
||||||
|
- `pop-layout.ts`: v5 전용 타입 정의
|
||||||
|
- `gridUtils.ts`: 그리드 유틸리티 함수
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `PopCanvasV4.tsx`, `PopCanvas.tsx (v3)`
|
||||||
|
- `PopFlexRenderer.tsx`, `PopLayoutRenderer.tsx`
|
||||||
|
- `ComponentEditorPanelV4.tsx`, `PopPanel.tsx`
|
||||||
|
- v1, v2, v3, v4 타입 정의 및 유틸리티 함수
|
||||||
|
- `test-v4` 테스트 페이지
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `screenManagementService.ts`: v5 전용으로 단순화
|
||||||
|
- `screen_layouts_pop` 테이블: 기존 데이터 삭제, v5 전용
|
||||||
|
- `PopDesigner.tsx`: v5 전용으로 리팩토링
|
||||||
|
- 뷰어 페이지: v5 렌더러 전용
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
```typescript
|
||||||
|
// v5 그리드 모드
|
||||||
|
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
||||||
|
|
||||||
|
// 그리드 설정
|
||||||
|
const GRID_BREAKPOINTS = {
|
||||||
|
mobile_portrait: { columns: 4, rowHeight: 48, gap: 8, padding: 12 },
|
||||||
|
mobile_landscape: { columns: 6, rowHeight: 44, gap: 8, padding: 16 },
|
||||||
|
tablet_portrait: { columns: 8, rowHeight: 52, gap: 12, padding: 20 },
|
||||||
|
tablet_landscape: { columns: 12, rowHeight: 56, gap: 12, padding: 24 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 위치
|
||||||
|
interface PopGridPosition {
|
||||||
|
col: number; // 시작 열 (1부터)
|
||||||
|
row: number; // 시작 행 (1부터)
|
||||||
|
colSpan: number; // 열 크기
|
||||||
|
rowSpan: number; // 행 크기
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
720
popdocs/FILES.md
720
popdocs/FILES.md
|
|
@ -1,6 +1,6 @@
|
||||||
# POP 파일 상세 목록
|
# POP 파일 상세 목록
|
||||||
|
|
||||||
**최종 업데이트: 2026-02-04**
|
**최종 업데이트: 2026-02-05 (v5 그리드 시스템 통합)**
|
||||||
|
|
||||||
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
|
이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다.
|
||||||
|
|
||||||
|
|
@ -13,10 +13,11 @@
|
||||||
3. [Panels 파일](#3-panels-파일)
|
3. [Panels 파일](#3-panels-파일)
|
||||||
4. [Renderers 파일](#4-renderers-파일)
|
4. [Renderers 파일](#4-renderers-파일)
|
||||||
5. [Types 파일](#5-types-파일)
|
5. [Types 파일](#5-types-파일)
|
||||||
6. [Management 파일](#6-management-파일)
|
6. [Utils 파일](#6-utils-파일)
|
||||||
7. [Dashboard 파일](#7-dashboard-파일)
|
7. [Management 파일](#7-management-파일)
|
||||||
8. [Library 파일](#8-library-파일)
|
8. [Dashboard 파일](#8-dashboard-파일)
|
||||||
9. [루트 컴포넌트 파일](#9-루트-컴포넌트-파일)
|
9. [Library 파일](#9-library-파일)
|
||||||
|
10. [루트 컴포넌트 파일](#10-루트-컴포넌트-파일)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -46,34 +47,36 @@
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | 개별 POP 화면 뷰어 |
|
| 역할 | 개별 POP 화면 뷰어 (v5 전용) |
|
||||||
| 경로 | `/pop/screens/:screenId` |
|
| 경로 | `/pop/screens/:screenId` |
|
||||||
| 라인 수 | 468줄 |
|
| 버전 | v5 그리드 시스템 전용 |
|
||||||
|
|
||||||
**핵심 코드 구조**:
|
**핵심 코드 구조**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 상태
|
// v5 레이아웃 상태
|
||||||
const [popLayoutV3, setPopLayoutV3] = useState<PopLayoutDataV3 | null>(null);
|
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||||
const [popLayoutV4, setPopLayoutV4] = useState<PopLayoutDataV4 | null>(null);
|
|
||||||
|
|
||||||
// 레이아웃 로드
|
// 레이아웃 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||||
|
|
||||||
if (isPopLayoutV4(popLayout)) {
|
if (isV5Layout(popLayout)) {
|
||||||
setPopLayoutV4(popLayout);
|
setLayout(popLayout);
|
||||||
} else if (isPopLayout(popLayout)) {
|
} else {
|
||||||
const v3Layout = ensureV3Layout(popLayout);
|
// 레거시 레이아웃은 빈 v5로 처리
|
||||||
setPopLayoutV3(v3Layout);
|
setLayout(createEmptyPopLayoutV5());
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 렌더링 분기
|
// v5 그리드 렌더링
|
||||||
{popLayoutV4 ? (
|
{hasComponents ? (
|
||||||
<PopFlexRenderer layout={popLayoutV4} viewportWidth={...} />
|
<PopRenderer
|
||||||
) : popLayoutV3 ? (
|
layout={layout}
|
||||||
<PopLayoutV3Renderer layout={popLayoutV3} modeKey={currentModeKey} />
|
viewportWidth={viewportWidth}
|
||||||
|
currentMode={currentModeKey}
|
||||||
|
isDesignMode={false}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
// 빈 화면
|
// 빈 화면
|
||||||
)}
|
)}
|
||||||
|
|
@ -83,43 +86,7 @@ useEffect(() => {
|
||||||
- 반응형 모드 감지 (useResponsiveModeWithOverride)
|
- 반응형 모드 감지 (useResponsiveModeWithOverride)
|
||||||
- 프리뷰 모드 (`?preview=true`)
|
- 프리뷰 모드 (`?preview=true`)
|
||||||
- 디바이스/방향 수동 전환 (프리뷰 모드)
|
- 디바이스/방향 수동 전환 (프리뷰 모드)
|
||||||
- v1/v2/v3/v4 레이아웃 자동 감지
|
- 4개 그리드 모드 지원
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/app/(pop)/pop/test-v4/page.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | v4 렌더러 테스트 페이지 |
|
|
||||||
| 경로 | `/pop/test-v4` |
|
|
||||||
| 라인 수 | 150줄 |
|
|
||||||
|
|
||||||
**핵심 코드 구조**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export default function TestV4Page() {
|
|
||||||
const [layout, setLayout] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
|
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
|
||||||
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
|
||||||
const [idCounter, setIdCounter] = useState(1);
|
|
||||||
|
|
||||||
// 컴포넌트 CRUD
|
|
||||||
const handleDropComponent = useCallback(...);
|
|
||||||
const handleDeleteComponent = useCallback(...);
|
|
||||||
const handleUpdateComponent = useCallback(...);
|
|
||||||
const handleUpdateContainer = useCallback(...);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndProvider backend={HTML5Backend}>
|
|
||||||
{/* 3-column 레이아웃 */}
|
|
||||||
<PopPanel />
|
|
||||||
<PopCanvasV4 ... />
|
|
||||||
<ComponentEditorPanelV4 ... />
|
|
||||||
</DndProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -138,8 +105,7 @@ export default function TestV4Page() {
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | POP 화면 디자이너 메인 |
|
| 역할 | POP 화면 디자이너 메인 (v5 전용) |
|
||||||
| 라인 수 | 524줄 |
|
|
||||||
| 의존성 | react-dnd, ResizablePanelGroup |
|
| 의존성 | react-dnd, ResizablePanelGroup |
|
||||||
|
|
||||||
**핵심 Props**:
|
**핵심 Props**:
|
||||||
|
|
@ -155,41 +121,29 @@ interface PopDesignerProps {
|
||||||
**상태 관리**:
|
**상태 관리**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 레이아웃 모드
|
// v5 레이아웃
|
||||||
const [layoutMode, setLayoutMode] = useState<"v3" | "v4">("v3");
|
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||||
|
|
||||||
// v3 레이아웃
|
|
||||||
const [layoutV3, setLayoutV3] = useState<PopLayoutDataV3>(createEmptyPopLayoutV3());
|
|
||||||
|
|
||||||
// v4 레이아웃
|
|
||||||
const [layoutV4, setLayoutV4] = useState<PopLayoutDataV4>(createEmptyPopLayoutV4());
|
|
||||||
|
|
||||||
// 선택 상태
|
// 선택 상태
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
const [selectedContainerId, setSelectedContainerId] = useState<string | null>(null);
|
|
||||||
|
// 그리드 모드 (4개)
|
||||||
|
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||||
|
|
||||||
// UI 상태
|
// UI 상태
|
||||||
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);
|
||||||
|
|
||||||
// v3용 상태
|
|
||||||
const [activeDevice, setActiveDevice] = useState<DeviceType>("tablet");
|
|
||||||
const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_landscape");
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**주요 핸들러**:
|
**주요 핸들러**:
|
||||||
|
|
||||||
| 핸들러 | 역할 |
|
| 핸들러 | 역할 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| `handleDropComponentV3` | v3 컴포넌트 드롭 |
|
| `handleDropComponent` | 컴포넌트 드롭 (그리드 위치 계산) |
|
||||||
| `handleDropComponentV4` | v4 컴포넌트 드롭 |
|
| `handleUpdateComponent` | 컴포넌트 속성 수정 |
|
||||||
| `handleUpdateComponentDefinitionV3` | v3 컴포넌트 정의 수정 |
|
| `handleDeleteComponent` | 컴포넌트 삭제 |
|
||||||
| `handleUpdateComponentV4` | v4 컴포넌트 수정 |
|
| `handleSave` | v5 레이아웃 저장 |
|
||||||
| `handleUpdateContainerV4` | v4 컨테이너 수정 |
|
|
||||||
| `handleDeleteComponentV3` | v3 컴포넌트 삭제 |
|
|
||||||
| `handleDeleteComponentV4` | v4 컴포넌트 삭제 |
|
|
||||||
| `handleSave` | 레이아웃 저장 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -197,32 +151,21 @@ const [activeModeKey, setActiveModeKey] = useState<PopLayoutModeKey>("tablet_lan
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | v3 CSS Grid 기반 캔버스 |
|
| 역할 | v5 CSS Grid 기반 캔버스 |
|
||||||
| 렌더링 | CSS Grid |
|
| 렌더링 | CSS Grid (4/6/8/12칸) |
|
||||||
| 모드 | 4개 (태블릿/모바일 x 가로/세로) |
|
| 모드 | 4개 (태블릿/모바일 x 가로/세로) |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/designer/PopCanvasV4.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | v4 Flexbox 기반 캔버스 |
|
|
||||||
| 라인 수 | 309줄 |
|
|
||||||
| 렌더링 | Flexbox (via PopFlexRenderer) |
|
|
||||||
|
|
||||||
**핵심 Props**:
|
**핵심 Props**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface PopCanvasV4Props {
|
interface PopCanvasProps {
|
||||||
layout: PopLayoutDataV4;
|
layout: PopLayoutDataV5;
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
selectedContainerId: string | null;
|
currentMode: GridMode;
|
||||||
|
onModeChange: (mode: GridMode) => void;
|
||||||
onSelectComponent: (id: string | null) => void;
|
onSelectComponent: (id: string | null) => void;
|
||||||
onSelectContainer: (id: string | null) => void;
|
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
||||||
onDropComponent: (type: PopComponentType, containerId: string) => void;
|
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV4>) => void;
|
|
||||||
onUpdateContainer: (containerId: string, updates: Partial<PopContainerV4>) => void;
|
|
||||||
onDeleteComponent: (componentId: string) => void;
|
onDeleteComponent: (componentId: string) => void;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -231,18 +174,19 @@ interface PopCanvasV4Props {
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const VIEWPORT_PRESETS = [
|
const VIEWPORT_PRESETS = [
|
||||||
{ id: "mobile", label: "모바일", width: 375, height: 667, icon: Smartphone },
|
{ id: "mobile_portrait", label: "모바일 세로", width: 375, height: 667 }, // 4칸
|
||||||
{ id: "tablet", label: "태블릿", width: 768, height: 1024, icon: Tablet },
|
{ id: "mobile_landscape", label: "모바일 가로", width: 667, height: 375 }, // 6칸
|
||||||
{ id: "desktop", label: "데스크톱", width: 1024, height: 768, icon: Monitor },
|
{ id: "tablet_portrait", label: "태블릿 세로", width: 768, height: 1024 }, // 8칸
|
||||||
|
{ id: "tablet_landscape", label: "태블릿 가로", width: 1024, height: 768 }, // 12칸
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
**제공 기능**:
|
**제공 기능**:
|
||||||
- 뷰포트 프리셋 전환
|
- 4개 모드 프리셋 전환
|
||||||
- 너비 슬라이더 (320px ~ 1440px)
|
|
||||||
- 줌 컨트롤 (30% ~ 150%)
|
- 줌 컨트롤 (30% ~ 150%)
|
||||||
- 패닝 (Space + 드래그 또는 휠 클릭)
|
- 패닝 (Space + 드래그)
|
||||||
- 컴포넌트 드롭
|
- 컴포넌트 드래그 앤 드롭
|
||||||
|
- 그리드 좌표 계산
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -250,75 +194,31 @@ const VIEWPORT_PRESETS = [
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export { default as PopDesigner } from "./PopDesigner";
|
export { default as PopDesigner } from "./PopDesigner";
|
||||||
export { PopCanvas } from "./PopCanvas";
|
export { default as PopCanvas } from "./PopCanvas";
|
||||||
export { PopCanvasV4 } from "./PopCanvasV4";
|
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
|
||||||
export * from "./panels";
|
export { default as PopRenderer } from "./renderers/PopRenderer";
|
||||||
export * from "./renderers";
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
export * from "./utils/gridUtils";
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Panels 파일
|
## 3. Panels 파일
|
||||||
|
|
||||||
### `frontend/components/pop/designer/panels/PopPanel.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | 왼쪽 패널 (컴포넌트 팔레트 + 편집) |
|
|
||||||
| 라인 수 | 369줄 |
|
|
||||||
|
|
||||||
**탭 구성**:
|
|
||||||
1. `components` - 컴포넌트 팔레트
|
|
||||||
2. `edit` - 선택된 컴포넌트 편집
|
|
||||||
|
|
||||||
**컴포넌트 팔레트**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const COMPONENT_PALETTE = [
|
|
||||||
{ type: "pop-field", label: "필드", icon: Type, description: "텍스트, 숫자 등 데이터 입력" },
|
|
||||||
{ type: "pop-button", label: "버튼", icon: MousePointer, description: "저장, 삭제 등 액션 실행" },
|
|
||||||
{ type: "pop-list", label: "리스트", icon: List, description: "데이터 목록 (카드 템플릿 지원)" },
|
|
||||||
{ type: "pop-indicator", label: "인디케이터", icon: Activity, description: "KPI, 상태 표시" },
|
|
||||||
{ type: "pop-scanner", label: "스캐너", icon: ScanLine, description: "바코드/QR 스캔" },
|
|
||||||
{ type: "pop-numpad", label: "숫자패드", icon: Calculator, description: "숫자 입력 전용" },
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
**내보내기 (exports)**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const DND_ITEM_TYPES = { COMPONENT: "component" };
|
|
||||||
export interface DragItemComponent { ... }
|
|
||||||
export function PopPanel({ ... }: PopPanelProps) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx`
|
### `frontend/components/pop/designer/panels/ComponentEditorPanel.tsx`
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | v3 컴포넌트 편집 패널 |
|
| 역할 | v5 컴포넌트 편집 패널 |
|
||||||
| 용도 | PopPanel 내부에서 사용 |
|
| 위치 | 오른쪽 사이드바 |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/designer/panels/ComponentEditorPanelV4.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | v4 오른쪽 속성 패널 |
|
|
||||||
| 라인 수 | 609줄 |
|
|
||||||
|
|
||||||
**핵심 Props**:
|
**핵심 Props**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ComponentEditorPanelV4Props {
|
interface ComponentEditorPanelProps {
|
||||||
component: PopComponentDefinitionV4 | null;
|
component: PopComponentDefinitionV5 | null;
|
||||||
container: PopContainerV4 | null;
|
currentMode: GridMode;
|
||||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV4>) => void;
|
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
onUpdateContainer?: (updates: Partial<PopContainerV4>) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -327,115 +227,71 @@ interface ComponentEditorPanelV4Props {
|
||||||
|
|
||||||
| 탭 | 아이콘 | 내용 |
|
| 탭 | 아이콘 | 내용 |
|
||||||
|----|--------|------|
|
|----|--------|------|
|
||||||
| `size` | Maximize2 | 크기 제약 (fixed/fill/hug) |
|
| `grid` | Grid3x3 | 그리드 위치 (col, row, colSpan, rowSpan) |
|
||||||
| `settings` | Settings | 라벨, 타입별 설정 |
|
| `settings` | Settings | 라벨, 타입별 설정 |
|
||||||
| `data` | Database | 데이터 바인딩 (미구현) |
|
| `data` | Database | 데이터 바인딩 (Phase 4) |
|
||||||
|
|
||||||
**내부 컴포넌트**:
|
|
||||||
|
|
||||||
| 컴포넌트 | 역할 |
|
|
||||||
|----------|------|
|
|
||||||
| `SizeConstraintForm` | 너비/높이 제약 편집 |
|
|
||||||
| `SizeButton` | fixed/fill/hug 선택 버튼 |
|
|
||||||
| `ContainerSettingsForm` | 컨테이너 방향/정렬/간격 편집 |
|
|
||||||
| `ComponentSettingsForm` | 라벨 편집 |
|
|
||||||
| `DataBindingPlaceholder` | 데이터 바인딩 플레이스홀더 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `frontend/components/pop/designer/panels/index.ts`
|
### `frontend/components/pop/designer/panels/index.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export { PopPanel, DND_ITEM_TYPES } from "./PopPanel";
|
export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel";
|
||||||
export type { DragItemComponent } from "./PopPanel";
|
|
||||||
export { ComponentEditorPanel } from "./ComponentEditorPanel";
|
|
||||||
export { ComponentEditorPanelV4 } from "./ComponentEditorPanelV4";
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Renderers 파일
|
## 4. Renderers 파일
|
||||||
|
|
||||||
### `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx`
|
### `frontend/components/pop/designer/renderers/PopRenderer.tsx`
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | v3 레이아웃 CSS Grid 렌더러 |
|
| 역할 | v5 레이아웃 CSS Grid 렌더러 |
|
||||||
| 입력 | PopLayoutDataV3, modeKey |
|
| 입력 | PopLayoutDataV5, viewportWidth, currentMode |
|
||||||
|
|
||||||
**내보내기**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function PopLayoutRenderer({ ... }) { ... }
|
|
||||||
export function hasBaseLayout(layout: PopLayoutDataV3): boolean { ... }
|
|
||||||
export function getEffectiveModeLayout(layout: PopLayoutDataV3, modeKey: PopLayoutModeKey) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/designer/renderers/PopFlexRenderer.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | v4 레이아웃 Flexbox 렌더러 |
|
|
||||||
| 라인 수 | 498줄 |
|
|
||||||
| 입력 | PopLayoutDataV4, viewportWidth |
|
|
||||||
|
|
||||||
**핵심 Props**:
|
**핵심 Props**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface PopFlexRendererProps {
|
interface PopRendererProps {
|
||||||
layout: PopLayoutDataV4;
|
layout: PopLayoutDataV5;
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
|
currentMode?: GridMode;
|
||||||
isDesignMode?: boolean;
|
isDesignMode?: boolean;
|
||||||
selectedComponentId?: string | null;
|
selectedComponentId?: string | null;
|
||||||
onComponentClick?: (componentId: string) => void;
|
onComponentClick?: (componentId: string) => void;
|
||||||
onContainerClick?: (containerId: string) => void;
|
|
||||||
onBackgroundClick?: () => void;
|
onBackgroundClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**내부 컴포넌트**:
|
**CSS Grid 스타일 생성**:
|
||||||
|
|
||||||
| 컴포넌트 | 역할 |
|
|
||||||
|----------|------|
|
|
||||||
| `ContainerRenderer` | 컨테이너 재귀 렌더링 |
|
|
||||||
| `ComponentRendererV4` | v4 컴포넌트 렌더링 |
|
|
||||||
|
|
||||||
**핵심 함수**:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 반응형 규칙 적용
|
const gridStyle: React.CSSProperties = {
|
||||||
function applyResponsiveRules(container: PopContainerV4, viewportWidth: number): PopContainerV4
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||||
// 크기 제약 → CSS 스타일
|
gridAutoRows: `${breakpoint.rowHeight}px`,
|
||||||
function calculateSizeStyle(size: PopSizeConstraintV4, settings: PopGlobalSettingsV4): React.CSSProperties
|
gap: `${breakpoint.gap}px`,
|
||||||
|
padding: `${breakpoint.padding}px`,
|
||||||
// 정렬 값 변환
|
};
|
||||||
function mapAlignment(value: string): React.CSSProperties["alignItems"]
|
|
||||||
function mapJustify(value: string): React.CSSProperties["justifyContent"]
|
|
||||||
|
|
||||||
// 컴포넌트 내용 렌더링
|
|
||||||
function renderComponentContent(component: PopComponentDefinitionV4, ...): React.ReactNode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
**컴포넌트 위치 변환**:
|
||||||
|
|
||||||
### `frontend/components/pop/designer/renderers/ComponentRenderer.tsx`
|
```typescript
|
||||||
|
const convertPosition = (position: PopGridPosition): React.CSSProperties => ({
|
||||||
| 항목 | 내용 |
|
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||||
|------|------|
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
| 역할 | 개별 컴포넌트 렌더러 (디자인 모드용) |
|
});
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `frontend/components/pop/designer/renderers/index.ts`
|
### `frontend/components/pop/designer/renderers/index.ts`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export { PopLayoutRenderer, hasBaseLayout, getEffectiveModeLayout } from "./PopLayoutRenderer";
|
export { default as PopRenderer, default } from "./PopRenderer";
|
||||||
export { ComponentRenderer } from "./ComponentRenderer";
|
|
||||||
export { PopFlexRenderer } from "./PopFlexRenderer";
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -446,85 +302,55 @@ export { PopFlexRenderer } from "./PopFlexRenderer";
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | POP 레이아웃 전체 타입 시스템 |
|
| 역할 | POP 레이아웃 v5 타입 시스템 |
|
||||||
| 라인 수 | 1442줄 |
|
| 버전 | v5 전용 (레거시 제거됨) |
|
||||||
|
|
||||||
**주요 타입** (v4):
|
**핵심 타입**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// v4 레이아웃
|
// 그리드 모드
|
||||||
interface PopLayoutDataV4 {
|
type GridMode = "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape";
|
||||||
version: "pop-4.0";
|
|
||||||
root: PopContainerV4;
|
|
||||||
components: Record<string, PopComponentDefinitionV4>;
|
|
||||||
dataFlow: PopDataFlow;
|
|
||||||
settings: PopGlobalSettingsV4;
|
|
||||||
metadata?: PopLayoutMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
// v4 컨테이너
|
// 그리드 브레이크포인트
|
||||||
interface PopContainerV4 {
|
interface GridBreakpoint {
|
||||||
id: string;
|
label: string;
|
||||||
type: "stack";
|
columns: number;
|
||||||
direction: "horizontal" | "vertical";
|
minWidth: number;
|
||||||
wrap: boolean;
|
maxWidth: number;
|
||||||
|
rowHeight: number;
|
||||||
gap: number;
|
gap: number;
|
||||||
alignItems: "start" | "center" | "end" | "stretch";
|
padding: number;
|
||||||
justifyContent: "start" | "center" | "end" | "space-between";
|
|
||||||
padding?: number;
|
|
||||||
responsive?: PopResponsiveRuleV4[];
|
|
||||||
children: (string | PopContainerV4)[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// v4 크기 제약
|
// v5 레이아웃
|
||||||
interface PopSizeConstraintV4 {
|
interface PopLayoutDataV5 {
|
||||||
width: "fixed" | "fill" | "hug";
|
version: "pop-5.0";
|
||||||
height: "fixed" | "fill" | "hug";
|
gridConfig: PopGridConfig;
|
||||||
fixedWidth?: number;
|
components: Record<string, PopComponentDefinitionV5>;
|
||||||
fixedHeight?: number;
|
|
||||||
minWidth?: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
minHeight?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// v4 반응형 규칙
|
|
||||||
interface PopResponsiveRuleV4 {
|
|
||||||
breakpoint: number;
|
|
||||||
direction?: "horizontal" | "vertical";
|
|
||||||
gap?: number;
|
|
||||||
hidden?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**주요 타입** (v3):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// v3 레이아웃
|
|
||||||
interface PopLayoutDataV3 {
|
|
||||||
version: "pop-3.0";
|
|
||||||
layouts: {
|
|
||||||
tablet_landscape: PopModeLayoutV3;
|
|
||||||
tablet_portrait: PopModeLayoutV3;
|
|
||||||
mobile_landscape: PopModeLayoutV3;
|
|
||||||
mobile_portrait: PopModeLayoutV3;
|
|
||||||
};
|
|
||||||
components: Record<string, PopComponentDefinition>;
|
|
||||||
dataFlow: PopDataFlow;
|
dataFlow: PopDataFlow;
|
||||||
settings: PopGlobalSettings;
|
settings: PopGlobalSettingsV5;
|
||||||
metadata?: PopLayoutMetadata;
|
metadata?: PopLayoutMetadata;
|
||||||
}
|
overrides?: Record<GridMode, PopModeOverrideV5>;
|
||||||
|
|
||||||
// v3 모드별 레이아웃
|
|
||||||
interface PopModeLayoutV3 {
|
|
||||||
componentPositions: Record<string, GridPosition>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드 위치
|
// 그리드 위치
|
||||||
interface GridPosition {
|
interface PopGridPosition {
|
||||||
col: number;
|
col: number; // 시작 열 (1부터)
|
||||||
row: number;
|
row: number; // 시작 행 (1부터)
|
||||||
colSpan: number;
|
colSpan: number; // 열 크기
|
||||||
rowSpan: number;
|
rowSpan: number; // 행 크기
|
||||||
|
}
|
||||||
|
|
||||||
|
// v5 컴포넌트 정의
|
||||||
|
interface PopComponentDefinitionV5 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
position: PopGridPosition;
|
||||||
|
visibility?: { modes: GridMode[]; defaultVisible: boolean };
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
style?: PopStylePreset;
|
||||||
|
config?: PopComponentConfig;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -532,21 +358,11 @@ interface GridPosition {
|
||||||
|
|
||||||
| 함수 | 역할 |
|
| 함수 | 역할 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `createEmptyPopLayoutV4()` | 빈 v4 레이아웃 생성 |
|
| `createEmptyPopLayoutV5()` | 빈 v5 레이아웃 생성 |
|
||||||
| `createEmptyPopLayoutV3()` | 빈 v3 레이아웃 생성 |
|
| `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
|
||||||
| `addComponentToV4Layout()` | v4에 컴포넌트 추가 |
|
| `createComponentDefinitionV5()` | v5 컴포넌트 정의 생성 |
|
||||||
| `removeComponentFromV4Layout()` | v4에서 컴포넌트 삭제 |
|
| `isV5Layout()` | v5 타입 가드 |
|
||||||
| `updateComponentInV4Layout()` | v4 컴포넌트 수정 |
|
| `detectGridMode()` | 뷰포트 너비로 모드 감지 |
|
||||||
| `updateContainerV4()` | v4 컨테이너 수정 |
|
|
||||||
| `findContainerV4()` | v4 컨테이너 찾기 |
|
|
||||||
| `addComponentToV3Layout()` | v3에 컴포넌트 추가 |
|
|
||||||
| `removeComponentFromV3Layout()` | v3에서 컴포넌트 삭제 |
|
|
||||||
| `updateComponentPositionInModeV3()` | v3 특정 모드 위치 수정 |
|
|
||||||
| `isV4Layout()` | v4 타입 가드 |
|
|
||||||
| `isV3Layout()` | v3 타입 가드 |
|
|
||||||
| `ensureV3Layout()` | v1/v2/v3 → v3 변환 |
|
|
||||||
| `migrateV2ToV3()` | v2 → v3 마이그레이션 |
|
|
||||||
| `migrateV1ToV3()` | v1 → v3 마이그레이션 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -558,7 +374,32 @@ export * from "./pop-layout";
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Management 파일
|
## 6. Utils 파일
|
||||||
|
|
||||||
|
### `frontend/components/pop/designer/utils/gridUtils.ts`
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 역할 | 그리드 위치 계산 유틸리티 |
|
||||||
|
| 용도 | 좌표 변환, 겹침 감지, 자동 배치 |
|
||||||
|
|
||||||
|
**주요 함수**:
|
||||||
|
|
||||||
|
| 함수 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `convertPositionToMode()` | 12칸 기준 위치를 다른 모드로 변환 |
|
||||||
|
| `isOverlapping()` | 두 위치 겹침 여부 확인 |
|
||||||
|
| `resolveOverlaps()` | 겹침 해결 (아래로 밀기) |
|
||||||
|
| `mouseToGridPosition()` | 마우스 좌표 → 그리드 좌표 |
|
||||||
|
| `gridToPixelPosition()` | 그리드 좌표 → 픽셀 좌표 |
|
||||||
|
| `isValidPosition()` | 위치 유효성 검사 |
|
||||||
|
| `clampPosition()` | 위치 범위 조정 |
|
||||||
|
| `findNextEmptyPosition()` | 다음 빈 위치 찾기 |
|
||||||
|
| `autoLayoutComponents()` | 자동 배치 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Management 파일
|
||||||
|
|
||||||
### `frontend/components/pop/management/PopCategoryTree.tsx`
|
### `frontend/components/pop/management/PopCategoryTree.tsx`
|
||||||
|
|
||||||
|
|
@ -607,7 +448,7 @@ export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Dashboard 파일
|
## 8. Dashboard 파일
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/PopDashboard.tsx`
|
### `frontend/components/pop/dashboard/PopDashboard.tsx`
|
||||||
|
|
||||||
|
|
@ -618,134 +459,27 @@ export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/DashboardHeader.tsx`
|
### 기타 Dashboard 컴포넌트
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 파일 | 역할 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | 상단 헤더 |
|
| `DashboardHeader.tsx` | 상단 헤더 |
|
||||||
| 표시 | 로고, 시간, 사용자 정보 |
|
| `DashboardFooter.tsx` | 하단 푸터 |
|
||||||
|
| `MenuGrid.tsx` | 메뉴 그리드 |
|
||||||
|
| `KpiBar.tsx` | KPI 요약 바 |
|
||||||
|
| `NoticeBanner.tsx` | 공지 배너 |
|
||||||
|
| `NoticeList.tsx` | 공지 목록 |
|
||||||
|
| `ActivityList.tsx` | 최근 활동 목록 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/DashboardFooter.tsx`
|
## 9. Library 파일
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | 하단 푸터 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/MenuGrid.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | 메뉴 그리드 |
|
|
||||||
| 스타일 | 앱 아이콘 형태 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/KpiBar.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | KPI 요약 바 |
|
|
||||||
| 표시 | 핵심 지표 수치 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/NoticeBanner.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | 공지 배너 |
|
|
||||||
| 스타일 | 슬라이드 배너 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/NoticeList.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | 공지 목록 |
|
|
||||||
| 스타일 | 리스트 형태 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/ActivityList.tsx`
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
|
||||||
|------|------|
|
|
||||||
| 역할 | 최근 활동 목록 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/index.ts`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export { PopDashboard } from "./PopDashboard";
|
|
||||||
export { DashboardHeader } from "./DashboardHeader";
|
|
||||||
export { DashboardFooter } from "./DashboardFooter";
|
|
||||||
export { MenuGrid } from "./MenuGrid";
|
|
||||||
export { KpiBar } from "./KpiBar";
|
|
||||||
export { NoticeBanner } from "./NoticeBanner";
|
|
||||||
export { NoticeList } from "./NoticeList";
|
|
||||||
export { ActivityList } from "./ActivityList";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/types.ts`
|
|
||||||
|
|
||||||
대시보드 관련 타입 정의
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/data.ts`
|
|
||||||
|
|
||||||
대시보드 샘플/목업 데이터
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/dashboard/dashboard.css`
|
|
||||||
|
|
||||||
대시보드 전용 스타일
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Library 파일
|
|
||||||
|
|
||||||
### `frontend/lib/api/popScreenGroup.ts`
|
### `frontend/lib/api/popScreenGroup.ts`
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | POP 화면 그룹 API 클라이언트 |
|
| 역할 | POP 화면 그룹 API 클라이언트 |
|
||||||
| 라인 수 | 183줄 |
|
|
||||||
|
|
||||||
**타입**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface PopScreenGroup extends ScreenGroup {
|
|
||||||
children?: PopScreenGroup[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatePopScreenGroupRequest {
|
|
||||||
group_name: string;
|
|
||||||
group_code: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
display_order?: number;
|
|
||||||
parent_group_id?: number | null;
|
|
||||||
target_company_code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdatePopScreenGroupRequest {
|
|
||||||
group_name?: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
display_order?: number;
|
|
||||||
is_active?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**API 함수**:
|
**API 함수**:
|
||||||
|
|
||||||
|
|
@ -757,12 +491,6 @@ async function deletePopScreenGroup(id: number): Promise<...>
|
||||||
async function ensurePopRootGroup(): Promise<...>
|
async function ensurePopRootGroup(): Promise<...>
|
||||||
```
|
```
|
||||||
|
|
||||||
**유틸리티**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `frontend/lib/registry/PopComponentRegistry.ts`
|
### `frontend/lib/registry/PopComponentRegistry.ts`
|
||||||
|
|
@ -770,56 +498,6 @@ function buildPopGroupTree(groups: PopScreenGroup[]): PopScreenGroup[]
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | POP 컴포넌트 중앙 레지스트리 |
|
| 역할 | POP 컴포넌트 중앙 레지스트리 |
|
||||||
| 라인 수 | 268줄 |
|
|
||||||
|
|
||||||
**타입**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface PopComponentDefinition {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
category: PopComponentCategory;
|
|
||||||
icon?: string;
|
|
||||||
component: React.ComponentType<any>;
|
|
||||||
configPanel?: React.ComponentType<any>;
|
|
||||||
defaultProps?: Record<string, any>;
|
|
||||||
touchOptimized?: boolean;
|
|
||||||
minTouchArea?: number;
|
|
||||||
supportedDevices?: ("mobile" | "tablet")[];
|
|
||||||
createdAt?: Date;
|
|
||||||
updatedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PopComponentCategory =
|
|
||||||
| "display"
|
|
||||||
| "input"
|
|
||||||
| "action"
|
|
||||||
| "layout"
|
|
||||||
| "feedback";
|
|
||||||
```
|
|
||||||
|
|
||||||
**메서드**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
class PopComponentRegistry {
|
|
||||||
static registerComponent(definition: PopComponentDefinition): void
|
|
||||||
static unregisterComponent(id: string): void
|
|
||||||
static getComponent(id: string): PopComponentDefinition | undefined
|
|
||||||
static getComponentByUrl(url: string): PopComponentDefinition | undefined
|
|
||||||
static getAllComponents(): PopComponentDefinition[]
|
|
||||||
static getComponentsByCategory(category: PopComponentCategory): PopComponentDefinition[]
|
|
||||||
static getComponentsByDevice(device: "mobile" | "tablet"): PopComponentDefinition[]
|
|
||||||
static searchComponents(query: string): PopComponentDefinition[]
|
|
||||||
static getComponentCount(): number
|
|
||||||
static getStatsByCategory(): Record<PopComponentCategory, number>
|
|
||||||
static addEventListener(callback: (event: PopComponentRegistryEvent) => void): void
|
|
||||||
static removeEventListener(callback: (event: PopComponentRegistryEvent) => void): void
|
|
||||||
static clear(): void
|
|
||||||
static hasComponent(id: string): boolean
|
|
||||||
static debug(): void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -828,39 +506,11 @@ class PopComponentRegistry {
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 역할 | POP 컴포넌트 설정 스키마 |
|
| 역할 | POP 컴포넌트 설정 스키마 |
|
||||||
| 라인 수 | 232줄 |
|
|
||||||
| 검증 | Zod 기반 |
|
| 검증 | Zod 기반 |
|
||||||
|
|
||||||
**기본값**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const popCardListDefaults = { ... }
|
|
||||||
const popTouchButtonDefaults = { ... }
|
|
||||||
const popScannerInputDefaults = { ... }
|
|
||||||
const popStatusBadgeDefaults = { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
**스키마**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const popCardListOverridesSchema = z.object({ ... })
|
|
||||||
const popTouchButtonOverridesSchema = z.object({ ... })
|
|
||||||
const popScannerInputOverridesSchema = z.object({ ... })
|
|
||||||
const popStatusBadgeOverridesSchema = z.object({ ... })
|
|
||||||
```
|
|
||||||
|
|
||||||
**유틸리티**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function getPopComponentUrl(componentType: string): string
|
|
||||||
function getPopComponentDefaults(componentType: string): Record<string, any>
|
|
||||||
function getPopDefaultsByUrl(componentUrl: string): Record<string, any>
|
|
||||||
function parsePopOverridesByUrl(componentUrl: string, overrides: Record<string, any>): Record<string, any>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 루트 컴포넌트 파일
|
## 10. 루트 컴포넌트 파일
|
||||||
|
|
||||||
### `frontend/components/pop/index.ts`
|
### `frontend/components/pop/index.ts`
|
||||||
|
|
||||||
|
|
@ -873,24 +523,6 @@ export * from "./dashboard";
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `frontend/components/pop/types.ts`
|
|
||||||
|
|
||||||
POP 공통 타입 정의
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/data.ts`
|
|
||||||
|
|
||||||
POP 샘플/목업 데이터
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `frontend/components/pop/styles.css`
|
|
||||||
|
|
||||||
POP 전역 스타일
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 기타 루트 레벨 컴포넌트
|
### 기타 루트 레벨 컴포넌트
|
||||||
|
|
||||||
| 파일 | 역할 |
|
| 파일 | 역할 |
|
||||||
|
|
@ -912,14 +544,28 @@ POP 전역 스타일
|
||||||
|
|
||||||
| 폴더 | 파일 수 | 설명 |
|
| 폴더 | 파일 수 | 설명 |
|
||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| `app/(pop)` | 6 | App Router 페이지 |
|
| `app/(pop)` | 4 | App Router 페이지 |
|
||||||
| `components/pop/designer` | 12 | 디자이너 모듈 |
|
| `components/pop/designer` | 9 | 디자이너 모듈 (v5) |
|
||||||
| `components/pop/management` | 5 | 관리 모듈 |
|
| `components/pop/management` | 5 | 관리 모듈 |
|
||||||
| `components/pop/dashboard` | 12 | 대시보드 모듈 |
|
| `components/pop/dashboard` | 12 | 대시보드 모듈 |
|
||||||
| `components/pop` (루트) | 15 | 루트 컴포넌트 |
|
| `components/pop` (루트) | 15 | 루트 컴포넌트 |
|
||||||
| `lib` | 3 | 라이브러리 |
|
| `lib` | 3 | 라이브러리 |
|
||||||
| **총계** | **53** | |
|
| **총계** | **48** | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다.*
|
## 삭제된 파일 (v5 통합으로 제거)
|
||||||
|
|
||||||
|
| 파일 | 이유 |
|
||||||
|
|------|------|
|
||||||
|
| `PopCanvasV4.tsx` | v4 Flexbox 캔버스 |
|
||||||
|
| `PopFlexRenderer.tsx` | v4 Flexbox 렌더러 |
|
||||||
|
| `PopLayoutRenderer.tsx` | v3 CSS Grid 렌더러 |
|
||||||
|
| `ComponentRenderer.tsx` | 레거시 컴포넌트 렌더러 |
|
||||||
|
| `ComponentEditorPanelV4.tsx` | v4 편집 패널 |
|
||||||
|
| `PopPanel.tsx` | 레거시 팔레트 패널 |
|
||||||
|
| `test-v4/page.tsx` | v4 테스트 페이지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서는 POP 화면 시스템의 파일 목록을 관리하기 위한 참조용으로 작성되었습니다. (v5 그리드 시스템 기준)*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,763 @@
|
||||||
|
# POP 그리드 시스템 코딩 계획
|
||||||
|
|
||||||
|
> 작성일: 2026-02-05
|
||||||
|
> 상태: 코딩 준비 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 목록
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 5.1: 타입 정의 ─────────────────────────────
|
||||||
|
[ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등)
|
||||||
|
[ ] 2. 브레이크포인트 상수 정의
|
||||||
|
[ ] 3. v5 생성/변환 함수
|
||||||
|
|
||||||
|
Phase 5.2: 그리드 렌더러 ─────────────────────────
|
||||||
|
[ ] 4. PopGridRenderer.tsx 생성
|
||||||
|
[ ] 5. 위치 변환 로직 (12칸→4칸)
|
||||||
|
|
||||||
|
Phase 5.3: 디자이너 UI ───────────────────────────
|
||||||
|
[ ] 6. PopCanvasV5.tsx 생성
|
||||||
|
[ ] 7. 드래그 스냅 기능
|
||||||
|
[ ] 8. ComponentEditorPanelV5.tsx
|
||||||
|
|
||||||
|
Phase 5.4: 통합 ──────────────────────────────────
|
||||||
|
[ ] 9. 자동 변환 알고리즘
|
||||||
|
[ ] 10. PopDesigner.tsx 통합
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.1: 타입 정의
|
||||||
|
|
||||||
|
### 작업 1: v5 타입 정의
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||||
|
|
||||||
|
**추가할 코드**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ========================================
|
||||||
|
// v5.0 그리드 기반 레이아웃
|
||||||
|
// ========================================
|
||||||
|
// 핵심: CSS Grid로 정확한 위치 지정
|
||||||
|
// - 열/행 좌표로 배치 (col, row)
|
||||||
|
// - 칸 단위 크기 (colSpan, rowSpan)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 레이아웃 (그리드 기반)
|
||||||
|
*/
|
||||||
|
export interface PopLayoutDataV5 {
|
||||||
|
version: "pop-5.0";
|
||||||
|
|
||||||
|
// 그리드 설정
|
||||||
|
gridConfig: PopGridConfig;
|
||||||
|
|
||||||
|
// 컴포넌트 정의 (ID → 정의)
|
||||||
|
components: Record<string, PopComponentDefinitionV5>;
|
||||||
|
|
||||||
|
// 데이터 흐름 (기존과 동일)
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
|
||||||
|
// 전역 설정
|
||||||
|
settings: PopGlobalSettingsV5;
|
||||||
|
|
||||||
|
// 메타데이터
|
||||||
|
metadata?: PopLayoutMetadata;
|
||||||
|
|
||||||
|
// 모드별 오버라이드 (위치 변경용)
|
||||||
|
overrides?: {
|
||||||
|
mobile_portrait?: PopModeOverrideV5;
|
||||||
|
mobile_landscape?: PopModeOverrideV5;
|
||||||
|
tablet_portrait?: PopModeOverrideV5;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 설정
|
||||||
|
*/
|
||||||
|
export interface PopGridConfig {
|
||||||
|
// 행 높이 (px) - 1행의 기본 높이
|
||||||
|
rowHeight: number; // 기본 48px
|
||||||
|
|
||||||
|
// 간격 (px)
|
||||||
|
gap: number; // 기본 8px
|
||||||
|
|
||||||
|
// 패딩 (px)
|
||||||
|
padding: number; // 기본 16px
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 컴포넌트 정의
|
||||||
|
*/
|
||||||
|
export interface PopComponentDefinitionV5 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준
|
||||||
|
position: PopGridPosition;
|
||||||
|
|
||||||
|
// 모드별 표시/숨김
|
||||||
|
visibility?: {
|
||||||
|
tablet_landscape?: boolean;
|
||||||
|
tablet_portrait?: boolean;
|
||||||
|
mobile_landscape?: boolean;
|
||||||
|
mobile_portrait?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기존 속성
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
style?: PopStylePreset;
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 위치
|
||||||
|
*/
|
||||||
|
export interface PopGridPosition {
|
||||||
|
col: number; // 시작 열 (1부터, 최대 12)
|
||||||
|
row: number; // 시작 행 (1부터)
|
||||||
|
colSpan: number; // 차지할 열 수 (1~12)
|
||||||
|
rowSpan: number; // 차지할 행 수 (1~)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 전역 설정
|
||||||
|
*/
|
||||||
|
export interface PopGlobalSettingsV5 {
|
||||||
|
// 터치 최소 크기 (px)
|
||||||
|
touchTargetMin: number; // 기본 48
|
||||||
|
|
||||||
|
// 모드
|
||||||
|
mode: "normal" | "industrial";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 모드별 오버라이드
|
||||||
|
*/
|
||||||
|
export interface PopModeOverrideV5 {
|
||||||
|
// 컴포넌트별 위치 오버라이드
|
||||||
|
positions?: Record<string, Partial<PopGridPosition>>;
|
||||||
|
|
||||||
|
// 컴포넌트별 숨김
|
||||||
|
hidden?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 작업 2: 브레이크포인트 상수
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ========================================
|
||||||
|
// 그리드 브레이크포인트
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export type GridMode =
|
||||||
|
| "mobile_portrait"
|
||||||
|
| "mobile_landscape"
|
||||||
|
| "tablet_portrait"
|
||||||
|
| "tablet_landscape";
|
||||||
|
|
||||||
|
export const GRID_BREAKPOINTS: Record<GridMode, {
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
columns: number;
|
||||||
|
rowHeight: number;
|
||||||
|
gap: number;
|
||||||
|
padding: number;
|
||||||
|
label: string;
|
||||||
|
}> = {
|
||||||
|
// 4~6인치 모바일 세로
|
||||||
|
mobile_portrait: {
|
||||||
|
maxWidth: 599,
|
||||||
|
columns: 4,
|
||||||
|
rowHeight: 40,
|
||||||
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
label: "모바일 세로 (4칸)",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6~8인치 모바일 가로 / 작은 태블릿
|
||||||
|
mobile_landscape: {
|
||||||
|
minWidth: 600,
|
||||||
|
maxWidth: 839,
|
||||||
|
columns: 6,
|
||||||
|
rowHeight: 44,
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
label: "모바일 가로 (6칸)",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 8~10인치 태블릿 세로
|
||||||
|
tablet_portrait: {
|
||||||
|
minWidth: 840,
|
||||||
|
maxWidth: 1023,
|
||||||
|
columns: 8,
|
||||||
|
rowHeight: 48,
|
||||||
|
gap: 12,
|
||||||
|
padding: 16,
|
||||||
|
label: "태블릿 세로 (8칸)",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 10~14인치 태블릿 가로 (기본)
|
||||||
|
tablet_landscape: {
|
||||||
|
minWidth: 1024,
|
||||||
|
columns: 12,
|
||||||
|
rowHeight: 48,
|
||||||
|
gap: 16,
|
||||||
|
padding: 24,
|
||||||
|
label: "태블릿 가로 (12칸)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 모드
|
||||||
|
export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
|
||||||
|
|
||||||
|
// 뷰포트 너비로 모드 감지
|
||||||
|
export function detectGridMode(viewportWidth: number): GridMode {
|
||||||
|
if (viewportWidth < 600) return "mobile_portrait";
|
||||||
|
if (viewportWidth < 840) return "mobile_landscape";
|
||||||
|
if (viewportWidth < 1024) return "tablet_portrait";
|
||||||
|
return "tablet_landscape";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 작업 3: v5 생성/변환 함수
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/types/pop-layout.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ========================================
|
||||||
|
// v5 유틸리티 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 v5 레이아웃 생성
|
||||||
|
*/
|
||||||
|
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
|
||||||
|
version: "pop-5.0",
|
||||||
|
gridConfig: {
|
||||||
|
rowHeight: 48,
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
dataFlow: { connections: [] },
|
||||||
|
settings: {
|
||||||
|
touchTargetMin: 48,
|
||||||
|
mode: "normal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 레이아웃 여부 확인
|
||||||
|
*/
|
||||||
|
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
||||||
|
return layout?.version === "pop-5.0";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v5 컴포넌트 정의 생성
|
||||||
|
*/
|
||||||
|
export const createComponentDefinitionV5 = (
|
||||||
|
id: string,
|
||||||
|
type: PopComponentType,
|
||||||
|
position: PopGridPosition,
|
||||||
|
label?: string
|
||||||
|
): PopComponentDefinitionV5 => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 타입별 기본 크기 (칸 단위)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_COMPONENT_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||||
|
"pop-field": { colSpan: 3, rowSpan: 1 },
|
||||||
|
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||||
|
"pop-list": { colSpan: 12, rowSpan: 4 },
|
||||||
|
"pop-indicator": { colSpan: 3, rowSpan: 2 },
|
||||||
|
"pop-scanner": { colSpan: 6, rowSpan: 2 },
|
||||||
|
"pop-numpad": { colSpan: 4, rowSpan: 5 },
|
||||||
|
"pop-spacer": { colSpan: 1, rowSpan: 1 },
|
||||||
|
"pop-break": { colSpan: 12, rowSpan: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v4 → v5 마이그레이션
|
||||||
|
*/
|
||||||
|
export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => {
|
||||||
|
const componentsV4 = Object.values(layoutV4.components);
|
||||||
|
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
|
||||||
|
|
||||||
|
// Flexbox 순서 → Grid 위치 변환
|
||||||
|
let currentRow = 1;
|
||||||
|
let currentCol = 1;
|
||||||
|
const columns = 12;
|
||||||
|
|
||||||
|
componentsV4.forEach((comp) => {
|
||||||
|
// 픽셀 → 칸 변환 (대략적)
|
||||||
|
const colSpan = comp.size.width === "fill"
|
||||||
|
? columns
|
||||||
|
: Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85)));
|
||||||
|
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
|
||||||
|
|
||||||
|
// 줄바꿈 체크
|
||||||
|
if (currentCol + colSpan - 1 > columns) {
|
||||||
|
currentRow += 1;
|
||||||
|
currentCol = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentsV5[comp.id] = {
|
||||||
|
id: comp.id,
|
||||||
|
type: comp.type,
|
||||||
|
label: comp.label,
|
||||||
|
position: {
|
||||||
|
col: currentCol,
|
||||||
|
row: currentRow,
|
||||||
|
colSpan,
|
||||||
|
rowSpan,
|
||||||
|
},
|
||||||
|
visibility: comp.visibility,
|
||||||
|
dataBinding: comp.dataBinding,
|
||||||
|
config: comp.config,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentCol += colSpan;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "pop-5.0",
|
||||||
|
gridConfig: {
|
||||||
|
rowHeight: 48,
|
||||||
|
gap: layoutV4.settings.defaultGap,
|
||||||
|
padding: layoutV4.settings.defaultPadding,
|
||||||
|
},
|
||||||
|
components: componentsV5,
|
||||||
|
dataFlow: layoutV4.dataFlow,
|
||||||
|
settings: {
|
||||||
|
touchTargetMin: layoutV4.settings.touchTargetMin,
|
||||||
|
mode: layoutV4.settings.mode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.2: 그리드 렌더러
|
||||||
|
|
||||||
|
### 작업 4: PopGridRenderer.tsx
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
PopLayoutDataV5,
|
||||||
|
PopComponentDefinitionV5,
|
||||||
|
PopGridPosition,
|
||||||
|
GridMode,
|
||||||
|
GRID_BREAKPOINTS,
|
||||||
|
detectGridMode,
|
||||||
|
} from "../types/pop-layout";
|
||||||
|
|
||||||
|
interface PopGridRendererProps {
|
||||||
|
layout: PopLayoutDataV5;
|
||||||
|
viewportWidth: number;
|
||||||
|
currentMode?: GridMode;
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
selectedComponentId?: string | null;
|
||||||
|
onComponentClick?: (componentId: string) => void;
|
||||||
|
onBackgroundClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopGridRenderer({
|
||||||
|
layout,
|
||||||
|
viewportWidth,
|
||||||
|
currentMode,
|
||||||
|
isDesignMode = false,
|
||||||
|
selectedComponentId,
|
||||||
|
onComponentClick,
|
||||||
|
onBackgroundClick,
|
||||||
|
className,
|
||||||
|
}: PopGridRendererProps) {
|
||||||
|
const { gridConfig, components, overrides } = layout;
|
||||||
|
|
||||||
|
// 현재 모드 (자동 감지 또는 지정)
|
||||||
|
const mode = currentMode || detectGridMode(viewportWidth);
|
||||||
|
const breakpoint = GRID_BREAKPOINTS[mode];
|
||||||
|
|
||||||
|
// CSS Grid 스타일
|
||||||
|
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||||
|
gridAutoRows: `${breakpoint.rowHeight}px`,
|
||||||
|
gap: `${breakpoint.gap}px`,
|
||||||
|
padding: `${breakpoint.padding}px`,
|
||||||
|
minHeight: "100%",
|
||||||
|
}), [breakpoint]);
|
||||||
|
|
||||||
|
// visibility 체크
|
||||||
|
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
|
||||||
|
if (!comp.visibility) return true;
|
||||||
|
return comp.visibility[mode] !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
|
||||||
|
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
|
||||||
|
const sourceColumns = 12; // 항상 12칸 기준으로 저장
|
||||||
|
const targetColumns = breakpoint.columns;
|
||||||
|
|
||||||
|
if (sourceColumns === targetColumns) {
|
||||||
|
return {
|
||||||
|
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||||
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비율 계산
|
||||||
|
const ratio = targetColumns / sourceColumns;
|
||||||
|
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||||
|
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||||
|
|
||||||
|
// 범위 초과 방지
|
||||||
|
if (newCol + newColSpan - 1 > targetColumns) {
|
||||||
|
newColSpan = targetColumns - newCol + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`,
|
||||||
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 오버라이드 적용
|
||||||
|
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
|
||||||
|
const override = overrides?.[mode]?.positions?.[comp.id];
|
||||||
|
if (override) {
|
||||||
|
return { ...comp.position, ...override };
|
||||||
|
}
|
||||||
|
return comp.position;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative min-h-full w-full bg-white", className)}
|
||||||
|
style={gridStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onBackgroundClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.values(components).map((comp) => {
|
||||||
|
if (!isVisible(comp)) return null;
|
||||||
|
|
||||||
|
const position = getEffectivePosition(comp);
|
||||||
|
const positionStyle = convertPosition(position);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-lg border-2 bg-white transition-all overflow-hidden",
|
||||||
|
selectedComponentId === comp.id
|
||||||
|
? "border-primary ring-2 ring-primary/30 z-10"
|
||||||
|
: "border-gray-200",
|
||||||
|
isDesignMode && "cursor-pointer hover:border-gray-300"
|
||||||
|
)}
|
||||||
|
style={positionStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onComponentClick?.(comp.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 컴포넌트 내용 */}
|
||||||
|
<ComponentContent component={comp} isDesignMode={isDesignMode} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 내용 렌더링
|
||||||
|
function ComponentContent({
|
||||||
|
component,
|
||||||
|
isDesignMode
|
||||||
|
}: {
|
||||||
|
component: PopComponentDefinitionV5;
|
||||||
|
isDesignMode: boolean;
|
||||||
|
}) {
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
"pop-field": "필드",
|
||||||
|
"pop-button": "버튼",
|
||||||
|
"pop-list": "리스트",
|
||||||
|
"pop-indicator": "인디케이터",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
|
"pop-numpad": "숫자패드",
|
||||||
|
"pop-spacer": "스페이서",
|
||||||
|
"pop-break": "줄바꿈",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
<div className="flex h-5 shrink-0 items-center border-b bg-gray-50 px-2">
|
||||||
|
<span className="text-[10px] font-medium text-gray-600">
|
||||||
|
{component.label || typeLabels[component.type] || component.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center p-2">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{typeLabels[component.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 컴포넌트 렌더링 (Phase 4에서 구현)
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center p-2">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{component.label || typeLabels[component.type]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PopGridRenderer;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 작업 5: 위치 변환 유틸리티
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/utils/gridUtils.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 12칸 기준 위치를 다른 모드로 변환
|
||||||
|
*/
|
||||||
|
export function convertPositionToMode(
|
||||||
|
position: PopGridPosition,
|
||||||
|
targetMode: GridMode
|
||||||
|
): PopGridPosition {
|
||||||
|
const sourceColumns = 12;
|
||||||
|
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||||
|
|
||||||
|
if (sourceColumns === targetColumns) {
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = targetColumns / sourceColumns;
|
||||||
|
|
||||||
|
// 열 위치 변환
|
||||||
|
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||||
|
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||||
|
|
||||||
|
// 범위 초과 방지
|
||||||
|
if (newCol > targetColumns) {
|
||||||
|
newCol = 1;
|
||||||
|
}
|
||||||
|
if (newCol + newColSpan - 1 > targetColumns) {
|
||||||
|
newColSpan = targetColumns - newCol + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
col: newCol,
|
||||||
|
row: position.row,
|
||||||
|
colSpan: Math.max(1, newColSpan),
|
||||||
|
rowSpan: position.rowSpan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 위치가 겹치는지 확인
|
||||||
|
*/
|
||||||
|
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
||||||
|
// 열 겹침
|
||||||
|
const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col);
|
||||||
|
// 행 겹침
|
||||||
|
const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < a.row);
|
||||||
|
|
||||||
|
return colOverlap && rowOverlap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 겹침 해결 (아래로 밀기)
|
||||||
|
*/
|
||||||
|
export function resolveOverlaps(
|
||||||
|
positions: Array<{ id: string; position: PopGridPosition }>,
|
||||||
|
columns: number
|
||||||
|
): Array<{ id: string; position: PopGridPosition }> {
|
||||||
|
// row, col 순으로 정렬
|
||||||
|
const sorted = [...positions].sort((a, b) =>
|
||||||
|
a.position.row - b.position.row || a.position.col - b.position.col
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
|
||||||
|
|
||||||
|
sorted.forEach((item) => {
|
||||||
|
let { row, col, colSpan, rowSpan } = item.position;
|
||||||
|
|
||||||
|
// 기존 배치와 겹치면 아래로 이동
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < 100) {
|
||||||
|
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||||
|
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
||||||
|
|
||||||
|
if (!hasOverlap) break;
|
||||||
|
|
||||||
|
row++;
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.push({
|
||||||
|
id: item.id,
|
||||||
|
position: { col, row, colSpan, rowSpan },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마우스 좌표 → 그리드 좌표 변환
|
||||||
|
*/
|
||||||
|
export function mouseToGridPosition(
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
canvasRect: DOMRect,
|
||||||
|
columns: number,
|
||||||
|
rowHeight: number,
|
||||||
|
gap: number,
|
||||||
|
padding: number
|
||||||
|
): { col: number; row: number } {
|
||||||
|
// 캔버스 내 상대 위치
|
||||||
|
const relX = mouseX - canvasRect.left - padding;
|
||||||
|
const relY = mouseY - canvasRect.top - padding;
|
||||||
|
|
||||||
|
// 칸 너비 계산
|
||||||
|
const totalGap = gap * (columns - 1);
|
||||||
|
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
|
||||||
|
|
||||||
|
// 그리드 좌표 계산 (1부터 시작)
|
||||||
|
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
|
||||||
|
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
||||||
|
|
||||||
|
return { col, row };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 좌표 → 픽셀 좌표 변환
|
||||||
|
*/
|
||||||
|
export function gridToPixelPosition(
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
canvasWidth: number,
|
||||||
|
columns: number,
|
||||||
|
rowHeight: number,
|
||||||
|
gap: number,
|
||||||
|
padding: number
|
||||||
|
): { x: number; y: number; width: number; height: number } {
|
||||||
|
const totalGap = gap * (columns - 1);
|
||||||
|
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: padding + (col - 1) * (colWidth + gap),
|
||||||
|
y: padding + (row - 1) * (rowHeight + gap),
|
||||||
|
width: colWidth,
|
||||||
|
height: rowHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.3: 디자이너 UI
|
||||||
|
|
||||||
|
### 작업 6-7: PopCanvasV5.tsx
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/PopCanvasV5.tsx`
|
||||||
|
|
||||||
|
핵심 기능:
|
||||||
|
- 그리드 배경 표시 (바둑판)
|
||||||
|
- 4개 모드 프리셋 버튼
|
||||||
|
- 드래그 앤 드롭 (칸에 스냅)
|
||||||
|
- 컴포넌트 리사이즈 (칸 단위)
|
||||||
|
|
||||||
|
### 작업 8: ComponentEditorPanelV5.tsx
|
||||||
|
|
||||||
|
**파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
|
||||||
|
|
||||||
|
핵심 기능:
|
||||||
|
- 위치 편집 (col, row 입력)
|
||||||
|
- 크기 편집 (colSpan, rowSpan 입력)
|
||||||
|
- visibility 체크박스
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.4: 통합
|
||||||
|
|
||||||
|
### 작업 9: 자동 변환 알고리즘
|
||||||
|
|
||||||
|
이미 `gridUtils.ts`에 포함
|
||||||
|
|
||||||
|
### 작업 10: PopDesigner.tsx 통합
|
||||||
|
|
||||||
|
**수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx`
|
||||||
|
|
||||||
|
변경 사항:
|
||||||
|
- v5 레이아웃 상태 추가
|
||||||
|
- v3/v4/v5 자동 판별
|
||||||
|
- 새 화면 → v5로 시작
|
||||||
|
- v4 → v5 마이그레이션 옵션
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 목록
|
||||||
|
|
||||||
|
| 상태 | 파일 | 작업 |
|
||||||
|
|------|------|------|
|
||||||
|
| 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 |
|
||||||
|
| 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 |
|
||||||
|
| 생성 | `utils/gridUtils.ts` | 유틸리티 함수 |
|
||||||
|
| 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 |
|
||||||
|
| 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 |
|
||||||
|
| 수정 | `PopDesigner.tsx` | v5 통합 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시작 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. pop-layout.ts에 v5 타입 추가 (작업 1-3)
|
||||||
|
↓
|
||||||
|
2. PopGridRenderer.tsx 생성 (작업 4)
|
||||||
|
↓
|
||||||
|
3. gridUtils.ts 생성 (작업 5)
|
||||||
|
↓
|
||||||
|
4. PopCanvasV5.tsx 생성 (작업 6-7)
|
||||||
|
↓
|
||||||
|
5. ComponentEditorPanelV5.tsx 생성 (작업 8)
|
||||||
|
↓
|
||||||
|
6. PopDesigner.tsx 수정 (작업 9-10)
|
||||||
|
↓
|
||||||
|
7. 테스트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)*
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
# POP 화면 그리드 시스템 설계
|
||||||
|
|
||||||
|
> 작성일: 2026-02-05
|
||||||
|
> 상태: 계획 (Plan)
|
||||||
|
> 관련: Softr, Ant Design, Material Design 분석 기반
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목적
|
||||||
|
|
||||||
|
POP 화면의 반응형 레이아웃을 **일관성 있고 예측 가능하게** 만들기 위한 그리드 시스템 설계
|
||||||
|
|
||||||
|
### 현재 문제
|
||||||
|
- 픽셀 단위 자유 배치 → 화면 크기별로 깨짐
|
||||||
|
- 컴포넌트 크기 규칙 없음 → 디자인 불일치
|
||||||
|
- 반응형 규칙 부족 → 모드별 수동 조정 필요
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
- 그리드 기반 배치로 일관성 확보
|
||||||
|
- 크기 프리셋으로 디자인 통일
|
||||||
|
- 자동 반응형으로 작업량 감소
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 대상 디바이스
|
||||||
|
|
||||||
|
### 지원 범위
|
||||||
|
|
||||||
|
| 구분 | 크기 범위 | 기준 해상도 | 비고 |
|
||||||
|
|------|----------|-------------|------|
|
||||||
|
| 모바일 | 4~8인치 | 375x667 (세로) | 산업용 PDA 포함 |
|
||||||
|
| 태블릿 | 8~14인치 | 1024x768 (가로) | 기본 기준 |
|
||||||
|
|
||||||
|
### 참고: 산업용 디바이스 해상도
|
||||||
|
|
||||||
|
| 디바이스 | 화면 크기 | 해상도 |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| Zebra TC57 PDA | 5인치 | 720x1280 |
|
||||||
|
| Honeywell CT47 | 5.5인치 | 2160x1080 |
|
||||||
|
| Honeywell RT10A | 10.1인치 | 1920x1200 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 그리드 시스템 설계
|
||||||
|
|
||||||
|
### 3.1 브레이크포인트 (Breakpoints)
|
||||||
|
|
||||||
|
Material Design 가이드라인 기반으로 4단계 정의:
|
||||||
|
|
||||||
|
| 모드 | 약어 | 너비 범위 | 대표 디바이스 | 그리드 칸 수 |
|
||||||
|
|------|------|----------|---------------|-------------|
|
||||||
|
| 모바일 세로 | `mp` | ~599px | 4~6인치 폰 | **4 columns** |
|
||||||
|
| 모바일 가로 | `ml` | 600~839px | 폰 가로, 7인치 태블릿 | **6 columns** |
|
||||||
|
| 태블릿 세로 | `tp` | 840~1023px | 8~10인치 태블릿 세로 | **8 columns** |
|
||||||
|
| 태블릿 가로 | `tl` | 1024px~ | 10~14인치 태블릿 가로 | **12 columns** |
|
||||||
|
|
||||||
|
### 3.2 기준 해상도
|
||||||
|
|
||||||
|
| 모드 | 기준 너비 | 기준 높이 | 비고 |
|
||||||
|
|------|----------|----------|------|
|
||||||
|
| 모바일 세로 | 375px | 667px | iPhone SE 기준 |
|
||||||
|
| 모바일 가로 | 667px | 375px | - |
|
||||||
|
| 태블릿 세로 | 768px | 1024px | iPad 기준 |
|
||||||
|
| **태블릿 가로** | **1024px** | **768px** | **기본 설계 모드** |
|
||||||
|
|
||||||
|
### 3.3 그리드 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
태블릿 가로 (12 columns)
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ ← 16px →│ Col │ 16px │ Col │ 16px │ ... │ Col │← 16px →│
|
||||||
|
│ margin │ 1 │ gap │ 2 │ gap │ │ 12 │ margin │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
모바일 세로 (4 columns)
|
||||||
|
┌────────────────────────┐
|
||||||
|
│← 16px →│ Col │ 8px │ Col │ 8px │ Col │ 8px │ Col │← 16px →│
|
||||||
|
│ margin │ 1 │ gap │ 2 │ gap │ 3 │ gap │ 4 │ margin │
|
||||||
|
└────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 마진/간격 규칙
|
||||||
|
|
||||||
|
8px 기반 간격 시스템 (Material Design 표준):
|
||||||
|
|
||||||
|
| 속성 | 태블릿 | 모바일 | 용도 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| screenPadding | 24px | 16px | 화면 가장자리 여백 |
|
||||||
|
| gapSm | 8px | 8px | 컴포넌트 사이 최소 간격 |
|
||||||
|
| gapMd | 16px | 12px | 기본 간격 |
|
||||||
|
| gapLg | 24px | 16px | 섹션 간 간격 |
|
||||||
|
| rowGap | 16px | 12px | 줄 사이 간격 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 컴포넌트 크기 시스템
|
||||||
|
|
||||||
|
### 4.1 열 단위 (Span) 크기
|
||||||
|
|
||||||
|
픽셀 대신 **열 단위(span)** 로 크기 지정:
|
||||||
|
|
||||||
|
| 크기 | 태블릿 가로 (12col) | 태블릿 세로 (8col) | 모바일 (4col) |
|
||||||
|
|------|--------------------|--------------------|---------------|
|
||||||
|
| XS | 1 span | 1 span | 1 span |
|
||||||
|
| S | 2 span | 2 span | 2 span |
|
||||||
|
| M | 3 span | 2 span | 2 span |
|
||||||
|
| L | 4 span | 4 span | 4 span (full) |
|
||||||
|
| XL | 6 span | 4 span | 4 span (full) |
|
||||||
|
| Full | 12 span | 8 span | 4 span |
|
||||||
|
|
||||||
|
### 4.2 높이 프리셋
|
||||||
|
|
||||||
|
| 프리셋 | 픽셀값 | 용도 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| `xs` | 32px | 배지, 아이콘 버튼 |
|
||||||
|
| `sm` | 48px | 일반 버튼, 입력 필드 |
|
||||||
|
| `md` | 80px | 카드, 인디케이터 |
|
||||||
|
| `lg` | 120px | 큰 카드, 리스트 아이템 |
|
||||||
|
| `xl` | 200px | 대형 영역 |
|
||||||
|
| `auto` | 내용 기반 | 가변 높이 |
|
||||||
|
|
||||||
|
### 4.3 컴포넌트별 기본값
|
||||||
|
|
||||||
|
| 컴포넌트 | 태블릿 span | 모바일 span | 높이 | 비고 |
|
||||||
|
|----------|------------|-------------|------|------|
|
||||||
|
| pop-field | 3 (M) | 2 (S) | sm | 입력/표시 |
|
||||||
|
| pop-button | 2 (S) | 2 (S) | sm | 액션 버튼 |
|
||||||
|
| pop-list | 12 (Full) | 4 (Full) | auto | 데이터 목록 |
|
||||||
|
| pop-indicator | 3 (M) | 2 (S) | md | KPI 표시 |
|
||||||
|
| pop-scanner | 6 (XL) | 4 (Full) | lg | 스캔 영역 |
|
||||||
|
| pop-numpad | 6 (XL) | 4 (Full) | auto | 숫자 패드 |
|
||||||
|
| pop-spacer | 1 (XS) | 1 (XS) | - | 빈 공간 |
|
||||||
|
| pop-break | Full | Full | 0 | 줄바꿈 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 반응형 규칙
|
||||||
|
|
||||||
|
### 5.1 자동 조정
|
||||||
|
|
||||||
|
설계자가 별도 설정하지 않아도 자동 적용:
|
||||||
|
|
||||||
|
```
|
||||||
|
태블릿 가로 (12col): [A:3] [B:3] [C:3] [D:3] → 한 줄
|
||||||
|
태블릿 세로 (8col): [A:2] [B:2] [C:2] [D:2] → 한 줄
|
||||||
|
모바일 (4col): [A:2] [B:2] → 두 줄
|
||||||
|
[C:2] [D:2]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 수동 오버라이드
|
||||||
|
|
||||||
|
필요시 모드별 설정 가능:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ResponsiveOverride {
|
||||||
|
// 크기 변경
|
||||||
|
span?: number;
|
||||||
|
height?: HeightPreset;
|
||||||
|
|
||||||
|
// 표시/숨김
|
||||||
|
hidden?: boolean;
|
||||||
|
|
||||||
|
// 내부 요소 숨김 (컴포넌트별)
|
||||||
|
hideElements?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 표시/숨김 예시
|
||||||
|
|
||||||
|
```
|
||||||
|
태블릿: [제품명] [수량] [단가] [합계] [비고]
|
||||||
|
모바일: [제품명] [수량] [비고] ← 단가, 합계 숨김
|
||||||
|
```
|
||||||
|
|
||||||
|
설정:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: "unit-price",
|
||||||
|
type: "pop-field",
|
||||||
|
visibility: {
|
||||||
|
mobile_portrait: false,
|
||||||
|
mobile_landscape: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 데이터 구조 (제안)
|
||||||
|
|
||||||
|
### 6.1 레이아웃 데이터 (v5 제안)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopLayoutDataV5 {
|
||||||
|
version: "5.0";
|
||||||
|
|
||||||
|
// 그리드 설정 (전역)
|
||||||
|
gridConfig: {
|
||||||
|
tablet: { columns: 12; gap: 16; padding: 24 };
|
||||||
|
mobile: { columns: 4; gap: 8; padding: 16 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 목록 (순서대로)
|
||||||
|
components: PopComponentV5[];
|
||||||
|
|
||||||
|
// 모드별 오버라이드 (선택)
|
||||||
|
modeOverrides?: {
|
||||||
|
[mode: string]: {
|
||||||
|
gridConfig?: Partial<GridConfig>;
|
||||||
|
componentOverrides?: Record<string, ResponsiveOverride>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 컴포넌트 데이터
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopComponentV5 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
|
||||||
|
// 크기 (span 단위)
|
||||||
|
size: {
|
||||||
|
span: number; // 기본 열 개수 (1~12)
|
||||||
|
height: HeightPreset; // xs, sm, md, lg, xl, auto
|
||||||
|
};
|
||||||
|
|
||||||
|
// 반응형 크기 (선택)
|
||||||
|
responsiveSize?: {
|
||||||
|
mobile?: { span?: number; height?: HeightPreset };
|
||||||
|
tablet_portrait?: { span?: number; height?: HeightPreset };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 표시/숨김
|
||||||
|
visibility?: {
|
||||||
|
[mode: string]: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트별 설정
|
||||||
|
config?: any;
|
||||||
|
|
||||||
|
// 데이터 바인딩
|
||||||
|
dataBinding?: any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 현재 v4와의 관계
|
||||||
|
|
||||||
|
### 7.1 v4 유지 사항
|
||||||
|
- Flexbox 기반 렌더링
|
||||||
|
- 오버라이드 시스템
|
||||||
|
- visibility 속성
|
||||||
|
|
||||||
|
### 7.2 변경 사항
|
||||||
|
|
||||||
|
| v4 | v5 (제안) |
|
||||||
|
|----|-----------|
|
||||||
|
| `fixedWidth: number` | `span: 1~12` |
|
||||||
|
| `minWidth`, `maxWidth` | 그리드 기반 자동 계산 |
|
||||||
|
| 자유 픽셀 | 열 단위 프리셋 |
|
||||||
|
|
||||||
|
### 7.3 마이그레이션 방향
|
||||||
|
|
||||||
|
```
|
||||||
|
v4 fixedWidth: 200px
|
||||||
|
↓
|
||||||
|
v5 span: 3 (태블릿 기준 약 25%)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 프리셋만 적용 (최소 변경)
|
||||||
|
- [ ] 높이 프리셋 드롭다운
|
||||||
|
- [ ] 너비 프리셋 드롭다운 (XS~Full)
|
||||||
|
- [ ] 기존 Flexbox 렌더링 유지
|
||||||
|
|
||||||
|
### Phase 2: 그리드 시스템 도입
|
||||||
|
- [ ] 브레이크포인트 감지
|
||||||
|
- [ ] 그리드 칸 수 자동 변경
|
||||||
|
- [ ] span → 픽셀 자동 계산
|
||||||
|
|
||||||
|
### Phase 3: 반응형 자동화
|
||||||
|
- [ ] 모드별 자동 span 변환
|
||||||
|
- [ ] 줄바꿈 자동 처리
|
||||||
|
- [ ] 오버라이드 최소화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 참고 자료
|
||||||
|
|
||||||
|
### 분석 대상
|
||||||
|
|
||||||
|
| 도구 | 핵심 특징 | 적용 가능 요소 |
|
||||||
|
|------|----------|---------------|
|
||||||
|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
|
||||||
|
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
|
||||||
|
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
|
||||||
|
|
||||||
|
### 핵심 원칙
|
||||||
|
|
||||||
|
1. **Flexbox는 도구**: 그리드 시스템 안에서 사용
|
||||||
|
2. **제약은 자유**: 규칙이 있어야 일관된 디자인 가능
|
||||||
|
3. **최소 설정, 최대 효과**: 기본값이 좋으면 오버라이드 불필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. FAQ
|
||||||
|
|
||||||
|
### Q1: 기존 v4 화면은 어떻게 되나요?
|
||||||
|
A: 하위 호환 유지. v4 화면은 v4로 계속 동작.
|
||||||
|
|
||||||
|
### Q2: 컴포넌트를 그리드 칸 사이에 배치할 수 있나요?
|
||||||
|
A: 아니요. 칸 단위로만 배치. 이게 일관성의 핵심.
|
||||||
|
|
||||||
|
### Q3: 그리드 칸 수를 바꿀 수 있나요?
|
||||||
|
A: 기본값(4/6/8/12) 권장. 필요시 프로젝트 레벨 설정 가능.
|
||||||
|
|
||||||
|
### Q4: Flexbox와 Grid 중 뭘 쓰나요?
|
||||||
|
A: 둘 다. Grid로 칸 나누고, Flexbox로 칸 안에서 정렬.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서는 계획 단계이며, 실제 구현 시 수정될 수 있습니다.*
|
||||||
|
*최종 업데이트: 2026-02-05*
|
||||||
|
|
@ -0,0 +1,480 @@
|
||||||
|
# POP 그리드 시스템 도입 계획
|
||||||
|
|
||||||
|
> 작성일: 2026-02-05
|
||||||
|
> 상태: 계획 승인, 구현 대기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
현재 Flexbox 기반 v4 시스템을 **CSS Grid 기반 v5 시스템**으로 전환하여
|
||||||
|
4~14인치 화면에서 일관된 배치와 예측 가능한 반응형 레이아웃 구현
|
||||||
|
|
||||||
|
### 핵심 변경점
|
||||||
|
|
||||||
|
| 항목 | v4 (현재) | v5 (그리드) |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| 배치 방식 | Flexbox 흐름 | **Grid 위치 지정** |
|
||||||
|
| 크기 단위 | 픽셀 (200px) | **칸 (col, row)** |
|
||||||
|
| 위치 지정 | 순서대로 자동 | **열/행 좌표** |
|
||||||
|
| 줄바꿈 | 자동 (넘치면) | **명시적 (row 지정)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
|
||||||
|
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
|
||||||
|
1주 1주 1~2주 1주
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.1: 그리드 타입 정의
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
v5 레이아웃 데이터 구조 설계
|
||||||
|
|
||||||
|
### 작업 항목
|
||||||
|
|
||||||
|
- [ ] `PopLayoutDataV5` 인터페이스 정의
|
||||||
|
- [ ] `PopGridConfig` 인터페이스 (그리드 설정)
|
||||||
|
- [ ] `PopComponentPositionV5` 인터페이스 (위치: col, row, colSpan, rowSpan)
|
||||||
|
- [ ] `PopSizeConstraintV5` 인터페이스 (칸 기반 크기)
|
||||||
|
- [ ] 브레이크포인트 상수 정의
|
||||||
|
- [ ] `createEmptyPopLayoutV5()` 생성 함수
|
||||||
|
- [ ] `isV5Layout()` 타입 가드
|
||||||
|
|
||||||
|
### 데이터 구조 설계
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// v5 레이아웃
|
||||||
|
interface PopLayoutDataV5 {
|
||||||
|
version: "pop-5.0";
|
||||||
|
|
||||||
|
// 그리드 설정
|
||||||
|
gridConfig: PopGridConfig;
|
||||||
|
|
||||||
|
// 컴포넌트 목록
|
||||||
|
components: Record<string, PopComponentDefinitionV5>;
|
||||||
|
|
||||||
|
// 모드별 오버라이드
|
||||||
|
overrides?: {
|
||||||
|
mobile_portrait?: PopModeOverrideV5;
|
||||||
|
mobile_landscape?: PopModeOverrideV5;
|
||||||
|
tablet_portrait?: PopModeOverrideV5;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기존 호환
|
||||||
|
dataFlow: PopDataFlow;
|
||||||
|
settings: PopGlobalSettingsV5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그리드 설정
|
||||||
|
interface PopGridConfig {
|
||||||
|
// 모드별 칸 수
|
||||||
|
columns: {
|
||||||
|
tablet_landscape: 12; // 기본 (10~14인치)
|
||||||
|
tablet_portrait: 8; // 8~10인치 세로
|
||||||
|
mobile_landscape: 6; // 6~8인치 가로
|
||||||
|
mobile_portrait: 4; // 4~6인치 세로
|
||||||
|
};
|
||||||
|
|
||||||
|
// 행 높이 (px) - 1행의 기본 높이
|
||||||
|
rowHeight: number; // 기본 48px
|
||||||
|
|
||||||
|
// 간격
|
||||||
|
gap: number; // 기본 8px
|
||||||
|
padding: number; // 기본 16px
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 정의
|
||||||
|
interface PopComponentDefinitionV5 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType;
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로) 기준
|
||||||
|
position: {
|
||||||
|
col: number; // 시작 열 (1부터)
|
||||||
|
row: number; // 시작 행 (1부터)
|
||||||
|
colSpan: number; // 차지할 열 수 (1~12)
|
||||||
|
rowSpan: number; // 차지할 행 수 (1~)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모드별 표시/숨김
|
||||||
|
visibility?: {
|
||||||
|
tablet_landscape?: boolean;
|
||||||
|
tablet_portrait?: boolean;
|
||||||
|
mobile_landscape?: boolean;
|
||||||
|
mobile_portrait?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기존 속성
|
||||||
|
dataBinding?: PopDataBinding;
|
||||||
|
config?: PopComponentConfig;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 브레이크포인트 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 브레이크포인트 상수
|
||||||
|
const GRID_BREAKPOINTS = {
|
||||||
|
// 4~6인치 모바일 세로
|
||||||
|
mobile_portrait: {
|
||||||
|
maxWidth: 599,
|
||||||
|
columns: 4,
|
||||||
|
rowHeight: 40,
|
||||||
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6~8인치 모바일 가로 / 작은 태블릿
|
||||||
|
mobile_landscape: {
|
||||||
|
minWidth: 600,
|
||||||
|
maxWidth: 839,
|
||||||
|
columns: 6,
|
||||||
|
rowHeight: 44,
|
||||||
|
gap: 8,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 8~10인치 태블릿 세로
|
||||||
|
tablet_portrait: {
|
||||||
|
minWidth: 840,
|
||||||
|
maxWidth: 1023,
|
||||||
|
columns: 8,
|
||||||
|
rowHeight: 48,
|
||||||
|
gap: 12,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 10~14인치 태블릿 가로 (기본)
|
||||||
|
tablet_landscape: {
|
||||||
|
minWidth: 1024,
|
||||||
|
columns: 12,
|
||||||
|
rowHeight: 48,
|
||||||
|
gap: 16,
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 산출물
|
||||||
|
- `frontend/components/pop/designer/types/pop-layout-v5.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.2: 그리드 렌더러
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
CSS Grid 기반 렌더러 구현
|
||||||
|
|
||||||
|
### 작업 항목
|
||||||
|
|
||||||
|
- [ ] `PopGridRenderer.tsx` 생성
|
||||||
|
- [ ] CSS Grid 스타일 계산 로직
|
||||||
|
- [ ] 브레이크포인트 감지 및 칸 수 자동 변경
|
||||||
|
- [ ] 컴포넌트 위치 렌더링 (grid-column, grid-row)
|
||||||
|
- [ ] 모드별 자동 위치 재계산 (12칸→4칸 변환)
|
||||||
|
- [ ] visibility 처리
|
||||||
|
- [ ] 기존 PopFlexRenderer와 공존
|
||||||
|
|
||||||
|
### 렌더링 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CSS Grid 스타일 생성
|
||||||
|
function calculateGridStyle(config: PopGridConfig, mode: string): React.CSSProperties {
|
||||||
|
const columns = config.columns[mode];
|
||||||
|
const { rowHeight, gap, padding } = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
|
gridAutoRows: `${rowHeight}px`,
|
||||||
|
gap: `${gap}px`,
|
||||||
|
padding: `${padding}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 위치 스타일
|
||||||
|
function calculatePositionStyle(
|
||||||
|
position: PopComponentPositionV5['position'],
|
||||||
|
sourceColumns: number, // 원본 모드 칸 수 (12)
|
||||||
|
targetColumns: number // 현재 모드 칸 수 (4)
|
||||||
|
): React.CSSProperties {
|
||||||
|
// 12칸 → 4칸 변환 예시
|
||||||
|
// col: 7, colSpan: 3 → col: 3, colSpan: 1
|
||||||
|
const ratio = targetColumns / sourceColumns;
|
||||||
|
const newCol = Math.max(1, Math.ceil(position.col * ratio));
|
||||||
|
const newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridColumn: `${newCol} / span ${Math.min(newColSpan, targetColumns - newCol + 1)}`,
|
||||||
|
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 산출물
|
||||||
|
- `frontend/components/pop/designer/renderers/PopGridRenderer.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.3: 디자이너 UI
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
그리드 기반 편집 UI 구현
|
||||||
|
|
||||||
|
### 작업 항목
|
||||||
|
|
||||||
|
- [ ] `PopCanvasV5.tsx` 생성 (그리드 캔버스)
|
||||||
|
- [ ] 그리드 배경 표시 (바둑판 모양)
|
||||||
|
- [ ] 컴포넌트 드래그 배치 (칸에 스냅)
|
||||||
|
- [ ] 컴포넌트 리사이즈 (칸 단위)
|
||||||
|
- [ ] 위치 편집 패널 (col, row, colSpan, rowSpan)
|
||||||
|
- [ ] 모드 전환 시 그리드 칸 수 변경 표시
|
||||||
|
- [ ] v4/v5 자동 판별 및 전환
|
||||||
|
|
||||||
|
### UI 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ← 목록 화면명 *변경됨 [↶][↷] 그리드 레이아웃 (v5) [저장] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 미리보기: [모바일↕ 4칸] [모바일↔ 6칸] [태블릿↕ 8칸] [태블릿↔ 12칸] │
|
||||||
|
├────────────┬────────────────────────────────────┬───────────────┤
|
||||||
|
│ │ 1 2 3 4 5 6 ... 12 │ │
|
||||||
|
│ 컴포넌트 │ ┌───────────┬───────────┐ │ 위치 │
|
||||||
|
│ │1│ A │ B │ │ 열: [1-12] │
|
||||||
|
│ 필드 │ ├───────────┴───────────┤ │ 행: [1-99] │
|
||||||
|
│ 버튼 │2│ C │ │ 너비: [1-12]│
|
||||||
|
│ 리스트 │ ├───────────┬───────────┤ │ 높이: [1-10]│
|
||||||
|
│ 인디케이터 │3│ D │ E │ │ │
|
||||||
|
│ ... │ └───────────┴───────────┘ │ 표시 설정 │
|
||||||
|
│ │ │ [x] 태블릿↔ │
|
||||||
|
│ │ (그리드 배경 표시) │ [x] 모바일↕ │
|
||||||
|
└────────────┴────────────────────────────────────┴───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 드래그 앤 드롭 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 마우스 위치 → 그리드 좌표 변환
|
||||||
|
function mouseToGridPosition(
|
||||||
|
mouseX: number,
|
||||||
|
mouseY: number,
|
||||||
|
gridConfig: PopGridConfig,
|
||||||
|
canvasRect: DOMRect
|
||||||
|
): { col: number; row: number } {
|
||||||
|
const { columns, rowHeight, gap, padding } = gridConfig;
|
||||||
|
|
||||||
|
// 캔버스 내 상대 위치
|
||||||
|
const relX = mouseX - canvasRect.left - padding;
|
||||||
|
const relY = mouseY - canvasRect.top - padding;
|
||||||
|
|
||||||
|
// 칸 너비 계산
|
||||||
|
const totalGap = gap * (columns - 1);
|
||||||
|
const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns;
|
||||||
|
|
||||||
|
// 그리드 좌표 계산
|
||||||
|
const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1));
|
||||||
|
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
||||||
|
|
||||||
|
return { col, row };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 산출물
|
||||||
|
- `frontend/components/pop/designer/PopCanvasV5.tsx`
|
||||||
|
- `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5.4: 반응형 자동화
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
모드 전환 시 자동 레이아웃 조정
|
||||||
|
|
||||||
|
### 작업 항목
|
||||||
|
|
||||||
|
- [ ] 12칸 → 4칸 자동 변환 알고리즘
|
||||||
|
- [ ] 겹침 감지 및 자동 재배치
|
||||||
|
- [ ] 모드별 오버라이드 저장
|
||||||
|
- [ ] "자동 배치" vs "수동 고정" 선택
|
||||||
|
- [ ] 변환 미리보기
|
||||||
|
|
||||||
|
### 자동 변환 알고리즘
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 12칸 → 4칸 변환 전략
|
||||||
|
function convertLayoutToMode(
|
||||||
|
components: PopComponentDefinitionV5[],
|
||||||
|
sourceMode: 'tablet_landscape', // 12칸
|
||||||
|
targetMode: 'mobile_portrait' // 4칸
|
||||||
|
): PopComponentDefinitionV5[] {
|
||||||
|
const sourceColumns = 12;
|
||||||
|
const targetColumns = 4;
|
||||||
|
const ratio = targetColumns / sourceColumns; // 0.333
|
||||||
|
|
||||||
|
// 1. 각 컴포넌트 위치 변환
|
||||||
|
const converted = components.map(comp => {
|
||||||
|
const newCol = Math.max(1, Math.ceil(comp.position.col * ratio));
|
||||||
|
const newColSpan = Math.max(1, Math.round(comp.position.colSpan * ratio));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comp,
|
||||||
|
position: {
|
||||||
|
...comp.position,
|
||||||
|
col: newCol,
|
||||||
|
colSpan: Math.min(newColSpan, targetColumns),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 겹침 감지 및 해결
|
||||||
|
return resolveOverlaps(converted, targetColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 겹침 해결 (아래로 밀기)
|
||||||
|
function resolveOverlaps(
|
||||||
|
components: PopComponentDefinitionV5[],
|
||||||
|
columns: number
|
||||||
|
): PopComponentDefinitionV5[] {
|
||||||
|
// 행 단위로 그리드 점유 상태 추적
|
||||||
|
const grid: boolean[][] = [];
|
||||||
|
|
||||||
|
// row 순서대로 처리
|
||||||
|
const sorted = [...components].sort((a, b) =>
|
||||||
|
a.position.row - b.position.row || a.position.col - b.position.col
|
||||||
|
);
|
||||||
|
|
||||||
|
return sorted.map(comp => {
|
||||||
|
let { row, col, colSpan, rowSpan } = comp.position;
|
||||||
|
|
||||||
|
// 배치 가능한 위치 찾기
|
||||||
|
while (isOccupied(grid, row, col, colSpan, rowSpan, columns)) {
|
||||||
|
row++; // 아래로 이동
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그리드에 표시
|
||||||
|
markOccupied(grid, row, col, colSpan, rowSpan);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...comp,
|
||||||
|
position: { row, col, colSpan, rowSpan },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 산출물
|
||||||
|
- `frontend/components/pop/designer/utils/gridLayoutUtils.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 마이그레이션 전략
|
||||||
|
|
||||||
|
### v4 → v5 변환
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function migrateV4ToV5(layoutV4: PopLayoutDataV4): PopLayoutDataV5 {
|
||||||
|
const componentsList = Object.values(layoutV4.components);
|
||||||
|
|
||||||
|
// Flexbox 순서 → Grid 위치 변환
|
||||||
|
let currentRow = 1;
|
||||||
|
let currentCol = 1;
|
||||||
|
const columns = 12;
|
||||||
|
|
||||||
|
const componentsV5: Record<string, PopComponentDefinitionV5> = {};
|
||||||
|
|
||||||
|
componentsList.forEach((comp, index) => {
|
||||||
|
// 기본 크기 추정 (픽셀 → 칸)
|
||||||
|
const colSpan = Math.max(1, Math.round((comp.size.fixedWidth || 100) / 85));
|
||||||
|
const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48));
|
||||||
|
|
||||||
|
// 줄바꿈 체크
|
||||||
|
if (currentCol + colSpan - 1 > columns) {
|
||||||
|
currentRow++;
|
||||||
|
currentCol = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentsV5[comp.id] = {
|
||||||
|
...comp,
|
||||||
|
position: {
|
||||||
|
col: currentCol,
|
||||||
|
row: currentRow,
|
||||||
|
colSpan,
|
||||||
|
rowSpan,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
currentCol += colSpan;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "pop-5.0",
|
||||||
|
gridConfig: { /* 기본값 */ },
|
||||||
|
components: componentsV5,
|
||||||
|
dataFlow: layoutV4.dataFlow,
|
||||||
|
settings: { /* 변환 */ },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 하위 호환
|
||||||
|
|
||||||
|
| 버전 | 처리 방식 |
|
||||||
|
|------|----------|
|
||||||
|
| v1~v2 | v3로 변환 후 v5로 |
|
||||||
|
| v3 | v5로 직접 변환 |
|
||||||
|
| v4 | v5로 직접 변환 |
|
||||||
|
| v5 | 그대로 사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 일정 (예상)
|
||||||
|
|
||||||
|
| Phase | 작업 | 예상 기간 |
|
||||||
|
|-------|------|----------|
|
||||||
|
| 5.1 | 타입 정의 | 2~3일 |
|
||||||
|
| 5.2 | 그리드 렌더러 | 3~5일 |
|
||||||
|
| 5.3 | 디자이너 UI | 5~7일 |
|
||||||
|
| 5.4 | 반응형 자동화 | 3~5일 |
|
||||||
|
| - | 테스트 및 버그 수정 | 2~3일 |
|
||||||
|
| **총** | | **약 2~3주** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리스크 및 대응
|
||||||
|
|
||||||
|
| 리스크 | 영향 | 대응 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 기존 v4 화면 깨짐 | 높음 | 하위 호환 유지, v4 렌더러 보존 |
|
||||||
|
| 자동 변환 품질 | 중간 | 수동 오버라이드로 보완 |
|
||||||
|
| 드래그 UX 복잡 | 중간 | 스냅 기능으로 편의성 확보 |
|
||||||
|
| 성능 저하 | 낮음 | CSS Grid는 네이티브 성능 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 성공 기준
|
||||||
|
|
||||||
|
1. **배치 예측 가능**: "2열 3행"이라고 하면 정확히 그 위치에 표시
|
||||||
|
2. **일관된 디자인**: 12칸 → 4칸 전환 시 비율 유지
|
||||||
|
3. **쉬운 편집**: 드래그로 칸에 스냅되어 배치
|
||||||
|
4. **하위 호환**: 기존 v4 화면이 정상 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- [GRID_SYSTEM_DESIGN.md](./GRID_SYSTEM_DESIGN.md) - 그리드 시스템 설계 상세
|
||||||
|
- [PLAN.md](./PLAN.md) - 전체 POP 개발 계획
|
||||||
|
- [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) - 현재 v4 스펙
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*최종 업데이트: 2026-02-05*
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 기능별 색인
|
||||||
|
|
||||||
|
> **용도**: "이 기능 어디있어?", "비슷한 기능 찾아줘"
|
||||||
|
> **검색 팁**: Ctrl+F로 기능명, 키워드 검색
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 렌더링
|
||||||
|
|
||||||
|
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 |
|
||||||
|
| 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 |
|
||||||
|
| 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 |
|
||||||
|
| 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS |
|
||||||
|
|
||||||
|
## 드래그 앤 드롭
|
||||||
|
|
||||||
|
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| 드롭 영역 | PopCanvas.tsx | `useDrop` | 캔버스에 컴포넌트 드롭 |
|
||||||
|
| 좌표 계산 | gridUtils.ts | `mouseToGridPosition()` | 마우스 → 그리드 좌표 |
|
||||||
|
| 빈 위치 찾기 | gridUtils.ts | `findNextEmptyPosition()` | 자동 배치 |
|
||||||
|
| DnD 타입 정의 | PopCanvas.tsx | `DND_ITEM_TYPES` | 인라인 정의 |
|
||||||
|
|
||||||
|
## 편집
|
||||||
|
|
||||||
|
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| 위치 편집 | ComponentEditorPanel.tsx | position 탭 | col, row 수정 |
|
||||||
|
| 크기 편집 | ComponentEditorPanel.tsx | position 탭 | colSpan, rowSpan 수정 |
|
||||||
|
| 라벨 편집 | ComponentEditorPanel.tsx | settings 탭 | 컴포넌트 라벨 |
|
||||||
|
| 표시/숨김 | ComponentEditorPanel.tsx | visibility 탭 | 모드별 표시 |
|
||||||
|
|
||||||
|
## 레이아웃 관리
|
||||||
|
|
||||||
|
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| 컴포넌트 추가 | pop-layout.ts | `addComponentToV5Layout()` | v5에 컴포넌트 추가 |
|
||||||
|
| 빈 레이아웃 | pop-layout.ts | `createEmptyPopLayoutV5()` | 초기 레이아웃 생성 |
|
||||||
|
| 타입 가드 | pop-layout.ts | `isV5Layout()` | v5 여부 확인 |
|
||||||
|
|
||||||
|
## 상태 관리
|
||||||
|
|
||||||
|
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| 레이아웃 상태 | PopDesigner.tsx | `useState<PopLayoutDataV5>` | 메인 레이아웃 |
|
||||||
|
| 히스토리 | PopDesigner.tsx | `history[]`, `historyIndex` | Undo/Redo |
|
||||||
|
| 선택 상태 | PopDesigner.tsx | `selectedComponentId` | 현재 선택 |
|
||||||
|
| 모드 상태 | PopDesigner.tsx | `currentMode` | 그리드 모드 |
|
||||||
|
|
||||||
|
## 저장/로드
|
||||||
|
|
||||||
|
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| 레이아웃 로드 | PopDesigner.tsx | `useEffect` | 화면 로드 시 |
|
||||||
|
| 레이아웃 저장 | PopDesigner.tsx | `handleSave()` | 저장 버튼 |
|
||||||
|
| API 호출 | screen.ts (lib/api) | `screenApi.saveLayoutPop()` | 백엔드 통신 |
|
||||||
|
|
||||||
|
## 뷰포트/줌
|
||||||
|
|
||||||
|
| 기능 | 파일 | 함수/컴포넌트 | 설명 |
|
||||||
|
|------|------|--------------|------|
|
||||||
|
| 프리셋 전환 | PopCanvas.tsx | `VIEWPORT_PRESETS` | 4개 모드 |
|
||||||
|
| 줌 컨트롤 | PopCanvas.tsx | `canvasScale` | 30%~150% |
|
||||||
|
| 패닝 | PopCanvas.tsx | Space + 드래그 | 캔버스 이동 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일별 주요 기능
|
||||||
|
|
||||||
|
| 파일 | 핵심 기능 |
|
||||||
|
|------|----------|
|
||||||
|
| PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 |
|
||||||
|
| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 그리드 표시 |
|
||||||
|
| PopRenderer.tsx | CSS Grid 렌더링, 위치 변환, 컴포넌트 표시 |
|
||||||
|
| ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) |
|
||||||
|
| pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 |
|
||||||
|
| gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*새 기능 추가 시 해당 카테고리 테이블에 행 추가*
|
||||||
|
|
@ -48,11 +48,20 @@
|
||||||
## 작업 순서
|
## 작업 순서
|
||||||
|
|
||||||
```
|
```
|
||||||
[Phase 1] [Phase 2] [Phase 3] [Phase 4]
|
[Phase 1~3] [Phase 4] [Phase 5]
|
||||||
v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 → 순서 오버라이드
|
v4 Flexbox → 실제 컴포넌트 → 그리드 시스템 (v5)
|
||||||
완료 다음 계획 계획
|
완료 다음 계획 승인
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Phase 5: 그리드 시스템 (v5) - 신규 계획
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 5.1] [Phase 5.2] [Phase 5.3] [Phase 5.4]
|
||||||
|
그리드 타입 정의 → 그리드 렌더러 → 디자이너 UI → 반응형 자동화
|
||||||
|
```
|
||||||
|
|
||||||
|
**상세 계획**: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1: 기본 구조 (완료)
|
## Phase 1: 기본 구조 (완료)
|
||||||
|
|
@ -127,6 +136,58 @@ v4 기본 구조 → 오버라이드 기능 → 컴포넌트 숨김 →
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 5: 그리드 시스템 (v5) - 계획 승인
|
||||||
|
|
||||||
|
> 상세 계획: [GRID_SYSTEM_PLAN.md](./GRID_SYSTEM_PLAN.md)
|
||||||
|
|
||||||
|
### 개요
|
||||||
|
|
||||||
|
Flexbox 흐름 기반 → **CSS Grid 위치 지정** 방식으로 전환
|
||||||
|
|
||||||
|
| 항목 | v4 (현재) | v5 (그리드) |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| 배치 | Flexbox 흐름 | Grid 좌표 (열/행) |
|
||||||
|
| 크기 | 픽셀 (200px) | 칸 (colSpan, rowSpan) |
|
||||||
|
| 줄바꿈 | 자동 | 명시적 |
|
||||||
|
|
||||||
|
### Phase 5.1: 그리드 타입 정의
|
||||||
|
|
||||||
|
- [ ] `PopLayoutDataV5` 인터페이스
|
||||||
|
- [ ] `PopGridConfig` (칸 수, 행 높이, 간격)
|
||||||
|
- [ ] `PopComponentPositionV5` (col, row, colSpan, rowSpan)
|
||||||
|
- [ ] 브레이크포인트 상수 (4칸/6칸/8칸/12칸)
|
||||||
|
|
||||||
|
### Phase 5.2: 그리드 렌더러
|
||||||
|
|
||||||
|
- [ ] `PopGridRenderer.tsx` 생성
|
||||||
|
- [ ] CSS Grid 스타일 계산
|
||||||
|
- [ ] 브레이크포인트 감지 및 칸 수 변경
|
||||||
|
- [ ] 위치 변환 (12칸 → 4칸)
|
||||||
|
|
||||||
|
### Phase 5.3: 디자이너 UI
|
||||||
|
|
||||||
|
- [ ] `PopCanvasV5.tsx` (그리드 캔버스)
|
||||||
|
- [ ] 바둑판 배경 표시
|
||||||
|
- [ ] 드래그 스냅 (칸에 맞춤)
|
||||||
|
- [ ] 위치 편집 패널
|
||||||
|
|
||||||
|
### Phase 5.4: 반응형 자동화
|
||||||
|
|
||||||
|
- [ ] 자동 변환 알고리즘 (12칸 → 4칸)
|
||||||
|
- [ ] 겹침 감지 및 재배치
|
||||||
|
- [ ] 모드별 오버라이드
|
||||||
|
|
||||||
|
### 브레이크포인트
|
||||||
|
|
||||||
|
| 모드 | 화면 범위 | 그리드 칸 수 |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| 모바일 세로 | ~599px (4~6인치) | 4칸 |
|
||||||
|
| 모바일 가로 | 600~839px (6~8인치) | 6칸 |
|
||||||
|
| 태블릿 세로 | 840~1023px (8~10인치) | 8칸 |
|
||||||
|
| 태블릿 가로 | 1024px~ (10~14인치) | 12칸 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 완료된 기능 목록
|
## 완료된 기능 목록
|
||||||
|
|
||||||
### v4 타입 정의
|
### v4 타입 정의
|
||||||
|
|
@ -241,4 +302,4 @@ scaledSize = originalSize * scale
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*최종 업데이트: 2026-02-04*
|
*최종 업데이트: 2026-02-05 (Phase 5 그리드 시스템 계획 추가)*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# 문제-해결 색인
|
||||||
|
|
||||||
|
> **용도**: "이전에 비슷한 문제 어떻게 해결했어?"
|
||||||
|
> **검색 팁**: Ctrl+F로 키워드 검색 (에러 메시지, 컴포넌트명 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 렌더링 관련
|
||||||
|
|
||||||
|
| 문제 | 해결 | 날짜 | 키워드 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| rowSpan이 적용 안됨 | gridTemplateRows를 `1fr`로 변경 | 2026-02-02 | grid, rowSpan, CSS |
|
||||||
|
| 컴포넌트 크기 스케일 안됨 | viewportWidth 기반 scale 계산 추가 | 2026-02-04 | scale, viewport, 반응형 |
|
||||||
|
|
||||||
|
## DnD (드래그앤드롭) 관련
|
||||||
|
|
||||||
|
| 문제 | 해결 | 날짜 | 키워드 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 |
|
||||||
|
| DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd |
|
||||||
|
|
||||||
|
## 타입 관련
|
||||||
|
|
||||||
|
| 문제 | 해결 | 날짜 | 키워드 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 인터페이스 이름 불일치 | V5 접미사 제거, 통일 | 2026-02-05 | 타입, interface, Props |
|
||||||
|
| v3/v4 타입 혼재 | v5 전용으로 통합, 레거시 삭제 | 2026-02-05 | 버전, 타입, 마이그레이션 |
|
||||||
|
|
||||||
|
## 레이아웃 관련
|
||||||
|
|
||||||
|
| 문제 | 해결 | 날짜 | 키워드 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| Flexbox 배치 예측 불가 | CSS Grid로 전환 (v5) | 2026-02-05 | Flexbox, Grid, 반응형 |
|
||||||
|
| 4모드 각각 배치 힘듦 | 제약조건 기반 시스템 (v4) | 2026-02-03 | 모드, 반응형, 제약조건 |
|
||||||
|
| 4모드 자동 전환 안됨 | useResponsiveMode 훅 추가 | 2026-02-01 | 모드, 훅, 반응형 |
|
||||||
|
|
||||||
|
## 저장/로드 관련
|
||||||
|
|
||||||
|
| 문제 | 해결 | 날짜 | 키워드 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 레이아웃 버전 충돌 | isV5Layout 타입 가드로 분기 | 2026-02-05 | 버전, 로드, 타입가드 |
|
||||||
|
| 빈 레이아웃 판별 실패 | components 존재 여부로 판별 | 2026-02-04 | 빈 레이아웃, 로드 |
|
||||||
|
|
||||||
|
## UI/UX 관련
|
||||||
|
|
||||||
|
| 문제 | 해결 | 날짜 | 키워드 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| root 레이아웃 오염 | tempLayout 도입 (임시 상태 분리) | 2026-02-04 | tempLayout, 상태, 오염 |
|
||||||
|
| 속성 패널 다른 모드 수정 | isDefaultMode 체크로 비활성화 | 2026-02-04 | 속성패널, 모드, 비활성화 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 해결 안 된 문제 (진행 중)
|
||||||
|
|
||||||
|
| 문제 | 상태 | 관련 파일 |
|
||||||
|
|------|------|----------|
|
||||||
|
| PopCanvas 타입 오류 | 미해결 | PopCanvas.tsx:76 |
|
||||||
|
| 팔레트 UI 없음 | 미해결 | PopDesigner.tsx |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*새 문제-해결 추가 시 해당 카테고리 테이블에 행 추가*
|
||||||
|
|
@ -1,12 +1,74 @@
|
||||||
# POP 화면 시스템
|
# POP 화면 시스템
|
||||||
|
|
||||||
> Point of Production - 현장 작업자용 모바일/태블릿 화면
|
> **AI 에이전트 시작점**: 이 파일 → STATUS.md 순서로 읽으세요.
|
||||||
|
> 저장 요청 시: [SAVE_RULES.md](./SAVE_RULES.md) 참조
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Reference
|
## 현재 상태
|
||||||
|
|
||||||
### 주요 경로
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 버전 | **v5** (CSS Grid 기반) |
|
||||||
|
| 상태 | **기본 기능 완료** |
|
||||||
|
| 다음 | 실제 테스트, Phase 4 (실제 컴포넌트 구현) |
|
||||||
|
|
||||||
|
**마지막 업데이트**: 2026-02-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 마지막 대화 요약
|
||||||
|
|
||||||
|
> (B)(C)(D) 모두 완료. 팔레트 UI 추가, 타입 오류 수정, 문서 v5 기준 통일.
|
||||||
|
> 다음: 실제 테스트 후 Phase 4 (실제 컴포넌트 렌더링, 데이터 바인딩) 진행.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 빠른 경로
|
||||||
|
|
||||||
|
| 알고 싶은 것 | 문서 |
|
||||||
|
|--------------|------|
|
||||||
|
| 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) |
|
||||||
|
| 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) |
|
||||||
|
| 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) |
|
||||||
|
| 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) |
|
||||||
|
| 코드 어디 있어? | [FILES.md](./FILES.md) |
|
||||||
|
| 기능별 색인 | [INDEX.md](./INDEX.md) |
|
||||||
|
| 변경 히스토리 | [CHANGELOG.md](./CHANGELOG.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 | 경로 |
|
||||||
|
|------|------|------|
|
||||||
|
| 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
|
||||||
|
| 캔버스 | 그리드 캔버스 + DnD | `frontend/components/pop/designer/PopCanvas.tsx` |
|
||||||
|
| 렌더러 | CSS Grid 렌더링 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` |
|
||||||
|
| 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문서 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[Layer 1: 먼저 읽기]
|
||||||
|
README.md (지금 여기) → STATUS.md
|
||||||
|
|
||||||
|
[Layer 2: 필요시 읽기]
|
||||||
|
CHANGELOG, PROBLEMS, INDEX, FILES, ARCHITECTURE, SPEC, PLAN
|
||||||
|
|
||||||
|
[Layer 3: 심화]
|
||||||
|
decisions/, sessions/, archive/
|
||||||
|
```
|
||||||
|
|
||||||
|
**컨텍스트 효율화**: 모든 문서를 읽지 마세요. 필요한 것만 단계적으로.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POP이란?
|
||||||
|
|
||||||
|
**Point of Production** - 현장 작업자용 모바일/태블릿 화면 시스템
|
||||||
|
|
||||||
| 용도 | 경로 |
|
| 용도 | 경로 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|
@ -14,103 +76,19 @@
|
||||||
| 관리 | `/admin/screenMng/popScreenMngList` |
|
| 관리 | `/admin/screenMng/popScreenMngList` |
|
||||||
| API | `/api/screen-management/layout-pop/:screenId` |
|
| API | `/api/screen-management/layout-pop/:screenId` |
|
||||||
|
|
||||||
### 핵심 파일
|
---
|
||||||
|
|
||||||
| 작업 | 파일 |
|
## v5 그리드 시스템 (현재)
|
||||||
|------|------|
|
|
||||||
| 타입 | `frontend/components/pop/designer/types/pop-layout.ts` |
|
|
||||||
| 렌더러 | `frontend/components/pop/designer/renderers/` |
|
|
||||||
| 디자이너 | `frontend/components/pop/designer/PopDesigner.tsx` |
|
|
||||||
|
|
||||||
### 현재 상태
|
| 모드 | 화면 너비 | 칸 수 |
|
||||||
|
|------|----------|-------|
|
||||||
|
| mobile_portrait | ~599px | 4칸 |
|
||||||
|
| mobile_landscape | 600~839px | 6칸 |
|
||||||
|
| tablet_portrait | 840~1023px | 8칸 |
|
||||||
|
| tablet_landscape | 1024px~ | 12칸 |
|
||||||
|
|
||||||
- **버전**: v4.0 (제약조건 기반) ✅
|
**핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan)
|
||||||
- **Phase**: Phase 3 완료 (visibility + 줄바꿈)
|
|
||||||
- **다음**: Phase 4 (실제 컴포넌트 구현)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 문서 구조
|
*상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)*
|
||||||
|
|
||||||
| 파일 | 용도 |
|
|
||||||
|------|------|
|
|
||||||
| [SPEC.md](./SPEC.md) | 기술 스펙 |
|
|
||||||
| [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md) | v4 통합 설계 모드 |
|
|
||||||
| [PLAN.md](./PLAN.md) | 계획/로드맵 |
|
|
||||||
| [CHANGELOG.md](./CHANGELOG.md) | 변경 이력 |
|
|
||||||
| [decisions/](./decisions/) | 중요 결정 기록 (ADR) |
|
|
||||||
| [components-spec.md](./components-spec.md) | 컴포넌트 상세 |
|
|
||||||
| [archive/](./archive/) | 이전 문서 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 저장/조회 시스템
|
|
||||||
|
|
||||||
### 역할 분담
|
|
||||||
|
|
||||||
| 저장소 | 역할 | 특징 |
|
|
||||||
|--------|------|------|
|
|
||||||
| **rangraph** | AI 장기 기억 | 시맨틱 검색, 요약 저장 |
|
|
||||||
| **popdocs** | 상세 기록 | 파일 기반, 히스토리 |
|
|
||||||
|
|
||||||
### 저장 요청
|
|
||||||
|
|
||||||
| 요청 예시 | AI 행동 |
|
|
||||||
|----------|--------|
|
|
||||||
| "@CHANGELOG.md 오늘 작업 정리해줘" | 파일 형식 맞춰 추가 + rangraph 요약 |
|
|
||||||
| "이 결정 저장해줘" | rangraph save_decision + decisions/ ADR |
|
|
||||||
| "해결됐어" | rangraph save_lesson + CHANGELOG Fixed |
|
|
||||||
| "작업 완료" | rangraph workflow_submit + CHANGELOG Added |
|
|
||||||
|
|
||||||
### 조회 요청
|
|
||||||
|
|
||||||
| 요청 예시 | AI 행동 |
|
|
||||||
|----------|--------|
|
|
||||||
| "어제 뭐했지?" | rangraph 검색 |
|
|
||||||
| "지금 뭐하는 중이었지?" | rangraph workflow_status |
|
|
||||||
| "이 버그 전에도 있었어?" | rangraph search_memory |
|
|
||||||
| "v4 관련 작업들" | rangraph search_memory "v4" |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v4 핵심 (요약)
|
|
||||||
|
|
||||||
```
|
|
||||||
v3: 4개 모드 각각 위치 설정 → 4배 작업량
|
|
||||||
v4: 3가지 규칙만 설정 → 자동 적응
|
|
||||||
|
|
||||||
핵심 규칙:
|
|
||||||
1. 크기: fixed(고정) / fill(채움) / hug(맞춤)
|
|
||||||
2. 배치: direction, wrap, gap
|
|
||||||
3. 반응형: breakpoint별 변경
|
|
||||||
|
|
||||||
Phase 3 추가:
|
|
||||||
4. visibility: 모드별 표시/숨김
|
|
||||||
5. pop-break: 강제 줄바꿈
|
|
||||||
```
|
|
||||||
|
|
||||||
상세: [V4_UNIFIED_DESIGN_SPEC.md](./V4_UNIFIED_DESIGN_SPEC.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 완료 사항 (2026-02-04) ✅
|
|
||||||
|
|
||||||
### 새 기능
|
|
||||||
- **visibility 속성**: 모드별 컴포넌트 표시/숨김 제어
|
|
||||||
- **pop-break 컴포넌트**: Flexbox 강제 줄바꿈 (`flex-basis: 100%`)
|
|
||||||
- **컴포넌트 오버라이드 병합**: 모드별 설정 변경 (리스트 컬럼 수 등)
|
|
||||||
- **오버라이드 정리 로직**: 컴포넌트 삭제 시 모든 오버라이드 자동 정리
|
|
||||||
|
|
||||||
### 사용 예시
|
|
||||||
```
|
|
||||||
태블릿: [A] [B] [C] [D] (한 줄)
|
|
||||||
모바일: [A] [B] (두 줄, 줄바꿈 적용)
|
|
||||||
[C] [D]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 참고
|
|
||||||
- [decisions/002-phase3-visibility-break.md](./decisions/002-phase3-visibility-break.md) - 상세 설계
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*최종 업데이트: 2026-02-04 (Phase 3 완료)*
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
# popdocs 사용 규칙
|
||||||
|
|
||||||
|
> **AI 에이전트 필독**: 이 문서는 popdocs 폴더 사용법입니다.
|
||||||
|
> 사용자가 "@popdocs"와 함께 요청하면 이 규칙을 참조하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요청 유형 인식
|
||||||
|
|
||||||
|
### 키워드로 요청 유형 판별
|
||||||
|
|
||||||
|
| 유형 | 키워드 예시 | 행동 |
|
||||||
|
|------|------------|------|
|
||||||
|
| **저장** | 저장해줘, 기록해줘, 정리해줘, 추가해줘 | → 저장 규칙 따르기 |
|
||||||
|
| **조회** | 찾아줘, 검색해줘, 뭐 있어?, 어디있어? | → 조회 규칙 따르기 |
|
||||||
|
| **분석** | 분석해줘, 비교해줘, 어떻게 달라? | → 분석 규칙 따르기 |
|
||||||
|
| **수정** | 수정해줘, 업데이트해줘, 고쳐줘 | → 수정 규칙 따르기 |
|
||||||
|
| **요약** | 요약해줘, 정리해서 보여줘, 보고서 | → 요약 규칙 따르기 |
|
||||||
|
| **작업시작** | 시작하자, 이어서 하자, 뭐 해야 해? | → 작업 시작 규칙 |
|
||||||
|
|
||||||
|
### 요청 유형별 행동
|
||||||
|
|
||||||
|
```
|
||||||
|
[저장 요청]
|
||||||
|
"@popdocs 오늘 작업 저장해줘"
|
||||||
|
→ SAVE_RULES.md 저장 섹션 → 적절한 파일에 저장 → 동기화
|
||||||
|
|
||||||
|
[조회 요청]
|
||||||
|
"@popdocs 이전에 DnD 문제 어떻게 해결했어?"
|
||||||
|
→ PROBLEMS.md 검색 → 관련 내용만 반환
|
||||||
|
|
||||||
|
[분석 요청]
|
||||||
|
"@popdocs v4랑 v5 뭐가 달라?"
|
||||||
|
→ decisions/ 또는 CHANGELOG 검색 → 비교표 생성
|
||||||
|
|
||||||
|
[수정 요청]
|
||||||
|
"@popdocs STATUS 업데이트해줘"
|
||||||
|
→ STATUS.md 수정 → README.md 동기화
|
||||||
|
|
||||||
|
[요약 요청]
|
||||||
|
"@popdocs 이번 주 작업 요약해줘"
|
||||||
|
→ sessions/ 해당 기간 검색 → 요약 생성
|
||||||
|
|
||||||
|
[작업 시작]
|
||||||
|
"@popdocs 오늘 작업 시작하자"
|
||||||
|
→ README → STATUS → 중단점 확인 → 작업 시작
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컨텍스트 효율화 원칙
|
||||||
|
|
||||||
|
### Progressive Disclosure (점진적 공개)
|
||||||
|
|
||||||
|
**핵심**: 모든 문서를 한 번에 읽지 마세요. 필요한 것만 단계적으로.
|
||||||
|
|
||||||
|
```
|
||||||
|
Layer 1 (진입점) → README.md, STATUS.md (먼저 읽기, ~100줄)
|
||||||
|
Layer 2 (상세) → 필요한 문서만 선택적으로
|
||||||
|
Layer 3 (심화) → 코드 파일, archive/ (필요시만)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token as Currency (토큰은 자원)
|
||||||
|
|
||||||
|
| 원칙 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| **관련성 > 최신성** | 모든 히스토리 대신 관련 있는 것만 |
|
||||||
|
| **요약 > 전문** | 긴 내용 대신 요약 먼저 확인 |
|
||||||
|
| **링크 > 복사** | 내용 복사 대신 파일 경로 참조 |
|
||||||
|
| **테이블 > 산문** | 긴 설명 대신 표로 압축 |
|
||||||
|
| **검색 > 전체읽기** | Ctrl+F 키워드 검색 활용 |
|
||||||
|
|
||||||
|
### Context Bloat 방지
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ 잘못된 방법:
|
||||||
|
"모든 문서를 읽고 파악한 후 작업하겠습니다"
|
||||||
|
→ 1,300줄 이상 낭비
|
||||||
|
|
||||||
|
✅ 올바른 방법:
|
||||||
|
"README → STATUS → 필요한 섹션만"
|
||||||
|
→ 평균 50~100줄로 작업 가능
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문서 구조 (3계층)
|
||||||
|
|
||||||
|
```
|
||||||
|
popdocs/
|
||||||
|
│
|
||||||
|
├── [Layer 1: 진입점] ─────────────────────────
|
||||||
|
│ ├── README.md ← 시작점 (현재 상태 요약)
|
||||||
|
│ ├── STATUS.md ← 진행 상태, 다음 작업
|
||||||
|
│ └── SAVE_RULES.md ← 사용 규칙 (지금 읽는 문서)
|
||||||
|
│
|
||||||
|
├── [Layer 2: 상세 문서] ─────────────────────────
|
||||||
|
│ ├── CHANGELOG.md ← 변경 이력 (날짜별)
|
||||||
|
│ ├── PROBLEMS.md ← 문제-해결 색인
|
||||||
|
│ ├── INDEX.md ← 기능별 색인
|
||||||
|
│ ├── ARCHITECTURE.md ← 코드 구조
|
||||||
|
│ ├── FILES.md ← 파일 목록
|
||||||
|
│ ├── SPEC.md ← 기술 스펙
|
||||||
|
│ └── PLAN.md ← 계획
|
||||||
|
│
|
||||||
|
├── [Layer 3: 심화/기록] ─────────────────────────
|
||||||
|
│ ├── decisions/ ← ADR (결정 기록)
|
||||||
|
│ ├── sessions/ ← 날짜별 작업 기록
|
||||||
|
│ └── archive/ ← 보관 (레거시)
|
||||||
|
│
|
||||||
|
└── [외부 참조] ─────────────────────────
|
||||||
|
└── 실제 코드 → frontend/components/pop/designer/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 조회 규칙 (읽기)
|
||||||
|
|
||||||
|
### 작업 시작 시
|
||||||
|
|
||||||
|
```
|
||||||
|
1. README.md 읽기 (~60줄)
|
||||||
|
└→ 현재 상태, 다음 작업 확인
|
||||||
|
|
||||||
|
2. STATUS.md 읽기 (~40줄)
|
||||||
|
└→ 상세 진행 상황, 중단점 확인
|
||||||
|
|
||||||
|
3. 필요한 문서만 선택적으로
|
||||||
|
```
|
||||||
|
|
||||||
|
### 요청별 조회 경로
|
||||||
|
|
||||||
|
| 사용자 요청 | 조회 경로 |
|
||||||
|
|-------------|----------|
|
||||||
|
| "지금 뭐 해야 해?" | README → STATUS |
|
||||||
|
| "어제 뭐 했어?" | sessions/어제날짜.md |
|
||||||
|
| "이전에 비슷한 문제?" | PROBLEMS.md (키워드 검색) |
|
||||||
|
| "이 기능 어디있어?" | INDEX.md 또는 FILES.md |
|
||||||
|
| "왜 이렇게 결정했어?" | decisions/ |
|
||||||
|
| "전체 히스토리" | CHANGELOG.md (기간 한정) |
|
||||||
|
| "코드 구조 알려줘" | ARCHITECTURE.md |
|
||||||
|
| "v4랑 v5 뭐가 달라?" | decisions/003 또는 CHANGELOG |
|
||||||
|
|
||||||
|
### 효율적 검색
|
||||||
|
|
||||||
|
```
|
||||||
|
# 전체 파일 읽지 말고 키워드 검색
|
||||||
|
PROBLEMS.md에서 "DnD" 검색 → 관련 행만
|
||||||
|
CHANGELOG.md에서 "2026-02-05" 검색 → 해당 날짜만
|
||||||
|
FILES.md에서 "렌더러" 검색 → 관련 파일만
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 저장 규칙 (쓰기)
|
||||||
|
|
||||||
|
### 저장 유형별 위치
|
||||||
|
|
||||||
|
| 요청 패턴 | 저장 위치 | 형식 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| "오늘 작업 저장/정리해줘" | sessions/YYYY-MM-DD.md | 세션 템플릿 |
|
||||||
|
| "이 결정 기록해줘" | decisions/NNN-제목.md | ADR 템플릿 |
|
||||||
|
| "이 문제 해결 기록해줘" | PROBLEMS.md | 행 추가 |
|
||||||
|
| "작업 내용 추가해줘" | CHANGELOG.md | 섹션 추가 |
|
||||||
|
| "현재 상태 업데이트" | STATUS.md | 상태 수정 |
|
||||||
|
| "기능 색인 추가해줘" | INDEX.md | 행 추가 |
|
||||||
|
|
||||||
|
### 저장 후 필수 동기화
|
||||||
|
|
||||||
|
```
|
||||||
|
저장 완료 후 항상:
|
||||||
|
1. STATUS.md 업데이트 (진행 상태, 다음 작업)
|
||||||
|
2. README.md "마지막 대화 요약" 업데이트 (1-2줄)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 분석/비교 규칙
|
||||||
|
|
||||||
|
### 비교 요청 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs v4랑 v5 뭐가 달라?"
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
1. decisions/003-v5-grid-system.md 확인 (있으면)
|
||||||
|
2. 없으면 CHANGELOG에서 관련 날짜 검색
|
||||||
|
3. 비교표 형식으로 응답
|
||||||
|
|
||||||
|
응답 형식:
|
||||||
|
| 항목 | v4 | v5 |
|
||||||
|
|------|----|----|
|
||||||
|
| 배치 | Flexbox | CSS Grid |
|
||||||
|
| ... | ... | ... |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 분석 요청 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 이번 달 작업 분석해줘"
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
1. sessions/ 폴더에서 해당 기간 파일 목록
|
||||||
|
2. 각 파일의 "요약" 섹션만 추출
|
||||||
|
3. 종합 분석 제공
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 규칙
|
||||||
|
|
||||||
|
### 문서 수정 요청 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs STATUS 업데이트해줘"
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
1. STATUS.md 읽기
|
||||||
|
2. 변경 내용 적용
|
||||||
|
3. README.md 동기화 (마지막 대화 요약)
|
||||||
|
4. 변경 내용 사용자에게 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### 여러 문서 수정 시
|
||||||
|
|
||||||
|
```
|
||||||
|
수정 순서:
|
||||||
|
상세 문서 먼저 → STATUS.md → README.md
|
||||||
|
(역방향: 진입점이 항상 최신 상태 유지)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약/보고서 규칙
|
||||||
|
|
||||||
|
### 요약 요청 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 이번 주 요약해줘"
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
1. sessions/ 해당 기간 파일 확인
|
||||||
|
2. 각 파일의 "요약" + "완료" 섹션 추출
|
||||||
|
3. 압축된 형식으로 응답
|
||||||
|
|
||||||
|
응답 형식:
|
||||||
|
## 이번 주 요약 (02-01 ~ 02-05)
|
||||||
|
| 날짜 | 주요 작업 |
|
||||||
|
|------|----------|
|
||||||
|
| 02-05 | v5 통합 완료 |
|
||||||
|
| 02-04 | ... |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 보고서 생성 요청 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 진행 보고서 만들어줘"
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
1. STATUS.md (현재 상태)
|
||||||
|
2. CHANGELOG.md (최근 작업)
|
||||||
|
3. sessions/ (상세 기록)
|
||||||
|
4. 보고서 형식으로 조합
|
||||||
|
|
||||||
|
→ 새 파일 생성하지 말고 응답으로 제공
|
||||||
|
→ 필요시 사용자가 저장 요청
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 예외 처리
|
||||||
|
|
||||||
|
### 분류 불가 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 이거 저장해줘" (유형 불명확)
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
→ 사용자에게 질문: "어떤 유형으로 저장할까요?"
|
||||||
|
- 오늘 작업 기록 (sessions/)
|
||||||
|
- 문제-해결 기록 (PROBLEMS.md)
|
||||||
|
- 결정 사항 (decisions/)
|
||||||
|
- 변경 이력 (CHANGELOG.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 복합 요청 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 문제 해결했고 결정도 내렸어. 저장해줘"
|
||||||
|
|
||||||
|
AI 행동 (순서):
|
||||||
|
1. PROBLEMS.md에 문제-해결 추가
|
||||||
|
2. decisions/에 ADR 생성 (필요시)
|
||||||
|
3. CHANGELOG.md에 섹션 추가
|
||||||
|
4. STATUS.md 업데이트
|
||||||
|
5. README.md 동기화
|
||||||
|
```
|
||||||
|
|
||||||
|
### 전체 조회 요청 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 전체 히스토리 보여줘"
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
→ "기간을 지정해주시면 더 정확히 찾아드릴게요."
|
||||||
|
- 예: "최근 1주일", "2월 작업", "v5 관련"
|
||||||
|
|
||||||
|
→ 기간 없이 강행 시: CHANGELOG.md 최근 5개 항목만
|
||||||
|
```
|
||||||
|
|
||||||
|
### 파일 없음 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 어제 작업 보여줘" (sessions/어제.md 없음)
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
→ "어제 작업 기록이 없습니다. CHANGELOG.md에서 찾아볼까요?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 키워드 검색 실패 시
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs DnD 문제 찾아줘" (PROBLEMS.md에 없음)
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
→ "PROBLEMS.md에서 못 찾았습니다. 다른 곳도 검색할까요?"
|
||||||
|
- CHANGELOG.md
|
||||||
|
- INDEX.md
|
||||||
|
- sessions/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 동기화 규칙
|
||||||
|
|
||||||
|
### 항상 동기화해야 하는 쌍
|
||||||
|
|
||||||
|
| 변경 문서 | 동기화 대상 |
|
||||||
|
|----------|-----------|
|
||||||
|
| sessions/ 생성 | STATUS.md (최근 세션) |
|
||||||
|
| PROBLEMS.md 추가 | - |
|
||||||
|
| decisions/ 생성 | STATUS.md (관련 결정), CHANGELOG.md |
|
||||||
|
| CHANGELOG.md 추가 | STATUS.md (진행 상태) |
|
||||||
|
| STATUS.md 수정 | README.md (마지막 요약) |
|
||||||
|
|
||||||
|
### 불일치 발견 시
|
||||||
|
|
||||||
|
```
|
||||||
|
README.md와 STATUS.md 내용이 다르면:
|
||||||
|
→ STATUS.md를 정본(正本)으로
|
||||||
|
→ README.md를 STATUS.md 기준으로 업데이트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 정리 규칙
|
||||||
|
|
||||||
|
### 주기적 정리 (수동 요청 시)
|
||||||
|
|
||||||
|
| 대상 | 조건 | 조치 |
|
||||||
|
|------|------|------|
|
||||||
|
| sessions/ | 30일 이상 | archive/sessions/로 이동 |
|
||||||
|
| PROBLEMS.md | 100행 초과 | 카테고리별 분리 검토 |
|
||||||
|
| CHANGELOG.md | 연도 변경 | 이전 연도 archive/로 |
|
||||||
|
|
||||||
|
### 정리 요청 패턴
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 오래된 파일 정리해줘"
|
||||||
|
|
||||||
|
AI 행동:
|
||||||
|
1. sessions/ 30일 이상 파일 목록 제시
|
||||||
|
2. 사용자 확인 후 archive/로 이동
|
||||||
|
3. 강제 삭제하지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 템플릿
|
||||||
|
|
||||||
|
### 세션 기록 (sessions/YYYY-MM-DD.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# YYYY-MM-DD 작업 기록
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
(한 줄 요약 - 50자 이내)
|
||||||
|
|
||||||
|
## 완료
|
||||||
|
- [x] 작업1
|
||||||
|
- [x] 작업2
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
- [ ] 작업3 (이유: ...)
|
||||||
|
|
||||||
|
## 중단점
|
||||||
|
> (내일 이어서 할 때 바로 시작할 수 있는 정보)
|
||||||
|
|
||||||
|
## 대화 핵심
|
||||||
|
- 키워드1: 설명
|
||||||
|
- 키워드2: 설명
|
||||||
|
|
||||||
|
## 관련 링크
|
||||||
|
- CHANGELOG: #YYYY-MM-DD
|
||||||
|
- ADR: decisions/NNN (있으면)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 문제-해결 (PROBLEMS.md 행 추가)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 문제 | 해결 | 날짜 | 키워드 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| (에러/문제 설명) | (해결 방법) | YYYY-MM-DD | 검색용 |
|
||||||
|
```
|
||||||
|
|
||||||
|
### ADR (decisions/NNN-제목.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# ADR-NNN: 제목
|
||||||
|
|
||||||
|
**날짜**: YYYY-MM-DD
|
||||||
|
**상태**: 채택됨
|
||||||
|
|
||||||
|
## 배경 (왜)
|
||||||
|
(2-3문장)
|
||||||
|
|
||||||
|
## 결정 (무엇)
|
||||||
|
(핵심 결정 사항)
|
||||||
|
|
||||||
|
## 대안
|
||||||
|
| 옵션 | 장점 | 단점 | 결과 |
|
||||||
|
|------|------|------|------|
|
||||||
|
|
||||||
|
## 교훈
|
||||||
|
- (배운 점)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CHANGELOG 섹션
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [YYYY-MM-DD] 제목
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
(왜 - 2-3문장)
|
||||||
|
|
||||||
|
### 작업
|
||||||
|
- [x] 완료1
|
||||||
|
- [ ] 미완료 → STATUS.md로
|
||||||
|
|
||||||
|
### 다음
|
||||||
|
→ STATUS.md 참조
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 토큰 비용 가이드
|
||||||
|
|
||||||
|
| 문서 | 평균 줄 수 | 언제 읽나 |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| README.md | ~60 | 항상 (진입점) |
|
||||||
|
| STATUS.md | ~40 | 항상 (진입점) |
|
||||||
|
| SAVE_RULES.md | ~350 | 저장/조회 요청 시 |
|
||||||
|
| sessions/날짜.md | ~30 | 해당 날짜만 |
|
||||||
|
| PROBLEMS.md | ~100 | 키워드 검색 |
|
||||||
|
| INDEX.md | ~80 | 키워드 검색 |
|
||||||
|
| CHANGELOG.md | ~600 | 날짜/키워드 검색 |
|
||||||
|
| ARCHITECTURE.md | ~300 | 구조 파악 시 |
|
||||||
|
| FILES.md | ~200 | 파일 찾을 때 |
|
||||||
|
|
||||||
|
**권장**: README + STATUS = ~100줄로 작업 시작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 예시 워크플로우
|
||||||
|
|
||||||
|
### 1. 작업 시작
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 오늘 작업 시작하자"
|
||||||
|
|
||||||
|
AI:
|
||||||
|
1. README.md 읽기 (60줄)
|
||||||
|
2. STATUS.md 읽기 (40줄)
|
||||||
|
3. 중단점 확인
|
||||||
|
4. "중단점: PopCanvas.tsx line 76 타입 오류. 여기서 시작할까요?"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 자료 검색
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs DnD 관련 자료 찾아줘"
|
||||||
|
|
||||||
|
AI:
|
||||||
|
1. PROBLEMS.md에서 "DnD" 검색
|
||||||
|
2. INDEX.md에서 "DnD" 검색
|
||||||
|
3. 관련 내용 종합해서 응답
|
||||||
|
4. 전체 파일 읽지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 비교 요청
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs v4랑 v5 비교해줘"
|
||||||
|
|
||||||
|
AI:
|
||||||
|
1. decisions/003-v5-grid-system.md 확인
|
||||||
|
2. "대안 검토" 섹션에서 비교표 추출
|
||||||
|
3. 필요시 CHANGELOG 보충
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 작업 저장
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 오늘 작업 저장해줘"
|
||||||
|
|
||||||
|
AI:
|
||||||
|
1. sessions/2026-02-05.md 생성
|
||||||
|
2. STATUS.md 업데이트
|
||||||
|
3. README.md "마지막 대화 요약" 업데이트
|
||||||
|
4. "저장 완료. 내일 이어서 할 중단점도 기록했습니다."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 불명확한 요청
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자: "@popdocs 이거 저장해줘"
|
||||||
|
|
||||||
|
AI:
|
||||||
|
"어떤 유형으로 저장할까요?
|
||||||
|
1. 오늘 작업 기록 (sessions/)
|
||||||
|
2. 문제-해결 (PROBLEMS.md)
|
||||||
|
3. 결정 사항 (decisions/)
|
||||||
|
4. 변경 이력 (CHANGELOG.md)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*2025-2026 AI 컨텍스트 엔지니어링 기반*
|
||||||
|
*Progressive Disclosure, Agentic Context Engineering, Token as Currency*
|
||||||
197
popdocs/SPEC.md
197
popdocs/SPEC.md
|
|
@ -1,43 +1,122 @@
|
||||||
# POP 기술 스펙
|
# POP 기술 스펙
|
||||||
|
|
||||||
|
**버전: v5 (CSS Grid 기반)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v4 핵심 규칙 (3가지)
|
## v5 핵심 규칙
|
||||||
|
|
||||||
### 1. 크기 규칙 (Size)
|
### 1. 그리드 시스템
|
||||||
|
|
||||||
| 모드 | 설명 | 예시 |
|
| 모드 | 화면 너비 | 칸 수 | 대상 |
|
||||||
|------|------|------|
|
|------|----------|-------|------|
|
||||||
| `fixed` | 고정 px | 버튼 48px |
|
| mobile_portrait | ~599px | 4칸 | 4~6인치 |
|
||||||
| `fill` | 부모 채움 | 입력창 100% |
|
| mobile_landscape | 600~839px | 6칸 | 7인치 |
|
||||||
| `hug` | 내용 맞춤 | 라벨 |
|
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 |
|
||||||
|
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 (기본) |
|
||||||
|
|
||||||
### 2. 배치 규칙 (Layout)
|
### 2. 위치 지정
|
||||||
|
|
||||||
| 항목 | 옵션 |
|
|
||||||
|------|------|
|
|
||||||
| direction | horizontal / vertical |
|
|
||||||
| wrap | true / false |
|
|
||||||
| gap | 8 / 16 / 24 px |
|
|
||||||
| alignItems | start / center / end / stretch |
|
|
||||||
|
|
||||||
### 3. 반응형 규칙 (Responsive)
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
interface PopGridPosition {
|
||||||
direction: "horizontal",
|
col: number; // 시작 열 (1부터)
|
||||||
responsive: [{ breakpoint: 768, direction: "vertical" }]
|
row: number; // 시작 행 (1부터)
|
||||||
|
colSpan: number; // 열 크기 (1~12)
|
||||||
|
rowSpan: number; // 행 크기 (1~)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3. 브레이크포인트 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const GRID_BREAKPOINTS = {
|
||||||
|
mobile_portrait: {
|
||||||
|
columns: 4,
|
||||||
|
rowHeight: 48,
|
||||||
|
gap: 8,
|
||||||
|
padding: 12
|
||||||
|
},
|
||||||
|
mobile_landscape: {
|
||||||
|
columns: 6,
|
||||||
|
rowHeight: 44,
|
||||||
|
gap: 8,
|
||||||
|
padding: 16
|
||||||
|
},
|
||||||
|
tablet_portrait: {
|
||||||
|
columns: 8,
|
||||||
|
rowHeight: 52,
|
||||||
|
gap: 12,
|
||||||
|
padding: 20
|
||||||
|
},
|
||||||
|
tablet_landscape: {
|
||||||
|
columns: 12,
|
||||||
|
rowHeight: 56,
|
||||||
|
gap: 12,
|
||||||
|
padding: 24
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 구조
|
||||||
|
|
||||||
|
### v5 레이아웃
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopLayoutDataV5 {
|
||||||
|
version: "pop-5.0";
|
||||||
|
metadata: {
|
||||||
|
screenId: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
gridConfig: {
|
||||||
|
defaultMode: GridMode;
|
||||||
|
maxRows: number;
|
||||||
|
};
|
||||||
|
components: PopComponentDefinitionV5[];
|
||||||
|
globalSettings: {
|
||||||
|
backgroundColor: string;
|
||||||
|
padding: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### v5 컴포넌트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopComponentDefinitionV5 {
|
||||||
|
id: string;
|
||||||
|
type: PopComponentType; // "pop-label" | "pop-button" | ...
|
||||||
|
label: string;
|
||||||
|
gridPosition: PopGridPosition;
|
||||||
|
config: PopComponentConfig;
|
||||||
|
visibility: Record<GridMode, boolean>; // 모드별 표시/숨김
|
||||||
|
modeOverrides?: Record<GridMode, PopModeOverrideV5>; // 모드별 오버라이드
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컴포넌트 타입
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type PopComponentType =
|
||||||
|
| "pop-label" // 텍스트 라벨
|
||||||
|
| "pop-button" // 버튼
|
||||||
|
| "pop-input" // 입력 필드
|
||||||
|
| "pop-select" // 선택 박스
|
||||||
|
| "pop-grid" // 데이터 그리드
|
||||||
|
| "pop-container"; // 컨테이너
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 크기 프리셋
|
## 크기 프리셋
|
||||||
|
|
||||||
### 터치 요소
|
### 터치 요소
|
||||||
|
|
||||||
| 요소 | 일반 | 산업 |
|
| 요소 | 일반 | 산업용 |
|
||||||
|------|-----|------|
|
|------|-----|-------|
|
||||||
| 버튼 높이 | 48px | 60px |
|
| 버튼 높이 | 48px | 60px |
|
||||||
| 입력창 높이 | 48px | 56px |
|
| 입력창 높이 | 48px | 56px |
|
||||||
| 터치 영역 | 48px | 60px |
|
| 터치 영역 | 48px | 60px |
|
||||||
|
|
@ -62,60 +141,64 @@
|
||||||
## 반응형 원칙
|
## 반응형 원칙
|
||||||
|
|
||||||
```
|
```
|
||||||
누르는 것 → 고정 (48px)
|
누르는 것 → 고정 (48px) - 버튼, 터치 영역
|
||||||
읽는 것 → 범위 (clamp)
|
읽는 것 → 범위 (clamp) - 텍스트
|
||||||
담는 것 → 비율 (%)
|
담는 것 → 칸 (colSpan) - 컨테이너
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 데이터 구조
|
## 위치 변환
|
||||||
|
|
||||||
### v3 (현재)
|
12칸 기준으로 설계 → 다른 모드에서 자동 변환
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface PopLayoutDataV3 {
|
// 12칸 → 4칸 변환 예시
|
||||||
version: "pop-3.0";
|
const ratio = 4 / 12; // = 0.333
|
||||||
layouts: {
|
|
||||||
tablet_landscape: { componentPositions: Record<string, GridPosition> };
|
|
||||||
// ... 4개 모드
|
|
||||||
};
|
|
||||||
components: Record<string, PopComponentDefinition>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### v4 (계획)
|
original: { col: 1, colSpan: 6 } // 12칸에서 절반
|
||||||
|
converted: { col: 1, colSpan: 2 } // 4칸에서 절반
|
||||||
```typescript
|
|
||||||
interface PopLayoutDataV4 {
|
|
||||||
version: "pop-4.0";
|
|
||||||
root: PopContainer;
|
|
||||||
components: Record<string, PopComponentDefinitionV4>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PopContainer {
|
|
||||||
type: "stack";
|
|
||||||
direction: "horizontal" | "vertical";
|
|
||||||
children: (string | PopContainer)[];
|
|
||||||
responsive?: { breakpoint: number; direction?: string }[];
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### 캔버스 rowSpan 문제
|
### 컴포넌트가 얇게 보임
|
||||||
|
|
||||||
- **증상**: 컴포넌트가 얇게 보임
|
- **증상**: rowSpan이 적용 안됨
|
||||||
- **원인**: gridTemplateRows 고정 px
|
- **원인**: gridTemplateRows 고정 px
|
||||||
- **해결**: `1fr` 사용
|
- **해결**: `1fr` 사용
|
||||||
|
|
||||||
### 4모드 전환 안 됨
|
### 모드 전환 안 됨
|
||||||
|
|
||||||
- **증상**: 크기 줄여도 레이아웃 유지
|
- **증상**: 화면 크기 변경해도 레이아웃 유지
|
||||||
- **해결**: useResponsiveMode 훅 사용
|
- **해결**: `detectGridMode()` 사용
|
||||||
|
|
||||||
|
### 겹침 발생
|
||||||
|
|
||||||
|
- **증상**: 컴포넌트끼리 겹침
|
||||||
|
- **해결**: `resolveOverlaps()` 호출
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*상세 archive 참조: `archive/V4_CORE_RULES.md`, `archive/SIZE_PRESETS.md`*
|
## 타입 가드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// v5 레이아웃 판별
|
||||||
|
function isV5Layout(data: any): data is PopLayoutDataV5 {
|
||||||
|
return data?.version === "pop-5.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
if (isV5Layout(savedData)) {
|
||||||
|
setLayout(savedData);
|
||||||
|
} else {
|
||||||
|
setLayout(createEmptyPopLayoutV5());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*상세 아키텍처: [ARCHITECTURE.md](./ARCHITECTURE.md)*
|
||||||
|
*파일 목록: [FILES.md](./FILES.md)*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# 현재 상태
|
||||||
|
|
||||||
|
> **마지막 업데이트**: 2026-02-05
|
||||||
|
> **담당**: POP 화면 디자이너
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 진행 상태
|
||||||
|
|
||||||
|
| 단계 | 상태 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| v5 타입 정의 | 완료 | `pop-layout.ts` |
|
||||||
|
| v5 렌더러 | 완료 | `PopRenderer.tsx` |
|
||||||
|
| v5 캔버스 | 완료 | `PopCanvas.tsx` |
|
||||||
|
| v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` |
|
||||||
|
| v5 유틸리티 | 완료 | `gridUtils.ts` |
|
||||||
|
| 레거시 삭제 | 완료 | v1~v4 코드, 데이터 |
|
||||||
|
| 문서 정리 | **완료** | popdocs v5 기준 재정비 |
|
||||||
|
| 컴포넌트 팔레트 | **완료** | `ComponentPalette.tsx` |
|
||||||
|
| 타입 오류 수정 | **완료** | PopCanvas.tsx:76 |
|
||||||
|
| 드래그앤드롭 | **완료** | 팔레트 → 캔버스 연결 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 다음 작업 (우선순위)
|
||||||
|
|
||||||
|
1. **실제 테스트**
|
||||||
|
- 디자이너 페이지에서 컴포넌트 드래그앤드롭 테스트
|
||||||
|
- 저장/로드 동작 확인
|
||||||
|
|
||||||
|
2. **실제 컴포넌트 구현** (Phase 4)
|
||||||
|
- pop-label, pop-button 등 실제 렌더링
|
||||||
|
- 데이터 바인딩 연결
|
||||||
|
|
||||||
|
3. **추가 기능**
|
||||||
|
- 컴포넌트 복사/붙여넣기
|
||||||
|
- 다중 선택
|
||||||
|
- 정렬 도우미
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 알려진 문제
|
||||||
|
|
||||||
|
| 문제 | 상태 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 타입 이름 불일치 | 해결됨 | V5 접미사 제거 |
|
||||||
|
| 팔레트 없음 | 해결됨 | ComponentPalette.tsx 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최근 세션
|
||||||
|
|
||||||
|
| 날짜 | 요약 | 상세 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-02-05 | v5 통합, 문서 재정비, 팔레트 UI 추가 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 결정
|
||||||
|
|
||||||
|
| ADR | 제목 | 날짜 |
|
||||||
|
|-----|------|------|
|
||||||
|
| 003 | v5 CSS Grid 채택 | 2026-02-05 |
|
||||||
|
| 001 | v4 제약조건 기반 | 2026-02-03 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*전체 히스토리: [CHANGELOG.md](./CHANGELOG.md)*
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
# ADR-003: v5 CSS Grid 기반 그리드 시스템 채택
|
||||||
|
|
||||||
|
**날짜**: 2026-02-05
|
||||||
|
**상태**: 채택됨
|
||||||
|
**의사결정자**: 프로젝트 담당자, 상급자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
### 문제 상황
|
||||||
|
|
||||||
|
v4 Flexbox 기반 레이아웃으로 반응형 구현을 시도했으나 실패:
|
||||||
|
|
||||||
|
1. **배치 예측 불가능**: 컴포넌트가 자유롭게 움직이지만 원하는 위치에 안 감
|
||||||
|
2. **캔버스 방식의 한계**: "그리듯이" 배치하면 화면 크기별로 깨짐
|
||||||
|
3. **규칙 부재**: 어디에 뭘 배치해야 하는지 기준이 없음
|
||||||
|
|
||||||
|
### 상급자 피드백
|
||||||
|
|
||||||
|
> "이런 식이면 나중에 문제가 생긴다."
|
||||||
|
>
|
||||||
|
> "스크린의 픽셀 규격과 마진 간격 규칙을 설정해라.
|
||||||
|
> 큰 화면 디자인의 전체 프레임 규격과 사이즈 간격 규칙을 정한 다음에
|
||||||
|
> 거기에 컴포넌트를 끼워 맞추듯 우리의 규칙 내로 움직이게 바탕을 잡아라."
|
||||||
|
|
||||||
|
### 연구 내용
|
||||||
|
|
||||||
|
| 도구 | 핵심 특징 | 적용 가능 요소 |
|
||||||
|
|------|----------|---------------|
|
||||||
|
| **Softr** | 블록 기반, 제약 기반 레이아웃 | 컨테이너 슬롯 방식 |
|
||||||
|
| **Ant Design** | 24열 그리드, 8px 간격 | 그리드 시스템, 간격 규칙 |
|
||||||
|
| **Material Design** | 4/8/12열, 반응형 브레이크포인트 | 디바이스별 칸 수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 결정
|
||||||
|
|
||||||
|
**CSS Grid 기반 그리드 시스템 (v5) 채택**
|
||||||
|
|
||||||
|
### 핵심 규칙
|
||||||
|
|
||||||
|
| 모드 | 화면 너비 | 칸 수 | 대상 디바이스 |
|
||||||
|
|------|----------|-------|--------------|
|
||||||
|
| mobile_portrait | ~599px | 4칸 | 4~6인치 모바일 |
|
||||||
|
| mobile_landscape | 600~839px | 6칸 | 7인치 모바일 |
|
||||||
|
| tablet_portrait | 840~1023px | 8칸 | 8~10인치 태블릿 |
|
||||||
|
| tablet_landscape | 1024px~ | 12칸 | 10~14인치 태블릿 (기본) |
|
||||||
|
|
||||||
|
### 컴포넌트 배치
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PopGridPosition {
|
||||||
|
col: number; // 시작 열 (1부터)
|
||||||
|
row: number; // 시작 행 (1부터)
|
||||||
|
colSpan: number; // 열 크기 (1~12)
|
||||||
|
rowSpan: number; // 행 크기 (1~)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### v4 대비 변경점
|
||||||
|
|
||||||
|
| 항목 | v4 (Flexbox) | v5 (Grid) |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| 배치 방식 | 흐름 기반 (자동) | 좌표 기반 (명시적) |
|
||||||
|
| 크기 단위 | 픽셀 (200px) | 칸 (colSpan: 3) |
|
||||||
|
| 예측성 | 낮음 | 높음 |
|
||||||
|
| 반응형 | 복잡한 규칙 | 칸 수 변환 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 검토
|
||||||
|
|
||||||
|
### A. v4 Flexbox 유지 (기각)
|
||||||
|
|
||||||
|
- **장점**: 기존 코드 활용 가능
|
||||||
|
- **단점**: 상급자 지적한 문제 해결 안됨 (규칙 부재)
|
||||||
|
- **결과**: 기각
|
||||||
|
|
||||||
|
### B. 자유 배치 (절대 좌표) (기각)
|
||||||
|
|
||||||
|
- **장점**: 완전한 자유도
|
||||||
|
- **단점**: 반응형 불가능, 화면별로 전부 다시 배치 필요
|
||||||
|
- **결과**: 기각
|
||||||
|
|
||||||
|
### C. CSS Grid 그리드 시스템 (채택)
|
||||||
|
|
||||||
|
- **장점**:
|
||||||
|
- 규칙 기반으로 예측 가능
|
||||||
|
- 반응형 자동화 (12칸 → 4칸 변환)
|
||||||
|
- Material Design 표준 준수
|
||||||
|
- **단점**:
|
||||||
|
- 기존 v4 데이터 호환 불가
|
||||||
|
- 자유도 제한 (칸 단위로만)
|
||||||
|
- **결과**: **채택**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 영향
|
||||||
|
|
||||||
|
### 변경 필요
|
||||||
|
|
||||||
|
- [x] 타입 정의 (`PopLayoutDataV5`, `PopGridPosition`)
|
||||||
|
- [x] 렌더러 (`PopRenderer.tsx` - CSS Grid)
|
||||||
|
- [x] 캔버스 (`PopCanvas.tsx` - 그리드 표시)
|
||||||
|
- [x] 유틸리티 (`gridUtils.ts` - 좌표 계산)
|
||||||
|
- [x] 레거시 삭제 (v1~v4 코드, 데이터)
|
||||||
|
|
||||||
|
### 호환성
|
||||||
|
|
||||||
|
- v1~v4 레이아웃: **삭제** (마이그레이션 없이 초기화)
|
||||||
|
- 새 화면: v5로만 생성
|
||||||
|
|
||||||
|
### 제한 사항
|
||||||
|
|
||||||
|
- 컴포넌트는 칸 단위로만 배치 (칸 사이 배치 불가)
|
||||||
|
- 12칸 기준으로 설계 후 다른 모드는 자동 변환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 교훈
|
||||||
|
|
||||||
|
1. **규칙이 자유를 만든다**: 제약이 있어야 일관된 디자인 가능
|
||||||
|
2. **상급자 피드백 중요**: "프레임 규격 먼저" 조언이 핵심 방향 제시
|
||||||
|
3. **연구 후 결정**: Softr, Ant Design 분석이 구체적 방향 제시
|
||||||
|
4. **과감한 삭제**: 레거시 유지보다 깔끔한 재시작이 나음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참조
|
||||||
|
|
||||||
|
- Softr: https://www.softr.io
|
||||||
|
- Ant Design Grid: https://ant.design/components/grid
|
||||||
|
- Material Design Layout: https://m3.material.io/foundations/layout
|
||||||
|
- GRID_SYSTEM_DESIGN.md: 상세 설계 스펙
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련
|
||||||
|
|
||||||
|
- [ADR-001](./001-v4-constraint-based.md): v4 제약조건 기반 (이전 시도)
|
||||||
|
- [CHANGELOG 2026-02-05](../CHANGELOG.md#2026-02-05): 작업 내역
|
||||||
|
- [sessions/2026-02-05](../sessions/2026-02-05.md): 대화 기록
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
# 2026-02-05 작업 기록
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
v5 그리드 시스템 통합 완료, popdocs 문서 구조 재정비
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료
|
||||||
|
|
||||||
|
### v5 통합 작업
|
||||||
|
- [x] 레거시 파일 삭제 (PopCanvasV4, PopFlexRenderer, PopLayoutRenderer 등)
|
||||||
|
- [x] 파일명 정규화 (V5 접미사 제거)
|
||||||
|
- [x] 뷰어 페이지 v5 전용으로 업데이트
|
||||||
|
- [x] 백엔드 screenManagementService v5 전용 단순화
|
||||||
|
- [x] DB 기존 레이아웃 데이터 삭제
|
||||||
|
|
||||||
|
### 문서 재정비 작업
|
||||||
|
- [x] SAVE_RULES.md 생성 (AI 저장/조회 규칙)
|
||||||
|
- [x] README.md 재작성 (진입점 역할)
|
||||||
|
- [x] STATUS.md 생성 (현재 상태)
|
||||||
|
- [x] PROBLEMS.md 생성 (문제-해결 색인)
|
||||||
|
- [x] INDEX.md 생성 (기능별 색인)
|
||||||
|
- [x] sessions/ 폴더 구조 도입
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
|
||||||
|
- [ ] 컴포넌트 팔레트 UI 추가 (PopDesigner.tsx 좌측)
|
||||||
|
- [ ] PopCanvas.tsx 타입 오류 수정 (line 76)
|
||||||
|
- [ ] ARCHITECTURE.md v5 기준 업데이트
|
||||||
|
- [ ] CHANGELOG.md 오늘 작업 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 중단점
|
||||||
|
|
||||||
|
> **다음 작업자 참고**:
|
||||||
|
>
|
||||||
|
> 1. **타입 오류**: PopCanvas.tsx line 76
|
||||||
|
> - `}: PopCanvasV5Props)` → `}: PopCanvasProps)`로 변경
|
||||||
|
> - 인터페이스는 이미 `PopCanvasProps`로 정의됨 (line 48)
|
||||||
|
>
|
||||||
|
> 2. **팔레트 UI**: PopDesigner.tsx에 컴포넌트 팔레트 추가 필요
|
||||||
|
> - 위치: 좌측 ResizablePanel (현재 비어있음)
|
||||||
|
> - 참고: 이전 ComponentPaletteV4.tsx (삭제됨, archive에서 참고 가능)
|
||||||
|
> - DnD 타입: PopCanvas.tsx에 `DND_ITEM_TYPES` 인라인 정의됨
|
||||||
|
>
|
||||||
|
> 3. **문서**: ARCHITECTURE.md가 아직 v3/v4 기준임
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대화 핵심
|
||||||
|
|
||||||
|
### v5 전환 배경
|
||||||
|
- **문제**: v4 Flexbox로 반응형 시도 → 배치 예측 불가능
|
||||||
|
- **상급자 피드백**: "스크린 규격과 마진 간격 규칙을 먼저 정해라"
|
||||||
|
- **연구**: Softr, Ant Design, Material Design 분석
|
||||||
|
- **결정**: CSS Grid 기반 그리드 시스템 채택
|
||||||
|
|
||||||
|
### popdocs 재정비 배경
|
||||||
|
- **문제**: 문서 구조가 AI 에이전트 진입점 역할 못함
|
||||||
|
- **해결**: Progressive Disclosure 적용, 저장/조회 규칙 명시화
|
||||||
|
- **참고**: 2025-2026 AI 컨텍스트 엔지니어링 최신 기법
|
||||||
|
|
||||||
|
### 핵심 결정
|
||||||
|
- Layer 1 (진입점): README, STATUS, SAVE_RULES
|
||||||
|
- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX 등
|
||||||
|
- Layer 3 (심화): decisions/, sessions/, archive/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 링크
|
||||||
|
|
||||||
|
- ADR: [decisions/003-v5-grid-system.md](../decisions/003-v5-grid-system.md)
|
||||||
|
- CHANGELOG: 오늘 작업 추가 필요
|
||||||
|
- 삭제된 파일 목록: FILES.md 하단 "삭제된 파일" 섹션
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 메모
|
||||||
|
|
||||||
|
- POPUPDATE.md (루트)는 별도로 유지 (전체 프로젝트 기록용)
|
||||||
|
- popdocs/는 POP 디자이너 개발 전용
|
||||||
|
- rangraph 연동 고려 (장기 기억 검색용)
|
||||||
Loading…
Reference in New Issue