500 lines
16 KiB
TypeScript
500 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
|
import { LayoutDefinition } from "@/types/layout";
|
|
import { LayoutRegistry } from "../LayoutRegistry";
|
|
import { ComponentData, LayoutComponent } from "@/types/screen";
|
|
import { LayoutZone } from "@/types/layout";
|
|
import React from "react";
|
|
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
|
|
|
|
/**
|
|
* 자동 등록 기능을 제공하는 베이스 레이아웃 렌더러
|
|
*
|
|
* 사용 방법:
|
|
* 1. 이 클래스를 상속받습니다
|
|
* 2. static layoutDefinition을 정의합니다
|
|
* 3. 파일을 import하면 자동으로 등록됩니다
|
|
*/
|
|
export class AutoRegisteringLayoutRenderer {
|
|
protected props: LayoutRendererProps;
|
|
|
|
constructor(props: LayoutRendererProps) {
|
|
this.props = props;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 정의 - 각 구현 클래스에서 반드시 정의해야 함
|
|
*/
|
|
static readonly layoutDefinition: LayoutDefinition;
|
|
|
|
/**
|
|
* 렌더링 메서드 - 각 구현 클래스에서 오버라이드해야 함
|
|
*/
|
|
render(): React.ReactElement {
|
|
throw new Error("render() method must be implemented by subclass");
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 컨테이너 스타일을 계산합니다.
|
|
*/
|
|
getLayoutContainerStyle(): React.CSSProperties {
|
|
const { layout, style: propStyle } = this.props;
|
|
|
|
const style: React.CSSProperties = {
|
|
width: layout.size.width,
|
|
height: layout.size.height,
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
...propStyle,
|
|
};
|
|
|
|
// 레이아웃 커스텀 스타일 적용
|
|
if (layout.style) {
|
|
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
|
|
}
|
|
|
|
return style;
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 스타일을 CSS 스타일로 변환합니다.
|
|
*/
|
|
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
|
|
const cssStyle: React.CSSProperties = {};
|
|
|
|
if (componentStyle.backgroundColor) {
|
|
cssStyle.backgroundColor = componentStyle.backgroundColor;
|
|
}
|
|
if (componentStyle.borderColor) {
|
|
cssStyle.borderColor = componentStyle.borderColor;
|
|
}
|
|
if (componentStyle.borderWidth) {
|
|
cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
|
|
}
|
|
if (componentStyle.borderStyle) {
|
|
cssStyle.borderStyle = componentStyle.borderStyle;
|
|
}
|
|
if (componentStyle.borderRadius) {
|
|
cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
|
|
}
|
|
|
|
return cssStyle;
|
|
}
|
|
|
|
/**
|
|
* 존별 자식 컴포넌트들을 분류합니다.
|
|
*/
|
|
getZoneChildren(zoneId: string): ComponentData[] {
|
|
if (!this.props.allComponents) {
|
|
return [];
|
|
}
|
|
|
|
// 1. 레이아웃 내의 존에 직접 배치된 컴포넌트들 (zoneId 기준)
|
|
const zoneComponents = this.props.allComponents.filter(
|
|
(comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId,
|
|
);
|
|
|
|
// 2. 기존 방식 호환성 (parentId가 존 ID인 경우)
|
|
const legacyComponents = this.props.allComponents.filter((comp) => comp.parentId === zoneId);
|
|
|
|
// 중복 제거하여 반환
|
|
const allComponents = [...zoneComponents, ...legacyComponents];
|
|
const uniqueComponents = allComponents.filter(
|
|
(component, index, array) => array.findIndex((c) => c.id === component.id) === index,
|
|
);
|
|
|
|
return uniqueComponents;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 존을 렌더링합니다.
|
|
*/
|
|
renderZone(
|
|
zone: LayoutZone,
|
|
zoneChildren: ComponentData[] = [],
|
|
additionalProps: Record<string, any> = {},
|
|
): React.ReactElement {
|
|
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
|
|
|
|
// 존 스타일 계산 - 항상 구역 경계 표시
|
|
const zoneStyle: React.CSSProperties = {
|
|
position: "relative",
|
|
// 구역 경계 시각화 - 항상 표시
|
|
border: "1px solid #e2e8f0",
|
|
borderRadius: "6px",
|
|
backgroundColor: "rgba(248, 250, 252, 0.5)",
|
|
transition: "all 0.2s ease",
|
|
...this.getZoneStyle(zone),
|
|
...additionalProps.style,
|
|
};
|
|
|
|
// 디자인 모드일 때 더 강조된 스타일
|
|
if (isDesignMode) {
|
|
zoneStyle.border = "2px dashed #cbd5e1";
|
|
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
|
|
}
|
|
|
|
// 호버 효과를 위한 추가 스타일
|
|
const dropZoneStyle: React.CSSProperties = {
|
|
minHeight: isDesignMode ? "60px" : "40px",
|
|
borderRadius: "4px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
|
justifyContent: zoneChildren.length === 0 ? "flex-start" : "flex-start",
|
|
color: "#64748b",
|
|
fontSize: "12px",
|
|
transition: "all 0.2s ease",
|
|
padding: "8px",
|
|
position: "relative",
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={zone.id}
|
|
className={`layout-zone ${additionalProps.className || ""}`}
|
|
style={zoneStyle}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onZoneClick?.(zone.id, e);
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
const componentData = e.dataTransfer.getData("application/json");
|
|
if (componentData) {
|
|
try {
|
|
const component = JSON.parse(componentData);
|
|
onComponentDrop?.(zone.id, component, e);
|
|
} catch (error) {
|
|
console.error("컴포넌트 드롭 데이터 파싱 오류:", error);
|
|
}
|
|
}
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = "#3b82f6";
|
|
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
|
e.currentTarget.style.boxShadow = "0 0 0 2px rgba(59, 130, 246, 0.1)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
|
e.currentTarget.style.backgroundColor = isDesignMode
|
|
? "rgba(241, 245, 249, 0.8)"
|
|
: "rgba(248, 250, 252, 0.5)";
|
|
e.currentTarget.style.boxShadow = "none";
|
|
}}
|
|
{...additionalProps}
|
|
>
|
|
{/* 존 라벨 */}
|
|
<div
|
|
className="zone-label"
|
|
style={{
|
|
position: "absolute",
|
|
top: "-2px",
|
|
left: "8px",
|
|
backgroundColor: isDesignMode ? "#3b82f6" : "#64748b",
|
|
color: "white",
|
|
fontSize: "10px",
|
|
padding: "2px 6px",
|
|
borderRadius: "0 0 4px 4px",
|
|
fontWeight: "500",
|
|
zIndex: 10,
|
|
opacity: isDesignMode ? 1 : 0.7,
|
|
}}
|
|
>
|
|
{zone.name || zone.id}
|
|
</div>
|
|
|
|
{/* 드롭존 */}
|
|
<div
|
|
className="drop-zone"
|
|
style={dropZoneStyle}
|
|
onDragOver={(e) => {
|
|
if (isDesignMode) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}}
|
|
onDrop={(e) => {
|
|
if (isDesignMode) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.handleZoneDrop(e, zone.id);
|
|
}
|
|
}}
|
|
>
|
|
{zoneChildren.length > 0 ? (
|
|
zoneChildren.map((child) => (
|
|
<DynamicComponentRenderer
|
|
key={child.id}
|
|
component={child}
|
|
allComponents={this.props.allComponents}
|
|
isDesignMode={isDesignMode}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="empty-zone-indicator" style={{ textAlign: "center", opacity: 0.6 }}>
|
|
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : ""}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 존에 드롭된 컴포넌트를 처리합니다.
|
|
*/
|
|
private handleZoneDrop = (e: React.DragEvent, zoneId: string) => {
|
|
try {
|
|
const dragData = e.dataTransfer.getData("application/json");
|
|
if (!dragData) return;
|
|
|
|
const { type, table, column, component } = JSON.parse(dragData);
|
|
|
|
// 드롭 위치 계산 (존 내부 상대 좌표)
|
|
const dropZone = e.currentTarget as HTMLElement;
|
|
const rect = dropZone.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
let newComponent: any;
|
|
|
|
if (type === "table") {
|
|
// 테이블 컨테이너 생성
|
|
newComponent = {
|
|
id: `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
type: "container",
|
|
label: table.tableLabel || table.tableName,
|
|
tableName: table.tableName,
|
|
position: { x, y, z: 1 },
|
|
size: { width: 300, height: 200 },
|
|
parentId: this.props.layout.id, // 레이아웃을 부모로 설정
|
|
zoneId: zoneId, // 존 ID 설정
|
|
};
|
|
} else if (type === "column") {
|
|
// 컬럼 위젯 생성
|
|
newComponent = {
|
|
id: `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
type: "widget",
|
|
label: column.columnLabel || column.columnName,
|
|
tableName: table.tableName,
|
|
columnName: column.columnName,
|
|
widgetType: column.widgetType,
|
|
dataType: column.dataType,
|
|
required: column.required,
|
|
position: { x, y, z: 1 },
|
|
size: { width: 200, height: 40 },
|
|
parentId: this.props.layout.id, // 레이아웃을 부모로 설정
|
|
zoneId: zoneId, // 존 ID 설정
|
|
};
|
|
} else if (type === "component" && component) {
|
|
// 컴포넌트 생성
|
|
newComponent = {
|
|
id: `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
type: component.componentType || "widget",
|
|
label: component.name,
|
|
widgetType: component.webType,
|
|
position: { x, y, z: 1 },
|
|
size: component.defaultSize || { width: 200, height: 40 },
|
|
parentId: this.props.layout.id, // 레이아웃을 부모로 설정
|
|
zoneId: zoneId, // 존 ID 설정
|
|
componentConfig: component.componentConfig || {},
|
|
webTypeConfig: this.getDefaultWebTypeConfig(component.webType),
|
|
style: {
|
|
labelDisplay: true,
|
|
labelFontSize: "14px",
|
|
labelColor: "#3b83f6",
|
|
labelFontWeight: "500",
|
|
labelMarginBottom: "4px",
|
|
},
|
|
};
|
|
}
|
|
|
|
if (newComponent && this.props.onUpdateLayout) {
|
|
// 레이아웃에 컴포넌트 추가
|
|
const updatedComponents = [...(this.props.layout.components || []), newComponent];
|
|
this.props.onUpdateLayout({
|
|
...this.props.layout,
|
|
components: updatedComponents,
|
|
});
|
|
|
|
console.log(`컴포넌트가 존 ${zoneId}에 추가되었습니다:`, newComponent);
|
|
}
|
|
} catch (error) {
|
|
console.error("존 드롭 처리 중 오류:", error);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 웹타입별 기본 설정을 반환합니다.
|
|
*/
|
|
private getDefaultWebTypeConfig = (webType: string) => {
|
|
const configs: Record<string, any> = {
|
|
text: { maxLength: 255 },
|
|
number: { min: 0, max: 999999 },
|
|
date: { format: "YYYY-MM-DD" },
|
|
select: { options: [] },
|
|
// 추가 웹타입 설정...
|
|
};
|
|
return configs[webType] || {};
|
|
};
|
|
|
|
/**
|
|
* 존의 스타일을 계산합니다.
|
|
*/
|
|
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
|
|
const style: React.CSSProperties = {};
|
|
|
|
if (zone.size) {
|
|
if (zone.size.width) {
|
|
style.width = typeof zone.size.width === "number" ? `${zone.size.width}px` : zone.size.width;
|
|
}
|
|
if (zone.size.height) {
|
|
style.height = typeof zone.size.height === "number" ? `${zone.size.height}px` : zone.size.height;
|
|
}
|
|
if (zone.size.minWidth) {
|
|
style.minWidth = typeof zone.size.minWidth === "number" ? `${zone.size.minWidth}px` : zone.size.minWidth;
|
|
}
|
|
if (zone.size.minHeight) {
|
|
style.minHeight = typeof zone.size.minHeight === "number" ? `${zone.size.minHeight}px` : zone.size.minHeight;
|
|
}
|
|
}
|
|
|
|
return style;
|
|
}
|
|
|
|
/**
|
|
* 자동 등록 상태 추적
|
|
*/
|
|
private static registeredLayouts = new Set<string>();
|
|
|
|
/**
|
|
* 클래스가 정의될 때 자동으로 레지스트리에 등록
|
|
*/
|
|
static registerSelf(): void {
|
|
const definition = this.layoutDefinition;
|
|
|
|
if (!definition) {
|
|
console.error(`❌ ${this.name}: layoutDefinition이 정의되지 않았습니다.`);
|
|
return;
|
|
}
|
|
|
|
if (this.registeredLayouts.has(definition.id)) {
|
|
console.warn(`⚠️ ${definition.id} 레이아웃이 이미 등록되어 있습니다.`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 레지스트리에 등록
|
|
LayoutRegistry.registerLayout(definition);
|
|
this.registeredLayouts.add(definition.id);
|
|
|
|
console.log(`✅ 자동 등록 완료: ${definition.id} (${definition.name})`);
|
|
|
|
// 개발 모드에서 추가 정보 출력
|
|
if (process.env.NODE_ENV === "development") {
|
|
console.log(`📦 ${definition.id}:`, {
|
|
name: definition.name,
|
|
category: definition.category,
|
|
zones: definition.defaultZones?.length || 0,
|
|
tags: definition.tags?.join(", ") || "none",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ ${definition.id} 레이아웃 등록 실패:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 등록 해제 (개발 모드에서 Hot Reload용)
|
|
*/
|
|
static unregisterSelf(): void {
|
|
const definition = this.layoutDefinition;
|
|
|
|
if (definition && this.registeredLayouts.has(definition.id)) {
|
|
LayoutRegistry.unregisterLayout(definition.id);
|
|
this.registeredLayouts.delete(definition.id);
|
|
console.log(`🗑️ 등록 해제: ${definition.id}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hot Reload 지원 (개발 모드)
|
|
*/
|
|
static reloadSelf(): void {
|
|
if (process.env.NODE_ENV === "development") {
|
|
this.unregisterSelf();
|
|
this.registerSelf();
|
|
console.log(`🔄 Hot Reload: ${this.layoutDefinition?.id}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 등록된 레이아웃 목록 조회
|
|
*/
|
|
static getRegisteredLayouts(): string[] {
|
|
return Array.from(this.registeredLayouts);
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 정의 유효성 검사
|
|
*/
|
|
static validateDefinition(): { isValid: boolean; errors: string[]; warnings: string[] } {
|
|
const definition = this.layoutDefinition;
|
|
|
|
if (!definition) {
|
|
return {
|
|
isValid: false,
|
|
errors: ["layoutDefinition이 정의되지 않았습니다."],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
// 필수 필드 검사
|
|
if (!definition.id) errors.push("ID가 필요합니다.");
|
|
if (!definition.name) errors.push("이름이 필요합니다.");
|
|
if (!definition.component) errors.push("컴포넌트가 필요합니다.");
|
|
if (!definition.category) errors.push("카테고리가 필요합니다.");
|
|
|
|
// 권장사항 검사
|
|
if (!definition.description || definition.description.length < 10) {
|
|
warnings.push("설명은 10자 이상 권장됩니다.");
|
|
}
|
|
if (!definition.defaultZones || definition.defaultZones.length === 0) {
|
|
warnings.push("기본 존 정의가 권장됩니다.");
|
|
}
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 개발 모드에서 Hot Module Replacement 지원
|
|
*/
|
|
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
|
// HMR API가 있는 경우 등록
|
|
if ((module as any).hot) {
|
|
(module as any).hot.accept();
|
|
|
|
// 글로벌 Hot Reload 함수 등록
|
|
(window as any).__reloadLayout__ = (layoutId: string) => {
|
|
const layouts = AutoRegisteringLayoutRenderer.getRegisteredLayouts();
|
|
console.log(`🔄 Available layouts for reload:`, layouts);
|
|
|
|
// TODO: 특정 레이아웃만 리로드하는 로직 구현
|
|
};
|
|
}
|
|
}
|