400 lines
9.8 KiB
TypeScript
400 lines
9.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* UnifiedLayout
|
||
|
|
*
|
||
|
|
* 통합 레이아웃 컴포넌트
|
||
|
|
* - grid: 그리드 레이아웃
|
||
|
|
* - split: 분할 레이아웃
|
||
|
|
* - flex: 플렉스 레이아웃
|
||
|
|
* - divider: 구분선
|
||
|
|
* - screen-embed: 화면 임베딩
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { forwardRef, useCallback, useRef, useState } from "react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { UnifiedLayoutProps } from "@/types/unified-components";
|
||
|
|
import { GripVertical, GripHorizontal } from "lucide-react";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 그리드 레이아웃 컴포넌트 (12컬럼 시스템)
|
||
|
|
*
|
||
|
|
* 사용법:
|
||
|
|
* - columns: 컬럼 수 (기본 12, 전통적 그리드)
|
||
|
|
* - colSpan: 자식 요소별 span 지정 시 사용
|
||
|
|
* - Tailwind의 grid-cols-12 기반
|
||
|
|
*/
|
||
|
|
const GridLayout = forwardRef<HTMLDivElement, {
|
||
|
|
columns?: number; // 12컬럼 시스템에서 몇 컬럼으로 나눌지 (1-12)
|
||
|
|
gap?: string;
|
||
|
|
children?: React.ReactNode;
|
||
|
|
className?: string;
|
||
|
|
use12Column?: boolean; // 12컬럼 시스템 사용 여부
|
||
|
|
}>(({ columns = 12, gap = "16px", children, className, use12Column = true }, ref) => {
|
||
|
|
// 12컬럼 그리드 클래스 매핑
|
||
|
|
const gridColsClass: Record<number, string> = {
|
||
|
|
1: "grid-cols-1",
|
||
|
|
2: "grid-cols-2",
|
||
|
|
3: "grid-cols-3",
|
||
|
|
4: "grid-cols-4",
|
||
|
|
5: "grid-cols-5",
|
||
|
|
6: "grid-cols-6",
|
||
|
|
7: "grid-cols-7",
|
||
|
|
8: "grid-cols-8",
|
||
|
|
9: "grid-cols-9",
|
||
|
|
10: "grid-cols-10",
|
||
|
|
11: "grid-cols-11",
|
||
|
|
12: "grid-cols-12",
|
||
|
|
};
|
||
|
|
|
||
|
|
// 12컬럼 시스템 사용 시
|
||
|
|
if (use12Column) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
className={cn(
|
||
|
|
"grid",
|
||
|
|
gridColsClass[columns] || "grid-cols-12",
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
style={{ gap }}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 기존 방식 (동적 컬럼 수)
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
className={cn("grid", className)}
|
||
|
|
style={{
|
||
|
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||
|
|
gap,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
GridLayout.displayName = "GridLayout";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 분할 레이아웃 컴포넌트 (리사이즈 가능)
|
||
|
|
*/
|
||
|
|
const SplitLayout = forwardRef<HTMLDivElement, {
|
||
|
|
direction?: "horizontal" | "vertical";
|
||
|
|
splitRatio?: number[];
|
||
|
|
gap?: string;
|
||
|
|
children?: React.ReactNode;
|
||
|
|
className?: string;
|
||
|
|
}>(({ direction = "horizontal", splitRatio = [50, 50], gap = "8px", children, className }, ref) => {
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const [ratio, setRatio] = useState(splitRatio);
|
||
|
|
const [isDragging, setIsDragging] = useState(false);
|
||
|
|
|
||
|
|
const childArray = React.Children.toArray(children);
|
||
|
|
const isHorizontal = direction === "horizontal";
|
||
|
|
|
||
|
|
// 리사이저 드래그 시작
|
||
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setIsDragging(true);
|
||
|
|
|
||
|
|
const startPos = isHorizontal ? e.clientX : e.clientY;
|
||
|
|
const startRatio = [...ratio];
|
||
|
|
const container = containerRef.current;
|
||
|
|
|
||
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||
|
|
if (!container) return;
|
||
|
|
|
||
|
|
const containerSize = isHorizontal ? container.offsetWidth : container.offsetHeight;
|
||
|
|
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||
|
|
const delta = currentPos - startPos;
|
||
|
|
const deltaPercent = (delta / containerSize) * 100;
|
||
|
|
|
||
|
|
const newFirst = Math.max(10, Math.min(90, startRatio[0] + deltaPercent));
|
||
|
|
const newSecond = 100 - newFirst;
|
||
|
|
|
||
|
|
setRatio([newFirst, newSecond]);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleMouseUp = () => {
|
||
|
|
setIsDragging(false);
|
||
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
||
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
||
|
|
};
|
||
|
|
|
||
|
|
document.addEventListener("mousemove", handleMouseMove);
|
||
|
|
document.addEventListener("mouseup", handleMouseUp);
|
||
|
|
}, [isHorizontal, ratio]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={(node) => {
|
||
|
|
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||
|
|
if (typeof ref === "function") ref(node);
|
||
|
|
else if (ref) ref.current = node;
|
||
|
|
}}
|
||
|
|
className={cn(
|
||
|
|
"flex",
|
||
|
|
isHorizontal ? "flex-row" : "flex-col",
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
style={{ gap }}
|
||
|
|
>
|
||
|
|
{/* 첫 번째 패널 */}
|
||
|
|
<div
|
||
|
|
className="overflow-auto"
|
||
|
|
style={{
|
||
|
|
[isHorizontal ? "width" : "height"]: `${ratio[0]}%`,
|
||
|
|
flexShrink: 0,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{childArray[0]}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 리사이저 */}
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex items-center justify-center bg-border hover:bg-primary/20 transition-colors",
|
||
|
|
isHorizontal ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
|
||
|
|
isDragging && "bg-primary/30"
|
||
|
|
)}
|
||
|
|
onMouseDown={handleMouseDown}
|
||
|
|
>
|
||
|
|
{isHorizontal ? (
|
||
|
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||
|
|
) : (
|
||
|
|
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 두 번째 패널 */}
|
||
|
|
<div
|
||
|
|
className="overflow-auto flex-1"
|
||
|
|
style={{
|
||
|
|
[isHorizontal ? "width" : "height"]: `${ratio[1]}%`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{childArray[1]}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
SplitLayout.displayName = "SplitLayout";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 플렉스 레이아웃 컴포넌트
|
||
|
|
*/
|
||
|
|
const FlexLayout = forwardRef<HTMLDivElement, {
|
||
|
|
direction?: "horizontal" | "vertical";
|
||
|
|
gap?: string;
|
||
|
|
wrap?: boolean;
|
||
|
|
justify?: "start" | "center" | "end" | "between" | "around";
|
||
|
|
align?: "start" | "center" | "end" | "stretch";
|
||
|
|
children?: React.ReactNode;
|
||
|
|
className?: string;
|
||
|
|
}>(({
|
||
|
|
direction = "horizontal",
|
||
|
|
gap = "16px",
|
||
|
|
wrap = false,
|
||
|
|
justify = "start",
|
||
|
|
align = "stretch",
|
||
|
|
children,
|
||
|
|
className
|
||
|
|
}, ref) => {
|
||
|
|
const justifyMap = {
|
||
|
|
start: "flex-start",
|
||
|
|
center: "center",
|
||
|
|
end: "flex-end",
|
||
|
|
between: "space-between",
|
||
|
|
around: "space-around",
|
||
|
|
};
|
||
|
|
|
||
|
|
const alignMap = {
|
||
|
|
start: "flex-start",
|
||
|
|
center: "center",
|
||
|
|
end: "flex-end",
|
||
|
|
stretch: "stretch",
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
className={cn("flex", className)}
|
||
|
|
style={{
|
||
|
|
flexDirection: direction === "horizontal" ? "row" : "column",
|
||
|
|
flexWrap: wrap ? "wrap" : "nowrap",
|
||
|
|
justifyContent: justifyMap[justify],
|
||
|
|
alignItems: alignMap[align],
|
||
|
|
gap,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
FlexLayout.displayName = "FlexLayout";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 구분선 컴포넌트
|
||
|
|
*/
|
||
|
|
const DividerLayout = forwardRef<HTMLDivElement, {
|
||
|
|
direction?: "horizontal" | "vertical";
|
||
|
|
className?: string;
|
||
|
|
}>(({ direction = "horizontal", className }, ref) => {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
className={cn(
|
||
|
|
"bg-border",
|
||
|
|
direction === "horizontal" ? "h-px w-full my-4" : "w-px h-full mx-4",
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
DividerLayout.displayName = "DividerLayout";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 화면 임베딩 컴포넌트
|
||
|
|
*/
|
||
|
|
const ScreenEmbedLayout = forwardRef<HTMLDivElement, {
|
||
|
|
screenId?: number;
|
||
|
|
className?: string;
|
||
|
|
}>(({ screenId, className }, ref) => {
|
||
|
|
if (!screenId) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
className={cn(
|
||
|
|
"flex items-center justify-center h-32 border-2 border-dashed rounded-lg text-muted-foreground",
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
화면을 선택하세요
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// TODO: 실제 화면 임베딩 로직 구현
|
||
|
|
// InteractiveScreenViewer와 연동 필요
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
className={cn(
|
||
|
|
"border rounded-lg p-4",
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<div className="text-sm text-muted-foreground mb-2">
|
||
|
|
임베딩된 화면 (ID: {screenId})
|
||
|
|
</div>
|
||
|
|
<div className="h-48 bg-muted/30 rounded flex items-center justify-center">
|
||
|
|
{/* 여기에 InteractiveScreenViewer 렌더링 */}
|
||
|
|
화면 내용이 여기에 표시됩니다
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
ScreenEmbedLayout.displayName = "ScreenEmbedLayout";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메인 UnifiedLayout 컴포넌트
|
||
|
|
*/
|
||
|
|
export const UnifiedLayout = forwardRef<HTMLDivElement, UnifiedLayoutProps>(
|
||
|
|
(props, ref) => {
|
||
|
|
const {
|
||
|
|
id,
|
||
|
|
style,
|
||
|
|
size,
|
||
|
|
config: configProp,
|
||
|
|
children,
|
||
|
|
} = props;
|
||
|
|
|
||
|
|
// config가 없으면 기본값 사용
|
||
|
|
const config = configProp || { type: "grid" as const, columns: 2 };
|
||
|
|
|
||
|
|
// 타입별 레이아웃 렌더링
|
||
|
|
const renderLayout = () => {
|
||
|
|
const layoutType = config.type || "grid";
|
||
|
|
switch (layoutType) {
|
||
|
|
case "grid":
|
||
|
|
return (
|
||
|
|
<GridLayout
|
||
|
|
columns={config.columns}
|
||
|
|
gap={config.gap}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</GridLayout>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "split":
|
||
|
|
return (
|
||
|
|
<SplitLayout
|
||
|
|
direction={config.direction}
|
||
|
|
splitRatio={config.splitRatio}
|
||
|
|
gap={config.gap}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</SplitLayout>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "flex":
|
||
|
|
return (
|
||
|
|
<FlexLayout
|
||
|
|
direction={config.direction}
|
||
|
|
gap={config.gap}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</FlexLayout>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "divider":
|
||
|
|
return (
|
||
|
|
<DividerLayout
|
||
|
|
direction={config.direction}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
case "screen-embed":
|
||
|
|
return (
|
||
|
|
<ScreenEmbedLayout
|
||
|
|
screenId={config.screenId}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
default:
|
||
|
|
return (
|
||
|
|
<GridLayout columns={config.columns} gap={config.gap}>
|
||
|
|
{children}
|
||
|
|
</GridLayout>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const componentWidth = size?.width || style?.width;
|
||
|
|
const componentHeight = size?.height || style?.height;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={ref}
|
||
|
|
id={id}
|
||
|
|
style={{
|
||
|
|
width: componentWidth,
|
||
|
|
height: componentHeight,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{renderLayout()}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
UnifiedLayout.displayName = "UnifiedLayout";
|
||
|
|
|
||
|
|
export default UnifiedLayout;
|
||
|
|
|