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

792 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";
import { MM_TO_PX, DIVIDER_HIT_AREA_PX } from "@/lib/report/constants";
import { evaluateConditionalRules, type ConditionalRule } from "@/lib/report/conditionalUtils";
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.fieldName) {
// 쿼리가 연결되어 있으면 실제 데이터 조회
if (component.queryId) {
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);
}
}
}
// 기본값이 있으면 기본값 표시, 없으면 필드명 플레이스홀더
if (component.defaultValue) {
return component.defaultValue;
}
return `{${component.fieldName}}`;
}
if (component.defaultValue) {
return component.defaultValue;
}
return "";
};
// 텍스트 컴포넌트: 더블 클릭 시 컨텐츠에 맞게 크기 조절
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);
});
// component.padding 기반 패딩 계산 + 여유분
const parsePadding = (p: string | number | undefined): number => {
if (p == null) return 8; // 기본값 8px
if (typeof p === "number") return p;
const parts = p.trim().split(/\s+/);
// "0 8px" → 좌우 8, "8px" → 전체 8
if (parts.length >= 2) {
return parseFloat(parts[1]) || 0;
}
return parseFloat(parts[0]) || 0;
};
const pad = parsePadding(component.padding);
const horizontalPadding = pad * 2 + 8;
const verticalPadding = pad * 2 + 4;
// 줄 높이 계산 (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 isConditionalVisible = (() => {
const isGrid = component.gridMode === true;
const rulesArr = isGrid ? component.gridConditionalRules : component.conditionalRules;
const singleRule = isGrid ? component.gridConditionalRule : component.conditionalRule;
const rules: ConditionalRule[] = rulesArr?.length
? (rulesArr as ConditionalRule[])
: singleRule
? [singleRule as ConditionalRule]
: [];
return evaluateConditionalRules(rules, getQueryResult);
})();
const isDivider = component.type === "divider";
const isHorizontalDivider = isDivider && component.orientation !== "vertical";
const isVerticalDivider = isDivider && component.orientation === "vertical";
const DIVIDER_HIT_AREA = DIVIDER_HIT_AREA_PX;
// 컴포넌트 타입별 렌더링
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 shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
isSelected
? isLocked
? "ring-2 ring-red-500"
: isGrouped
? "ring-2 ring-purple-500"
: "ring-2 ring-blue-500"
: isMultiSelected
? isLocked
? "ring-2 ring-red-300"
: isGrouped
? "ring-2 ring-purple-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,
padding: isDivider
? 0
: component.padding != null
? typeof component.padding === "number"
? `${component.padding}px`
: component.padding
: "8px",
boxSizing: "border-box",
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 && (
<button
className="absolute top-1 right-1 z-20 rounded bg-red-500 px-1.5 py-0.5 text-[10px] text-white shadow hover:bg-red-600 transition-colors cursor-pointer"
title="클릭하여 잠금 해제"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
updateComponent(component.id, { locked: false });
}}
>
🔒
</button>
)}
{/* 삭제 버튼 (선택 + 잠금 해제 상태에서만 표시) */}
{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 && (
<div className="absolute bottom-1 left-1 flex items-center gap-0.5 rounded bg-purple-500/90 px-1.5 py-0.5 text-[9px] font-medium text-white shadow-sm">
<svg className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="8" height="8" rx="1" />
<rect x="14" y="2" width="8" height="8" rx="1" />
<rect x="2" y="14" width="8" height="8" rx="1" />
<rect x="14" y="14" width="8" height="8" rx="1" />
</svg>
</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>
);
}