318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { ComponentData, LayoutComponent } from "@/types/screen";
|
|
import { LayoutZone } from "@/types/layout";
|
|
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
|
|
|
|
export interface LayoutRendererProps {
|
|
layout: LayoutComponent;
|
|
allComponents: ComponentData[];
|
|
isDesignMode?: boolean;
|
|
isSelected?: boolean;
|
|
onClick?: (e: React.MouseEvent) => void;
|
|
onZoneClick?: (zoneId: string, e: React.MouseEvent) => void;
|
|
onComponentDrop?: (zoneId: string, component: ComponentData, e: React.DragEvent) => void;
|
|
onDragStart?: (e: React.DragEvent) => void;
|
|
onDragEnd?: (e: React.DragEvent) => void;
|
|
onUpdateLayout?: (updatedLayout: LayoutComponent) => void; // 레이아웃 업데이트 콜백
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
// 추가된 props들 (레이아웃에서 사용되지 않지만 필터링 시 필요)
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
|
isInteractive?: boolean;
|
|
screenId?: number;
|
|
tableName?: string;
|
|
onRefresh?: () => void;
|
|
onClose?: () => void;
|
|
mode?: "view" | "edit";
|
|
isInModal?: boolean;
|
|
originalData?: Record<string, any>;
|
|
[key: string]: any; // 기타 props 허용
|
|
}
|
|
|
|
export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererProps> {
|
|
abstract render(): React.ReactElement;
|
|
|
|
/**
|
|
* 레이아웃 존을 렌더링합니다.
|
|
*/
|
|
protected 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 ? "center" : "flex-start",
|
|
color: "#64748b",
|
|
fontSize: "12px",
|
|
transition: "all 0.2s ease",
|
|
padding: "8px",
|
|
position: "relative",
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={zone.id}
|
|
className={`layout-zone ${isDesignMode ? "design-mode" : ""} ${additionalProps.className || ""}`}
|
|
data-zone-id={zone.id}
|
|
style={zoneStyle}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onZoneClick?.(zone.id, e);
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const element = e.currentTarget;
|
|
element.style.borderColor = "#3b82f6";
|
|
element.style.backgroundColor = "rgba(59, 130, 246, 0.02)";
|
|
element.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const element = e.currentTarget;
|
|
element.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
|
element.style.backgroundColor = isDesignMode ? "rgba(241, 245, 249, 0.8)" : "rgba(248, 250, 252, 0.5)";
|
|
element.style.boxShadow = "none";
|
|
}}
|
|
onDrop={this.handleDrop(zone.id)}
|
|
onDragOver={this.handleDragOver}
|
|
onDragEnter={this.handleDragEnter}
|
|
onDragLeave={this.handleDragLeave}
|
|
>
|
|
{/* 구역 라벨 - 항상 표시 */}
|
|
<div
|
|
className="zone-label"
|
|
style={{
|
|
position: "absolute",
|
|
top: "-12px",
|
|
left: "8px",
|
|
backgroundColor: "#ffffff",
|
|
border: "1px solid #e2e8f0",
|
|
borderRadius: "12px",
|
|
padding: "2px 8px",
|
|
fontSize: "10px",
|
|
fontWeight: "500",
|
|
color: "#6b7280",
|
|
zIndex: 10,
|
|
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
|
}}
|
|
>
|
|
{zone.name || zone.id}
|
|
</div>
|
|
|
|
<div className="zone-content" style={dropZoneStyle}>
|
|
{zoneChildren.length === 0 && isDesignMode ? (
|
|
<div className="drop-placeholder">{zone.name}에 컴포넌트를 드롭하세요</div>
|
|
) : zoneChildren.length === 0 ? (
|
|
<div
|
|
className="empty-zone-indicator"
|
|
style={{
|
|
color: "#9ca3af",
|
|
fontSize: "11px",
|
|
textAlign: "center",
|
|
fontStyle: "italic",
|
|
}}
|
|
>
|
|
빈 구역
|
|
</div>
|
|
) : (
|
|
zoneChildren.map((child) => (
|
|
<DynamicComponentRenderer
|
|
key={child.id}
|
|
component={child}
|
|
allComponents={this.props.allComponents}
|
|
isDesignMode={isDesignMode}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 존 스타일을 계산합니다.
|
|
*/
|
|
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
|
|
const style: React.CSSProperties = {};
|
|
|
|
// 크기 설정
|
|
if (typeof zone.size.width === "number") {
|
|
style.width = `${zone.size.width}px`;
|
|
} else {
|
|
style.width = zone.size.width;
|
|
}
|
|
|
|
if (typeof zone.size.height === "number") {
|
|
style.height = `${zone.size.height}px`;
|
|
} else {
|
|
style.height = zone.size.height;
|
|
}
|
|
|
|
// 최소/최대 크기
|
|
if (zone.size.minWidth) style.minWidth = `${zone.size.minWidth}px`;
|
|
if (zone.size.minHeight) style.minHeight = `${zone.size.minHeight}px`;
|
|
if (zone.size.maxWidth) style.maxWidth = `${zone.size.maxWidth}px`;
|
|
if (zone.size.maxHeight) style.maxHeight = `${zone.size.maxHeight}px`;
|
|
|
|
// 커스텀 스타일 적용
|
|
if (zone.style) {
|
|
Object.assign(style, this.convertComponentStyleToCSS(zone.style));
|
|
}
|
|
|
|
return style;
|
|
}
|
|
|
|
/**
|
|
* ComponentStyle을 CSS 스타일로 변환합니다.
|
|
*/
|
|
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
|
|
const cssStyle: React.CSSProperties = {};
|
|
|
|
// 여백
|
|
if (componentStyle.margin) cssStyle.margin = componentStyle.margin;
|
|
if (componentStyle.padding) cssStyle.padding = componentStyle.padding;
|
|
|
|
// 테두리
|
|
if (componentStyle.borderWidth) cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
|
|
if (componentStyle.borderColor) cssStyle.borderColor = componentStyle.borderColor;
|
|
if (componentStyle.borderStyle) cssStyle.borderStyle = componentStyle.borderStyle;
|
|
if (componentStyle.borderRadius) cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
|
|
|
|
// 배경
|
|
if (componentStyle.backgroundColor) cssStyle.backgroundColor = componentStyle.backgroundColor;
|
|
|
|
// 텍스트
|
|
if (componentStyle.color) cssStyle.color = componentStyle.color;
|
|
if (componentStyle.fontSize) cssStyle.fontSize = `${componentStyle.fontSize}px`;
|
|
if (componentStyle.fontWeight) cssStyle.fontWeight = componentStyle.fontWeight;
|
|
if (componentStyle.textAlign) cssStyle.textAlign = componentStyle.textAlign;
|
|
|
|
return cssStyle;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 컨테이너 스타일을 계산합니다.
|
|
*/
|
|
protected 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;
|
|
}
|
|
|
|
/**
|
|
* 존별 자식 컴포넌트들을 분류합니다.
|
|
*/
|
|
protected getZoneChildren(zoneId: string): ComponentData[] {
|
|
return this.props.allComponents.filter(
|
|
(component) => component.parentId === this.props.layout.id && (component as any).zoneId === zoneId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 드래그 드롭 핸들러
|
|
*/
|
|
private handleDrop = (zoneId: string) => (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// 드롭존 하이라이트 제거
|
|
this.removeDragHighlight(e.currentTarget as HTMLElement);
|
|
|
|
try {
|
|
const componentData = e.dataTransfer.getData("application/json");
|
|
if (componentData) {
|
|
const component = JSON.parse(componentData);
|
|
this.props.onComponentDrop?.(zoneId, component, e);
|
|
}
|
|
} catch (error) {
|
|
console.error("드롭 데이터 파싱 오류:", error);
|
|
}
|
|
};
|
|
|
|
private handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// 드롭존 하이라이트 추가
|
|
this.addDragHighlight(e.currentTarget as HTMLElement);
|
|
};
|
|
|
|
private handleDragEnter = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
private handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// 실제로 존을 벗어났는지 확인
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
const x = e.clientX;
|
|
const y = e.clientY;
|
|
|
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
this.removeDragHighlight(e.currentTarget as HTMLElement);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 드래그 하이라이트 추가
|
|
*/
|
|
private addDragHighlight(element: HTMLElement) {
|
|
element.classList.add("drag-over");
|
|
element.style.borderColor = "#3b82f6";
|
|
element.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
|
}
|
|
|
|
/**
|
|
* 드래그 하이라이트 제거
|
|
*/
|
|
private removeDragHighlight(element: HTMLElement) {
|
|
element.classList.remove("drag-over");
|
|
element.style.borderColor = "";
|
|
element.style.backgroundColor = "";
|
|
}
|
|
}
|