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

850 lines
27 KiB
TypeScript
Raw Normal View History

2025-10-01 12:00:13 +09:00
"use client";
import { useRef, useEffect, useState } from "react";
2025-10-01 12:00:13 +09:00
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
2025-12-22 15:40:31 +09:00
import { ComponentConfig, WatermarkConfig } from "@/types/report";
2025-10-01 12:00:13 +09:00
import { CanvasComponent } from "./CanvasComponent";
2025-10-01 16:27:05 +09:00
import { Ruler } from "./Ruler";
2025-10-01 12:00:13 +09:00
import { v4 as uuidv4 } from "uuid";
2025-12-22 15:40:31 +09:00
import { getFullImageUrl } from "@/lib/api/client";
2025-10-01 12:00:13 +09:00
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
// A4 기준: 210mm x 297mm → 840px x 1188px
export const MM_TO_PX = 4;
2025-12-22 15:40:31 +09:00
// 워터마크 레이어 컴포넌트
interface WatermarkLayerProps {
watermark: WatermarkConfig;
canvasWidth: number;
canvasHeight: number;
}
function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) {
// 공통 스타일
const baseStyle: React.CSSProperties = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
zIndex: 1, // 컴포넌트보다 낮은 z-index
};
// 대각선 스타일
if (watermark.style === "diagonal") {
const rotation = watermark.rotation ?? -45;
return (
<div style={baseStyle}>
<div
className="absolute flex items-center justify-center"
style={{
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
opacity: watermark.opacity,
whiteSpace: "nowrap",
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 48}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={getFullImageUrl(watermark.imageUrl)}
alt="watermark"
style={{
maxWidth: "50%",
maxHeight: "50%",
objectFit: "contain",
}}
/>
)
)}
</div>
</div>
);
}
// 중앙 스타일
if (watermark.style === "center") {
return (
<div style={baseStyle}>
<div
className="absolute flex items-center justify-center"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
opacity: watermark.opacity,
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 48}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={getFullImageUrl(watermark.imageUrl)}
alt="watermark"
style={{
maxWidth: "50%",
maxHeight: "50%",
objectFit: "contain",
}}
/>
)
)}
</div>
</div>
);
}
// 타일 스타일
if (watermark.style === "tile") {
const rotation = watermark.rotation ?? -30;
// 타일 간격 계산
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
const cols = Math.ceil(canvasWidth / tileSize) + 2;
const rows = Math.ceil(canvasHeight / tileSize) + 2;
return (
<div style={baseStyle}>
<div
style={{
position: "absolute",
top: "-50%",
left: "-50%",
width: "200%",
height: "200%",
display: "flex",
flexWrap: "wrap",
alignContent: "flex-start",
transform: `rotate(${rotation}deg)`,
opacity: watermark.opacity,
}}
>
{Array.from({ length: rows * cols }).map((_, index) => (
<div
key={index}
style={{
width: `${tileSize}px`,
height: `${tileSize}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{watermark.type === "text" ? (
<span
style={{
fontSize: `${watermark.fontSize || 24}px`,
color: watermark.fontColor || "#cccccc",
fontWeight: "bold",
userSelect: "none",
whiteSpace: "nowrap",
}}
>
{watermark.text || "WATERMARK"}
</span>
) : (
watermark.imageUrl && (
<img
src={getFullImageUrl(watermark.imageUrl)}
alt="watermark"
style={{
width: `${tileSize * 0.6}px`,
height: `${tileSize * 0.6}px`,
objectFit: "contain",
}}
/>
)
)}
</div>
))}
</div>
</div>
);
}
return null;
}
2025-10-01 12:00:13 +09:00
export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const {
currentPageId,
currentPage,
components,
addComponent,
2025-10-01 16:09:34 +09:00
updateComponent,
canvasWidth,
canvasHeight,
2025-10-02 14:19:38 +09:00
margins,
selectComponent,
selectMultipleComponents,
selectedComponentId,
selectedComponentIds,
removeComponent,
showGrid,
gridSize,
snapValueToGrid,
2025-10-01 15:35:16 +09:00
alignmentGuides,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
undo,
redo,
2025-10-01 16:27:05 +09:00
showRuler,
layoutConfig,
} = useReportDesigner();
2025-10-01 12:00:13 +09:00
// 드래그 영역 선택 (Marquee Selection) 상태
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
const [marqueeStart, setMarqueeStart] = useState({ x: 0, y: 0 });
const [marqueeEnd, setMarqueeEnd] = useState({ x: 0, y: 0 });
// 클로저 문제 해결을 위한 refs (동기적으로 업데이트)
const marqueeStartRef = useRef({ x: 0, y: 0 });
const marqueeEndRef = useRef({ x: 0, y: 0 });
const componentsRef = useRef(components);
const selectMultipleRef = useRef(selectMultipleComponents);
// 마퀴 선택 직후 click 이벤트 무시를 위한 플래그
const justFinishedMarqueeRef = useRef(false);
// refs 동기적 업데이트 (useEffect 대신 직접 할당)
componentsRef.current = components;
selectMultipleRef.current = selectMultipleComponents;
2025-10-01 12:00:13 +09:00
const [{ isOver }, drop] = useDrop(() => ({
accept: "component",
drop: (item: { componentType: string }, monitor) => {
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
const canvasRect = canvasRef.current.getBoundingClientRect();
if (!offset) return;
const x = offset.x - canvasRect.left;
const y = offset.y - canvasRect.top;
2025-10-01 16:53:35 +09:00
// 컴포넌트 타입별 기본 설정
let width = 200;
let height = 100;
if (item.componentType === "table") {
height = 200;
} else if (item.componentType === "image") {
width = 150;
height = 150;
} else if (item.componentType === "divider") {
width = 300;
2025-12-19 18:19:29 +09:00
height = 10; // 선 두께 + 여백 (선택/드래그를 위한 최소 높이)
2025-10-01 17:31:15 +09:00
} else if (item.componentType === "signature") {
width = 120;
height = 70;
} else if (item.componentType === "stamp") {
width = 70;
height = 70;
} else if (item.componentType === "pageNumber") {
width = 100;
height = 30;
} else if (item.componentType === "barcode") {
width = 200;
height = 80;
2025-12-19 18:06:25 +09:00
} else if (item.componentType === "checkbox") {
width = 150;
height = 30;
2025-10-01 16:53:35 +09:00
}
// 여백을 px로 변환
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;
2025-10-02 14:19:38 +09:00
// 캔버스 경계 (px)
const canvasWidthPx = canvasWidth * MM_TO_PX;
const canvasHeightPx = canvasHeight * MM_TO_PX;
2025-10-02 14:19:38 +09:00
// 드롭 위치 계산 (여백 내부로 제한)
const rawX = x - 100;
const rawY = y - 25;
const minX = marginLeftPx;
const minY = marginTopPx;
const maxX = canvasWidthPx - marginRightPx - width;
const maxY = canvasHeightPx - marginBottomPx - height;
const boundedX = Math.min(Math.max(minX, rawX), maxX);
const boundedY = Math.min(Math.max(minY, rawY), maxY);
// 새 컴포넌트 생성 (Grid Snap 적용)
2025-10-01 12:00:13 +09:00
const newComponent: ComponentConfig = {
id: `comp_${uuidv4()}`,
type: item.componentType,
2025-10-02 14:19:38 +09:00
x: snapValueToGrid(boundedX),
y: snapValueToGrid(boundedY),
2025-10-01 16:53:35 +09:00
width: snapValueToGrid(width),
height: snapValueToGrid(height),
2025-10-01 12:00:13 +09:00
zIndex: components.length,
fontSize: 13,
fontFamily: "Malgun Gothic",
fontWeight: "normal",
fontColor: "#000000",
2025-10-01 14:23:00 +09:00
backgroundColor: "transparent",
borderWidth: 0,
borderColor: "#cccccc",
2025-10-01 12:00:13 +09:00
borderRadius: 5,
textAlign: "left",
padding: 10,
visible: true,
printable: true,
2025-10-01 16:53:35 +09:00
// 이미지 전용
...(item.componentType === "image" && {
imageUrl: "",
objectFit: "contain" as const,
}),
// 구분선 전용
...(item.componentType === "divider" && {
orientation: "horizontal" as const,
lineStyle: "solid" as const,
lineWidth: 1,
lineColor: "#000000",
}),
2025-10-01 17:31:15 +09:00
// 서명란 전용
...(item.componentType === "signature" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "서명:",
labelPosition: "left" as const,
borderWidth: 0,
2025-10-01 17:31:15 +09:00
borderColor: "#cccccc",
}),
// 도장란 전용
...(item.componentType === "stamp" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "(인)",
labelPosition: "top" as const,
personName: "",
borderWidth: 0,
2025-10-01 17:31:15 +09:00
borderColor: "#cccccc",
}),
// 페이지 번호 전용
...(item.componentType === "pageNumber" && {
pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber
textAlign: "center" as const,
}),
// 카드 컴포넌트 전용
...(item.componentType === "card" && {
width: 300,
height: 180,
cardTitle: "정보 카드",
showCardTitle: true,
cardItems: [
{ label: "항목1", value: "내용1", fieldName: "" },
{ label: "항목2", value: "내용2", fieldName: "" },
{ label: "항목3", value: "내용3", fieldName: "" },
],
labelWidth: 80,
showCardBorder: true,
titleFontSize: 14,
labelFontSize: 13,
valueFontSize: 13,
titleColor: "#1e40af",
labelColor: "#374151",
valueColor: "#000000",
borderWidth: 1,
borderColor: "#e5e7eb",
}),
// 계산 컴포넌트 전용
...(item.componentType === "calculation" && {
width: 350,
height: 120,
calcItems: [
{ label: "공급가액", value: 0, operator: "+" as const, fieldName: "" },
{ label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" },
],
resultLabel: "합계 금액",
labelWidth: 120,
labelFontSize: 13,
valueFontSize: 13,
resultFontSize: 16,
labelColor: "#374151",
valueColor: "#000000",
resultColor: "#2563eb",
showCalcBorder: false,
numberFormat: "currency" as const,
currencySuffix: "원",
borderWidth: 0,
borderColor: "#e5e7eb",
}),
2025-10-01 18:04:38 +09:00
// 테이블 전용
...(item.componentType === "table" && {
queryId: undefined,
tableColumns: [],
headerBackgroundColor: "#f3f4f6",
headerTextColor: "#111827",
showBorder: true,
rowHeight: 32,
}),
// 바코드 컴포넌트 전용
...(item.componentType === "barcode" && {
barcodeType: "CODE128" as const,
barcodeValue: "SAMPLE123",
barcodeFieldName: "",
showBarcodeText: true,
barcodeColor: "#000000",
barcodeBackground: "transparent",
barcodeMargin: 10,
qrErrorCorrectionLevel: "M" as const,
}),
2025-12-19 18:06:25 +09:00
// 체크박스 컴포넌트 전용
...(item.componentType === "checkbox" && {
checkboxChecked: false,
checkboxLabel: "항목",
checkboxSize: 18,
checkboxColor: "#2563eb",
checkboxBorderColor: "#6b7280",
checkboxLabelPosition: "right" as const,
}),
2025-10-01 12:00:13 +09:00
};
addComponent(newComponent);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
// 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
2025-10-01 12:00:13 +09:00
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 마퀴 선택 직후의 click 이벤트는 무시
if (justFinishedMarqueeRef.current) {
justFinishedMarqueeRef.current = false;
return;
}
if (e.target === e.currentTarget && !isMarqueeSelecting) {
2025-10-01 12:00:13 +09:00
selectComponent(null);
}
};
// 드래그 영역 선택 시작
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// 캔버스 자체를 클릭했을 때만 (컴포넌트 클릭 시 제외)
if (e.target !== e.currentTarget) return;
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// state와 ref 모두 설정
setIsMarqueeSelecting(true);
setMarqueeStart({ x, y });
setMarqueeEnd({ x, y });
marqueeStartRef.current = { x, y };
marqueeEndRef.current = { x, y };
// Ctrl/Cmd 키가 눌리지 않았으면 기존 선택 해제
if (!e.ctrlKey && !e.metaKey) {
selectComponent(null);
}
};
// 드래그 영역 선택 중
useEffect(() => {
if (!isMarqueeSelecting) return;
const handleMouseMove = (e: MouseEvent) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, canvasWidth * MM_TO_PX));
const y = Math.max(0, Math.min(e.clientY - rect.top, canvasHeight * MM_TO_PX));
// state와 ref 둘 다 업데이트
setMarqueeEnd({ x, y });
marqueeEndRef.current = { x, y };
};
const handleMouseUp = () => {
// ref에서 최신 값 가져오기 (클로저 문제 해결)
const currentStart = marqueeStartRef.current;
const currentEnd = marqueeEndRef.current;
const currentComponents = componentsRef.current;
const currentSelectMultiple = selectMultipleRef.current;
// 선택 영역 계산
const selectionRect = {
left: Math.min(currentStart.x, currentEnd.x),
top: Math.min(currentStart.y, currentEnd.y),
right: Math.max(currentStart.x, currentEnd.x),
bottom: Math.max(currentStart.y, currentEnd.y),
};
// 최소 드래그 거리 체크 (5px 이상이어야 선택으로 인식)
const dragDistance = Math.sqrt(
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2)
);
if (dragDistance > 5) {
// 선택 영역과 교차하는 컴포넌트 찾기
const intersectingComponents = currentComponents.filter((comp) => {
const compRect = {
left: comp.x,
top: comp.y,
right: comp.x + comp.width,
bottom: comp.y + comp.height,
};
// 두 사각형이 교차하는지 확인
return !(
compRect.right < selectionRect.left ||
compRect.left > selectionRect.right ||
compRect.bottom < selectionRect.top ||
compRect.top > selectionRect.bottom
);
});
// 교차하는 컴포넌트들 한번에 선택
if (intersectingComponents.length > 0) {
const ids = intersectingComponents.map((comp) => comp.id);
currentSelectMultiple(ids);
// click 이벤트가 선택을 해제하지 않도록 플래그 설정
justFinishedMarqueeRef.current = true;
}
}
setIsMarqueeSelecting(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isMarqueeSelecting, canvasWidth, canvasHeight]);
// 선택 영역 사각형 계산
const getMarqueeRect = () => {
return {
left: Math.min(marqueeStart.x, marqueeEnd.x),
top: Math.min(marqueeStart.y, marqueeEnd.y),
width: Math.abs(marqueeEnd.x - marqueeStart.x),
height: Math.abs(marqueeEnd.y - marqueeStart.y),
};
};
2025-10-01 16:09:34 +09:00
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
2025-10-01 14:14:06 +09:00
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 입력 필드에서는 단축키 무시
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
return;
}
2025-10-01 16:09:34 +09:00
// 화살표 키: 선택된 컴포넌트 이동
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.preventDefault();
// 선택된 컴포넌트가 없으면 무시
if (!selectedComponentId && selectedComponentIds.length === 0) {
return;
}
// 이동 거리 (Shift 키를 누르면 10px, 아니면 1px)
const moveDistance = e.shiftKey ? 10 : 1;
// 이동할 컴포넌트 ID 목록
const idsToMove =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
2025-10-01 16:23:20 +09:00
// 각 컴포넌트 이동 (잠긴 컴포넌트는 제외)
2025-10-01 16:09:34 +09:00
idsToMove.forEach((id) => {
const component = components.find((c) => c.id === id);
2025-10-01 16:23:20 +09:00
if (!component || component.locked) return;
2025-10-01 16:09:34 +09:00
let newX = component.x;
let newY = component.y;
switch (e.key) {
case "ArrowLeft":
newX = Math.max(0, component.x - moveDistance);
break;
case "ArrowRight":
newX = component.x + moveDistance;
break;
case "ArrowUp":
newY = Math.max(0, component.y - moveDistance);
break;
case "ArrowDown":
newY = component.y + moveDistance;
break;
}
updateComponent(id, { x: newX, y: newY });
});
return;
}
2025-10-01 16:23:20 +09:00
// Delete 키: 삭제 (잠긴 컴포넌트는 제외)
if (e.key === "Delete") {
if (selectedComponentIds.length > 0) {
2025-10-01 16:23:20 +09:00
selectedComponentIds.forEach((id) => {
const component = components.find((c) => c.id === id);
if (component && !component.locked) {
removeComponent(id);
}
});
} else if (selectedComponentId) {
2025-10-01 16:23:20 +09:00
const component = components.find((c) => c.id === selectedComponentId);
if (component && !component.locked) {
removeComponent(selectedComponentId);
}
}
}
// Ctrl+Shift+C (또는 Cmd+Shift+C): 스타일 복사 (일반 복사보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") {
e.preventDefault();
copyStyles();
return;
}
// Ctrl+Shift+V (또는 Cmd+Shift+V): 스타일 붙여넣기 (일반 붙여넣기보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "v") {
e.preventDefault();
pasteStyles();
return;
}
// Ctrl+Shift+F (또는 Cmd+Shift+F): 텍스트 크기 자동 맞춤
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "f") {
e.preventDefault();
fitSelectedToContent();
return;
}
// Ctrl+C (또는 Cmd+C): 복사
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
e.preventDefault();
copyComponents();
return;
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
e.preventDefault();
pasteComponents();
return;
}
// Ctrl+D (또는 Cmd+D): 복제
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
e.preventDefault();
duplicateComponents();
return;
}
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") {
e.preventDefault();
redo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
return;
}
// Ctrl+Z (또는 Cmd+Z): Undo
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
undo();
2025-10-01 14:14:06 +09:00
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
2025-10-01 16:09:34 +09:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedComponentId,
selectedComponentIds,
components,
removeComponent,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
2025-10-01 16:09:34 +09:00
undo,
redo,
]);
2025-10-01 14:14:06 +09:00
// 페이지가 없는 경우
if (!currentPageId || !currentPage) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-100">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-700"> </h3>
<p className="mt-2 text-sm text-gray-500"> .</p>
</div>
</div>
);
}
2025-10-01 12:00:13 +09:00
return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 캔버스 스크롤 영역 */}
2025-12-17 16:31:58 +09:00
<div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
2025-10-01 16:27:05 +09:00
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="inline-flex flex-col">
2025-10-01 16:27:05 +09:00
{/* 좌상단 코너 + 가로 눈금자 */}
{showRuler && (
<div className="flex">
{/* 좌상단 코너 (20x20) */}
<div className="h-5 w-5 bg-gray-200" />
{/* 가로 눈금자 */}
<Ruler orientation="horizontal" length={canvasWidth} />
</div>
)}
{/* 세로 눈금자 + 캔버스 */}
<div className="flex">
{/* 세로 눈금자 */}
{showRuler && <Ruler orientation="vertical" length={canvasHeight} />}
{/* 캔버스 */}
2025-10-01 15:35:16 +09:00
<div
2025-10-01 16:27:05 +09:00
ref={(node) => {
canvasRef.current = node;
drop(node);
2025-10-01 15:35:16 +09:00
}}
2025-10-01 16:27:05 +09:00
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
2025-10-01 15:35:16 +09:00
style={{
width: `${canvasWidth * MM_TO_PX}px`,
minHeight: `${canvasHeight * MM_TO_PX}px`,
2025-10-13 19:15:52 +09:00
backgroundImage: showGrid
? `
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
cursor: isMarqueeSelecting ? "crosshair" : "default",
2025-10-01 15:35:16 +09:00
}}
2025-10-01 16:27:05 +09:00
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
2025-10-01 16:27:05 +09:00
>
2025-10-02 14:19:38 +09:00
{/* 페이지 여백 가이드 */}
{currentPage && (
<div
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
style={{
top: `${currentPage.margins.top * MM_TO_PX}px`,
left: `${currentPage.margins.left * MM_TO_PX}px`,
right: `${currentPage.margins.right * MM_TO_PX}px`,
bottom: `${currentPage.margins.bottom * MM_TO_PX}px`,
2025-10-02 14:19:38 +09:00
}}
/>
)}
{/* 워터마크 렌더링 (전체 페이지 공유) */}
{layoutConfig.watermark?.enabled && (
2025-12-22 15:40:31 +09:00
<WatermarkLayer
watermark={layoutConfig.watermark}
2025-12-22 15:40:31 +09:00
canvasWidth={canvasWidth * MM_TO_PX}
canvasHeight={canvasHeight * MM_TO_PX}
/>
)}
2025-10-01 16:27:05 +09:00
{/* 정렬 가이드라인 렌더링 */}
{alignmentGuides.vertical.map((x, index) => (
<div
key={`v-${index}`}
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: `${x}px`,
width: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{alignmentGuides.horizontal.map((y, index) => (
<div
key={`h-${index}`}
className="pointer-events-none absolute right-0 left-0"
style={{
top: `${y}px`,
height: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
2025-10-01 15:35:16 +09:00
2025-10-01 16:27:05 +09:00
{/* 컴포넌트 렌더링 */}
{components.map((component) => (
<CanvasComponent key={component.id} component={component} />
))}
2025-10-01 12:00:13 +09:00
{/* 드래그 영역 선택 사각형 */}
{isMarqueeSelecting && (
<div
className="pointer-events-none absolute border-2 border-blue-500 bg-blue-500/10"
style={{
left: `${getMarqueeRect().left}px`,
top: `${getMarqueeRect().top}px`,
width: `${getMarqueeRect().width}px`,
height: `${getMarqueeRect().height}px`,
zIndex: 10000,
}}
/>
)}
2025-10-01 16:27:05 +09:00
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<p className="text-sm"> </p>
</div>
)}
2025-10-01 12:00:13 +09:00
</div>
2025-10-01 16:27:05 +09:00
</div>
2025-10-01 12:00:13 +09:00
</div>
</div>
</div>
);
}