feat: Enhance ScreenDesigner with alignment and distribution features

- Added new alignment, distribution, and size matching functionalities to the ScreenDesigner component.
- Implemented keyboard shortcuts for nudging components and toggling labels.
- Introduced a modal for displaying keyboard shortcuts to improve user experience.
- Updated SlimToolbar to support new alignment and distribution actions based on selected components.
- Enhanced zoom control with RAF throttling to prevent flickering during zoom operations.
This commit is contained in:
DDD1542 2026-02-06 15:18:27 +09:00
parent 153ec5b65f
commit f9803b0e6c
6 changed files with 814 additions and 38 deletions

View File

@ -35,6 +35,17 @@ import {
snapSizeToGrid,
snapToGrid,
} from "@/lib/utils/gridUtils";
import {
alignComponents,
distributeComponents,
matchComponentSize,
toggleAllLabels,
nudgeComponents,
AlignMode,
DistributeDirection,
MatchSizeMode,
} from "@/lib/utils/alignmentUtils";
import { KeyboardShortcutsModal } from "./modals/KeyboardShortcutsModal";
// 10px 단위 스냅 함수
const snapTo10px = (value: number): number => {
@ -170,6 +181,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 메뉴 할당 모달 상태
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
// 단축키 도움말 모달 상태
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// 파일첨부 상세 모달 상태
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
@ -360,6 +374,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100%
const MIN_ZOOM = 0.1; // 10%
const MAX_ZOOM = 3; // 300%
const zoomRafRef = useRef<number | null>(null); // 줌 RAF throttle용
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
@ -1647,7 +1662,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
panState.innerScrollTop,
]);
// 마우스 휠로 줌 제어
// 마우스 휠로 줌 제어 (RAF throttle 적용으로 깜빡임 방지)
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
// 캔버스 컨테이너 내에서만 동작
@ -1660,9 +1675,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const delta = e.deltaY;
const zoomFactor = 0.001; // 줌 속도 조절
setZoomLevel((prevZoom) => {
const newZoom = prevZoom - delta * zoomFactor;
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
// RAF throttle: 프레임당 한 번만 상태 업데이트
if (zoomRafRef.current !== null) {
cancelAnimationFrame(zoomRafRef.current);
}
zoomRafRef.current = requestAnimationFrame(() => {
setZoomLevel((prevZoom) => {
const newZoom = prevZoom - delta * zoomFactor;
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
});
zoomRafRef.current = null;
});
}
}
@ -1674,6 +1696,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const containerRef = canvasContainerRef.current;
return () => {
containerRef?.removeEventListener("wheel", handleWheel);
if (zoomRafRef.current !== null) {
cancelAnimationFrame(zoomRafRef.current);
}
};
}, [MIN_ZOOM, MAX_ZOOM]);
@ -1785,6 +1810,103 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`);
}, [layout, screenResolution, saveToHistory]);
// === 정렬/배분/동일크기/라벨토글/Nudge 핸들러 ===
// 컴포넌트 정렬
const handleGroupAlign = useCallback(
(mode: AlignMode) => {
if (groupState.selectedComponents.length < 2) {
toast.warning("2개 이상의 컴포넌트를 선택해주세요.");
return;
}
saveToHistory(layout);
const newComponents = alignComponents(layout.components, groupState.selectedComponents, mode);
setLayout((prev) => ({ ...prev, components: newComponents }));
const modeNames: Record<AlignMode, string> = {
left: "좌측", right: "우측", centerX: "가로 중앙",
top: "상단", bottom: "하단", centerY: "세로 중앙",
};
toast.success(`${modeNames[mode]} 정렬 완료`);
},
[groupState.selectedComponents, layout, saveToHistory]
);
// 컴포넌트 균등 배분
const handleGroupDistribute = useCallback(
(direction: DistributeDirection) => {
if (groupState.selectedComponents.length < 3) {
toast.warning("3개 이상의 컴포넌트를 선택해주세요.");
return;
}
saveToHistory(layout);
const newComponents = distributeComponents(layout.components, groupState.selectedComponents, direction);
setLayout((prev) => ({ ...prev, components: newComponents }));
toast.success(`${direction === "horizontal" ? "가로" : "세로"} 균등 배분 완료`);
},
[groupState.selectedComponents, layout, saveToHistory]
);
// 동일 크기 맞추기
const handleMatchSize = useCallback(
(mode: MatchSizeMode) => {
if (groupState.selectedComponents.length < 2) {
toast.warning("2개 이상의 컴포넌트를 선택해주세요.");
return;
}
saveToHistory(layout);
const newComponents = matchComponentSize(
layout.components,
groupState.selectedComponents,
mode,
selectedComponent?.id
);
setLayout((prev) => ({ ...prev, components: newComponents }));
const modeNames: Record<MatchSizeMode, string> = {
width: "너비", height: "높이", both: "크기",
};
toast.success(`${modeNames[mode]} 맞추기 완료`);
},
[groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory]
);
// 라벨 일괄 토글
const handleToggleAllLabels = useCallback(() => {
saveToHistory(layout);
const newComponents = toggleAllLabels(layout.components);
setLayout((prev) => ({ ...prev, components: newComponents }));
const hasHidden = layout.components.some(
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
);
toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기");
}, [layout, saveToHistory]);
// Nudge (화살표 키 이동)
const handleNudge = useCallback(
(direction: "up" | "down" | "left" | "right", distance: number) => {
const targetIds =
groupState.selectedComponents.length > 0
? groupState.selectedComponents
: selectedComponent
? [selectedComponent.id]
: [];
if (targetIds.length === 0) return;
const newComponents = nudgeComponents(layout.components, targetIds, direction, distance);
setLayout((prev) => ({ ...prev, components: newComponents }));
// 선택된 컴포넌트 업데이트
if (selectedComponent && targetIds.includes(selectedComponent.id)) {
const updated = newComponents.find((c) => c.id === selectedComponent.id);
if (updated) setSelectedComponent(updated);
}
},
[groupState.selectedComponents, selectedComponent, layout.components]
);
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) {
@ -5359,6 +5481,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
return false;
}
// === 9. 화살표 키 Nudge (컴포넌트 미세 이동) ===
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
// 입력 필드에서는 무시
const active = document.activeElement;
if (
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement ||
active?.getAttribute("contenteditable") === "true"
) {
return;
}
if (selectedComponent || groupState.selectedComponents.length > 0) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const distance = e.shiftKey ? 10 : 1; // Shift 누르면 10px
const dirMap: Record<string, "up" | "down" | "left" | "right"> = {
ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right",
};
handleNudge(dirMap[e.key], distance);
return false;
}
}
// === 10. 정렬 단축키 (Alt + 키) - 다중 선택 시 ===
if (e.altKey && !e.ctrlKey && !e.metaKey) {
const alignKey = e.key?.toLowerCase();
const alignMap: Record<string, AlignMode> = {
l: "left", r: "right", c: "centerX",
t: "top", b: "bottom", m: "centerY",
};
if (alignMap[alignKey] && groupState.selectedComponents.length >= 2) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
handleGroupAlign(alignMap[alignKey]);
return false;
}
// 균등 배분 (Alt+H: 가로, Alt+V: 세로)
if (alignKey === "h" && groupState.selectedComponents.length >= 3) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
handleGroupDistribute("horizontal");
return false;
}
if (alignKey === "v" && groupState.selectedComponents.length >= 3) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
handleGroupDistribute("vertical");
return false;
}
// 동일 크기 맞추기 (Alt+W: 너비, Alt+E: 높이)
if (alignKey === "w" && groupState.selectedComponents.length >= 2) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
handleMatchSize("width");
return false;
}
if (alignKey === "e" && groupState.selectedComponents.length >= 2) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
handleMatchSize("height");
return false;
}
}
// === 11. 라벨 일괄 토글 (Alt+Shift+L) ===
if (e.altKey && e.shiftKey && e.key?.toLowerCase() === "l") {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
handleToggleAllLabels();
return false;
}
// === 12. 단축키 도움말 (? 키) ===
if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) {
// 입력 필드에서는 무시
const active = document.activeElement;
if (
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement ||
active?.getAttribute("contenteditable") === "true"
) {
return;
}
e.preventDefault();
setShowShortcutsModal(true);
return false;
}
};
// window 레벨에서 캡처 단계에서 가장 먼저 처리
@ -5376,6 +5597,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
groupState.selectedComponents,
layout,
selectedScreen,
handleNudge,
handleGroupAlign,
handleGroupDistribute,
handleMatchSize,
handleToggleAllLabels,
]);
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
@ -5503,6 +5729,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
isPanelOpen={panelStates.v2?.isOpen || false}
onTogglePanel={() => togglePanel("v2")}
selectedCount={groupState.selectedComponents.length}
onAlign={handleGroupAlign}
onDistribute={handleGroupDistribute}
onMatchSize={handleMatchSize}
onToggleLabels={handleToggleAllLabels}
onShowShortcuts={() => setShowShortcutsModal(true)}
/>
{/* 메인 컨테이너 (패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
@ -6013,8 +6245,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
)}
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
<div ref={canvasContainerRef} className="bg-muted relative flex-1 overflow-auto px-16 py-6">
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */}
<div
ref={canvasContainerRef}
className="bg-muted relative flex-1 overflow-auto px-16 py-6"
style={{ willChange: "scroll-position" }}
>
{/* Pan 모드 안내 - 제거됨 */}
{/* 줌 레벨 표시 */}
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
@ -6123,12 +6359,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
);
})()}
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
<div
className="flex justify-center"
style={{
width: "100%",
minHeight: screenResolution.height * zoomLevel,
contain: "layout style", // 레이아웃 재계산 범위 제한
}}
>
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
@ -6141,8 +6378,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
maxWidth: `${screenResolution.width}px`,
minHeight: `${screenResolution.height}px`,
flexShrink: 0,
transform: `scale(${zoomLevel})`,
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
transformOrigin: "top center", // 중앙 기준으로 스케일
willChange: "transform", // GPU 가속 레이어 생성
backfaceVisibility: "hidden" as const, // 리페인트 최적화
}}
>
<div
@ -6842,6 +7081,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
}}
/>
{/* 단축키 도움말 모달 */}
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</div>
</TableOptionsProvider>
</LayerProvider>

View File

@ -365,7 +365,7 @@ export function ScreenSettingModal({
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-lg">
@ -525,34 +525,30 @@ export function ScreenSettingModal({
</DialogContent>
</Dialog>
{/* ScreenDesigner 전체 화면 모달 */}
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
<DialogTitle className="sr-only"> </DialogTitle>
<div className="flex flex-col h-full">
<ScreenDesigner
selectedScreen={{
screenId: currentScreenId,
screenCode: `screen_${currentScreenId}`,
screenName: currentScreenName,
tableName: currentMainTable || "",
companyCode: companyCode || "*",
description: "",
isActive: "Y" as const,
createdDate: new Date(),
updatedDate: new Date(),
}}
onBackToList={async () => {
setShowDesignerModal(false);
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
await loadData();
// 데이터 로드 완료 후 iframe 갱신
setIframeKey(prev => prev + 1);
}}
/>
</div>
</DialogContent>
</Dialog>
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
{showDesignerModal && (
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
<ScreenDesigner
selectedScreen={{
screenId: currentScreenId,
screenCode: `screen_${currentScreenId}`,
screenName: currentScreenName,
tableName: currentMainTable || "",
companyCode: companyCode || "*",
description: "",
isActive: "Y" as const,
createdDate: new Date(),
updatedDate: new Date(),
}}
onBackToList={async () => {
setShowDesignerModal(false);
await loadData();
setIframeKey(prev => prev + 1);
}}
/>
</div>
)}
{/* TableSettingModal */}
{tableSettingTarget && (

View File

@ -0,0 +1,144 @@
"use client";
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
interface ShortcutItem {
keys: string[];
description: string;
}
interface ShortcutGroup {
title: string;
shortcuts: ShortcutItem[];
}
const shortcutGroups: ShortcutGroup[] = [
{
title: "기본 조작",
shortcuts: [
{ keys: ["Ctrl", "S"], description: "레이아웃 저장" },
{ keys: ["Ctrl", "Z"], description: "실행취소" },
{ keys: ["Ctrl", "Y"], description: "다시실행" },
{ keys: ["Ctrl", "A"], description: "전체 선택" },
{ keys: ["Delete"], description: "선택 삭제" },
{ keys: ["Esc"], description: "선택 해제" },
],
},
{
title: "복사/붙여넣기",
shortcuts: [
{ keys: ["Ctrl", "C"], description: "컴포넌트 복사" },
{ keys: ["Ctrl", "V"], description: "컴포넌트 붙여넣기" },
],
},
{
title: "그룹 관리",
shortcuts: [
{ keys: ["Ctrl", "G"], description: "그룹 생성" },
{ keys: ["Ctrl", "Shift", "G"], description: "그룹 해제" },
],
},
{
title: "이동 (Nudge)",
shortcuts: [
{ keys: ["Arrow"], description: "1px 이동" },
{ keys: ["Shift", "Arrow"], description: "10px 이동" },
],
},
{
title: "정렬 (다중 선택 시)",
shortcuts: [
{ keys: ["Alt", "L"], description: "좌측 정렬" },
{ keys: ["Alt", "R"], description: "우측 정렬" },
{ keys: ["Alt", "C"], description: "가로 중앙 정렬" },
{ keys: ["Alt", "T"], description: "상단 정렬" },
{ keys: ["Alt", "B"], description: "하단 정렬" },
{ keys: ["Alt", "M"], description: "세로 중앙 정렬" },
],
},
{
title: "배분/크기 (다중 선택 시)",
shortcuts: [
{ keys: ["Alt", "H"], description: "가로 균등 배분" },
{ keys: ["Alt", "V"], description: "세로 균등 배분" },
{ keys: ["Alt", "W"], description: "너비 맞추기" },
{ keys: ["Alt", "E"], description: "높이 맞추기" },
],
},
{
title: "보기/탐색",
shortcuts: [
{ keys: ["Space", "Drag"], description: "캔버스 팬(이동)" },
{ keys: ["Wheel"], description: "줌 인/아웃" },
{ keys: ["P"], description: "패널 열기/닫기" },
{ keys: ["Alt", "Shift", "L"], description: "라벨 일괄 표시/숨기기" },
{ keys: ["?"], description: "단축키 도움말" },
],
},
];
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
}
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
isOpen,
onClose,
}) => {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. Mac에서는 Ctrl Cmd를 .
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{shortcutGroups.map((group) => (
<div key={group.title}>
<h3 className="text-sm font-semibold text-foreground mb-2">
{group.title}
</h3>
<div className="space-y-1">
{group.shortcuts.map((shortcut, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-muted/50 transition-colors"
>
<span className="text-sm text-muted-foreground">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, kidx) => (
<React.Fragment key={kidx}>
{kidx > 0 && (
<span className="text-xs text-muted-foreground">+</span>
)}
<kbd className="inline-flex h-6 min-w-[24px] items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs text-muted-foreground shadow-sm">
{key}
</kbd>
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -22,6 +22,18 @@ import {
Settings2,
PanelLeft,
PanelLeftClose,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
AlignStartHorizontal,
AlignCenterHorizontal,
AlignEndHorizontal,
AlignHorizontalSpaceAround,
AlignVerticalSpaceAround,
RulerIcon,
Tag,
Keyboard,
Equal,
} from "lucide-react";
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
import {
@ -50,6 +62,10 @@ interface GridSettings {
gridOpacity?: number;
}
type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
type DistributeDirection = "horizontal" | "vertical";
type MatchSizeMode = "width" | "height" | "both";
interface SlimToolbarProps {
screenName?: string;
tableName?: string;
@ -67,6 +83,13 @@ interface SlimToolbarProps {
// 패널 토글 기능
isPanelOpen?: boolean;
onTogglePanel?: () => void;
// 정렬/배분/크기 기능
selectedCount?: number;
onAlign?: (mode: AlignMode) => void;
onDistribute?: (direction: DistributeDirection) => void;
onMatchSize?: (mode: MatchSizeMode) => void;
onToggleLabels?: () => void;
onShowShortcuts?: () => void;
}
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
@ -85,6 +108,12 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onOpenMultilangSettings,
isPanelOpen = false,
onTogglePanel,
selectedCount = 0,
onAlign,
onDistribute,
onMatchSize,
onToggleLabels,
onShowShortcuts,
}) => {
// 사용자 정의 해상도 상태
const [customWidth, setCustomWidth] = useState("");
@ -325,8 +354,100 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
)}
</div>
{/* 중앙: 정렬/배분 도구 (다중 선택 시 표시) */}
{selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && (
<div className="flex items-center space-x-1 rounded-md bg-blue-50 px-2 py-1">
{/* 정렬 */}
{onAlign && (
<>
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("left")} title="좌측 정렬 (Alt+L)">
<AlignStartVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerX")} title="가로 중앙 (Alt+C)">
<AlignCenterVertical className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("right")} title="우측 정렬 (Alt+R)">
<AlignEndVertical className="h-3.5 w-3.5" />
</Button>
<div className="mx-0.5 h-4 w-px bg-blue-200" />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("top")} title="상단 정렬 (Alt+T)">
<AlignStartHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerY")} title="세로 중앙 (Alt+M)">
<AlignCenterHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("bottom")} title="하단 정렬 (Alt+B)">
<AlignEndHorizontal className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 배분 (3개 이상 선택 시) */}
{onDistribute && selectedCount >= 3 && (
<>
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("horizontal")} title="가로 균등 배분 (Alt+H)">
<AlignHorizontalSpaceAround className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("vertical")} title="세로 균등 배분 (Alt+V)">
<AlignVerticalSpaceAround className="h-3.5 w-3.5" />
</Button>
</>
)}
{/* 크기 맞추기 */}
{onMatchSize && (
<>
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="mr-1 text-xs font-medium text-blue-700"></span>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("width")} title="너비 맞추기 (Alt+W)">
<RulerIcon className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("height")} title="높이 맞추기 (Alt+E)">
<RulerIcon className="h-3.5 w-3.5 rotate-90" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("both")} title="크기 모두 맞추기">
<Equal className="h-3.5 w-3.5" />
</Button>
</>
)}
<div className="mx-1 h-4 w-px bg-blue-200" />
<span className="text-xs text-blue-600">{selectedCount} </span>
</div>
)}
{/* 우측: 버튼들 */}
<div className="flex items-center space-x-2">
{/* 라벨 토글 버튼 */}
{onToggleLabels && (
<Button
variant="outline"
size="sm"
onClick={onToggleLabels}
className="flex items-center space-x-1"
title="라벨 일괄 표시/숨기기 (Alt+Shift+L)"
>
<Tag className="h-4 w-4" />
<span></span>
</Button>
)}
{/* 단축키 도움말 */}
{onShowShortcuts && (
<Button
variant="ghost"
size="icon"
onClick={onShowShortcuts}
className="h-9 w-9"
title="단축키 도움말 (?)"
>
<Keyboard className="h-4 w-4" />
</Button>
)}
{onPreview && (
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
<Smartphone className="h-4 w-4" />

View File

@ -122,7 +122,13 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between font-normal", className)}
className={cn(
"w-full justify-between font-normal",
"bg-transparent hover:bg-transparent", // 표준 Select와 동일한 투명 배경
"border-input shadow-xs", // 표준 Select와 동일한 테두리
"h-6 px-2 py-0 text-sm", // 표준 Select xs와 동일한 높이
className,
)}
style={style}
>
<span className="truncate flex-1 text-left">

View File

@ -0,0 +1,265 @@
/**
* //
*
* , , .
*/
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),
},
};
});
}