From f9803b0e6ce503cad802674d6bd5f50890754cba Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 6 Feb 2026 15:18:27 +0900 Subject: [PATCH] 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. --- frontend/components/screen/ScreenDesigner.tsx | 260 ++++++++++++++++- .../components/screen/ScreenSettingModal.tsx | 54 ++-- .../screen/modals/KeyboardShortcutsModal.tsx | 144 ++++++++++ .../components/screen/toolbar/SlimToolbar.tsx | 121 ++++++++ frontend/components/v2/V2Select.tsx | 8 +- frontend/lib/utils/alignmentUtils.ts | 265 ++++++++++++++++++ 6 files changed, 814 insertions(+), 38 deletions(-) create mode 100644 frontend/components/screen/modals/KeyboardShortcutsModal.tsx create mode 100644 frontend/lib/utils/alignmentUtils.ts diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index df88cb04..429f91f8 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -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(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(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 = { + 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 = { + 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 = { + 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 = { + 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)} /> {/* 메인 컨테이너 (패널들 + 캔버스) */}
@@ -6013,8 +6245,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
)} - {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */} -
+ {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */} +
{/* Pan 모드 안내 - 제거됨 */} {/* 줌 레벨 표시 */}
@@ -6123,12 +6359,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
); })()} - {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */} + {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */} @@ -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, // 리페인트 최적화 }} >
+ {/* 단축키 도움말 모달 */} + setShowShortcutsModal(false)} + />
diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index 88ee9ece..fa802893 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -365,7 +365,7 @@ export function ScreenSettingModal({ return ( <> - + @@ -525,34 +525,30 @@ export function ScreenSettingModal({ - {/* ScreenDesigner 전체 화면 모달 */} - - - 화면 디자이너 -
- { - setShowDesignerModal(false); - // 디자이너에서 저장 후 모달 닫으면 데이터 새로고침 - await loadData(); - // 데이터 로드 완료 후 iframe 갱신 - setIframeKey(prev => prev + 1); - }} - /> -
-
-
+ {/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */} + {/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */} + {showDesignerModal && ( +
+ { + setShowDesignerModal(false); + await loadData(); + setIframeKey(prev => prev + 1); + }} + /> +
+ )} {/* TableSettingModal */} {tableSettingTarget && ( diff --git a/frontend/components/screen/modals/KeyboardShortcutsModal.tsx b/frontend/components/screen/modals/KeyboardShortcutsModal.tsx new file mode 100644 index 00000000..0f122c53 --- /dev/null +++ b/frontend/components/screen/modals/KeyboardShortcutsModal.tsx @@ -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 = ({ + isOpen, + onClose, +}) => { + return ( + + + + + 키보드 단축키 + + + 화면 디자이너에서 사용할 수 있는 단축키 목록입니다. Mac에서는 Ctrl 대신 Cmd를 사용합니다. + + + +
+ {shortcutGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, idx) => ( +
+ + {shortcut.description} + +
+ {shortcut.keys.map((key, kidx) => ( + + {kidx > 0 && ( + + + )} + + {key} + + + ))} +
+
+ ))} +
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/components/screen/toolbar/SlimToolbar.tsx b/frontend/components/screen/toolbar/SlimToolbar.tsx index d71ed93a..2dbd7129 100644 --- a/frontend/components/screen/toolbar/SlimToolbar.tsx +++ b/frontend/components/screen/toolbar/SlimToolbar.tsx @@ -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 = ({ @@ -85,6 +108,12 @@ export const SlimToolbar: React.FC = ({ onOpenMultilangSettings, isPanelOpen = false, onTogglePanel, + selectedCount = 0, + onAlign, + onDistribute, + onMatchSize, + onToggleLabels, + onShowShortcuts, }) => { // 사용자 정의 해상도 상태 const [customWidth, setCustomWidth] = useState(""); @@ -325,8 +354,100 @@ export const SlimToolbar: React.FC = ({ )}
+ {/* 중앙: 정렬/배분 도구 (다중 선택 시 표시) */} + {selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && ( +
+ {/* 정렬 */} + {onAlign && ( + <> + 정렬 + + + +
+ + + + + )} + + {/* 배분 (3개 이상 선택 시) */} + {onDistribute && selectedCount >= 3 && ( + <> +
+ 배분 + + + + )} + + {/* 크기 맞추기 */} + {onMatchSize && ( + <> +
+ 크기 + + + + + )} + +
+ {selectedCount}개 선택 +
+ )} + {/* 우측: 버튼들 */}
+ {/* 라벨 토글 버튼 */} + {onToggleLabels && ( + + )} + + {/* 단축키 도움말 */} + {onShowShortcuts && ( + + )} + {onPreview && (