Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

This commit is contained in:
DDD1542
2026-02-24 09:29:44 +09:00
parent 9614ce3973
commit 4e422fc477
20 changed files with 1233 additions and 92 deletions

View File

@@ -112,6 +112,8 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
/**
* 컴포넌트 초기화 함수

View File

@@ -26,6 +26,8 @@ export interface SplitPanelInfo {
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
panelType?: "component" | "canvas";
}
export interface SplitPanelResizeContextValue {

View File

@@ -0,0 +1,448 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { entityJoinApi } from "@/lib/api/entityJoin";
/**
* BOM 트리 노드 데이터
*/
interface BomTreeNode {
id: string;
bom_id: string;
parent_detail_id: string | null;
seq_no: string;
level: string;
child_item_id: string;
child_item_code: string;
child_item_name: string;
child_item_type: string;
quantity: string;
unit: string;
loss_rate: string;
remark: string;
children: BomTreeNode[];
}
/**
* BOM 헤더 정보
*/
interface BomHeaderInfo {
id: string;
bom_number: string;
item_code: string;
item_name: string;
item_type: string;
base_qty: string;
unit: string;
version: string;
revision: string;
status: string;
effective_date: string;
expired_date: string;
remark: string;
}
interface BomTreeComponentProps {
component?: any;
formData?: Record<string, any>;
tableName?: string;
companyCode?: string;
isDesignMode?: boolean;
selectedRowsData?: any[];
[key: string]: any;
}
/**
* BOM 트리 컴포넌트
* 좌측 패널에서 BOM 헤더 선택 시 계층 구조로 BOM 디테일을 표시
*/
export function BomTreeComponent({
component,
formData,
companyCode,
isDesignMode = false,
selectedRowsData,
...props
}: BomTreeComponentProps) {
const [headerInfo, setHeaderInfo] = useState<BomHeaderInfo | null>(null);
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const config = component?.componentConfig || {};
// 선택된 BOM 헤더에서 bom_id 추출
const selectedBomId = useMemo(() => {
// SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨
if (selectedRowsData && selectedRowsData.length > 0) {
return selectedRowsData[0]?.id;
}
if (formData?.id) return formData.id;
return null;
}, [formData, selectedRowsData]);
// 선택된 BOM 헤더 정보 추출
const selectedHeaderData = useMemo(() => {
if (selectedRowsData && selectedRowsData.length > 0) {
return selectedRowsData[0] as BomHeaderInfo;
}
if (formData?.id) return formData as unknown as BomHeaderInfo;
return null;
}, [formData, selectedRowsData]);
// BOM 디테일 데이터 로드
const loadBomDetails = useCallback(async (bomId: string) => {
if (!bomId) return;
setLoading(true);
try {
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
page: 1,
size: 500,
search: { bom_id: bomId },
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
});
const rows = result.data || [];
const tree = buildTree(rows);
setTreeData(tree);
const firstLevelIds = new Set<string>(tree.map((n: BomTreeNode) => n.id));
setExpandedNodes(firstLevelIds);
} catch (error) {
console.error("[BomTree] 데이터 로드 실패:", error);
} finally {
setLoading(false);
}
}, []);
// 평면 데이터 -> 트리 구조 변환
const buildTree = (flatData: any[]): BomTreeNode[] => {
const nodeMap = new Map<string, BomTreeNode>();
const roots: BomTreeNode[] = [];
// 모든 노드를 맵에 등록
flatData.forEach((item) => {
nodeMap.set(item.id, { ...item, children: [] });
});
// 부모-자식 관계 설정
flatData.forEach((item) => {
const node = nodeMap.get(item.id)!;
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
nodeMap.get(item.parent_detail_id)!.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
// 선택된 BOM 변경 시 데이터 로드
useEffect(() => {
if (selectedBomId) {
setHeaderInfo(selectedHeaderData);
loadBomDetails(selectedBomId);
} else {
setHeaderInfo(null);
setTreeData([]);
}
}, [selectedBomId, selectedHeaderData, loadBomDetails]);
// 노드 펼치기/접기 토글
const toggleNode = useCallback((nodeId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
}, []);
// 전체 펼치기
const expandAll = useCallback(() => {
const allIds = new Set<string>();
const collectIds = (nodes: BomTreeNode[]) => {
nodes.forEach((n) => {
allIds.add(n.id);
if (n.children.length > 0) collectIds(n.children);
});
};
collectIds(treeData);
setExpandedNodes(allIds);
}, [treeData]);
// 전체 접기
const collapseAll = useCallback(() => {
setExpandedNodes(new Set());
}, []);
// 품목 구분 라벨
const getItemTypeLabel = (type: string) => {
switch (type) {
case "product": return "제품";
case "semi": return "반제품";
case "material": return "원자재";
case "part": return "부품";
default: return type || "-";
}
};
// 품목 구분 아이콘 & 색상
const getItemTypeStyle = (type: string) => {
switch (type) {
case "product":
return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" };
case "semi":
return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" };
case "material":
return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" };
default:
return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" };
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
return (
<div className="flex h-full flex-col rounded-md border bg-white p-4">
<div className="mb-3 flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">BOM </span>
</div>
<div className="flex-1 space-y-1 rounded border border-dashed border-gray-300 p-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<ChevronDown className="h-3 w-3" />
<Package className="h-3 w-3 text-blue-500" />
<span> A ()</span>
<span className="ml-auto text-gray-400">수량: 1</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<ChevronRight className="h-3 w-3" />
<Layers className="h-3 w-3 text-amber-500" />
<span> B ()</span>
<span className="ml-auto text-gray-400">수량: 2</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<span className="ml-3.5" />
<Box className="h-3 w-3 text-emerald-500" />
<span> C ()</span>
<span className="ml-auto text-gray-400">수량: 5</span>
</div>
</div>
</div>
);
}
// 선택 안 된 상태
if (!selectedBomId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> BOM을 </p>
<p className="text-xs"> BOM의 </p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 정보 */}
{headerInfo && (
<div className="border-b bg-gray-50/80 px-4 py-3">
<div className="mb-2 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{headerInfo.item_name || "-"}</h3>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{headerInfo.bom_number || "-"}
</span>
<span className={cn(
"ml-1 rounded px-1.5 py-0.5 text-[10px] font-medium",
headerInfo.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-gray-100 text-gray-500"
)}>
{headerInfo.status === "active" ? "사용" : "미사용"}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>: <b className="text-foreground">{headerInfo.item_code || "-"}</b></span>
<span>: <b className="text-foreground">{getItemTypeLabel(headerInfo.item_type)}</b></span>
<span>: <b className="text-foreground">{headerInfo.base_qty || "1"} {headerInfo.unit || ""}</b></span>
<span>: <b className="text-foreground">v{headerInfo.version || "1.0"} ( {headerInfo.revision || "1"})</b></span>
</div>
</div>
)}
{/* 트리 툴바 */}
<div className="flex items-center gap-2 border-b px-4 py-2">
<span className="text-xs font-medium text-muted-foreground">BOM </span>
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{treeData.length}
</span>
<div className="ml-auto flex gap-1">
<button
onClick={expandAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
<button
onClick={collapseAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
</div>
</div>
{/* 트리 컨텐츠 */}
<div className="flex-1 overflow-auto px-2 py-2">
{loading ? (
<div className="flex h-32 items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : treeData.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2">
<AlertCircle className="h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-0.5">
{treeData.map((node) => (
<TreeNodeRow
key={node.id}
node={node}
depth={0}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={toggleNode}
onSelect={setSelectedNodeId}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</div>
</div>
);
}
/**
* 트리 노드 행 (재귀 렌더링)
*/
interface TreeNodeRowProps {
node: BomTreeNode;
depth: number;
expandedNodes: Set<string>;
selectedNodeId: string | null;
onToggle: (id: string) => void;
onSelect: (id: string) => void;
getItemTypeLabel: (type: string) => string;
getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string };
}
function TreeNodeRow({
node,
depth,
expandedNodes,
selectedNodeId,
onToggle,
onSelect,
getItemTypeLabel,
getItemTypeStyle,
}: TreeNodeRowProps) {
const isExpanded = expandedNodes.has(node.id);
const hasChildren = node.children.length > 0;
const isSelected = selectedNodeId === node.id;
const style = getItemTypeStyle(node.child_item_type);
const ItemIcon = style.icon;
return (
<>
<div
className={cn(
"group flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-gray-50"
)}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => {
onSelect(node.id);
if (hasChildren) onToggle(node.id);
}}
>
{/* 펼치기/접기 화살표 */}
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
)
) : (
<span className="h-1 w-1 rounded-full bg-gray-300" />
)}
</span>
{/* 품목 타입 아이콘 */}
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", style.bg)}>
<ItemIcon className={cn("h-3 w-3", style.color)} />
</span>
{/* 품목 정보 */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
{node.child_item_name || "-"}
</span>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">
{node.child_item_code || ""}
</span>
<span className={cn(
"flex-shrink-0 rounded px-1 py-0.5 text-[10px]",
style.bg, style.color
)}>
{getItemTypeLabel(node.child_item_type)}
</span>
</div>
{/* 수량/단위 */}
<div className="flex flex-shrink-0 items-center gap-2 text-[11px]">
<span className="text-muted-foreground">
: <b className="text-foreground">{node.quantity || "0"}</b> {node.unit || ""}
</span>
{node.loss_rate && node.loss_rate !== "0" && (
<span className="text-amber-600">
: {node.loss_rate}%
</span>
)}
</div>
</div>
{/* 하위 노드 재귀 렌더링 */}
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<TreeNodeRow
key={child.id}
node={child}
depth={depth + 1}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={onToggle}
onSelect={onSelect}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2BomTreeDefinition } from "./index";
import { BomTreeComponent } from "./BomTreeComponent";
export class BomTreeRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2BomTreeDefinition;
render(): React.ReactElement {
return <BomTreeComponent {...this.props} />;
}
}
BomTreeRenderer.registerSelf();
if (typeof window !== "undefined") {
setTimeout(() => {
try {
BomTreeRenderer.registerSelf();
} catch (error) {
console.error("BomTree 등록 실패:", error);
}
}, 1000);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { ComponentCategory } from "@/types/component";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { BomTreeComponent } from "./BomTreeComponent";
export const V2BomTreeDefinition = createComponentDefinition({
id: "v2-bom-tree",
name: "BOM 트리 뷰",
nameEng: "BOM Tree View",
description: "BOM 구성을 계층 트리 형태로 표시하는 컴포넌트",
category: ComponentCategory.V2,
webType: "text",
component: BomTreeComponent,
defaultConfig: {
detailTable: "bom_detail",
foreignKey: "bom_id",
parentKey: "parent_detail_id",
},
defaultSize: { width: 900, height: 600 },
icon: "GitBranch",
tags: ["BOM", "트리", "계층", "제조", "생산"],
version: "1.0.0",
author: "개발팀",
});
export default V2BomTreeDefinition;

View File

@@ -0,0 +1,275 @@
"use client";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { ComponentRendererProps } from "@/types/component";
import { SplitLineConfig } from "./types";
import { setCanvasSplit, resetCanvasSplit } from "./canvasSplitStore";
export interface SplitLineComponentProps extends ComponentRendererProps {
config?: SplitLineConfig;
}
export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
config,
className,
style,
...props
}) => {
const componentConfig = {
...config,
...component.componentConfig,
} as SplitLineConfig;
const resizable = componentConfig.resizable ?? true;
const lineColor = componentConfig.lineColor || "#e2e8f0";
const lineWidth = componentConfig.lineWidth || 4;
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// CSS transform: scale()이 적용된 캔버스에서 정확한 디자인 해상도
const detectCanvasWidth = useCallback((): number => {
if (containerRef.current) {
const canvas =
containerRef.current.closest("[data-screen-runtime]") ||
containerRef.current.closest("[data-screen-canvas]");
if (canvas) {
const w = parseInt((canvas as HTMLElement).style.width);
if (w > 0) return w;
}
}
const canvas = document.querySelector("[data-screen-runtime]");
if (canvas) {
const w = parseInt((canvas as HTMLElement).style.width);
if (w > 0) return w;
}
return 1200;
}, []);
// CSS scale 보정 계수
const getScaleFactor = useCallback((): number => {
if (containerRef.current) {
const canvas = containerRef.current.closest("[data-screen-runtime]");
if (canvas) {
const el = canvas as HTMLElement;
const designWidth = parseInt(el.style.width) || 1200;
const renderedWidth = el.getBoundingClientRect().width;
if (renderedWidth > 0 && designWidth > 0) {
return designWidth / renderedWidth;
}
}
}
return 1;
}, []);
// 스코프 ID (같은 data-screen-runtime 안의 컴포넌트만 영향)
const scopeIdRef = useRef("");
// 글로벌 스토어에 등록 (런타임 모드)
useEffect(() => {
if (isDesignMode) return;
const timer = setTimeout(() => {
const cw = detectCanvasWidth();
const posX = component.position?.x || 0;
// 스코프 ID: 가장 가까운 data-screen-runtime 요소에 고유 ID 부여
let scopeId = "";
if (containerRef.current) {
const runtimeEl = containerRef.current.closest("[data-screen-runtime]");
if (runtimeEl) {
scopeId = runtimeEl.getAttribute("data-split-scope") || "";
if (!scopeId) {
scopeId = `split-scope-${component.id}`;
runtimeEl.setAttribute("data-split-scope", scopeId);
}
}
}
scopeIdRef.current = scopeId;
console.log("[SplitLine] 등록:", { canvasWidth: cw, positionX: posX, scopeId });
setCanvasSplit({
initialDividerX: posX,
currentDividerX: posX,
canvasWidth: cw,
isDragging: false,
active: true,
scopeId,
});
}, 100);
return () => {
clearTimeout(timer);
resetCanvasSplit();
};
}, [isDesignMode, component.id, component.position?.x, detectCanvasWidth]);
// 드래그 핸들러 (requestAnimationFrame으로 스로틀링 → 렉 방지)
const rafIdRef = useRef(0);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!resizable || isDesignMode) return;
e.preventDefault();
e.stopPropagation();
const posX = component.position?.x || 0;
const startX = e.clientX;
const startOffset = dragOffset;
const scaleFactor = getScaleFactor();
const cw = detectCanvasWidth();
const MIN_POS = 50;
const MAX_POS = cw - 50;
setIsDragging(true);
setCanvasSplit({ isDragging: true });
const handleMouseMove = (moveEvent: MouseEvent) => {
// rAF로 스로틀링: 프레임당 1회만 업데이트
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = requestAnimationFrame(() => {
const rawDelta = moveEvent.clientX - startX;
const delta = rawDelta * scaleFactor;
let newOffset = startOffset + delta;
const newDividerX = posX + newOffset;
if (newDividerX < MIN_POS) newOffset = MIN_POS - posX;
if (newDividerX > MAX_POS) newOffset = MAX_POS - posX;
setDragOffset(newOffset);
setCanvasSplit({ currentDividerX: posX + newOffset });
});
};
const handleMouseUp = () => {
cancelAnimationFrame(rafIdRef.current);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
setIsDragging(false);
setCanvasSplit({ isDragging: false });
};
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[resizable, isDesignMode, dragOffset, component.position?.x, getScaleFactor, detectCanvasWidth],
);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// props 필터링
const {
selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3,
componentConfig: _4, component: _5, isSelected: _6,
onClick: _7, onDragStart: _8, onDragEnd: _9,
size: _10, position: _11, style: _12,
screenId: _13, tableName: _14, onRefresh: _15, onClose: _16,
webType: _17, autoGeneration: _18, isInteractive: _19,
formData: _20, onFormDataChange: _21,
menuId: _22, menuObjid: _23, onSave: _24,
userId: _25, userName: _26, companyCode: _27,
isInModal: _28, readonly: _29, originalData: _30,
_originalData: _31, _initialData: _32, _groupedData: _33,
allComponents: _34, onUpdateLayout: _35,
selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38,
sortBy: _39, sortOrder: _40, tableDisplayData: _41,
flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44,
onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48,
isPreview: _49, groupedData: _50,
...domProps
} = props as any;
if (isDesignMode) {
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
position: "relative",
...style,
}}
className={className}
onClick={handleClick}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
borderLeft: `${lineWidth}px dashed ${isSelected ? "#3b82f6" : lineColor}`,
}}
/>
<div
style={{
position: "absolute",
top: "4px",
left: "50%",
transform: "translateX(-50%)",
fontSize: "10px",
color: isSelected ? "#3b82f6" : "#9ca3af",
whiteSpace: "nowrap",
backgroundColor: "rgba(255,255,255,0.9)",
padding: "1px 6px",
borderRadius: "4px",
fontWeight: 500,
}}
>
릿
</div>
</div>
);
}
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: resizable ? "col-resize" : "default",
transform: `translateX(${dragOffset}px)`,
transition: isDragging ? "none" : "transform 0.1s ease-out",
zIndex: 50,
...style,
}}
className={className}
onMouseDown={handleMouseDown}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
backgroundColor: isDragging ? "hsl(var(--primary))" : lineColor,
transition: isDragging ? "none" : "background-color 0.15s ease",
}}
className="hover:bg-primary"
/>
</div>
);
};
export const SplitLineWrapper: React.FC<SplitLineComponentProps> = (props) => {
return <SplitLineComponent {...props} />;
};

View File

@@ -0,0 +1,78 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
interface SplitLineConfigPanelProps {
config: any;
onConfigChange: (config: any) => void;
}
/**
* SplitLine 설정 패널
*/
export const SplitLineConfigPanel: React.FC<SplitLineConfigPanelProps> = ({ config, onConfigChange }) => {
const currentConfig = config || {};
const handleChange = (key: string, value: any) => {
onConfigChange({
...currentConfig,
[key]: value,
});
};
return (
<div className="space-y-4 p-4">
<h3 className="text-sm font-semibold">릿 </h3>
{/* 드래그 리사이즈 허용 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.resizable ?? true}
onCheckedChange={(checked) => handleChange("resizable", checked)}
/>
</div>
{/* 분할선 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={currentConfig.lineWidth || 4}
onChange={(e) => handleChange("lineWidth", parseInt(e.target.value) || 4)}
className="h-8 text-xs"
min={1}
max={12}
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input
type="color"
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => handleChange("lineColor", e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<Input
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => handleChange("lineColor", e.target.value)}
className="h-8 flex-1 text-xs"
placeholder="#e2e8f0"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
릿 X .
.
</p>
</div>
);
};
export default SplitLineConfigPanel;

View File

@@ -0,0 +1,30 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2SplitLineDefinition } from "./index";
import { SplitLineComponent } from "./SplitLineComponent";
/**
* SplitLine 렌더러
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
*/
export class SplitLineRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2SplitLineDefinition;
render(): React.ReactElement {
return <SplitLineComponent {...this.props} renderer={this} />;
}
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
SplitLineRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SplitLineRenderer.enableHotReload();
}

View File

@@ -0,0 +1,73 @@
/**
* 캔버스 분할선 글로벌 스토어
*
* React Context를 우회하여 useSyncExternalStore로 직접 상태를 공유.
* SplitLineComponent가 드래그 시 이 스토어를 업데이트하고,
* RealtimePreviewDynamic이 구독하여 컴포넌트 위치를 조정.
*/
export interface CanvasSplitState {
/** 스플릿선의 초기 X 위치 (캔버스 기준 px) */
initialDividerX: number;
/** 스플릿선의 현재 X 위치 (드래그 중 변경) */
currentDividerX: number;
/** 캔버스 전체 너비 (px) */
canvasWidth: number;
/** 드래그 진행 중 여부 */
isDragging: boolean;
/** 활성 여부 (스플릿선이 등록되었는지) */
active: boolean;
/** 스코프 ID (같은 data-screen-runtime 컨테이너의 컴포넌트만 영향) */
scopeId: string;
}
let state: CanvasSplitState = {
initialDividerX: 0,
currentDividerX: 0,
canvasWidth: 0,
isDragging: false,
active: false,
scopeId: "",
};
const listeners = new Set<() => void>();
export function setCanvasSplit(updates: Partial<CanvasSplitState>): void {
state = { ...state, ...updates };
listeners.forEach((fn) => fn());
}
export function resetCanvasSplit(): void {
state = {
initialDividerX: 0,
currentDividerX: 0,
canvasWidth: 0,
isDragging: false,
active: false,
scopeId: "",
};
listeners.forEach((fn) => fn());
}
export function subscribe(callback: () => void): () => void {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
}
export function getSnapshot(): CanvasSplitState {
return state;
}
// SSR 호환
export function getServerSnapshot(): CanvasSplitState {
return {
initialDividerX: 0,
currentDividerX: 0,
canvasWidth: 0,
isDragging: false,
active: false,
scopeId: "",
};
}

View File

@@ -0,0 +1,41 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SplitLineWrapper } from "./SplitLineComponent";
import { SplitLineConfigPanel } from "./SplitLineConfigPanel";
import { SplitLineConfig } from "./types";
/**
* SplitLine 컴포넌트 정의
* 캔버스를 좌우로 분할하는 드래그 가능한 세로 분할선
*/
export const V2SplitLineDefinition = createComponentDefinition({
id: "v2-split-line",
name: "스플릿선",
nameEng: "SplitLine Component",
description: "캔버스를 좌우로 분할하는 드래그 가능한 분할선",
category: ComponentCategory.LAYOUT,
webType: "text",
component: SplitLineWrapper,
defaultConfig: {
resizable: true,
lineColor: "#e2e8f0",
lineWidth: 4,
} as SplitLineConfig,
defaultSize: { width: 8, height: 600 },
configPanel: SplitLineConfigPanel,
icon: "SeparatorVertical",
tags: ["스플릿", "분할", "분할선", "레이아웃"],
version: "1.0.0",
author: "개발팀",
documentation: "",
});
// 타입 내보내기
export type { SplitLineConfig } from "./types";
// 컴포넌트 내보내기
export { SplitLineComponent } from "./SplitLineComponent";
export { SplitLineRenderer } from "./SplitLineRenderer";

View File

@@ -0,0 +1,28 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* SplitLine 컴포넌트 설정 타입
* 캔버스를 좌우로 분할하는 드래그 가능한 분할선
*
* 초기 분할 지점은 캔버스 위 X 위치로 결정됨 (별도 splitRatio 불필요)
*/
export interface SplitLineConfig extends ComponentConfig {
// 드래그 리사이즈 허용 여부
resizable?: boolean;
// 분할선 스타일
lineColor?: string;
lineWidth?: number;
}
/**
* SplitLine 컴포넌트 Props 타입
*/
export interface SplitLineProps {
id?: string;
config?: SplitLineConfig;
className?: string;
style?: React.CSSProperties;
}

View File

@@ -26,6 +26,8 @@ export interface SplitPanelInfo {
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
panelType?: "component" | "canvas";
}
export interface SplitPanelResizeContextValue {