정렬 및 배치 기능 구현
This commit is contained in:
parent
46aa81ce6f
commit
43cdacb194
|
|
@ -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" />
|
||||
초기화
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue