463 lines
11 KiB
TypeScript
463 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { ComponentDefinition, ComponentRendererProps, ComponentConfig } from "@/types/component";
|
|
import { ComponentRegistry } from "./ComponentRegistry";
|
|
|
|
/**
|
|
* 자동 등록 컴포넌트 렌더러 기본 클래스
|
|
* 모든 컴포넌트 렌더러가 상속받아야 하는 기본 클래스
|
|
* 레이아웃 시스템의 AutoRegisteringLayoutRenderer와 동일한 패턴
|
|
*/
|
|
export class AutoRegisteringComponentRenderer {
|
|
protected props: ComponentRendererProps;
|
|
|
|
/**
|
|
* 각 컴포넌트 렌더러에서 반드시 정의해야 하는 컴포넌트 정의
|
|
* 이 정의를 바탕으로 자동 등록이 수행됩니다
|
|
*/
|
|
static componentDefinition: ComponentDefinition;
|
|
|
|
constructor(props: ComponentRendererProps) {
|
|
this.props = props;
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 렌더링 메서드
|
|
* 각 렌더러에서 반드시 구현해야 합니다
|
|
*/
|
|
render(): React.ReactElement {
|
|
throw new Error(`${this.constructor.name}: render() 메서드를 구현해야 합니다. 이는 추상 메서드입니다.`);
|
|
}
|
|
|
|
/**
|
|
* 공통 컴포넌트 스타일 생성
|
|
* 위치, 크기 등의 기본 스타일을 자동으로 계산합니다
|
|
*/
|
|
protected getComponentStyle(): React.CSSProperties {
|
|
const { component, isDesignMode = false } = this.props;
|
|
|
|
const baseStyle: React.CSSProperties = {
|
|
position: "absolute",
|
|
left: `${component.position?.x || 0}px`,
|
|
top: `${component.position?.y || 0}px`,
|
|
width: `${component.size?.width || 200}px`,
|
|
height: `${component.size?.height || 36}px`,
|
|
zIndex: component.position?.z || 1,
|
|
...component.style,
|
|
};
|
|
|
|
// 디자인 모드에서 추가 스타일
|
|
if (isDesignMode) {
|
|
baseStyle.border = "1px dashed #cbd5e1";
|
|
baseStyle.borderColor = this.props.isSelected ? "#3b82f6" : "#cbd5e1";
|
|
}
|
|
|
|
return baseStyle;
|
|
}
|
|
|
|
/**
|
|
* 웹타입에 따른 Props 생성
|
|
* 각 웹타입별로 적절한 HTML 속성들을 자동으로 생성합니다
|
|
*/
|
|
protected getWebTypeProps(): Record<string, any> {
|
|
const { component } = this.props;
|
|
|
|
const baseProps = {
|
|
id: component.id,
|
|
name: component.id,
|
|
value: component.value || "",
|
|
disabled: component.readonly || false,
|
|
required: component.required || false,
|
|
placeholder: component.placeholder || "",
|
|
};
|
|
|
|
switch (component.webType) {
|
|
case "text":
|
|
return {
|
|
...baseProps,
|
|
type: "text",
|
|
maxLength: component.maxLength,
|
|
minLength: component.minLength,
|
|
};
|
|
|
|
case "number":
|
|
return {
|
|
...baseProps,
|
|
type: "number",
|
|
min: component.min,
|
|
max: component.max,
|
|
step: component.step || 1,
|
|
};
|
|
|
|
case "email":
|
|
return {
|
|
...baseProps,
|
|
type: "email",
|
|
};
|
|
|
|
case "password":
|
|
return {
|
|
...baseProps,
|
|
type: "password",
|
|
};
|
|
|
|
case "date":
|
|
return {
|
|
...baseProps,
|
|
type: "date",
|
|
min: component.minDate,
|
|
max: component.maxDate,
|
|
};
|
|
|
|
case "datetime":
|
|
return {
|
|
...baseProps,
|
|
type: "datetime-local",
|
|
min: component.minDate,
|
|
max: component.maxDate,
|
|
};
|
|
|
|
case "time":
|
|
return {
|
|
...baseProps,
|
|
type: "time",
|
|
};
|
|
|
|
case "url":
|
|
return {
|
|
...baseProps,
|
|
type: "url",
|
|
};
|
|
|
|
case "tel":
|
|
return {
|
|
...baseProps,
|
|
type: "tel",
|
|
};
|
|
|
|
case "search":
|
|
return {
|
|
...baseProps,
|
|
type: "search",
|
|
};
|
|
|
|
case "textarea":
|
|
return {
|
|
...baseProps,
|
|
rows: component.rows || 3,
|
|
cols: component.cols,
|
|
wrap: component.wrap || "soft",
|
|
};
|
|
|
|
case "select":
|
|
case "dropdown":
|
|
return {
|
|
...baseProps,
|
|
multiple: component.multiple || false,
|
|
};
|
|
|
|
case "checkbox":
|
|
return {
|
|
...baseProps,
|
|
type: "checkbox",
|
|
checked: component.checked || false,
|
|
};
|
|
|
|
case "radio":
|
|
return {
|
|
...baseProps,
|
|
type: "radio",
|
|
checked: component.checked || false,
|
|
};
|
|
|
|
case "button":
|
|
return {
|
|
...baseProps,
|
|
type: component.buttonType || "button",
|
|
};
|
|
|
|
case "file":
|
|
return {
|
|
...baseProps,
|
|
type: "file",
|
|
accept: component.accept,
|
|
multiple: component.multiple || false,
|
|
};
|
|
|
|
case "range":
|
|
return {
|
|
...baseProps,
|
|
type: "range",
|
|
min: component.min || 0,
|
|
max: component.max || 100,
|
|
step: component.step || 1,
|
|
};
|
|
|
|
case "color":
|
|
return {
|
|
...baseProps,
|
|
type: "color",
|
|
};
|
|
|
|
default:
|
|
return baseProps;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 라벨 스타일 생성 헬퍼
|
|
* 라벨이 있는 컴포넌트들을 위한 공통 라벨 스타일 생성
|
|
*/
|
|
protected getLabelStyle(): React.CSSProperties {
|
|
const { component } = this.props;
|
|
|
|
return {
|
|
position: "absolute",
|
|
top: "-25px",
|
|
left: "0px",
|
|
fontSize: component.style?.labelFontSize || "14px",
|
|
color: component.style?.labelColor || "#374151",
|
|
marginBottom: component.style?.labelMarginBottom || "4px",
|
|
fontWeight: "500",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 라벨 정보 반환
|
|
*/
|
|
protected getLabelInfo(): { text: string; isRequired: boolean } | null {
|
|
const { component } = this.props;
|
|
|
|
if (!component.label && !component.style?.labelText) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
text: component.style?.labelText || component.label,
|
|
isRequired: component.required || false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 오류 스타일 생성 헬퍼
|
|
*/
|
|
protected getErrorStyle(): React.CSSProperties {
|
|
return {
|
|
position: "absolute",
|
|
top: "100%",
|
|
left: "0px",
|
|
fontSize: "12px",
|
|
color: "#ef4444",
|
|
marginTop: "4px",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 도움말 텍스트 스타일 생성 헬퍼
|
|
*/
|
|
protected getHelperTextStyle(): React.CSSProperties {
|
|
return {
|
|
position: "absolute",
|
|
top: "100%",
|
|
left: "0px",
|
|
fontSize: "12px",
|
|
color: "#6b7280",
|
|
marginTop: "4px",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 이벤트 핸들러 생성
|
|
* 공통적으로 사용되는 이벤트 핸들러들을 생성합니다
|
|
*/
|
|
protected getEventHandlers() {
|
|
const { onClick, onDragStart, onDragEnd } = this.props;
|
|
|
|
return {
|
|
onClick: (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClick?.();
|
|
},
|
|
onDragStart: (e: React.DragEvent) => {
|
|
onDragStart?.(e);
|
|
},
|
|
onDragEnd: (e: React.DragEvent) => {
|
|
onDragEnd?.(e);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 설정 접근 헬퍼
|
|
*/
|
|
protected getConfig<T = ComponentConfig>(): T {
|
|
const { component } = this.props;
|
|
const definition = ComponentRegistry.getComponent(component.componentType);
|
|
|
|
return {
|
|
...definition?.defaultConfig,
|
|
...component.config,
|
|
} as T;
|
|
}
|
|
|
|
/**
|
|
* 업데이트 헬퍼
|
|
*/
|
|
protected updateComponent(updates: Partial<any>): void {
|
|
this.props.onUpdate?.(updates);
|
|
}
|
|
|
|
/**
|
|
* 값 변경 헬퍼
|
|
*/
|
|
protected handleValueChange(value: any): void {
|
|
this.updateComponent({ value });
|
|
}
|
|
|
|
/**
|
|
* 자동 등록 상태 추적
|
|
*/
|
|
private static registeredComponents = new Set<string>();
|
|
|
|
/**
|
|
* 클래스가 정의될 때 자동으로 레지스트리에 등록
|
|
* 레이아웃 시스템과 동일한 방식
|
|
*/
|
|
static registerSelf(): void {
|
|
const definition = this.componentDefinition;
|
|
|
|
if (!definition) {
|
|
console.error(`❌ ${this.name}: componentDefinition이 정의되지 않았습니다.`);
|
|
return;
|
|
}
|
|
|
|
if (this.registeredComponents.has(definition.id)) {
|
|
console.warn(`⚠️ ${definition.id} 컴포넌트가 이미 등록되어 있습니다.`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 레지스트리에 등록
|
|
ComponentRegistry.registerComponent(definition);
|
|
this.registeredComponents.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,
|
|
webType: definition.webType,
|
|
tags: definition.tags?.join(", ") || "none",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ ${definition.id} 컴포넌트 등록 실패:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 등록 해제 (개발 모드에서 Hot Reload용)
|
|
*/
|
|
static unregisterSelf(): void {
|
|
const definition = this.componentDefinition;
|
|
if (definition && this.registeredComponents.has(definition.id)) {
|
|
ComponentRegistry.unregisterComponent(definition.id);
|
|
this.registeredComponents.delete(definition.id);
|
|
console.log(`🗑️ 컴포넌트 자동 해제: ${definition.id}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 개발 모드에서 Hot Reload 지원
|
|
*/
|
|
static enableHotReload(): void {
|
|
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
|
// HMR (Hot Module Replacement) 감지
|
|
if ((module as any).hot) {
|
|
(module as any).hot.dispose(() => {
|
|
this.unregisterSelf();
|
|
});
|
|
|
|
(module as any).hot.accept(() => {
|
|
this.registerSelf();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 정의 검증
|
|
*/
|
|
static validateDefinition(): {
|
|
isValid: boolean;
|
|
errors: string[];
|
|
warnings: string[];
|
|
} {
|
|
const definition = this.componentDefinition;
|
|
|
|
if (!definition) {
|
|
return {
|
|
isValid: false,
|
|
errors: ["componentDefinition이 정의되지 않았습니다"],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
// 기본적인 검증만 수행
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
if (!definition.id) errors.push("id가 필요합니다");
|
|
if (!definition.name) errors.push("name이 필요합니다");
|
|
if (!definition.category) errors.push("category가 필요합니다");
|
|
|
|
return {
|
|
isValid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 개발자 도구용 디버그 정보
|
|
*/
|
|
static getDebugInfo(): object {
|
|
const definition = this.componentDefinition;
|
|
|
|
return {
|
|
className: this.name,
|
|
definition: definition || null,
|
|
isRegistered: definition ? ComponentRegistry.hasComponent(definition.id) : false,
|
|
validation: this.validateDefinition(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// 클래스가 정의되는 즉시 자동 등록 활성화
|
|
// 하위 클래스에서 이 클래스를 상속받으면 자동으로 등록됩니다
|
|
if (typeof window !== "undefined") {
|
|
// 브라우저 환경에서만 실행
|
|
setTimeout(() => {
|
|
// 모든 모듈이 로드된 후 등록 실행
|
|
const subclasses = Object.getOwnPropertyNames(window)
|
|
.map((name) => (window as any)[name])
|
|
.filter(
|
|
(obj) =>
|
|
typeof obj === "function" &&
|
|
obj.prototype instanceof AutoRegisteringComponentRenderer &&
|
|
obj.componentDefinition,
|
|
);
|
|
|
|
subclasses.forEach((cls) => {
|
|
try {
|
|
cls.registerSelf();
|
|
} catch (error) {
|
|
console.error(`컴포넌트 자동 등록 실패: ${cls.name}`, error);
|
|
}
|
|
});
|
|
}, 0);
|
|
}
|