266 lines
8.7 KiB
TypeScript
266 lines
8.7 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,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 라벨 일괄 토글
|
|
* 모든 컴포넌트의 라벨 표시/숨기기를 토글합니다.
|
|
* 숨겨진 라벨이 하나라도 있으면 모두 표시, 모두 표시되어 있으면 모두 숨기기
|
|
*/
|
|
export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] {
|
|
// 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인
|
|
const hasHiddenLabel = components.some(
|
|
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
|
|
);
|
|
|
|
// forceShow가 지정되면 그 값 사용, 아니면 자동 판단
|
|
// 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기
|
|
const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel;
|
|
|
|
return components.map((c) => {
|
|
// 위젯 타입만 라벨 토글 대상
|
|
if (c.type !== "widget") 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),
|
|
},
|
|
};
|
|
});
|
|
}
|