2026-02-24 09:29:44 +09:00
|
|
|
"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();
|
2026-02-24 10:49:23 +09:00
|
|
|
const MIN_POS = Math.max(50, cw * 0.15);
|
|
|
|
|
const MAX_POS = cw - Math.max(50, cw * 0.15);
|
2026-02-24 09:29:44 +09:00
|
|
|
|
|
|
|
|
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} />;
|
|
|
|
|
};
|