ERP-node/frontend/contexts/report-designer/useClipboardActions.ts

303 lines
12 KiB
TypeScript

/**
* @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<React.SetStateAction<ReportLayoutConfig>>;
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<ComponentConfig[]>([]);
const [styleClipboard, setStyleClipboard] = useState<Partial<ComponentConfig> | 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<ComponentConfig> = {};
STYLE_PROPERTY_KEYS.forEach((key) => {
const value = component[key];
if (value !== undefined) {
(styleProps as Record<string, unknown>)[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,
};
}