193 lines
8.5 KiB
TypeScript
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,
|
||
|
|
};
|
||
|
|
}
|