ERP-node/frontend/lib/utils/alignmentUtils.ts

300 lines
9.8 KiB
TypeScript

/**
* 화면 디자이너 정렬/배분/동일크기 유틸리티
*
* 다중 선택된 컴포넌트에 대해 정렬, 균등 배분, 동일 크기 맞추기 기능을 제공합니다.
*/
import { ComponentData } from "@/types/screen";
// 정렬 모드 타입
export type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
// 배분 방향 타입
export type DistributeDirection = "horizontal" | "vertical";
// 크기 맞추기 모드 타입
export type MatchSizeMode = "width" | "height" | "both";
/**
* 컴포넌트 정렬
* 선택된 컴포넌트들을 지정된 방향으로 정렬합니다.
*/
export function alignComponents(
components: ComponentData[],
selectedIds: string[],
mode: AlignMode
): ComponentData[] {
const selected = components.filter((c) => selectedIds.includes(c.id));
if (selected.length < 2) return components;
let targetValue: number;
switch (mode) {
case "left":
// 가장 왼쪽 x값으로 정렬
targetValue = Math.min(...selected.map((c) => c.position.x));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
return { ...c, position: { ...c.position, x: targetValue } };
});
case "right":
// 가장 오른쪽 (x + width)로 정렬
targetValue = Math.max(...selected.map((c) => c.position.x + (c.size?.width || 100)));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const width = c.size?.width || 100;
return { ...c, position: { ...c.position, x: targetValue - width } };
});
case "centerX":
// 가로 중앙 정렬 (전체 범위의 중앙)
{
const minX = Math.min(...selected.map((c) => c.position.x));
const maxX = Math.max(...selected.map((c) => c.position.x + (c.size?.width || 100)));
const centerX = (minX + maxX) / 2;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const width = c.size?.width || 100;
return { ...c, position: { ...c.position, x: Math.round(centerX - width / 2) } };
});
}
case "top":
// 가장 위쪽 y값으로 정렬
targetValue = Math.min(...selected.map((c) => c.position.y));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
return { ...c, position: { ...c.position, y: targetValue } };
});
case "bottom":
// 가장 아래쪽 (y + height)로 정렬
targetValue = Math.max(...selected.map((c) => c.position.y + (c.size?.height || 40)));
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const height = c.size?.height || 40;
return { ...c, position: { ...c.position, y: targetValue - height } };
});
case "centerY":
// 세로 중앙 정렬 (전체 범위의 중앙)
{
const minY = Math.min(...selected.map((c) => c.position.y));
const maxY = Math.max(...selected.map((c) => c.position.y + (c.size?.height || 40)));
const centerY = (minY + maxY) / 2;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const height = c.size?.height || 40;
return { ...c, position: { ...c.position, y: Math.round(centerY - height / 2) } };
});
}
default:
return components;
}
}
/**
* 컴포넌트 균등 배분
* 선택된 컴포넌트들 간의 간격을 균등하게 분배합니다.
* 최소 3개 이상의 컴포넌트가 필요합니다.
*/
export function distributeComponents(
components: ComponentData[],
selectedIds: string[],
direction: DistributeDirection
): ComponentData[] {
const selected = components.filter((c) => selectedIds.includes(c.id));
if (selected.length < 3) return components;
if (direction === "horizontal") {
// x 기준 정렬
const sorted = [...selected].sort((a, b) => a.position.x - b.position.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
// 첫 번째 ~ 마지막 컴포넌트 사이의 총 공간
const totalSpace = last.position.x + (last.size?.width || 100) - first.position.x;
// 컴포넌트들이 차지하는 총 너비
const totalComponentWidth = sorted.reduce((sum, c) => sum + (c.size?.width || 100), 0);
// 균등 간격
const gap = (totalSpace - totalComponentWidth) / (sorted.length - 1);
// ID -> 새 x 좌표 매핑
const newPositions = new Map<string, number>();
let currentX = first.position.x;
for (const comp of sorted) {
newPositions.set(comp.id, Math.round(currentX));
currentX += (comp.size?.width || 100) + gap;
}
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const newX = newPositions.get(c.id);
if (newX === undefined) return c;
return { ...c, position: { ...c.position, x: newX } };
});
} else {
// y 기준 정렬
const sorted = [...selected].sort((a, b) => a.position.y - b.position.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalSpace = last.position.y + (last.size?.height || 40) - first.position.y;
const totalComponentHeight = sorted.reduce((sum, c) => sum + (c.size?.height || 40), 0);
const gap = (totalSpace - totalComponentHeight) / (sorted.length - 1);
const newPositions = new Map<string, number>();
let currentY = first.position.y;
for (const comp of sorted) {
newPositions.set(comp.id, Math.round(currentY));
currentY += (comp.size?.height || 40) + gap;
}
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const newY = newPositions.get(c.id);
if (newY === undefined) return c;
return { ...c, position: { ...c.position, y: newY } };
});
}
}
/**
* 컴포넌트 동일 크기 맞추기
* 선택된 컴포넌트들의 크기를 첫 번째 선택된 컴포넌트 기준으로 맞춥니다.
*/
export function matchComponentSize(
components: ComponentData[],
selectedIds: string[],
mode: MatchSizeMode,
referenceId?: string
): ComponentData[] {
const selected = components.filter((c) => selectedIds.includes(c.id));
if (selected.length < 2) return components;
// 기준 컴포넌트 (지정하지 않으면 첫 번째 선택된 컴포넌트)
const reference = referenceId
? selected.find((c) => c.id === referenceId) || selected[0]
: selected[0];
const refWidth = reference.size?.width || 100;
const refHeight = reference.size?.height || 40;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
const currentWidth = c.size?.width || 100;
const currentHeight = c.size?.height || 40;
let newWidth = currentWidth;
let newHeight = currentHeight;
if (mode === "width" || mode === "both") {
newWidth = refWidth;
}
if (mode === "height" || mode === "both") {
newHeight = refHeight;
}
return {
...c,
size: {
...c.size,
width: newWidth,
height: newHeight,
},
};
});
}
/**
* 컴포넌트 라벨 일괄 토글
* 모든 컴포넌트의 라벨 표시/숨기기를 토글합니다.
* 숨겨진 라벨이 하나라도 있으면 모두 표시, 모두 표시되어 있으면 모두 숨기기
*/
/**
* 라벨 토글 대상 타입 판별
* label 속성이 있고, style.labelDisplay를 지원하는 컴포넌트인지 확인
*/
function hasLabelSupport(component: ComponentData): boolean {
// 라벨이 없는 컴포넌트는 제외
if (!component.label) return false;
// 그룹, datatable 등은 라벨 토글 대상에서 제외
const excludedTypes = ["group", "datatable"];
if (excludedTypes.includes(component.type)) return false;
// 나머지 (widget, component, container, file, flow 등)는 대상
return true;
}
/**
* @param components - 전체 컴포넌트 배열
* @param selectedIds - 선택된 컴포넌트 ID 목록 (빈 배열이면 전체 대상)
* @param forceShow - 강제 표시/숨기기 (지정하지 않으면 자동 토글)
*/
export function toggleAllLabels(
components: ComponentData[],
selectedIds: string[] = [],
forceShow?: boolean
): ComponentData[] {
// 대상 컴포넌트 필터: selectedIds가 있으면 선택된 것만, 없으면 전체
const targetComponents = components.filter((c) => {
if (!hasLabelSupport(c)) return false;
if (selectedIds.length > 0) return selectedIds.includes(c.id);
return true;
});
// 대상 중 라벨이 숨겨진 컴포넌트가 있는지 확인
const hasHiddenLabel = targetComponents.some(
(c) => (c.style as any)?.labelDisplay === false
);
// forceShow가 지정되면 그 값 사용, 아니면 자동 판단
const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel;
// 대상 ID Set (빠른 조회용)
const targetIdSet = new Set(targetComponents.map((c) => c.id));
return components.map((c) => {
// 대상이 아닌 컴포넌트는 건드리지 않음
if (!targetIdSet.has(c.id)) return c;
return {
...c,
style: {
...(c.style || {}),
labelDisplay: shouldShow,
} as any,
};
});
}
/**
* 컴포넌트 nudge (화살표 키 이동)
* 선택된 컴포넌트를 지정 방향으로 이동합니다.
*/
export function nudgeComponents(
components: ComponentData[],
selectedIds: string[],
direction: "up" | "down" | "left" | "right",
distance: number = 1 // 기본 1px, Shift 누르면 10px
): ComponentData[] {
const dx = direction === "left" ? -distance : direction === "right" ? distance : 0;
const dy = direction === "up" ? -distance : direction === "down" ? distance : 0;
return components.map((c) => {
if (!selectedIds.includes(c.id)) return c;
return {
...c,
position: {
...c.position,
x: Math.max(0, c.position.x + dx),
y: Math.max(0, c.position.y + dy),
},
};
});
}