789 lines
28 KiB
TypeScript
789 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState, useEffect } from "react";
|
||
import { ComponentConfig } from "@/types/report";
|
||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||
|
||
const MM_TO_PX = 4;
|
||
const HANDLE_SIZE = 8;
|
||
const HANDLE_PX = `${HANDLE_SIZE}px`;
|
||
|
||
type HandleDef = {
|
||
dir: string;
|
||
cursor: string;
|
||
size: string;
|
||
top?: string;
|
||
left?: string;
|
||
right?: string;
|
||
bottom?: string;
|
||
transform: string;
|
||
};
|
||
|
||
const RESIZE_HANDLES_ALL: HandleDef[] = [
|
||
{ dir: "nw", cursor: "cursor-nw-resize", size: HANDLE_PX, top: "0", left: "0", transform: "translate(-50%,-50%)" },
|
||
{ dir: "n", cursor: "cursor-n-resize", size: HANDLE_PX, top: "0", left: "50%", transform: "translate(-50%,-50%)" },
|
||
{ dir: "ne", cursor: "cursor-ne-resize", size: HANDLE_PX, top: "0", right: "0", transform: "translate(50%,-50%)" },
|
||
{ dir: "w", cursor: "cursor-w-resize", size: HANDLE_PX, top: "50%", left: "0", transform: "translate(-50%,-50%)" },
|
||
{ dir: "e", cursor: "cursor-e-resize", size: HANDLE_PX, top: "50%", right: "0", transform: "translate(50%,-50%)" },
|
||
{ dir: "sw", cursor: "cursor-sw-resize", size: HANDLE_PX, bottom: "0", left: "0", transform: "translate(-50%,50%)" },
|
||
{ dir: "s", cursor: "cursor-s-resize", size: HANDLE_PX, bottom: "0", left: "50%", transform: "translate(-50%,50%)" },
|
||
{ dir: "se", cursor: "cursor-se-resize", size: HANDLE_PX, bottom: "0", right: "0", transform: "translate(50%,50%)" },
|
||
];
|
||
|
||
const RESIZE_HANDLES_HORIZONTAL_DIVIDER: HandleDef[] = [
|
||
{ dir: "w", cursor: "cursor-w-resize", size: HANDLE_PX, top: "50%", left: "0", transform: "translate(-50%,-50%)" },
|
||
{ dir: "e", cursor: "cursor-e-resize", size: HANDLE_PX, top: "50%", right: "0", transform: "translate(50%,-50%)" },
|
||
];
|
||
|
||
const RESIZE_HANDLES_VERTICAL_DIVIDER: HandleDef[] = [
|
||
{ dir: "n", cursor: "cursor-n-resize", size: HANDLE_PX, top: "0", left: "50%", transform: "translate(-50%,-50%)" },
|
||
{ dir: "s", cursor: "cursor-s-resize", size: HANDLE_PX, bottom: "0", left: "50%", transform: "translate(-50%,50%)" },
|
||
];
|
||
|
||
import {
|
||
TextRenderer,
|
||
TableRenderer,
|
||
ImageRenderer,
|
||
DividerRenderer,
|
||
SignatureRenderer,
|
||
StampRenderer,
|
||
PageNumberRenderer,
|
||
CardRenderer,
|
||
CalculationRenderer,
|
||
BarcodeCanvasRenderer,
|
||
CheckboxRenderer,
|
||
} from "./renderers";
|
||
|
||
interface CanvasComponentProps {
|
||
component: ComponentConfig;
|
||
}
|
||
|
||
export function CanvasComponent({ component }: CanvasComponentProps) {
|
||
const {
|
||
components,
|
||
selectedComponentId,
|
||
selectedComponentIds,
|
||
selectComponent,
|
||
selectMultipleComponents,
|
||
updateComponent,
|
||
getQueryResult,
|
||
snapValueToGrid,
|
||
calculateAlignmentGuides,
|
||
clearAlignmentGuides,
|
||
canvasWidth,
|
||
canvasHeight,
|
||
margins,
|
||
layoutConfig,
|
||
currentPageId,
|
||
duplicateAtPosition,
|
||
openComponentModal,
|
||
removeComponent,
|
||
} = useReportDesigner();
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [isResizing, setIsResizing] = useState(false);
|
||
const [resizeDirection, setResizeDirection] = useState("");
|
||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0, compX: 0, compY: 0 });
|
||
const componentRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Alt+드래그 복제를 위한 상태
|
||
const [isAltDuplicating, setIsAltDuplicating] = useState(false);
|
||
const duplicatedIdsRef = useRef<string[]>([]);
|
||
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
|
||
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||
|
||
const isSelected = selectedComponentId === component.id;
|
||
const isMultiSelected = selectedComponentIds.includes(component.id);
|
||
const isLocked = component.locked === true;
|
||
const isGrouped = !!component.groupId;
|
||
|
||
// 표시할 값 결정
|
||
const getDisplayValue = (): string => {
|
||
// 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
|
||
if (component.queryId && component.fieldName) {
|
||
const queryResult = getQueryResult(component.queryId);
|
||
|
||
// 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
|
||
if (queryResult && queryResult.rows.length > 0) {
|
||
const firstRow = queryResult.rows[0];
|
||
const value = firstRow[component.fieldName];
|
||
|
||
// 값이 있으면 문자열로 변환하여 반환
|
||
if (value !== null && value !== undefined) {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
// 실행 결과가 없거나 값이 없으면 필드명 표시
|
||
return `{${component.fieldName}}`;
|
||
}
|
||
|
||
// 기본값이 있으면 기본값 표시
|
||
if (component.defaultValue) {
|
||
return component.defaultValue;
|
||
}
|
||
|
||
// 둘 다 없으면 타입에 따라 기본 텍스트
|
||
return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
|
||
};
|
||
|
||
// 텍스트 컴포넌트: 더블 클릭 시 컨텐츠에 맞게 크기 조절
|
||
const fitTextToContent = () => {
|
||
if (isLocked) return;
|
||
if (component.type !== "text" && component.type !== "label") return;
|
||
|
||
const minWidth = 50;
|
||
const minHeight = 30;
|
||
|
||
// 여백을 px로 변환
|
||
const marginRightPx = margins.right * MM_TO_PX;
|
||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||
|
||
// 최대 크기 (여백 고려)
|
||
const maxWidth = canvasWidthPx - marginRightPx - component.x;
|
||
const maxHeight = canvasHeightPx - marginBottomPx - component.y;
|
||
|
||
const displayValue = getDisplayValue();
|
||
const fontSize = component.fontSize || 14;
|
||
|
||
// 줄바꿈으로 분리하여 각 줄의 너비 측정
|
||
const lines = displayValue.split("\n");
|
||
let maxLineWidth = 0;
|
||
|
||
lines.forEach((line) => {
|
||
const measureEl = document.createElement("span");
|
||
measureEl.style.position = "absolute";
|
||
measureEl.style.visibility = "hidden";
|
||
measureEl.style.whiteSpace = "nowrap";
|
||
measureEl.style.fontSize = `${fontSize}px`;
|
||
measureEl.style.fontWeight = component.fontWeight || "normal";
|
||
measureEl.style.fontFamily = component.fontFamily || "Malgun Gothic";
|
||
measureEl.textContent = line || " "; // 빈 줄은 공백으로
|
||
document.body.appendChild(measureEl);
|
||
|
||
const lineWidth = measureEl.getBoundingClientRect().width;
|
||
maxLineWidth = Math.max(maxLineWidth, lineWidth);
|
||
document.body.removeChild(measureEl);
|
||
});
|
||
|
||
// 컴포넌트 padding (p-2 = 8px * 2) + 여유분
|
||
const horizontalPadding = 24;
|
||
const verticalPadding = 20;
|
||
|
||
// 줄 높이 계산 (font-size * line-height 약 1.5)
|
||
const lineHeight = fontSize * 1.5;
|
||
const totalHeight = lines.length * lineHeight;
|
||
|
||
const finalWidth = Math.min(maxLineWidth + horizontalPadding, maxWidth);
|
||
const finalHeight = Math.min(totalHeight + verticalPadding, maxHeight);
|
||
|
||
const newWidth = Math.max(minWidth, finalWidth);
|
||
const newHeight = Math.max(minHeight, finalHeight);
|
||
|
||
// 크기 업데이트
|
||
updateComponent(component.id, {
|
||
width: snapValueToGrid(newWidth),
|
||
height: snapValueToGrid(newHeight),
|
||
});
|
||
};
|
||
|
||
// 더블 클릭 → 인캔버스 설정 모달 오픈 (Phase 5)
|
||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||
if (isLocked) return;
|
||
if (component.type === "divider") return;
|
||
e.stopPropagation();
|
||
openComponentModal(component.id);
|
||
};
|
||
|
||
// 드래그 시작
|
||
const handleMouseDown = (e: React.MouseEvent) => {
|
||
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
|
||
return;
|
||
}
|
||
|
||
// 잠긴 컴포넌트는 드래그 불가
|
||
if (isLocked) {
|
||
e.stopPropagation();
|
||
// Ctrl/Cmd 키 감지 (다중 선택)
|
||
const isMultiSelect = e.ctrlKey || e.metaKey;
|
||
selectComponent(component.id, isMultiSelect);
|
||
return;
|
||
}
|
||
|
||
e.stopPropagation();
|
||
|
||
// Ctrl/Cmd 키 감지 (다중 선택)
|
||
const isMultiSelect = e.ctrlKey || e.metaKey;
|
||
// Alt 키 감지 (복제 드래그)
|
||
const isAltPressed = e.altKey;
|
||
|
||
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
|
||
const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
|
||
|
||
if (!isPartOfMultiSelection) {
|
||
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
|
||
if (isGrouped && !isMultiSelect) {
|
||
const groupMembers = components.filter((c) => c.groupId === component.groupId);
|
||
const groupMemberIds = groupMembers.map((c) => c.id);
|
||
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
|
||
selectComponent(groupMemberIds[0], false);
|
||
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
|
||
} else {
|
||
selectComponent(component.id, isMultiSelect);
|
||
}
|
||
}
|
||
|
||
// Alt+드래그: 복제 모드
|
||
if (isAltPressed) {
|
||
// 복제할 컴포넌트 ID 목록 결정
|
||
let idsToClone: string[] = [];
|
||
|
||
if (isPartOfMultiSelection) {
|
||
// 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제
|
||
idsToClone = selectedComponentIds.filter((id) => {
|
||
const c = components.find((comp) => comp.id === id);
|
||
return c && !c.locked;
|
||
});
|
||
} else if (isGrouped) {
|
||
// 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제
|
||
idsToClone = components.filter((c) => c.groupId === component.groupId && !c.locked).map((c) => c.id);
|
||
} else {
|
||
// 단일 컴포넌트
|
||
idsToClone = [component.id];
|
||
}
|
||
|
||
if (idsToClone.length > 0) {
|
||
// 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용)
|
||
const positionsMap = new Map<string, { x: number; y: number }>();
|
||
idsToClone.forEach((id) => {
|
||
const comp = components.find((c) => c.id === id);
|
||
if (comp) {
|
||
positionsMap.set(id, { x: comp.x, y: comp.y });
|
||
}
|
||
});
|
||
|
||
// 복제 생성 (오프셋 없이 원래 위치에)
|
||
const newIds = duplicateAtPosition(idsToClone, 0, 0);
|
||
if (newIds.length > 0) {
|
||
// 복제된 컴포넌트 ID와 원본 위치 매핑
|
||
// newIds[i]는 idsToClone[i]에서 복제됨
|
||
const dupPositionsMap = new Map<string, { x: number; y: number }>();
|
||
newIds.forEach((newId, index) => {
|
||
const originalId = idsToClone[index];
|
||
const originalPos = positionsMap.get(originalId);
|
||
if (originalPos) {
|
||
dupPositionsMap.set(newId, originalPos);
|
||
}
|
||
});
|
||
originalPositionsRef.current = dupPositionsMap;
|
||
|
||
// 복제된 컴포넌트들을 선택하고 드래그 시작
|
||
duplicatedIdsRef.current = newIds;
|
||
setIsAltDuplicating(true);
|
||
|
||
// 복제된 컴포넌트들 선택
|
||
if (newIds.length === 1) {
|
||
selectComponent(newIds[0], false);
|
||
} else {
|
||
selectMultipleComponents(newIds);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
setIsDragging(true);
|
||
|
||
const hitAreaOffsetY = isHorizontalDivider ? (DIVIDER_HIT_AREA - component.height) / 2 : 0;
|
||
const hitAreaOffsetX = isVerticalDivider ? (DIVIDER_HIT_AREA - component.width) / 2 : 0;
|
||
setDragStart({
|
||
x: e.clientX - component.x + hitAreaOffsetX,
|
||
y: e.clientY - component.y + hitAreaOffsetY,
|
||
});
|
||
};
|
||
|
||
// 리사이즈 시작 (방향 인자)
|
||
const handleResizeStart = (e: React.MouseEvent, direction: string) => {
|
||
if (isLocked) {
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
e.stopPropagation();
|
||
setIsResizing(true);
|
||
setResizeDirection(direction);
|
||
setResizeStart({
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
width: component.width,
|
||
height: component.height,
|
||
compX: component.x,
|
||
compY: component.y,
|
||
});
|
||
};
|
||
|
||
// 마우스 이동 핸들러 (전역)
|
||
useEffect(() => {
|
||
if (!isDragging && !isResizing) return;
|
||
|
||
const handleMouseMove = (e: MouseEvent) => {
|
||
if (isDragging) {
|
||
const newX = Math.max(0, e.clientX - dragStart.x);
|
||
const newY = Math.max(0, e.clientY - dragStart.y);
|
||
|
||
// 여백을 px로 변환
|
||
const marginTopPx = margins.top * MM_TO_PX;
|
||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||
const marginLeftPx = margins.left * MM_TO_PX;
|
||
const marginRightPx = margins.right * MM_TO_PX;
|
||
|
||
// 캔버스 경계 체크 (mm를 px로 변환)
|
||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||
|
||
// 컴포넌트가 여백 안에 있도록 제한
|
||
const minX = marginLeftPx;
|
||
const minY = marginTopPx;
|
||
const maxX = canvasWidthPx - marginRightPx - component.width;
|
||
const maxY = canvasHeightPx - marginBottomPx - component.height;
|
||
|
||
const boundedX = Math.min(Math.max(minX, newX), maxX);
|
||
const boundedY = Math.min(Math.max(minY, newY), maxY);
|
||
|
||
const snappedX = snapValueToGrid(boundedX);
|
||
const snappedY = snapValueToGrid(boundedY);
|
||
|
||
// 정렬 가이드라인 계산
|
||
calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height);
|
||
|
||
// 이동 거리 계산
|
||
const deltaX = snappedX - component.x;
|
||
const deltaY = snappedY - component.y;
|
||
|
||
// Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동
|
||
if (isAltDuplicating && duplicatedIdsRef.current.length > 0) {
|
||
// 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정)
|
||
duplicatedIdsRef.current.forEach((dupId) => {
|
||
const dupComp = components.find((c) => c.id === dupId);
|
||
const originalPos = originalPositionsRef.current.get(dupId);
|
||
|
||
if (dupComp && originalPos) {
|
||
// 각 복제본의 원본 위치에서 delta만큼 이동
|
||
const targetX = originalPos.x + deltaX;
|
||
const targetY = originalPos.y + deltaY;
|
||
|
||
// 경계 체크
|
||
const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width;
|
||
const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height;
|
||
|
||
updateComponent(dupId, {
|
||
x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX),
|
||
y: Math.min(Math.max(marginTopPx, targetY), dupMaxY),
|
||
});
|
||
}
|
||
});
|
||
return; // 원본 컴포넌트는 이동하지 않음
|
||
}
|
||
|
||
// 현재 컴포넌트 이동
|
||
updateComponent(component.id, {
|
||
x: snappedX,
|
||
y: snappedY,
|
||
});
|
||
|
||
// 다중 선택된 경우: 선택된 다른 컴포넌트들도 함께 이동
|
||
if (selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id)) {
|
||
components.forEach((c) => {
|
||
// 현재 컴포넌트는 이미 이동됨, 잠긴 컴포넌트는 제외
|
||
if (c.id !== component.id && selectedComponentIds.includes(c.id) && !c.locked) {
|
||
const newMultiX = c.x + deltaX;
|
||
const newMultiY = c.y + deltaY;
|
||
|
||
// 경계 체크
|
||
const multiMaxX = canvasWidthPx - marginRightPx - c.width;
|
||
const multiMaxY = canvasHeightPx - marginBottomPx - c.height;
|
||
|
||
updateComponent(c.id, {
|
||
x: Math.min(Math.max(marginLeftPx, newMultiX), multiMaxX),
|
||
y: Math.min(Math.max(marginTopPx, newMultiY), multiMaxY),
|
||
});
|
||
}
|
||
});
|
||
}
|
||
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
|
||
else if (isGrouped) {
|
||
components.forEach((c) => {
|
||
if (c.groupId === component.groupId && c.id !== component.id) {
|
||
const newGroupX = c.x + deltaX;
|
||
const newGroupY = c.y + deltaY;
|
||
|
||
// 그룹 컴포넌트도 경계 체크
|
||
const groupMaxX = canvasWidthPx - c.width;
|
||
const groupMaxY = canvasHeightPx - c.height;
|
||
|
||
updateComponent(c.id, {
|
||
x: Math.min(Math.max(0, newGroupX), groupMaxX),
|
||
y: Math.min(Math.max(0, newGroupY), groupMaxY),
|
||
});
|
||
}
|
||
});
|
||
}
|
||
} else if (isResizing) {
|
||
const deltaX = e.clientX - resizeStart.x;
|
||
const deltaY = e.clientY - resizeStart.y;
|
||
|
||
const marginTopPx = margins.top * MM_TO_PX;
|
||
const marginLeftPx = margins.left * MM_TO_PX;
|
||
const marginRightPx = margins.right * MM_TO_PX;
|
||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||
|
||
const dir = resizeDirection;
|
||
const resizesLeft = dir.includes("w");
|
||
const resizesRight = dir.includes("e");
|
||
const resizesTop = dir.includes("n");
|
||
const resizesBottom = dir.includes("s");
|
||
|
||
let newX = resizeStart.compX;
|
||
let newY = resizeStart.compY;
|
||
let newW = resizeStart.width;
|
||
let newH = resizeStart.height;
|
||
|
||
if (resizesRight) {
|
||
newW = resizeStart.width + deltaX;
|
||
}
|
||
if (resizesLeft) {
|
||
newW = resizeStart.width - deltaX;
|
||
newX = resizeStart.compX + deltaX;
|
||
}
|
||
if (resizesBottom) {
|
||
newH = resizeStart.height + deltaY;
|
||
}
|
||
if (resizesTop) {
|
||
newH = resizeStart.height - deltaY;
|
||
newY = resizeStart.compY + deltaY;
|
||
}
|
||
|
||
const MIN_W = 20;
|
||
const MIN_H = 10;
|
||
|
||
// 최소 크기 제한 + 위치 보정
|
||
if (newW < MIN_W) {
|
||
if (resizesLeft) newX = resizeStart.compX + resizeStart.width - MIN_W;
|
||
newW = MIN_W;
|
||
}
|
||
if (newH < MIN_H) {
|
||
if (resizesTop) newY = resizeStart.compY + resizeStart.height - MIN_H;
|
||
newH = MIN_H;
|
||
}
|
||
|
||
// 캔버스 경계 제한
|
||
if (newX < marginLeftPx) {
|
||
newW -= marginLeftPx - newX;
|
||
newX = marginLeftPx;
|
||
}
|
||
if (newY < marginTopPx) {
|
||
newH -= marginTopPx - newY;
|
||
newY = marginTopPx;
|
||
}
|
||
const maxRight = canvasWidthPx - marginRightPx;
|
||
const maxBottom = canvasHeightPx - marginBottomPx;
|
||
if (newX + newW > maxRight) newW = maxRight - newX;
|
||
if (newY + newH > maxBottom) newH = maxBottom - newY;
|
||
|
||
// 구분선: 방향에 따라 한 축만 조절
|
||
if (component.type === "divider") {
|
||
if (component.orientation === "vertical") {
|
||
updateComponent(component.id, {
|
||
y: snapValueToGrid(newY),
|
||
height: snapValueToGrid(newH),
|
||
});
|
||
} else {
|
||
updateComponent(component.id, {
|
||
x: snapValueToGrid(newX),
|
||
width: snapValueToGrid(newW),
|
||
});
|
||
}
|
||
} else if (component.type === "barcode" && component.barcodeType === "QR") {
|
||
const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
|
||
const newSize = Math.max(50, resizeStart.width + maxDelta);
|
||
const maxSize = Math.min(maxRight - component.x, maxBottom - component.y);
|
||
const snappedSize = snapValueToGrid(Math.min(newSize, maxSize));
|
||
updateComponent(component.id, { width: snappedSize, height: snappedSize });
|
||
} else {
|
||
updateComponent(component.id, {
|
||
x: snapValueToGrid(newX),
|
||
y: snapValueToGrid(newY),
|
||
width: snapValueToGrid(newW),
|
||
height: snapValueToGrid(newH),
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
setIsDragging(false);
|
||
setIsResizing(false);
|
||
// Alt 복제 상태 초기화
|
||
setIsAltDuplicating(false);
|
||
duplicatedIdsRef.current = [];
|
||
originalPositionsRef.current = new Map();
|
||
// 가이드라인 초기화
|
||
clearAlignmentGuides();
|
||
};
|
||
|
||
document.addEventListener("mousemove", handleMouseMove);
|
||
document.addEventListener("mouseup", handleMouseUp);
|
||
|
||
return () => {
|
||
document.removeEventListener("mousemove", handleMouseMove);
|
||
document.removeEventListener("mouseup", handleMouseUp);
|
||
};
|
||
}, [
|
||
isDragging,
|
||
isResizing,
|
||
isAltDuplicating,
|
||
resizeDirection,
|
||
dragStart.x,
|
||
dragStart.y,
|
||
resizeStart.x,
|
||
resizeStart.y,
|
||
resizeStart.width,
|
||
resizeStart.height,
|
||
resizeStart.compX,
|
||
resizeStart.compY,
|
||
component.id,
|
||
component.x,
|
||
component.y,
|
||
component.width,
|
||
component.height,
|
||
component.groupId,
|
||
isGrouped,
|
||
components,
|
||
updateComponent,
|
||
snapValueToGrid,
|
||
calculateAlignmentGuides,
|
||
clearAlignmentGuides,
|
||
canvasWidth,
|
||
canvasHeight,
|
||
]);
|
||
|
||
// 단일 조건 평가
|
||
const evaluateSingleRule = (rule: { queryId: string; field: string; operator: string; value: string }): boolean => {
|
||
const queryResult = getQueryResult(rule.queryId);
|
||
if (!queryResult?.rows?.length) return false;
|
||
|
||
const rawValue = queryResult.rows[0][rule.field];
|
||
const fieldStr = rawValue !== null && rawValue !== undefined ? String(rawValue) : "";
|
||
const fieldNum = parseFloat(fieldStr);
|
||
const compareNum = parseFloat(rule.value);
|
||
|
||
switch (rule.operator) {
|
||
case "eq": return fieldStr === rule.value;
|
||
case "ne": return fieldStr !== rule.value;
|
||
case "gt": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum > compareNum;
|
||
case "lt": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum < compareNum;
|
||
case "gte": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum >= compareNum;
|
||
case "lte": return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum <= compareNum;
|
||
case "contains": return fieldStr.includes(rule.value);
|
||
case "notEmpty": return fieldStr !== "";
|
||
case "empty": return fieldStr === "";
|
||
default: return false;
|
||
}
|
||
};
|
||
|
||
// 조건부 표시 규칙 평가 (다중 AND 조건 지원)
|
||
// 격자 모드일 때는 gridConditionalRules를, 일반 테이블일 때는 conditionalRules를 사용
|
||
const evaluateConditional = (): boolean => {
|
||
const isGrid = component.gridMode === true;
|
||
const rulesArr = isGrid ? component.gridConditionalRules : component.conditionalRules;
|
||
const singleRule = isGrid ? component.gridConditionalRule : component.conditionalRule;
|
||
|
||
const rules = rulesArr?.length
|
||
? rulesArr
|
||
: singleRule
|
||
? [singleRule]
|
||
: [];
|
||
|
||
if (rules.length === 0) return true;
|
||
|
||
const validRules = rules.filter((r) => r.queryId && r.field);
|
||
if (validRules.length === 0) return true;
|
||
|
||
const action = validRules[0].action;
|
||
const allMet = validRules.every((rule) => evaluateSingleRule(rule));
|
||
|
||
return action === "show" ? allMet : !allMet;
|
||
};
|
||
|
||
const isConditionalVisible = evaluateConditional();
|
||
|
||
const isDivider = component.type === "divider";
|
||
const isHorizontalDivider = isDivider && component.orientation !== "vertical";
|
||
const isVerticalDivider = isDivider && component.orientation === "vertical";
|
||
const DIVIDER_HIT_AREA = 24;
|
||
|
||
// 컴포넌트 타입별 렌더링
|
||
const renderContent = () => {
|
||
const displayValue = getDisplayValue();
|
||
|
||
switch (component.type) {
|
||
case "text":
|
||
case "label":
|
||
return <TextRenderer component={component} getQueryResult={getQueryResult} displayValue={displayValue} />;
|
||
case "table":
|
||
return <TableRenderer component={component} getQueryResult={getQueryResult} />;
|
||
case "image":
|
||
return <ImageRenderer component={component} />;
|
||
case "divider":
|
||
return (
|
||
<div
|
||
className="flex items-center justify-center"
|
||
style={{
|
||
width: `${component.width}px`,
|
||
height: `${component.height}px`,
|
||
position: "absolute",
|
||
top: isHorizontalDivider ? "50%" : 0,
|
||
left: isVerticalDivider ? "50%" : 0,
|
||
transform: `translate(${isVerticalDivider ? "-50%" : "0"}, ${isHorizontalDivider ? "-50%" : "0"})`,
|
||
}}
|
||
>
|
||
<DividerRenderer component={component} />
|
||
</div>
|
||
);
|
||
case "signature":
|
||
return <SignatureRenderer component={component} />;
|
||
case "stamp":
|
||
return <StampRenderer component={component} />;
|
||
case "pageNumber":
|
||
return <PageNumberRenderer component={component} currentPageId={currentPageId} layoutConfig={layoutConfig} />;
|
||
case "card":
|
||
return <CardRenderer component={component} getQueryResult={getQueryResult} />;
|
||
case "calculation":
|
||
return <CalculationRenderer component={component} getQueryResult={getQueryResult} />;
|
||
case "barcode":
|
||
return <BarcodeCanvasRenderer component={component} getQueryResult={getQueryResult} />;
|
||
case "checkbox":
|
||
return <CheckboxRenderer component={component} getQueryResult={getQueryResult} />;
|
||
default:
|
||
return <div>알 수 없는 컴포넌트</div>;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={componentRef}
|
||
className={`absolute ${isDivider ? "p-0" : "p-2"} shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
|
||
isSelected
|
||
? isLocked
|
||
? "ring-2 ring-red-500"
|
||
: "ring-2 ring-blue-500"
|
||
: isMultiSelected
|
||
? isLocked
|
||
? "ring-2 ring-red-300"
|
||
: "ring-2 ring-blue-300"
|
||
: ""
|
||
}`}
|
||
style={{
|
||
left: `${component.x}px`,
|
||
top: isHorizontalDivider
|
||
? `${component.y - (DIVIDER_HIT_AREA - component.height) / 2}px`
|
||
: `${component.y}px`,
|
||
width: isVerticalDivider
|
||
? `${Math.max(component.width, DIVIDER_HIT_AREA)}px`
|
||
: `${component.width}px`,
|
||
height: isHorizontalDivider
|
||
? `${Math.max(component.height, DIVIDER_HIT_AREA)}px`
|
||
: `${component.height}px`,
|
||
zIndex: component.zIndex,
|
||
backgroundColor: isDivider ? "transparent" : component.backgroundColor,
|
||
opacity: isConditionalVisible ? undefined : 0.35,
|
||
border: isDivider
|
||
? isSelected || isMultiSelected
|
||
? undefined
|
||
: "none"
|
||
: component.borderWidth
|
||
? `${component.borderWidth}px solid ${component.borderColor}`
|
||
: "1px solid #e5e7eb",
|
||
borderRadius: component.borderRadius ? `${component.borderRadius}px` : undefined,
|
||
}}
|
||
onMouseDown={handleMouseDown}
|
||
onDoubleClick={handleDoubleClick}
|
||
>
|
||
{renderContent()}
|
||
|
||
{/* 조건부 표시 미충족 오버레이 (디자인 모드에서는 숨기지 않고 표시) */}
|
||
{!isConditionalVisible && (
|
||
<div
|
||
className="pointer-events-none absolute inset-0 border-2 border-dashed border-amber-400"
|
||
title="표시 조건: 조건 미충족"
|
||
>
|
||
<div className="absolute top-0 right-0 rounded-bl bg-amber-400 px-1 py-0.5 text-[9px] font-medium text-amber-900">
|
||
조건부
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 잠금 표시 */}
|
||
{isLocked && (
|
||
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
|
||
)}
|
||
|
||
{/* 삭제 버튼 (선택 + 잠금 해제 상태에서만 표시) */}
|
||
{isSelected && !isLocked && (
|
||
<button
|
||
className={`absolute z-20 flex items-center justify-center rounded-full bg-red-500 text-white shadow hover:bg-red-600 transition-colors ${
|
||
isDivider
|
||
? "h-[18px] w-[18px] -top-5 right-1"
|
||
: "h-5 w-5 -top-2.5 -right-2.5"
|
||
}`}
|
||
title="컴포넌트 삭제"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
removeComponent(component.id);
|
||
}}
|
||
>
|
||
<span className={`font-bold leading-none ${isDivider ? "text-[10px]" : "text-xs"}`}>×</span>
|
||
</button>
|
||
)}
|
||
|
||
{/* 그룹화 표시 */}
|
||
{isGrouped && !isLocked && (
|
||
<div className="absolute top-1 left-1 rounded bg-purple-500 px-1 py-0.5 text-[10px] text-white">👥</div>
|
||
)}
|
||
|
||
{/* 8방향 리사이즈 핸들 */}
|
||
{isSelected && !isLocked && (
|
||
<>
|
||
{(isDivider
|
||
? component.orientation === "vertical"
|
||
? RESIZE_HANDLES_VERTICAL_DIVIDER
|
||
: RESIZE_HANDLES_HORIZONTAL_DIVIDER
|
||
: RESIZE_HANDLES_ALL
|
||
).map((h) => (
|
||
<div
|
||
key={h.dir}
|
||
className={`resize-handle absolute z-10 ${h.cursor}`}
|
||
style={{
|
||
width: h.size,
|
||
height: h.size,
|
||
top: h.top,
|
||
left: h.left,
|
||
right: h.right,
|
||
bottom: h.bottom,
|
||
transform: h.transform,
|
||
backgroundColor: "#3b82f6",
|
||
borderRadius: "50%",
|
||
}}
|
||
onMouseDown={(e) => handleResizeStart(e, h.dir)}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|