303 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|