복제 및 스타일 복사 기능 추가
This commit is contained in:
parent
386ce629ac
commit
f300b637d1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue