정렬 및 배치 기능 구현

This commit is contained in:
dohyeons 2025-10-01 16:06:47 +09:00
parent 46aa81ce6f
commit 43cdacb194
2 changed files with 401 additions and 1 deletions

View File

@ -1,10 +1,38 @@
"use client";
import { Button } from "@/components/ui/button";
import { Save, Eye, RotateCcw, ArrowLeft, Loader2, BookTemplate, Grid3x3, Undo2, Redo2 } from "lucide-react";
import {
Save,
Eye,
RotateCcw,
ArrowLeft,
Loader2,
BookTemplate,
Grid3x3,
Undo2,
Redo2,
AlignLeft,
AlignRight,
AlignVerticalJustifyStart,
AlignVerticalJustifyEnd,
AlignCenterHorizontal,
AlignCenterVertical,
AlignHorizontalDistributeCenter,
AlignVerticalDistributeCenter,
RectangleHorizontal,
RectangleVertical,
Square,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
import { reportApi } from "@/lib/api/reportApi";
@ -30,11 +58,27 @@ export function ReportDesignerToolbar() {
redo,
canUndo,
canRedo,
selectedComponentIds,
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontal,
distributeVertical,
makeSameWidth,
makeSameHeight,
makeSameSize,
} = useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { toast } = useToast();
// 정렬 버튼 활성화 조건
const canAlign = selectedComponentIds && selectedComponentIds.length >= 2;
const canDistribute = selectedComponentIds && selectedComponentIds.length >= 3;
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
const canSaveAsTemplate = components.length > 0;
@ -173,6 +217,105 @@ export function ReportDesignerToolbar() {
>
<Redo2 className="h-4 w-4" />
</Button>
{/* 정렬 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canAlign}
className="gap-2"
title="정렬 (2개 이상 선택 필요)"
>
<AlignLeft className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={alignLeft}>
<AlignLeft className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignRight}>
<AlignRight className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignTop}>
<AlignVerticalJustifyStart className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignBottom}>
<AlignVerticalJustifyEnd className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={alignCenterHorizontal}>
<AlignCenterHorizontal className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignCenterVertical}>
<AlignCenterVertical className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 배치 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canDistribute}
className="gap-2"
title="균등 배치 (3개 이상 선택 필요)"
>
<AlignHorizontalDistributeCenter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={distributeHorizontal}>
<AlignHorizontalDistributeCenter className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={distributeVertical}>
<AlignVerticalDistributeCenter className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 크기 조정 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canAlign}
className="gap-2"
title="크기 조정 (2개 이상 선택 필요)"
>
<Square className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={makeSameWidth}>
<RectangleHorizontal className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={makeSameHeight}>
<RectangleVertical className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={makeSameSize}>
<Square className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />

View File

@ -332,6 +332,19 @@ interface ReportDesignerContextType {
redo: () => void;
canUndo: boolean;
canRedo: boolean;
// 정렬 기능
alignLeft: () => void;
alignRight: () => void;
alignTop: () => void;
alignBottom: () => void;
alignCenterHorizontal: () => void;
alignCenterVertical: () => void;
distributeHorizontal: () => void;
distributeVertical: () => void;
makeSameWidth: () => void;
makeSameHeight: () => void;
makeSameSize: () => void;
}
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
@ -524,6 +537,238 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
}
}, [historyIndex, history, toast]);
// 정렬 함수들 (선택된 컴포넌트들 기준)
const getSelectedComponents = useCallback(() => {
return components.filter((c) => selectedComponentIds.includes(c.id));
}, [components, selectedComponentIds]);
// 왼쪽 정렬
const alignLeft = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const minX = Math.min(...selected.map((c) => c.x));
const updates = selected.map((c) => ({ ...c, x: minX }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "왼쪽 정렬되었습니다." });
}, [getSelectedComponents, toast]);
// 오른쪽 정렬
const alignRight = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const maxRight = Math.max(...selected.map((c) => c.x + c.width));
const updates = selected.map((c) => ({ ...c, x: maxRight - c.width }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "오른쪽 정렬되었습니다." });
}, [getSelectedComponents, toast]);
// 위쪽 정렬
const alignTop = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const minY = Math.min(...selected.map((c) => c.y));
const updates = selected.map((c) => ({ ...c, y: minY }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "위쪽 정렬되었습니다." });
}, [getSelectedComponents, toast]);
// 아래쪽 정렬
const alignBottom = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const maxBottom = Math.max(...selected.map((c) => c.y + c.height));
const updates = selected.map((c) => ({ ...c, y: maxBottom - c.height }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "아래쪽 정렬되었습니다." });
}, [getSelectedComponents, toast]);
// 가로 중앙 정렬
const alignCenterHorizontal = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const minX = Math.min(...selected.map((c) => c.x));
const maxRight = Math.max(...selected.map((c) => c.x + c.width));
const centerX = (minX + maxRight) / 2;
const updates = selected.map((c) => ({ ...c, x: centerX - c.width / 2 }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "가로 중앙 정렬되었습니다." });
}, [getSelectedComponents, toast]);
// 세로 중앙 정렬
const alignCenterVertical = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const minY = Math.min(...selected.map((c) => c.y));
const maxBottom = Math.max(...selected.map((c) => c.y + c.height));
const centerY = (minY + maxBottom) / 2;
const updates = selected.map((c) => ({ ...c, y: centerY - c.height / 2 }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "세로 중앙 정렬되었습니다." });
}, [getSelectedComponents, toast]);
// 가로 균등 배치
const distributeHorizontal = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 3) return;
const sorted = [...selected].sort((a, b) => a.x - b.x);
const minX = sorted[0].x;
const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].width;
const totalWidth = sorted.reduce((sum, c) => sum + c.width, 0);
const totalGap = maxX - minX - totalWidth;
const gap = totalGap / (sorted.length - 1);
let currentX = minX;
const updates = sorted.map((c) => {
const newC = { ...c, x: currentX };
currentX += c.width + gap;
return newC;
});
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "가로 균등 배치되었습니다." });
}, [getSelectedComponents, toast]);
// 세로 균등 배치
const distributeVertical = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 3) return;
const sorted = [...selected].sort((a, b) => a.y - b.y);
const minY = sorted[0].y;
const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].height;
const totalHeight = sorted.reduce((sum, c) => sum + c.height, 0);
const totalGap = maxY - minY - totalHeight;
const gap = totalGap / (sorted.length - 1);
let currentY = minY;
const updates = sorted.map((c) => {
const newC = { ...c, y: currentY };
currentY += c.height + gap;
return newC;
});
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "정렬 완료", description: "세로 균등 배치되었습니다." });
}, [getSelectedComponents, toast]);
// 같은 너비로
const makeSameWidth = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const targetWidth = selected[0].width;
const updates = selected.map((c) => ({ ...c, width: targetWidth }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "크기 조정 완료", description: "같은 너비로 조정되었습니다." });
}, [getSelectedComponents, toast]);
// 같은 높이로
const makeSameHeight = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const targetHeight = selected[0].height;
const updates = selected.map((c) => ({ ...c, height: targetHeight }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "크기 조정 완료", description: "같은 높이로 조정되었습니다." });
}, [getSelectedComponents, toast]);
// 같은 크기로
const makeSameSize = useCallback(() => {
const selected = getSelectedComponents();
if (selected.length < 2) return;
const targetWidth = selected[0].width;
const targetHeight = selected[0].height;
const updates = selected.map((c) => ({ ...c, width: targetWidth, height: targetHeight }));
setComponents((prev) =>
prev.map((c) => {
const update = updates.find((u) => u.id === c.id);
return update || c;
}),
);
toast({ title: "크기 조정 완료", description: "같은 크기로 조정되었습니다." });
}, [getSelectedComponents, toast]);
// 캔버스 설정 (기본값)
const [canvasWidth, setCanvasWidth] = useState(210);
const [canvasHeight, setCanvasHeight] = useState(297);
@ -923,6 +1168,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
redo,
canUndo: historyIndex > 0,
canRedo: historyIndex < history.length - 1,
// 정렬 기능
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontal,
distributeVertical,
makeSameWidth,
makeSameHeight,
makeSameSize,
};
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;