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

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

@ -168,6 +168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
selectedComponentId,
selectedComponentIds,
selectComponent,
selectMultipleComponents,
updateComponent,
getQueryResult,
snapValueToGrid,
@ -178,6 +179,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
margins,
layoutConfig,
currentPageId,
duplicateAtPosition,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
@ -185,6 +187,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
const componentRef = useRef<HTMLDivElement>(null);
// Alt+드래그 복제를 위한 상태
const [isAltDuplicating, setIsAltDuplicating] = useState(false);
const duplicatedIdsRef = useRef<string[]>([]);
// 복제 시 원본 컴포넌트들의 위치 저장 (상대적 위치 유지용)
const originalPositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
const isLocked = component.locked === true;
@ -308,6 +316,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
// Alt 키 감지 (복제 드래그)
const isAltPressed = e.altKey;
// 이미 다중 선택의 일부인 경우: 선택 상태 유지 (드래그만 시작)
const isPartOfMultiSelection = selectedComponentIds.length > 1 && selectedComponentIds.includes(component.id);
@ -325,6 +335,66 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
}
}
// Alt+드래그: 복제 모드
if (isAltPressed) {
// 복제할 컴포넌트 ID 목록 결정
let idsToClone: string[] = [];
if (isPartOfMultiSelection) {
// 다중 선택된 경우: 잠기지 않은 선택된 모든 컴포넌트 복제
idsToClone = selectedComponentIds.filter((id) => {
const c = components.find((comp) => comp.id === id);
return c && !c.locked;
});
} else if (isGrouped) {
// 그룹화된 경우: 같은 그룹의 모든 컴포넌트 복제
idsToClone = components
.filter((c) => c.groupId === component.groupId && !c.locked)
.map((c) => c.id);
} else {
// 단일 컴포넌트
idsToClone = [component.id];
}
if (idsToClone.length > 0) {
// 원본 컴포넌트들의 위치 저장 (복제본 ID -> 원본 위치 매핑용)
const positionsMap = new Map<string, { x: number; y: number }>();
idsToClone.forEach((id) => {
const comp = components.find((c) => c.id === id);
if (comp) {
positionsMap.set(id, { x: comp.x, y: comp.y });
}
});
// 복제 생성 (오프셋 없이 원래 위치에)
const newIds = duplicateAtPosition(idsToClone, 0, 0);
if (newIds.length > 0) {
// 복제된 컴포넌트 ID와 원본 위치 매핑
// newIds[i]는 idsToClone[i]에서 복제됨
const dupPositionsMap = new Map<string, { x: number; y: number }>();
newIds.forEach((newId, index) => {
const originalId = idsToClone[index];
const originalPos = positionsMap.get(originalId);
if (originalPos) {
dupPositionsMap.set(newId, originalPos);
}
});
originalPositionsRef.current = dupPositionsMap;
// 복제된 컴포넌트들을 선택하고 드래그 시작
duplicatedIdsRef.current = newIds;
setIsAltDuplicating(true);
// 복제된 컴포넌트들 선택
if (newIds.length === 1) {
selectComponent(newIds[0], false);
} else {
selectMultipleComponents(newIds);
}
}
}
}
setIsDragging(true);
setDragStart({
x: e.clientX - component.x,
@ -388,6 +458,31 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const deltaX = snappedX - component.x;
const deltaY = snappedY - component.y;
// Alt+드래그 복제 모드: 원본은 이동하지 않고 복제본만 이동
if (isAltDuplicating && duplicatedIdsRef.current.length > 0) {
// 복제된 컴포넌트들 이동 (각각의 원본 위치 기준으로 절대 위치 설정)
duplicatedIdsRef.current.forEach((dupId) => {
const dupComp = components.find((c) => c.id === dupId);
const originalPos = originalPositionsRef.current.get(dupId);
if (dupComp && originalPos) {
// 각 복제본의 원본 위치에서 delta만큼 이동
const targetX = originalPos.x + deltaX;
const targetY = originalPos.y + deltaY;
// 경계 체크
const dupMaxX = canvasWidthPx - marginRightPx - dupComp.width;
const dupMaxY = canvasHeightPx - marginBottomPx - dupComp.height;
updateComponent(dupId, {
x: Math.min(Math.max(marginLeftPx, targetX), dupMaxX),
y: Math.min(Math.max(marginTopPx, targetY), dupMaxY),
});
}
});
return; // 원본 컴포넌트는 이동하지 않음
}
// 현재 컴포넌트 이동
updateComponent(component.id, {
x: snappedX,
@ -492,6 +587,10 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// Alt 복제 상태 초기화
setIsAltDuplicating(false);
duplicatedIdsRef.current = [];
originalPositionsRef.current = new Map();
// 가이드라인 초기화
clearAlignmentGuides();
};
@ -506,6 +605,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
}, [
isDragging,
isResizing,
isAltDuplicating,
dragStart.x,
dragStart.y,
resizeStart.x,

View File

@ -211,6 +211,9 @@ export function ReportDesignerCanvas() {
alignmentGuides,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
undo,
redo,
showRuler,
@ -629,16 +632,39 @@ export function ReportDesignerCanvas() {
}
}
// Ctrl+Shift+C (또는 Cmd+Shift+C): 스타일 복사 (일반 복사보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") {
e.preventDefault();
copyStyles();
return;
}
// Ctrl+Shift+V (또는 Cmd+Shift+V): 스타일 붙여넣기 (일반 붙여넣기보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "v") {
e.preventDefault();
pasteStyles();
return;
}
// Ctrl+C (또는 Cmd+C): 복사
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
e.preventDefault();
copyComponents();
return;
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
e.preventDefault();
pasteComponents();
return;
}
// Ctrl+D (또는 Cmd+D): 복제
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
e.preventDefault();
duplicateComponents();
return;
}
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
@ -670,6 +696,9 @@ export function ReportDesignerCanvas() {
removeComponent,
copyComponents,
pasteComponents,
duplicateComponents,
copyStyles,
pasteStyles,
undo,
redo,
]);

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,