정렬 및 배치 기능 구현
This commit is contained in:
@@ -1,10 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useRouter } from "next/navigation";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
|
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
|
||||||
import { reportApi } from "@/lib/api/reportApi";
|
import { reportApi } from "@/lib/api/reportApi";
|
||||||
@@ -30,11 +58,27 @@ export function ReportDesignerToolbar() {
|
|||||||
redo,
|
redo,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
|
selectedComponentIds,
|
||||||
|
alignLeft,
|
||||||
|
alignRight,
|
||||||
|
alignTop,
|
||||||
|
alignBottom,
|
||||||
|
alignCenterHorizontal,
|
||||||
|
alignCenterVertical,
|
||||||
|
distributeHorizontal,
|
||||||
|
distributeVertical,
|
||||||
|
makeSameWidth,
|
||||||
|
makeSameHeight,
|
||||||
|
makeSameSize,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 정렬 버튼 활성화 조건
|
||||||
|
const canAlign = selectedComponentIds && selectedComponentIds.length >= 2;
|
||||||
|
const canDistribute = selectedComponentIds && selectedComponentIds.length >= 3;
|
||||||
|
|
||||||
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
|
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
|
||||||
const canSaveAsTemplate = components.length > 0;
|
const canSaveAsTemplate = components.length > 0;
|
||||||
|
|
||||||
@@ -173,6 +217,105 @@ export function ReportDesignerToolbar() {
|
|||||||
>
|
>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
</Button>
|
</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">
|
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
초기화
|
초기화
|
||||||
|
|||||||
@@ -332,6 +332,19 @@ interface ReportDesignerContextType {
|
|||||||
redo: () => void;
|
redo: () => void;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: 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);
|
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
|
||||||
@@ -524,6 +537,238 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||||||
}
|
}
|
||||||
}, [historyIndex, history, toast]);
|
}, [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 [canvasWidth, setCanvasWidth] = useState(210);
|
||||||
const [canvasHeight, setCanvasHeight] = useState(297);
|
const [canvasHeight, setCanvasHeight] = useState(297);
|
||||||
@@ -923,6 +1168,18 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||||||
redo,
|
redo,
|
||||||
canUndo: historyIndex > 0,
|
canUndo: historyIndex > 0,
|
||||||
canRedo: historyIndex < history.length - 1,
|
canRedo: historyIndex < history.length - 1,
|
||||||
|
// 정렬 기능
|
||||||
|
alignLeft,
|
||||||
|
alignRight,
|
||||||
|
alignTop,
|
||||||
|
alignBottom,
|
||||||
|
alignCenterHorizontal,
|
||||||
|
alignCenterVertical,
|
||||||
|
distributeHorizontal,
|
||||||
|
distributeVertical,
|
||||||
|
makeSameWidth,
|
||||||
|
makeSameHeight,
|
||||||
|
makeSameSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
||||||
|
|||||||
Reference in New Issue
Block a user