diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 6684047b..e795c2c9 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -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, @@ -216,6 +217,22 @@ export function ReportDesignerCanvas() { 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 +437,127 @@ export function ReportDesignerCanvas() { }), })); + // 캔버스 클릭 시 선택 해제 (드래그 선택이 아닐 때만) const handleCanvasClick = (e: React.MouseEvent) => { - 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) => { + // 캔버스 자체를 클릭했을 때만 (컴포넌트 클릭 시 제외) + 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) => { @@ -592,8 +724,10 @@ export function ReportDesignerCanvas() { ` : undefined, backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined, + cursor: isMarqueeSelecting ? "crosshair" : "default", }} onClick={handleCanvasClick} + onMouseDown={handleCanvasMouseDown} > {/* 페이지 여백 가이드 */} {currentPage && ( @@ -648,6 +782,20 @@ export function ReportDesignerCanvas() { ))} + {/* 드래그 영역 선택 사각형 */} + {isMarqueeSelecting && ( +
+ )} + {/* 빈 캔버스 안내 */} {components.length === 0 && (
diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 3db07bc9..098e419e 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -63,6 +63,7 @@ interface ReportDesignerContextType { updateComponent: (id: string, updates: Partial) => void; removeComponent: (id: string) => void; selectComponent: (id: string | null, isMultiSelect?: boolean) => void; + selectMultipleComponents: (ids: string[]) => void; // 여러 컴포넌트 한번에 선택 // 레이아웃 관리 updateLayout: (updates: Partial) => void; @@ -1344,6 +1345,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) => { @@ -1639,6 +1651,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin updateComponent, removeComponent, selectComponent, + selectMultipleComponents, updateLayout, saveLayout, loadLayout,