복제 및 스타일 복사 기능 추가

This commit is contained in:
dohyeons
2025-12-24 10:42:34 +09:00
parent 386ce629ac
commit f300b637d1
3 changed files with 325 additions and 2 deletions

View File

@@ -101,6 +101,10 @@ interface ReportDesignerContextType {
// 복사/붙여넣기
copyComponents: () => void;
pasteComponents: () => void;
duplicateComponents: () => void; // Ctrl+D 즉시 복제
copyStyles: () => void; // Ctrl+Shift+C 스타일만 복사
pasteStyles: () => void; // Ctrl+Shift+V 스타일만 붙여넣기
duplicateAtPosition: (componentIds: string[], offsetX?: number, offsetY?: number) => string[]; // Alt+드래그 복제용
// Undo/Redo
undo: () => void;
@@ -268,6 +272,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 클립보드 (복사/붙여넣기)
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
// 스타일 클립보드 (스타일만 복사/붙여넣기)
const [styleClipboard, setStyleClipboard] = useState<Partial<ComponentConfig> | null>(null);
// Undo/Redo 히스토리
const [history, setHistory] = useState<ComponentConfig[][]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
@@ -353,6 +360,189 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
});
}, [clipboard, components.length, toast]);
// 복제 (Ctrl+D) - 선택된 컴포넌트를 즉시 복제
const duplicateComponents = useCallback(() => {
// 복제할 컴포넌트 결정
let componentsToDuplicate: ComponentConfig[] = [];
if (selectedComponentIds.length > 0) {
componentsToDuplicate = components.filter(
(comp) => selectedComponentIds.includes(comp.id) && !comp.locked
);
} else if (selectedComponentId) {
const comp = components.find((c) => c.id === selectedComponentId);
if (comp && !comp.locked) {
componentsToDuplicate = [comp];
}
}
if (componentsToDuplicate.length === 0) {
toast({
title: "복제 불가",
description: "복제할 컴포넌트가 없거나 잠긴 컴포넌트입니다.",
variant: "destructive",
});
return;
}
const newComponents = componentsToDuplicate.map((comp) => ({
...comp,
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
x: comp.x + 20,
y: comp.y + 20,
zIndex: components.length,
locked: false, // 복제된 컴포넌트는 잠금 해제
}));
setComponents((prev) => [...prev, ...newComponents]);
// 복제된 컴포넌트 선택
if (newComponents.length === 1) {
setSelectedComponentId(newComponents[0].id);
setSelectedComponentIds([newComponents[0].id]);
} else {
setSelectedComponentIds(newComponents.map((c) => c.id));
setSelectedComponentId(newComponents[0].id);
}
toast({
title: "복제 완료",
description: `${newComponents.length}개의 컴포넌트가 복제되었습니다.`,
});
}, [selectedComponentId, selectedComponentIds, components, toast]);
// 스타일 복사 (Ctrl+Shift+C)
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 styleProperties: Partial<ComponentConfig> = {
fontSize: component.fontSize,
fontColor: component.fontColor,
fontWeight: component.fontWeight,
fontFamily: component.fontFamily,
textAlign: component.textAlign,
backgroundColor: component.backgroundColor,
borderWidth: component.borderWidth,
borderColor: component.borderColor,
borderStyle: component.borderStyle,
borderRadius: component.borderRadius,
boxShadow: component.boxShadow,
opacity: component.opacity,
padding: component.padding,
letterSpacing: component.letterSpacing,
lineHeight: component.lineHeight,
};
// undefined 값 제거
Object.keys(styleProperties).forEach((key) => {
if (styleProperties[key as keyof typeof styleProperties] === undefined) {
delete styleProperties[key as keyof typeof styleProperties];
}
});
setStyleClipboard(styleProperties);
toast({
title: "스타일 복사 완료",
description: "스타일이 복사되었습니다. Ctrl+Shift+V로 적용할 수 있습니다.",
});
}, [selectedComponentId, selectedComponentIds, components, toast]);
// 스타일 붙여넣기 (Ctrl+Shift+V)
const pasteStyles = useCallback(() => {
if (!styleClipboard) {
toast({
title: "스타일 붙여넣기 불가",
description: "먼저 Ctrl+Shift+C로 스타일을 복사해주세요.",
variant: "destructive",
});
return;
}
// 선택된 컴포넌트들에 스타일 적용
const targetIds =
selectedComponentIds.length > 0
? selectedComponentIds
: selectedComponentId
? [selectedComponentId]
: [];
if (targetIds.length === 0) {
toast({
title: "스타일 붙여넣기 불가",
description: "스타일을 적용할 컴포넌트를 선택해주세요.",
variant: "destructive",
});
return;
}
// 잠긴 컴포넌트 필터링
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((comp) => {
if (applicableIds.includes(comp.id)) {
return { ...comp, ...styleClipboard };
}
return comp;
})
);
toast({
title: "스타일 적용 완료",
description: `${applicableIds.length}개의 컴포넌트에 스타일이 적용되었습니다.`,
});
}, [styleClipboard, selectedComponentId, selectedComponentIds, components, toast]);
// Alt+드래그 복제용: 지정된 위치에 컴포넌트 복제
const duplicateAtPosition = useCallback(
(componentIds: string[], offsetX: number = 0, offsetY: number = 0): string[] => {
const componentsToDuplicate = components.filter(
(comp) => componentIds.includes(comp.id) && !comp.locked
);
if (componentsToDuplicate.length === 0) return [];
const newComponents = componentsToDuplicate.map((comp) => ({
...comp,
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
x: comp.x + offsetX,
y: comp.y + offsetY,
zIndex: components.length,
locked: false,
}));
setComponents((prev) => [...prev, ...newComponents]);
return newComponents.map((c) => c.id);
},
[components]
);
// 히스토리에 현재 상태 저장
const saveToHistory = useCallback(
(newComponents: ComponentConfig[]) => {
@@ -1695,6 +1885,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 복사/붙여넣기
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
duplicateAtPosition,
// Undo/Redo
undo,
redo,