Merge pull request '마지막 merge' (#315) from reportMng into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/315
This commit is contained in:
hyeonsu 2025-12-29 17:53:24 +09:00
commit e30b1cc01a
3 changed files with 849 additions and 45 deletions

View File

@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
selectedComponentId,
selectedComponentIds,
selectComponent,
selectMultipleComponents,
updateComponent,
getQueryResult,
snapValueToGrid,
@ -178,20 +179,192 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
margins,
layoutConfig,
currentPageId,
duplicateAtPosition,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 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 [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
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 = "system-ui, -apple-system, sans-serif";
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),
});
};
// 더블 클릭 핸들러 (텍스트 컴포넌트: 인라인 편집 모드 진입)
const handleDoubleClick = (e: React.MouseEvent) => {
if (component.type !== "text" && component.type !== "label") return;
if (isLocked) return; // 잠긴 컴포넌트는 편집 불가
e.stopPropagation();
// 인라인 편집 모드 진입
setEditValue(component.defaultValue || "");
setIsEditing(true);
};
// 인라인 편집 시작 시 textarea에 포커스
useEffect(() => {
if (isEditing && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, [isEditing]);
// 선택 해제 시 편집 모드 종료를 위한 ref
const editValueRef = useRef(editValue);
const isEditingRef = useRef(isEditing);
editValueRef.current = editValue;
isEditingRef.current = isEditing;
// 선택 해제 시 편집 모드 종료 (저장 후 종료)
useEffect(() => {
if (!isSelected && !isMultiSelected && isEditingRef.current) {
// 현재 편집 값으로 저장
if (editValueRef.current !== component.defaultValue) {
updateComponent(component.id, { defaultValue: editValueRef.current });
}
setIsEditing(false);
}
}, [isSelected, isMultiSelected, component.id, component.defaultValue, updateComponent]);
// 인라인 편집 저장
const handleEditSave = () => {
if (!isEditing) return;
updateComponent(component.id, {
defaultValue: editValue,
});
setIsEditing(false);
};
// 인라인 편집 취소
const handleEditCancel = () => {
setIsEditing(false);
setEditValue("");
};
// 인라인 편집 키보드 핸들러
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleEditCancel();
} else if (e.key === "Enter" && !e.shiftKey) {
// Enter: 저장 (Shift+Enter는 줄바꿈)
e.preventDefault();
handleEditSave();
}
};
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
// 편집 모드에서는 드래그 비활성화
if (isEditing) return;
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
return;
}
@ -209,16 +382,83 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
// Alt 키 감지 (복제 드래그)
const isAltPressed = e.altKey;
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
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);
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
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);
@ -284,14 +524,58 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
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),
});
}
});
}
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
if (isGrouped) {
else if (isGrouped) {
components.forEach((c) => {
if (c.groupId === component.groupId && c.id !== component.id) {
const newGroupX = c.x + deltaX;
@ -369,6 +653,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// Alt 복제 상태 초기화
setIsAltDuplicating(false);
duplicatedIdsRef.current = [];
originalPositionsRef.current = new Map();
// 가이드라인 초기화
clearAlignmentGuides();
};
@ -383,6 +671,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
}, [
isDragging,
isResizing,
isAltDuplicating,
dragStart.x,
dragStart.y,
resizeStart.x,
@ -405,36 +694,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
canvasHeight,
]);
// 표시할 값 결정
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 renderContent = () => {
const displayValue = getDisplayValue();
@ -443,6 +702,27 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
switch (component.type) {
case "text":
case "label":
// 인라인 편집 모드
if (isEditing) {
return (
<textarea
ref={textareaRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleEditSave}
onKeyDown={handleEditKeyDown}
className="h-full w-full resize-none border-none bg-transparent p-0 outline-none focus:ring-2 focus:ring-blue-500"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
lineHeight: "1.5",
}}
/>
);
}
return (
<div
className="h-full w-full"
@ -1182,6 +1462,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
: "1px solid #e5e7eb",
}}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
>
{renderContent()}

View File

@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect } from "react";
import { useRef, useEffect, useState } from "react";
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig, WatermarkConfig } from "@/types/report";
@ -201,6 +201,7 @@ export function ReportDesignerCanvas() {
canvasHeight,
margins,
selectComponent,
selectMultipleComponents,
selectedComponentId,
selectedComponentIds,
removeComponent,
@ -210,12 +211,32 @@ export function ReportDesignerCanvas() {
alignmentGuides,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
undo,
redo,
showRuler,
layoutConfig,
} = useReportDesigner();
// 드래그 영역 선택 (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) => {
@ -420,12 +441,127 @@ export function ReportDesignerCanvas() {
}),
}));
// 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만)
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
// 마퀴 선택 직후의 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;
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),
};
};
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -497,16 +633,46 @@ export function ReportDesignerCanvas() {
}
}
// 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 === "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 === "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보다 먼저 체크)
@ -538,6 +704,10 @@ export function ReportDesignerCanvas() {
removeComponent,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
fitSelectedToContent,
undo,
redo,
]);
@ -592,8 +762,10 @@ export function ReportDesignerCanvas() {
`
: undefined,
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
cursor: isMarqueeSelecting ? "crosshair" : "default",
}}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
>
{/* 페이지 여백 가이드 */}
{currentPage && (
@ -648,6 +820,20 @@ export function ReportDesignerCanvas() {
<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="absolute inset-0 flex items-center justify-center text-gray-400">

View File

@ -63,6 +63,7 @@ interface ReportDesignerContextType {
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
removeComponent: (id: string) => void;
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
selectMultipleComponents: (ids: string[]) => void; // 여러 컴포넌트 한번에 선택
// 레이아웃 관리
updateLayout: (updates: Partial<ReportLayout>) => void;
@ -100,6 +101,11 @@ interface ReportDesignerContextType {
// 복사/붙여넣기
copyComponents: () => void;
pasteComponents: () => void;
duplicateComponents: () => void; // Ctrl+D 즉시 복제
copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사
pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용
fitSelectedToContent: () => void; // Ctrl+Shift+F 텍스트 크기 자동 맞춤
// Undo/Redo
undo: () => void;
@ -267,6 +273,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 클립보드 (복사/붙여넣기)
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
// 스타일 클립보드 (스타일만 복사/붙여넣기)
const [styleClipboard, setStyleClipboard] = useState<Partial<ComponentConfig> | null>(null);
// Undo/Redo 히스토리
const [history, setHistory] = useState<ComponentConfig[][]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
@ -284,7 +293,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 복사 (Ctrl+C)
const copyComponents = useCallback(() => {
if (selectedComponentIds.length > 0) {
const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id));
// 잠긴 컴포넌트는 복사에서 제외
const componentsToCopy = components.filter(
(comp) => selectedComponentIds.includes(comp.id) && !comp.locked
);
if (componentsToCopy.length === 0) {
toast({
title: "복사 불가",
description: "잠긴 컴포넌트는 복사할 수 없습니다.",
variant: "destructive",
});
return;
}
setClipboard(componentsToCopy);
toast({
title: "복사 완료",
@ -293,6 +313,15 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
} else if (selectedComponentId) {
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
if (componentToCopy) {
// 잠긴 컴포넌트는 복사 불가
if (componentToCopy.locked) {
toast({
title: "복사 불가",
description: "잠긴 컴포넌트는 복사할 수 없습니다.",
variant: "destructive",
});
return;
}
setClipboard([componentToCopy]);
toast({
title: "복사 완료",
@ -332,6 +361,189 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
});
}, [clipboard, components.length, toast]);
// 복제 (Ctrl+D) - 선택된 컴포넌트를 즉시 복제
const duplicateComponents = useCallback(() => {
// 복제할 컴포넌트 결정
let componentsToDuplicate: ComponentConfig[] = [];
if (selectedComponentIds.length > 0) {
componentsToDuplicate = components.filter(
(comp) => selectedComponentIds.includes(comp.id) && !comp.locked
);
} else if (selectedComponentId) {
const comp = components.find((c) => c.id === selectedComponentId);
if (comp && !comp.locked) {
componentsToDuplicate = [comp];
}
}
if (componentsToDuplicate.length === 0) {
toast({
title: "복제 불가",
description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.",
variant: "destructive",
});
return;
}
const newComponents = componentsToDuplicate.map((comp) => ({
...comp,
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
x: comp.x + 20,
y: comp.y + 20,
zIndex: components.length,
locked: false, // 복제된 컴포넌트는 잠금 해제
}));
setComponents((prev) => [...prev, ...newComponents]);
// 복제된 컴포넌트 선택
if (newComponents.length === 1) {
setSelectedComponentId(newComponents[0].id);
setSelectedComponentIds([newComponents[0].id]);
} else {
setSelectedComponentIds(newComponents.map((c) => c.id));
setSelectedComponentId(newComponents[0].id);
}
toast({
title: "복제 완료",
description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.`,
});
}, [selectedComponentId, selectedComponentIds, components, toast]);
// 스타일 복사 (Ctrl+Shift+C)
const copyStyles = useCallback(() => {
// 단일 컴포넌트만 스타일 복사 가능
const targetId = selectedComponentId || selectedComponentIds[0];
if (!targetId) {
toast({
title: "스타일 복사 불가",
description: "컴포넌트를 선택해주세요.",
variant: "destructive",
});
return;
}
const component = components.find((c) => c.id === targetId);
if (!component) return;
// 스타일 관련 속성만 추출
const styleProperties: Partial<ComponentConfig> = {
fontSize: component.fontSize,
fontColor: component.fontColor,
fontWeight: component.fontWeight,
fontFamily: component.fontFamily,
textAlign: component.textAlign,
backgroundColor: component.backgroundColor,
borderWidth: component.borderWidth,
borderColor: component.borderColor,
borderStyle: component.borderStyle,
borderRadius: component.borderRadius,
boxShadow: component.boxShadow,
opacity: component.opacity,
padding: component.padding,
letterSpacing: component.letterSpacing,
lineHeight: component.lineHeight,
};
// undefined 값 제거
Object.keys(styleProperties).forEach((key) => {
if (styleProperties[key as keyof typeof styleProperties] === undefined) {
delete styleProperties[key as keyof typeof styleProperties];
}
});
setStyleClipboard(styleProperties);
toast({
title: "스타일 복사 완료",
description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다.",
});
}, [selectedComponentId, selectedComponentIds, components, toast]);
// 스타일 붙여넣기 (Ctrl+Shift+V)
const pasteStyles = useCallback(() => {
if (!styleClipboard) {
toast({
title: "스타일 붙여넣기 불가",
description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.",
variant: "destructive",
});
return;
}
// 선택된 컴포넌트들에 스타일 적용
const targetIds =
selectedComponentIds.length > 0
? selectedComponentIds
: selectedComponentId
? [selectedComponentId]
: [];
if (targetIds.length === 0) {
toast({
title: "스타일 붙여넣기 불가",
description: "스타일을 적용할 컴포넌트를 선택해주세요.",
variant: "destructive",
});
return;
}
// 잠긴 컴포넌트 필터링
const applicableIds = targetIds.filter((id) => {
const comp = components.find((c) => c.id === id);
return comp && !comp.locked;
});
if (applicableIds.length === 0) {
toast({
title: "스타일 붙여넣기 불가",
description: "잠긴 컴포넌트에는 스타일을 적용할 수 없습니다.",
variant: "destructive",
});
return;
}
setComponents((prev) =>
prev.map((comp) => {
if (applicableIds.includes(comp.id)) {
return { ...comp, ...styleClipboard };
}
return comp;
})
);
toast({
title: "스타일 적용 완료",
description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.`,
});
}, [styleClipboard, selectedComponentId, selectedComponentIds, components, toast]);
// Alt+드래그 복제용: 지정된 위치에 컴포넌트 복제
const duplicateAtPosition = useCallback(
(componentIds: string[], offsetX: number = 0, offsetY: number = 0): string[] => {
const componentsToDuplicate = components.filter(
(comp) => componentIds.includes(comp.id) && !comp.locked
);
if (componentsToDuplicate.length === 0) return [];
const newComponents = componentsToDuplicate.map((comp) => ({
...comp,
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
x: comp.x + offsetX,
y: comp.y + offsetY,
zIndex: components.length,
locked: false,
}));
setComponents((prev) => [...prev, ...newComponents]);
return newComponents.map((c) => c.id);
},
[components]
);
// 히스토리에 현재 상태 저장
const saveToHistory = useCallback(
(newComponents: ComponentConfig[]) => {
@ -1292,6 +1504,114 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
[currentPageId],
);
// 텍스트 컴포넌트 크기 자동 맞춤 (Ctrl+Shift+F)
const fitSelectedToContent = useCallback(() => {
const MM_TO_PX = 4; // 고정 스케일 팩터
// 선택된 컴포넌트 ID 결정
const targetIds =
selectedComponentIds.length > 0
? selectedComponentIds
: selectedComponentId
? [selectedComponentId]
: [];
if (targetIds.length === 0) return;
// 텍스트/레이블 컴포넌트만 필터링
const textComponents = components.filter(
(c) =>
targetIds.includes(c.id) &&
(c.type === "text" || c.type === "label") &&
!c.locked
);
if (textComponents.length === 0) {
toast({
title: "크기 조정 불가",
description: "선택된 텍스트 컴포넌트가 없습니다.",
variant: "destructive",
});
return;
}
// 현재 페이지 설정 가져오기
const page = currentPage;
if (!page) return;
const canvasWidthPx = page.width * MM_TO_PX;
const canvasHeightPx = page.height * MM_TO_PX;
const marginRightPx = (page.margins?.right || 10) * MM_TO_PX;
const marginBottomPx = (page.margins?.bottom || 10) * MM_TO_PX;
// 각 텍스트 컴포넌트 크기 조정
textComponents.forEach((comp) => {
const displayValue = comp.defaultValue || (comp.type === "text" ? "텍스트 입력" : "레이블 텍스트");
const fontSize = comp.fontSize || 14;
// 최대 크기 (여백 고려)
const maxWidth = canvasWidthPx - marginRightPx - comp.x;
const maxHeight = canvasHeightPx - marginBottomPx - comp.y;
// 줄바꿈으로 분리하여 각 줄의 너비 측정
const lines = displayValue.split("\n");
let maxLineWidth = 0;
lines.forEach((line: string) => {
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 = comp.fontWeight || "normal";
measureEl.style.fontFamily = "system-ui, -apple-system, sans-serif";
measureEl.textContent = line || " ";
document.body.appendChild(measureEl);
const lineWidth = measureEl.getBoundingClientRect().width;
maxLineWidth = Math.max(maxLineWidth, lineWidth);
document.body.removeChild(measureEl);
});
// 패딩 및 높이 계산
const horizontalPadding = 24;
const verticalPadding = 20;
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(50, finalWidth);
const newHeight = Math.max(30, finalHeight);
// 크기 업데이트 - setLayoutConfig 직접 사용
setLayoutConfig((prev) => ({
pages: prev.pages.map((p) =>
p.page_id === currentPageId
? {
...p,
components: p.components.map((c) =>
c.id === comp.id
? {
...c,
width: snapToGrid ? Math.round(newWidth / gridSize) * gridSize : newWidth,
height: snapToGrid ? Math.round(newHeight / gridSize) * gridSize : newHeight,
}
: c
),
}
: p
),
}));
});
toast({
title: "크기 조정 완료",
description: `${textComponents.length}개의 컴포넌트 크기가 조정되었습니다.`,
});
}, [selectedComponentId, selectedComponentIds, components, currentPage, currentPageId, snapToGrid, gridSize, toast]);
// 컴포넌트 삭제 (현재 페이지에서)
const removeComponent = useCallback(
(id: string) => {
@ -1344,6 +1664,17 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
}
}, []);
// 여러 컴포넌트 한번에 선택 (마퀴 선택용)
const selectMultipleComponents = useCallback((ids: string[]) => {
if (ids.length === 0) {
setSelectedComponentId(null);
setSelectedComponentIds([]);
return;
}
setSelectedComponentId(ids[0]);
setSelectedComponentIds(ids);
}, []);
// 레이아웃 업데이트
const updateLayout = useCallback(
(updates: Partial<ReportLayout>) => {
@ -1639,6 +1970,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
updateComponent,
removeComponent,
selectComponent,
selectMultipleComponents,
updateLayout,
saveLayout,
loadLayout,
@ -1662,6 +1994,11 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 복사/붙여넣기
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
duplicateAtPosition,
fitSelectedToContent,
// Undo/Redo
undo,
redo,