jskim-node #393
|
|
@ -164,6 +164,7 @@ export const componentDefaults: Record<string, any> = {
|
|||
"v2-date": { type: "v2-date", webType: "date" },
|
||||
"v2-repeater": { type: "v2-repeater", webType: "custom" },
|
||||
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
|
||||
"v2-split-line": { type: "v2-split-line", webType: "custom", resizable: true, lineWidth: 4 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ childItemsSection: {
|
|||
"config": {
|
||||
"masterPanel": {
|
||||
"title": "BOM 목록",
|
||||
"entityId": "bom_header",
|
||||
"entityId": "bom",
|
||||
"columns": [
|
||||
{ "id": "item_code", "label": "품목코드", "width": 100 },
|
||||
{ "id": "item_name", "label": "품목명", "width": 150 },
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
|
|
|
|||
|
|
@ -1372,6 +1372,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
) : screenData ? (
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -20,6 +20,11 @@ import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
|||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
|
||||
import {
|
||||
subscribe as canvasSplitSubscribe,
|
||||
getSnapshot as canvasSplitGetSnapshot,
|
||||
getServerSnapshot as canvasSplitGetServerSnapshot,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
@ -82,9 +87,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
parentTabId,
|
||||
parentTabsComponentId,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { isPreviewMode } = useScreenPreview();
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
|
||||
// 캔버스 분할선 글로벌 스토어 구독
|
||||
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
|
||||
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
|
||||
const myScopeIdRef = React.useRef<string | null>(null);
|
||||
|
||||
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
|
||||
const userName = externalUserName || authUserName;
|
||||
|
|
@ -1079,22 +1089,96 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||
|
||||
// 캔버스 분할선에 따른 X 위치 조정 (너비는 변경하지 않음 - 내부 컴포넌트 깨짐 방지)
|
||||
const calculateCanvasSplitX = (): number => {
|
||||
const compType = (component as any).componentType || "";
|
||||
const isSplitLine = type === "component" && compType === "v2-split-line";
|
||||
const origX = position?.x || 0;
|
||||
|
||||
if (isSplitLine) return origX;
|
||||
|
||||
// DEBUG: 스플릿 스토어 상태 확인 (첫 컴포넌트만)
|
||||
if (canvasSplit.active && origX > 0 && origX < 50) {
|
||||
console.log("[SplitDebug]", {
|
||||
compId: component.id,
|
||||
compType,
|
||||
type,
|
||||
active: canvasSplit.active,
|
||||
scopeId: canvasSplit.scopeId,
|
||||
myScopeId: myScopeIdRef.current,
|
||||
canvasWidth: canvasSplit.canvasWidth,
|
||||
initialX: canvasSplit.initialDividerX,
|
||||
currentX: canvasSplit.currentDividerX,
|
||||
origX,
|
||||
});
|
||||
}
|
||||
|
||||
if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) {
|
||||
return origX;
|
||||
}
|
||||
|
||||
if (myScopeIdRef.current === null) {
|
||||
const el = document.getElementById(`interactive-${component.id}`);
|
||||
const container = el?.closest("[data-screen-runtime]");
|
||||
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
|
||||
console.log("[SplitDebug] scope resolved:", { compId: component.id, elFound: !!el, containerFound: !!container, myScopeId: myScopeIdRef.current, storeScopeId: canvasSplit.scopeId });
|
||||
}
|
||||
if (myScopeIdRef.current !== canvasSplit.scopeId) {
|
||||
return origX;
|
||||
}
|
||||
|
||||
const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
if (Math.abs(delta) < 1) return origX;
|
||||
|
||||
const origW = size?.width || 200;
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
const componentCenterX = origX + (origW / 2);
|
||||
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
let newX = origX;
|
||||
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
if (initialDividerX > 0) {
|
||||
newX = origX * (currentDividerX / initialDividerX);
|
||||
}
|
||||
} else {
|
||||
const initialRightWidth = canvasWidth - initialDividerX;
|
||||
const currentRightWidth = canvasWidth - currentDividerX;
|
||||
if (initialRightWidth > 0) {
|
||||
const posRatio = (origX - initialDividerX) / initialRightWidth;
|
||||
newX = currentDividerX + posRatio * currentRightWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// 캔버스 범위 내로 클램핑
|
||||
return Math.max(0, Math.min(newX, canvasWidth - 10));
|
||||
};
|
||||
|
||||
const adjustedX = calculateCanvasSplitX();
|
||||
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
|
||||
left: adjustedX,
|
||||
top: position?.y || 0,
|
||||
zIndex: position?.z || 1,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
...styleWithoutSize,
|
||||
width: size?.width || 200,
|
||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
|
||||
overflow: labelOffset > 0 ? "visible" : undefined,
|
||||
// GPU 가속: 드래그 중 will-change 활성화, 끝나면 해제
|
||||
willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined,
|
||||
transition: isSplitActive
|
||||
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out")
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
<div id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||
{renderInteractiveWidget(component)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useSyncExternalStore } from "react";
|
||||
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import {
|
||||
|
|
@ -17,6 +17,11 @@ import {
|
|||
File,
|
||||
} from "lucide-react";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
import {
|
||||
subscribe as canvasSplitSubscribe,
|
||||
getSnapshot as canvasSplitGetSnapshot,
|
||||
getServerSnapshot as canvasSplitGetServerSnapshot,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
// 컴포넌트 렌더러들 자동 등록
|
||||
|
|
@ -388,10 +393,12 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
}
|
||||
: component;
|
||||
|
||||
// 🆕 분할 패널 리사이즈 Context
|
||||
// 기존 분할 패널 리사이즈 Context (레거시 split-panel-layout용)
|
||||
const splitPanelContext = useSplitPanel();
|
||||
|
||||
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||
// 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독)
|
||||
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
|
||||
|
||||
const componentType = (component as any).componentType || "";
|
||||
const componentId = (component as any).componentId || "";
|
||||
const widgetType = (component as any).widgetType || "";
|
||||
|
|
@ -402,110 +409,113 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary"].includes(componentId)));
|
||||
|
||||
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
|
||||
// 레거시 분할 패널용 refs
|
||||
const initialPanelRatioRef = React.useRef<number | null>(null);
|
||||
const initialPanelIdRef = React.useRef<string | null>(null);
|
||||
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
|
||||
const isInLeftPanelRef = React.useRef<boolean | null>(null);
|
||||
|
||||
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||
const calculateButtonPosition = () => {
|
||||
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||
// 캔버스 분할선 좌/우 판정 (한 번만)
|
||||
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
|
||||
// 스코프 체크 캐시 (DOM 쿼리 최소화)
|
||||
const myScopeIdRef = React.useRef<string | null>(null);
|
||||
|
||||
const calculateSplitAdjustedPosition = () => {
|
||||
const isSplitLineComponent =
|
||||
type === "component" && componentType === "v2-split-line";
|
||||
|
||||
if (isSplitLineComponent) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
// === 1. 캔버스 분할선 (글로벌 스토어) - 위치만 조정, 너비 미변경 ===
|
||||
if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) {
|
||||
if (myScopeIdRef.current === null) {
|
||||
const el = document.getElementById(`component-${id}`);
|
||||
const container = el?.closest("[data-screen-runtime]");
|
||||
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
|
||||
}
|
||||
if (myScopeIdRef.current !== canvasSplit.scopeId) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
const origW = size?.width || 100;
|
||||
const componentCenterX = position.x + (origW / 2);
|
||||
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
if (Math.abs(delta) < 1) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
|
||||
}
|
||||
|
||||
let adjustedX = position.x;
|
||||
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
if (initialDividerX > 0) {
|
||||
adjustedX = position.x * (currentDividerX / initialDividerX);
|
||||
}
|
||||
} else {
|
||||
const initialRightWidth = canvasWidth - initialDividerX;
|
||||
const currentRightWidth = canvasWidth - currentDividerX;
|
||||
if (initialRightWidth > 0) {
|
||||
const posRatio = (position.x - initialDividerX) / initialRightWidth;
|
||||
adjustedX = currentDividerX + posRatio * currentRightWidth;
|
||||
}
|
||||
}
|
||||
|
||||
adjustedX = Math.max(0, Math.min(adjustedX, canvasWidth - 10));
|
||||
|
||||
return { adjustedPositionX: adjustedX, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
|
||||
}
|
||||
|
||||
// === 2. 레거시 분할 패널 (Context) - 버튼 전용 ===
|
||||
const isSplitPanelComponent =
|
||||
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
||||
|
||||
if (!isButtonComponent || isSplitPanelComponent) {
|
||||
if (isSplitPanelComponent) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
if (!isButtonComponent) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const componentWidth = size?.width || 100;
|
||||
const componentHeight = size?.height || 40;
|
||||
|
||||
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
|
||||
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
||||
|
||||
// 분할 패널 위에 없으면 기준점 초기화
|
||||
if (!overlap) {
|
||||
if (initialPanelIdRef.current !== null) {
|
||||
initialPanelRatioRef.current = null;
|
||||
initialPanelIdRef.current = null;
|
||||
isInLeftPanelRef.current = null;
|
||||
}
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: false,
|
||||
isDraggingSplitPanel: false,
|
||||
};
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const { panel } = overlap;
|
||||
|
||||
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
|
||||
if (initialPanelIdRef.current !== overlap.panelId) {
|
||||
initialPanelRatioRef.current = panel.leftWidthPercent;
|
||||
initialPanelRatioRef.current = panel.initialLeftWidthPercent;
|
||||
initialPanelIdRef.current = overlap.panelId;
|
||||
|
||||
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
|
||||
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
|
||||
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100;
|
||||
const componentCenterX = position.x + componentWidth / 2;
|
||||
const relativeX = componentCenterX - panel.x;
|
||||
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
|
||||
|
||||
isInLeftPanelRef.current = wasInLeftPanel;
|
||||
console.log("📌 [버튼 기준점 설정]:", {
|
||||
componentId: component.id,
|
||||
panelId: overlap.panelId,
|
||||
initialRatio: panel.leftWidthPercent,
|
||||
isInLeftPanel: wasInLeftPanel,
|
||||
buttonCenterX: componentCenterX,
|
||||
leftPanelWidth: initialLeftPanelWidth,
|
||||
});
|
||||
isInLeftPanelRef.current = componentCenterX < initialDividerX;
|
||||
}
|
||||
|
||||
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
|
||||
if (!isInLeftPanelRef.current) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
}
|
||||
const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent;
|
||||
const initialDividerX = panel.x + (panel.width * baseRatio) / 100;
|
||||
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100;
|
||||
const dividerDelta = currentDividerX - initialDividerX;
|
||||
|
||||
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
|
||||
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
|
||||
|
||||
// 기준 비율 대비 현재 비율로 분할선 위치 계산
|
||||
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
|
||||
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
|
||||
|
||||
// 분할선 이동량 (px)
|
||||
const dividerDelta = currentDividerX - baseDividerX;
|
||||
|
||||
// 변화가 없으면 원래 위치 반환
|
||||
if (Math.abs(dividerDelta) < 1) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging };
|
||||
}
|
||||
|
||||
// 🆕 버튼도 분할선과 같은 양만큼 이동
|
||||
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
|
||||
const adjustedX = position.x + dividerDelta;
|
||||
|
||||
console.log("📍 [버튼 위치 조정]:", {
|
||||
componentId: component.id,
|
||||
originalX: position.x,
|
||||
adjustedX,
|
||||
dividerDelta,
|
||||
baseRatio,
|
||||
currentRatio: panel.leftWidthPercent,
|
||||
baseDividerX,
|
||||
currentDividerX,
|
||||
isDragging: panel.isDragging,
|
||||
});
|
||||
const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x;
|
||||
|
||||
return {
|
||||
adjustedPositionX: adjustedX,
|
||||
|
|
@ -514,25 +524,25 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
};
|
||||
};
|
||||
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition();
|
||||
|
||||
// 🆕 리사이즈 크기가 있으면 우선 사용
|
||||
// (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정)
|
||||
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
|
||||
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
|
||||
|
||||
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
|
||||
|
||||
const baseStyle = {
|
||||
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
||||
left: `${adjustedPositionX}px`,
|
||||
top: `${position.y}px`,
|
||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||
width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
...componentStyle,
|
||||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음
|
||||
willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined,
|
||||
transition:
|
||||
isResizing ? "none" :
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out") : undefined,
|
||||
};
|
||||
|
||||
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
||||
|
|
|
|||
|
|
@ -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 트리 뷰
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export interface SplitPanelInfo {
|
|||
initialLeftWidthPercent: number;
|
||||
// 드래그 중 여부
|
||||
isDragging: boolean;
|
||||
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
|
||||
panelType?: "component" | "canvas";
|
||||
}
|
||||
|
||||
export interface SplitPanelResizeContextValue {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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: "",
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ export interface SplitPanelInfo {
|
|||
initialLeftWidthPercent: number;
|
||||
// 드래그 중 여부
|
||||
isDragging: boolean;
|
||||
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
|
||||
panelType?: "component" | "canvas";
|
||||
}
|
||||
|
||||
export interface SplitPanelResizeContextValue {
|
||||
|
|
|
|||
|
|
@ -689,6 +689,12 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
|
|||
"v2-repeater": v2V2RepeaterOverridesSchema,
|
||||
|
||||
// V2 컴포넌트 (9개)
|
||||
"v2-split-line": z.object({
|
||||
resizable: z.boolean().default(true),
|
||||
lineColor: z.string().default("#e2e8f0"),
|
||||
lineWidth: z.number().default(4),
|
||||
}).passthrough(),
|
||||
|
||||
"v2-input": v2InputOverridesSchema,
|
||||
"v2-select": v2SelectOverridesSchema,
|
||||
"v2-date": v2DateOverridesSchema,
|
||||
|
|
@ -738,6 +744,11 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
|||
autoLoad: true,
|
||||
syncSelection: true,
|
||||
},
|
||||
"v2-split-line": {
|
||||
resizable: true,
|
||||
lineColor: "#e2e8f0",
|
||||
lineWidth: 4,
|
||||
},
|
||||
"v2-section-card": {
|
||||
title: "섹션 제목",
|
||||
description: "",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
|||
"split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"),
|
||||
"screen-split-panel": () => import("@/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel"),
|
||||
"conditional-container": () => import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
||||
"v2-split-line": () => import("@/lib/registry/components/v2-split-line/SplitLineConfigPanel"),
|
||||
|
||||
// ========== 테이블/리스트 ==========
|
||||
"table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue