/** * @module useClipboardActions * @description 컴포넌트 복사/붙여넣기/복제와 스타일 전달 기능을 제공한다. * * - copyComponents / pasteComponents: 전체 컴포넌트 복사·붙여넣기 (Ctrl+C/V) * - duplicateComponents: 선택 컴포넌트 즉시 복제 + 20px 오프셋 (Ctrl+D) * - copyStyles / pasteStyles: 스타일 속성만 복사·붙여넣기 (Ctrl+Shift+C/V) * - duplicateAtPosition: Alt+드래그 시 지정 위치에 복제 * - fitSelectedToContent: 텍스트 컴포넌트 크기를 내용에 맞게 자동 조정 (Ctrl+Shift+F) * * 잠긴 컴포넌트는 모든 복사/복제 대상에서 제외된다. */ import { useState, useCallback } from "react"; import type { ComponentConfig, ReportPage, ReportLayoutConfig } from "@/types/report"; import type { SetComponentsFn, ToastFunction } from "./internalTypes"; import { MM_TO_PX, generateComponentId } from "@/lib/report/constants"; /** fitSelectedToContent에서 텍스트 너비 측정 시 추가하는 수평 패딩 (px) */ const HORIZONTAL_PADDING_PX = 24; /** fitSelectedToContent에서 높이 계산 시 추가하는 수직 패딩 (px) */ const VERTICAL_PADDING_PX = 20; /** 스타일 복사/붙여넣기 대상 속성 목록 (ComponentConfig에 실제로 존재하는 스타일 필드만 포함) */ const STYLE_PROPERTY_KEYS: (keyof ComponentConfig)[] = [ "fontSize", "fontColor", "fontWeight", "fontFamily", "textAlign", "backgroundColor", "borderWidth", "borderColor", "borderRadius", "padding", "letterSpacing", "lineHeight", ]; export interface ClipboardActions { copyComponents: () => void; pasteComponents: () => void; duplicateComponents: () => void; copyStyles: () => void; pasteStyles: () => void; duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; fitSelectedToContent: () => void; } interface ClipboardDeps { components: ComponentConfig[]; selectedComponentId: string | null; selectedComponentIds: string[]; setComponents: SetComponentsFn; /** fitSelectedToContent에 필요한 현재 페이지 정보 */ currentPage: ReportPage | undefined; currentPageId: string | null; setLayoutConfig: React.Dispatch>; snapToGrid: boolean; gridSize: number; toast: ToastFunction; } export function useClipboardActions({ components, selectedComponentId, selectedComponentIds, setComponents, currentPage, currentPageId, setLayoutConfig, snapToGrid, gridSize, toast, }: ClipboardDeps): ClipboardActions { const [clipboard, setClipboard] = useState([]); const [styleClipboard, setStyleClipboard] = useState | null>(null); /** * 선택된 컴포넌트를 클립보드에 저장한다. * 잠긴 컴포넌트는 복사에서 제외된다. */ const copyComponents = useCallback(() => { const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : []; const toCopy = components.filter((c) => targetIds.includes(c.id) && !c.locked); if (toCopy.length === 0) { toast({ title: "복사 불가", description: "잠긴 컴포넌트는 복사할 수 없습니다.", variant: "destructive" }); return; } setClipboard(toCopy); toast({ title: "복사 완료", description: `${toCopy.length}개의 컴포넌트가 복사되었습니다.` }); }, [selectedComponentId, selectedComponentIds, components, toast]); /** * 클립보드의 컴포넌트를 20px 오프셋으로 붙여넣는다. * 붙여넣기된 컴포넌트는 자동으로 선택 상태가 된다. */ const pasteComponents = useCallback(() => { if (clipboard.length === 0) return; const newComponents = clipboard.map((comp) => ({ ...comp, id: generateComponentId(), x: comp.x + 20, y: comp.y + 20, zIndex: components.length, })); setComponents((prev) => [...prev, ...newComponents]); toast({ title: "붙여넣기 완료", description: `${newComponents.length}개의 컴포넌트가 추가되었습니다.` }); }, [clipboard, components.length, setComponents, toast]); /** * 선택된 컴포넌트를 즉시 복제하고 20px 오프셋으로 배치한다. * 복제된 컴포넌트는 잠금 해제 상태로 생성된다. */ const duplicateComponents = useCallback(() => { const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : []; const toDuplicate = components.filter((c) => targetIds.includes(c.id) && !c.locked); if (toDuplicate.length === 0) { toast({ title: "복제 불가", description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.", variant: "destructive" }); return; } const newComponents = toDuplicate.map((comp) => ({ ...comp, id: generateComponentId(), x: comp.x + 20, y: comp.y + 20, zIndex: components.length, locked: false, })); setComponents((prev) => [...prev, ...newComponents]); toast({ title: "복제 완료", description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.` }); }, [selectedComponentId, selectedComponentIds, components, setComponents, toast]); /** * 선택된 컴포넌트의 스타일 속성만 스타일 클립보드에 저장한다. * 단일 컴포넌트 기준으로 복사하며, 위치/크기/타입 등 레이아웃 속성은 제외된다. */ 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 styleProps: Partial = {}; STYLE_PROPERTY_KEYS.forEach((key) => { const value = component[key]; if (value !== undefined) { (styleProps as Record)[key] = value; } }); setStyleClipboard(styleProps); toast({ title: "스타일 복사 완료", description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다." }); }, [selectedComponentId, selectedComponentIds, components, toast]); /** * 스타일 클립보드의 스타일을 선택된 컴포넌트에 적용한다. * 잠긴 컴포넌트는 제외된다. */ const pasteStyles = useCallback(() => { if (!styleClipboard) { toast({ title: "스타일 붙여넣기 불가", description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.", variant: "destructive" }); return; } const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : []; 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((c) => (applicableIds.includes(c.id) ? { ...c, ...styleClipboard } : c)), ); toast({ title: "스타일 적용 완료", description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.` }); }, [styleClipboard, selectedComponentId, selectedComponentIds, components, setComponents, toast]); /** * Alt+드래그 복제용. 지정된 오프셋 위치에 컴포넌트를 복제하고 새 ID 배열을 반환한다. * 잠긴 컴포넌트는 복제되지 않는다. */ const duplicateAtPosition = useCallback( (componentIds: string[], offsetX = 0, offsetY = 0): string[] => { const toDuplicate = components.filter((c) => componentIds.includes(c.id) && !c.locked); if (toDuplicate.length === 0) return []; const newComponents = toDuplicate.map((comp) => ({ ...comp, id: generateComponentId(), x: comp.x + offsetX, y: comp.y + offsetY, zIndex: components.length, locked: false, })); setComponents((prev) => [...prev, ...newComponents]); return newComponents.map((c) => c.id); }, [components, setComponents], ); /** * 선택된 텍스트/레이블 컴포넌트의 크기를 내용 텍스트에 맞게 자동 조정한다. * DOM에 임시 요소를 생성하여 실제 렌더링 너비를 측정한 뒤, 적절한 크기로 업데이트한다. * snapToGrid가 활성화된 경우 최종 크기도 그리드에 맞춰 반올림된다. */ const fitSelectedToContent = useCallback(() => { if (!currentPage || !currentPageId) return; const targetIds = selectedComponentIds.length > 0 ? selectedComponentIds : selectedComponentId ? [selectedComponentId] : []; 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 canvasWidthPx = currentPage.width * MM_TO_PX; const canvasHeightPx = currentPage.height * MM_TO_PX; const marginRightPx = (currentPage.margins?.right ?? 10) * MM_TO_PX; const marginBottomPx = (currentPage.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; // DOM에 임시 요소를 추가하여 각 줄의 실제 렌더링 너비 측정 const lines = displayValue.split("\n"); let maxLineWidth = 0; lines.forEach((line: string) => { const el = document.createElement("span"); el.style.cssText = `position:absolute;visibility:hidden;white-space:nowrap;font-size:${fontSize}px;font-weight:${comp.fontWeight || "normal"};font-family:system-ui,-apple-system,sans-serif`; el.textContent = line || " "; document.body.appendChild(el); maxLineWidth = Math.max(maxLineWidth, el.getBoundingClientRect().width); document.body.removeChild(el); }); const lineHeight = fontSize * 1.5; const rawWidth = Math.min(maxLineWidth + HORIZONTAL_PADDING_PX, maxWidth); const rawHeight = Math.min(lines.length * lineHeight + VERTICAL_PADDING_PX, maxHeight); const finalWidth = Math.max(50, snapToGrid ? Math.round(rawWidth / gridSize) * gridSize : rawWidth); const finalHeight = Math.max(30, snapToGrid ? Math.round(rawHeight / gridSize) * gridSize : rawHeight); setLayoutConfig((prev) => ({ pages: prev.pages.map((p) => p.page_id === currentPageId ? { ...p, components: p.components.map((c) => c.id === comp.id ? { ...c, width: finalWidth, height: finalHeight } : c, ), } : p, ), })); }); toast({ title: "크기 조정 완료", description: `${textComponents.length}개의 컴포넌트 크기가 조정되었습니다.` }); }, [ selectedComponentId, selectedComponentIds, components, currentPage, currentPageId, setLayoutConfig, snapToGrid, gridSize, toast, ]); return { copyComponents, pasteComponents, duplicateComponents, copyStyles, pasteStyles, duplicateAtPosition, fitSelectedToContent, }; }