712 lines
24 KiB
TypeScript
712 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useEffect, useState, useCallback } from "react";
|
||
import { useDrop } from "react-dnd";
|
||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||
import { ComponentConfig } from "@/types/report";
|
||
import { CanvasComponent } from "./CanvasComponent";
|
||
import { Ruler } from "./Ruler";
|
||
import { WatermarkLayer } from "./WatermarkLayer";
|
||
import { v4 as uuidv4 } from "uuid";
|
||
import { MousePointer } from "lucide-react";
|
||
|
||
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
|
||
// A4 기준: 210mm x 297mm → 840px x 1188px
|
||
export const MM_TO_PX = 4;
|
||
|
||
export function ReportDesignerCanvas() {
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const {
|
||
currentPageId,
|
||
currentPage,
|
||
components,
|
||
addComponent,
|
||
updateComponent,
|
||
canvasWidth,
|
||
canvasHeight,
|
||
margins,
|
||
selectComponent,
|
||
selectMultipleComponents,
|
||
selectedComponentId,
|
||
selectedComponentIds,
|
||
removeComponent,
|
||
showGrid,
|
||
gridSize,
|
||
snapValueToGrid,
|
||
alignmentGuides,
|
||
copyComponents,
|
||
pasteComponents,
|
||
duplicateComponents,
|
||
copyStyles,
|
||
pasteStyles,
|
||
fitSelectedToContent,
|
||
undo,
|
||
redo,
|
||
showRuler,
|
||
layoutConfig,
|
||
zoom,
|
||
setZoom,
|
||
fitTrigger,
|
||
} = useReportDesigner();
|
||
|
||
// 캔버스 Auto-Fit: fitTrigger 변경 시 컨테이너에 맞춰 줌 재계산
|
||
const calculateFitZoom = useCallback(() => {
|
||
if (!containerRef.current) return;
|
||
const rulerSpace = showRuler ? 20 : 0;
|
||
const padding = 24; // p-3 × 2
|
||
const availableWidth = containerRef.current.clientWidth - rulerSpace - padding;
|
||
const availableHeight = containerRef.current.clientHeight - rulerSpace - padding;
|
||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||
if (availableWidth <= 0 || availableHeight <= 0 || canvasWidthPx <= 0 || canvasHeightPx <= 0) return;
|
||
const newZoom = Math.min(availableWidth / canvasWidthPx, availableHeight / canvasHeightPx, 1);
|
||
setZoom(Math.round(Math.max(0.1, newZoom) * 100) / 100);
|
||
}, [showRuler, canvasWidth, canvasHeight, setZoom]);
|
||
|
||
useEffect(() => {
|
||
calculateFitZoom();
|
||
}, [fitTrigger, calculateFitZoom]);
|
||
|
||
// 드래그 영역 선택 (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;
|
||
|
||
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) / zoom;
|
||
const y = (offset.y - canvasRect.top) / zoom;
|
||
|
||
// 컴포넌트 타입별 기본 설정
|
||
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;
|
||
height = 10; // 선 두께 + 여백 (선택/드래그를 위한 최소 높이)
|
||
} 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;
|
||
} else if (item.componentType === "checkbox") {
|
||
width = 150;
|
||
height = 30;
|
||
}
|
||
|
||
// 여백을 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;
|
||
|
||
// 캔버스 경계 (px)
|
||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||
|
||
// 드롭 위치 계산 (여백 내부로 제한)
|
||
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 적용)
|
||
const newComponent: ComponentConfig = {
|
||
id: `comp_${uuidv4()}`,
|
||
type: item.componentType,
|
||
x: snapValueToGrid(boundedX),
|
||
y: snapValueToGrid(boundedY),
|
||
width: snapValueToGrid(width),
|
||
height: snapValueToGrid(height),
|
||
zIndex: components.length,
|
||
fontSize: 13,
|
||
fontFamily: "Malgun Gothic",
|
||
fontWeight: "normal",
|
||
fontColor: "#000000",
|
||
backgroundColor: "transparent",
|
||
borderWidth: 0,
|
||
borderColor: "#cccccc",
|
||
borderRadius: 5,
|
||
textAlign: "left",
|
||
padding: 10,
|
||
visible: true,
|
||
printable: true,
|
||
// 이미지 전용
|
||
...(item.componentType === "image" && {
|
||
imageUrl: "",
|
||
objectFit: "contain" as const,
|
||
imageOpacity: 1,
|
||
imageBorderRadius: 0,
|
||
imageCaption: "",
|
||
imageCaptionPosition: "bottom" as const,
|
||
imageCaptionFontSize: 12,
|
||
imageCaptionColor: "#666666",
|
||
imageCaptionAlign: "center" as const,
|
||
imageAlt: "",
|
||
imageRotation: 0,
|
||
imageFlipH: false,
|
||
imageFlipV: false,
|
||
}),
|
||
// 구분선 전용
|
||
...(item.componentType === "divider" && {
|
||
orientation: "horizontal" as const,
|
||
lineStyle: "solid" as const,
|
||
lineWidth: 1,
|
||
lineColor: "#000000",
|
||
}),
|
||
// 서명란 전용
|
||
...(item.componentType === "signature" && {
|
||
imageUrl: "",
|
||
objectFit: "contain" as const,
|
||
showLabel: true,
|
||
labelText: "서명:",
|
||
labelPosition: "left" as const,
|
||
borderWidth: 0,
|
||
borderColor: "#cccccc",
|
||
}),
|
||
// 도장란 전용
|
||
...(item.componentType === "stamp" && {
|
||
imageUrl: "",
|
||
objectFit: "contain" as const,
|
||
showLabel: true,
|
||
labelText: "(인)",
|
||
labelPosition: "top" as const,
|
||
personName: "",
|
||
borderWidth: 0,
|
||
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",
|
||
}),
|
||
// 테이블 전용
|
||
...(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,
|
||
}),
|
||
// 체크박스 컴포넌트 전용
|
||
...(item.componentType === "checkbox" && {
|
||
checkboxChecked: false,
|
||
checkboxLabel: "항목",
|
||
checkboxSize: 18,
|
||
checkboxColor: "#2563eb",
|
||
checkboxBorderColor: "#6b7280",
|
||
checkboxLabelPosition: "right" as const,
|
||
}),
|
||
};
|
||
|
||
addComponent(newComponent);
|
||
},
|
||
collect: (monitor) => ({
|
||
isOver: monitor.isOver(),
|
||
}),
|
||
}));
|
||
|
||
// 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
|
||
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
// 마퀴 선택 직후의 click 이벤트는 무시
|
||
if (justFinishedMarqueeRef.current) {
|
||
justFinishedMarqueeRef.current = false;
|
||
return;
|
||
}
|
||
if (e.target === e.currentTarget && !isMarqueeSelecting) {
|
||
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) / zoom;
|
||
const y = (e.clientY - rect.top) / zoom;
|
||
|
||
// 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) / zoom, canvasWidth * MM_TO_PX));
|
||
const y = Math.max(0, Math.min((e.clientY - rect.top) / zoom, 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),
|
||
};
|
||
};
|
||
|
||
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
// 입력 필드에서는 단축키 무시
|
||
const target = e.target as HTMLElement;
|
||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
|
||
return;
|
||
}
|
||
|
||
// 화살표 키: 선택된 컴포넌트 이동
|
||
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[]);
|
||
|
||
// 각 컴포넌트 이동 (잠긴 컴포넌트는 제외)
|
||
idsToMove.forEach((id) => {
|
||
const component = components.find((c) => c.id === id);
|
||
if (!component || component.locked) return;
|
||
|
||
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;
|
||
}
|
||
|
||
// Delete 키: 삭제 (잠긴 컴포넌트는 제외)
|
||
if (e.key === "Delete") {
|
||
if (selectedComponentIds.length > 0) {
|
||
selectedComponentIds.forEach((id) => {
|
||
const component = components.find((c) => c.id === id);
|
||
if (component && !component.locked) {
|
||
removeComponent(id);
|
||
}
|
||
});
|
||
} else if (selectedComponentId) {
|
||
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();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", handleKeyDown);
|
||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [
|
||
selectedComponentId,
|
||
selectedComponentIds,
|
||
components,
|
||
removeComponent,
|
||
copyComponents,
|
||
pasteComponents,
|
||
duplicateComponents,
|
||
copyStyles,
|
||
pasteStyles,
|
||
fitSelectedToContent,
|
||
undo,
|
||
redo,
|
||
]);
|
||
|
||
// 페이지가 없는 경우
|
||
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>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-gray-100">
|
||
{/* 캔버스 스크롤 영역 */}
|
||
<div ref={containerRef} className="flex flex-1 justify-center overflow-auto">
|
||
{/* 눈금자와 캔버스를 감싸는 컨테이너 (zoom 적용) */}
|
||
<div className="inline-flex flex-col p-3" style={{ zoom }}>
|
||
{/* 좌상단 코너 + 가로 눈금자 */}
|
||
{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} />}
|
||
|
||
{/* 캔버스 */}
|
||
<div
|
||
ref={(node) => {
|
||
canvasRef.current = node;
|
||
drop(node);
|
||
}}
|
||
className={`relative bg-white shadow-[0_4px_24px_rgba(0,0,0,0.12)] ${isOver ? "ring-2 ring-blue-500" : ""}`}
|
||
style={{
|
||
width: `${canvasWidth * MM_TO_PX}px`,
|
||
minHeight: `${canvasHeight * MM_TO_PX}px`,
|
||
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",
|
||
}}
|
||
onClick={handleCanvasClick}
|
||
onMouseDown={handleCanvasMouseDown}
|
||
>
|
||
{/* 페이지 여백 가이드 */}
|
||
{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`,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 워터마크 렌더링 (전체 페이지 공유) */}
|
||
{layoutConfig.watermark?.enabled && (
|
||
<WatermarkLayer
|
||
watermark={layoutConfig.watermark}
|
||
width={canvasWidth * MM_TO_PX}
|
||
height={canvasHeight * MM_TO_PX}
|
||
/>
|
||
)}
|
||
|
||
{/* 정렬 가이드라인 렌더링 */}
|
||
{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,
|
||
}}
|
||
/>
|
||
))}
|
||
|
||
{/* 컴포넌트 렌더링 */}
|
||
{components.map((component) => (
|
||
<CanvasComponent key={component.id} component={component} />
|
||
))}
|
||
|
||
{/* 드래그 영역 선택 사각형 */}
|
||
{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,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 빈 캔버스 안내 */}
|
||
{components.length === 0 && (
|
||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<MousePointer className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||
<p className="mb-1 text-base text-gray-400">좌측 도구상자에서</p>
|
||
<p className="text-base text-gray-400">컴포넌트를 끌어다 놓으세요</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|