ERP-node/frontend/contexts/report-designer/useAlignmentActions.ts

193 lines
8.5 KiB
TypeScript

/**
* @module useAlignmentActions
* @description 선택된 컴포넌트들의 위치/크기를 일괄 조정하는 정렬 기능을 제공한다.
* - 엣지 정렬: 좌/우/상/하 기준으로 정렬
* - 중앙 정렬: 가로/세로 중앙으로 정렬
* - 균등 배치: 컴포넌트 간 간격을 동일하게 분배 (최소 3개 필요)
* - 크기 동일화: 첫 번째 선택 컴포넌트 기준으로 너비/높이/둘 다 통일
*
* 모든 함수는 2개 이상(균등 배치는 3개 이상) 선택된 경우에만 동작한다.
*/
import { useCallback } from "react";
import type { ComponentConfig } from "@/types/report";
import type { SetComponentsFn, ToastFunction } from "./internalTypes";
export interface AlignmentActions {
alignLeft: () => void;
alignRight: () => void;
alignTop: () => void;
alignBottom: () => void;
alignCenterHorizontal: () => void;
alignCenterVertical: () => void;
distributeHorizontal: () => void;
distributeVertical: () => void;
makeSameWidth: () => void;
makeSameHeight: () => void;
makeSameSize: () => void;
}
interface AlignmentDeps {
components: ComponentConfig[];
selectedComponentIds: string[];
setComponents: SetComponentsFn;
toast: ToastFunction;
}
/** 선택된 컴포넌트를 업데이트 맵에서 찾아 교체하는 공통 헬퍼 */
function applyUpdates(
prev: ComponentConfig[],
updates: ComponentConfig[],
): ComponentConfig[] {
const updateMap = new Map(updates.map((u) => [u.id, u]));
return prev.map((c) => updateMap.get(c.id) ?? c);
}
export function useAlignmentActions({
components,
selectedComponentIds,
setComponents,
toast,
}: AlignmentDeps): AlignmentActions {
/** 선택된 컴포넌트 배열 반환 (정렬 함수 내부 공통 사용) */
const getSelected = useCallback(
() => components.filter((c) => selectedComponentIds.includes(c.id)),
[components, selectedComponentIds],
);
/** 모든 선택 컴포넌트의 x를 가장 왼쪽 x 기준으로 정렬 */
const alignLeft = useCallback(() => {
const selected = getSelected();
if (selected.length < 2) return;
const minX = Math.min(...selected.map((c) => c.x));
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, x: minX }))));
toast({ title: "정렬 완료", description: "왼쪽 정렬되었습니다." });
}, [getSelected, setComponents, toast]);
/** 모든 선택 컴포넌트의 오른쪽 엣지를 가장 오른쪽 엣지 기준으로 정렬 */
const alignRight = useCallback(() => {
const selected = getSelected();
if (selected.length < 2) return;
const maxRight = Math.max(...selected.map((c) => c.x + c.width));
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, x: maxRight - c.width }))));
toast({ title: "정렬 완료", description: "오른쪽 정렬되었습니다." });
}, [getSelected, setComponents, toast]);
/** 모든 선택 컴포넌트의 y를 가장 위쪽 y 기준으로 정렬 */
const alignTop = useCallback(() => {
const selected = getSelected();
if (selected.length < 2) return;
const minY = Math.min(...selected.map((c) => c.y));
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, y: minY }))));
toast({ title: "정렬 완료", description: "위쪽 정렬되었습니다." });
}, [getSelected, setComponents, toast]);
/** 모든 선택 컴포넌트의 아래쪽 엣지를 가장 아래쪽 엣지 기준으로 정렬 */
const alignBottom = useCallback(() => {
const selected = getSelected();
if (selected.length < 2) return;
const maxBottom = Math.max(...selected.map((c) => c.y + c.height));
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, y: maxBottom - c.height }))));
toast({ title: "정렬 완료", description: "아래쪽 정렬되었습니다." });
}, [getSelected, setComponents, toast]);
/** 선택 컴포넌트들의 수평 중앙을 전체 범위의 중앙으로 정렬 */
const alignCenterHorizontal = useCallback(() => {
const selected = getSelected();
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;
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, x: centerX - c.width / 2 }))));
toast({ title: "정렬 완료", description: "가로 중앙 정렬되었습니다." });
}, [getSelected, setComponents, toast]);
/** 선택 컴포넌트들의 수직 중앙을 전체 범위의 중앙으로 정렬 */
const alignCenterVertical = useCallback(() => {
const selected = getSelected();
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;
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, y: centerY - c.height / 2 }))));
toast({ title: "정렬 완료", description: "세로 중앙 정렬되었습니다." });
}, [getSelected, setComponents, toast]);
/** 3개 이상 선택된 컴포넌트를 가로 방향으로 균등하게 배치 */
const distributeHorizontal = useCallback(() => {
const selected = getSelected();
if (selected.length < 3) return;
const sorted = [...selected].sort((a, b) => a.x - b.x);
const totalWidth = sorted.reduce((sum, c) => sum + c.width, 0);
const span = sorted[sorted.length - 1].x + sorted[sorted.length - 1].width - sorted[0].x;
const gap = (span - totalWidth) / (sorted.length - 1);
let currentX = sorted[0].x;
const updates = sorted.map((c) => {
const updated = { ...c, x: currentX };
currentX += c.width + gap;
return updated;
});
setComponents((prev) => applyUpdates(prev, updates));
toast({ title: "정렬 완료", description: "가로 균등 배치되었습니다." });
}, [getSelected, setComponents, toast]);
/** 3개 이상 선택된 컴포넌트를 세로 방향으로 균등하게 배치 */
const distributeVertical = useCallback(() => {
const selected = getSelected();
if (selected.length < 3) return;
const sorted = [...selected].sort((a, b) => a.y - b.y);
const totalHeight = sorted.reduce((sum, c) => sum + c.height, 0);
const span = sorted[sorted.length - 1].y + sorted[sorted.length - 1].height - sorted[0].y;
const gap = (span - totalHeight) / (sorted.length - 1);
let currentY = sorted[0].y;
const updates = sorted.map((c) => {
const updated = { ...c, y: currentY };
currentY += c.height + gap;
return updated;
});
setComponents((prev) => applyUpdates(prev, updates));
toast({ title: "정렬 완료", description: "세로 균등 배치되었습니다." });
}, [getSelected, setComponents, toast]);
/** 선택된 모든 컴포넌트의 너비를 첫 번째 컴포넌트 너비로 통일 */
const makeSameWidth = useCallback(() => {
const selected = getSelected();
if (selected.length < 2) return;
const targetWidth = selected[0].width;
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, width: targetWidth }))));
toast({ title: "크기 조정 완료", description: "같은 너비로 조정되었습니다." });
}, [getSelected, setComponents, toast]);
/** 선택된 모든 컴포넌트의 높이를 첫 번째 컴포넌트 높이로 통일 */
const makeSameHeight = useCallback(() => {
const selected = getSelected();
if (selected.length < 2) return;
const targetHeight = selected[0].height;
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, height: targetHeight }))));
toast({ title: "크기 조정 완료", description: "같은 높이로 조정되었습니다." });
}, [getSelected, setComponents, toast]);
/** 선택된 모든 컴포넌트의 너비/높이를 첫 번째 컴포넌트 크기로 통일 */
const makeSameSize = useCallback(() => {
const selected = getSelected();
if (selected.length < 2) return;
const { width: targetWidth, height: targetHeight } = selected[0];
setComponents((prev) => applyUpdates(prev, selected.map((c) => ({ ...c, width: targetWidth, height: targetHeight }))));
toast({ title: "크기 조정 완료", description: "같은 크기로 조정되었습니다." });
}, [getSelected, setComponents, toast]);
return {
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontal,
distributeVertical,
makeSameWidth,
makeSameHeight,
makeSameSize,
};
}