ERP-node/frontend/components/report/designer/CanvasComponent.tsx

789 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}