ERP-node/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx

318 lines
9.1 KiB
TypeScript

"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]);
// 드래그 중 최종 오프셋 (DOM 직접 조작용)
const latestOffsetRef = useRef(dragOffset);
latestOffsetRef.current = dragOffset;
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 = latestOffsetRef.current;
const scaleFactor = getScaleFactor();
const cw = detectCanvasWidth();
const MIN_POS = Math.max(50, cw * 0.15);
const MAX_POS = cw - Math.max(50, cw * 0.15);
setIsDragging(true);
setCanvasSplit({ isDragging: true });
const handleMouseMove = (moveEvent: MouseEvent) => {
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;
latestOffsetRef.current = newOffset;
// 스플릿선 자체는 DOM 직접 조작 (React 리렌더 없음)
if (containerRef.current) {
containerRef.current.style.transform = `translateX(${newOffset}px)`;
}
// 스토어 업데이트 → DOM 리스너만 호출 (React 리렌더 없음)
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 = "";
// 최종 오프셋을 React 상태에 동기화 (1회만 리렌더)
setDragOffset(latestOffsetRef.current);
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, 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} />;
};