/** * @module useUIState * @description 리포트 디자이너의 UI 전용 상태를 관리한다. * - 그리드/스냅/눈금자: 캔버스 보조 도구 표시 여부와 크기 * - 줌(zoom): 캔버스 확대/축소 배율 * - 정렬 가이드라인: 드래그 중 다른 컴포넌트와의 정렬선 계산 * - 패널 접기/펼치기: 좌·우·페이지 리스트 패널 상태 * - 컴포넌트 설정 모달: 더블클릭 시 열리는 인캔버스 설정 모달 대상 ID */ import { useState, useCallback } from "react"; import type { ComponentConfig } from "@/types/report"; import { MM_TO_PX } from "@/lib/report/constants"; /** 캔버스에 표시할 정렬 가이드선 좌표 (px) */ interface AlignmentGuides { vertical: number[]; horizontal: number[]; } /** 정렬 가이드 계산에 필요한 캔버스 치수 (mm 단위) */ interface CanvasDimensions { canvasWidth: number; canvasHeight: number; } export interface UIStateResult { gridSize: number; setGridSize: (size: number) => void; showGrid: boolean; setShowGrid: (show: boolean) => void; snapToGrid: boolean; setSnapToGrid: (snap: boolean) => void; snapValueToGrid: (value: number) => number; showRuler: boolean; setShowRuler: (show: boolean) => void; zoom: number; setZoom: (zoom: number) => void; fitTrigger: number; fitToScreen: () => void; alignmentGuides: AlignmentGuides; calculateAlignmentGuides: (draggingId: string, x: number, y: number, width: number, height: number) => void; clearAlignmentGuides: () => void; isPageListCollapsed: boolean; setIsPageListCollapsed: (v: boolean) => void; isLeftPanelCollapsed: boolean; setIsLeftPanelCollapsed: (v: boolean) => void; isRightPanelCollapsed: boolean; setIsRightPanelCollapsed: (v: boolean) => void; componentModalTargetId: string | null; openComponentModal: (componentId: string) => void; closeComponentModal: () => void; } /** 드래그 중 정렬 가이드를 계산할 때 사용하는 오차 허용 범위 (px) */ const ALIGNMENT_THRESHOLD_PX = 5; export function useUIState( /** 정렬 가이드 계산에 필요한 현재 페이지 컴포넌트 목록 */ components: ComponentConfig[], /** 정렬 가이드 계산에 필요한 캔버스 치수 */ dimensions: CanvasDimensions, ): UIStateResult { const { canvasWidth, canvasHeight } = dimensions; const [gridSize, setGridSize] = useState(10); const [showGrid, setShowGrid] = useState(true); const [snapToGrid, setSnapToGrid] = useState(true); const [showRuler, setShowRuler] = useState(true); const [zoom, setZoom] = useState(1); const [fitTrigger, setFitTrigger] = useState(0); const [alignmentGuides, setAlignmentGuides] = useState({ vertical: [], horizontal: [] }); const [isPageListCollapsed, setIsPageListCollapsed] = useState(false); const [isLeftPanelCollapsed, setIsLeftPanelCollapsed] = useState(false); const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false); const [componentModalTargetId, setComponentModalTargetId] = useState(null); /** fitToScreen 호출 시 트리거 값을 증가시켜 Canvas가 화면 맞춤을 실행하게 한다. */ const fitToScreen = useCallback(() => setFitTrigger((n) => n + 1), []); /** 인캔버스 설정 모달을 열고 대상 컴포넌트 ID를 등록한다. */ const openComponentModal = useCallback((componentId: string) => { setComponentModalTargetId(componentId); }, []); /** 인캔버스 설정 모달을 닫는다. */ const closeComponentModal = useCallback(() => { setComponentModalTargetId(null); }, []); /** * 그리드 스냅이 활성화된 경우 value를 gridSize 단위로 반올림한다. * 비활성화된 경우 원래 값을 그대로 반환한다. */ const snapValueToGrid = useCallback( (value: number): number => { if (!snapToGrid) return value; return Math.round(value / gridSize) * gridSize; }, [snapToGrid, gridSize], ); /** * 드래그 중인 컴포넌트의 위치/크기를 기준으로 * 다른 컴포넌트 및 캔버스 중앙선과의 정렬 가이드라인을 계산한다. * * @param draggingId 현재 드래그 중인 컴포넌트 ID (자기 자신 제외용) * @param x 드래그 중인 컴포넌트의 현재 x 좌표 (px) * @param y 드래그 중인 컴포넌트의 현재 y 좌표 (px) * @param width 드래그 중인 컴포넌트의 너비 (px) * @param height 드래그 중인 컴포넌트의 높이 (px) */ const calculateAlignmentGuides = useCallback( (draggingId: string, x: number, y: number, width: number, height: number) => { const verticalLines: number[] = []; const horizontalLines: number[] = []; const canvasWidthPx = canvasWidth * MM_TO_PX; const canvasHeightPx = canvasHeight * MM_TO_PX; const canvasCenterX = canvasWidthPx / 2; const canvasCenterY = canvasHeightPx / 2; const left = x; const right = x + width; const centerX = x + width / 2; const top = y; const bottom = y + height; const centerY = y + height / 2; // 캔버스 중앙선 체크 if (Math.abs(centerX - canvasCenterX) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(canvasCenterX); if (Math.abs(centerY - canvasCenterY) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(canvasCenterY); // 다른 컴포넌트와 비교 components.forEach((comp) => { if (comp.id === draggingId) return; const cLeft = comp.x; const cRight = comp.x + comp.width; const cCenterX = comp.x + comp.width / 2; const cTop = comp.y; const cBottom = comp.y + comp.height; const cCenterY = comp.y + comp.height / 2; // 세로 정렬선 (left, right, centerX) if (Math.abs(left - cLeft) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cLeft); if (Math.abs(left - cRight) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cRight); if (Math.abs(right - cLeft) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cLeft); if (Math.abs(right - cRight) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cRight); if (Math.abs(centerX - cCenterX) < ALIGNMENT_THRESHOLD_PX) verticalLines.push(cCenterX); // 가로 정렬선 (top, bottom, centerY) if (Math.abs(top - cTop) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cTop); if (Math.abs(top - cBottom) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cBottom); if (Math.abs(bottom - cTop) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cTop); if (Math.abs(bottom - cBottom) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cBottom); if (Math.abs(centerY - cCenterY) < ALIGNMENT_THRESHOLD_PX) horizontalLines.push(cCenterY); }); setAlignmentGuides({ vertical: Array.from(new Set(verticalLines)), horizontal: Array.from(new Set(horizontalLines)), }); }, [components, canvasWidth, canvasHeight], ); /** 드래그 종료 후 정렬 가이드라인을 초기화한다. */ const clearAlignmentGuides = useCallback(() => { setAlignmentGuides({ vertical: [], horizontal: [] }); }, []); return { gridSize, setGridSize, showGrid, setShowGrid, snapToGrid, setSnapToGrid, snapValueToGrid, showRuler, setShowRuler, zoom, setZoom, fitTrigger, fitToScreen, alignmentGuides, calculateAlignmentGuides, clearAlignmentGuides, isPageListCollapsed, setIsPageListCollapsed, isLeftPanelCollapsed, setIsLeftPanelCollapsed, isRightPanelCollapsed, setIsRightPanelCollapsed, componentModalTargetId, openComponentModal, closeComponentModal, }; }