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:
parent
153ec5b65f
commit
f9803b0e6c
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue