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,
|
snapSizeToGrid,
|
||||||
snapToGrid,
|
snapToGrid,
|
||||||
} from "@/lib/utils/gridUtils";
|
} from "@/lib/utils/gridUtils";
|
||||||
|
import {
|
||||||
|
alignComponents,
|
||||||
|
distributeComponents,
|
||||||
|
matchComponentSize,
|
||||||
|
toggleAllLabels,
|
||||||
|
nudgeComponents,
|
||||||
|
AlignMode,
|
||||||
|
DistributeDirection,
|
||||||
|
MatchSizeMode,
|
||||||
|
} from "@/lib/utils/alignmentUtils";
|
||||||
|
import { KeyboardShortcutsModal } from "./modals/KeyboardShortcutsModal";
|
||||||
|
|
||||||
// 10px 단위 스냅 함수
|
// 10px 단위 스냅 함수
|
||||||
const snapTo10px = (value: number): number => {
|
const snapTo10px = (value: number): number => {
|
||||||
|
|
@ -170,6 +181,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
// 메뉴 할당 모달 상태
|
// 메뉴 할당 모달 상태
|
||||||
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
||||||
|
|
||||||
|
// 단축키 도움말 모달 상태
|
||||||
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
|
||||||
// 파일첨부 상세 모달 상태
|
// 파일첨부 상세 모달 상태
|
||||||
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
|
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
|
||||||
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
|
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 [zoomLevel, setZoomLevel] = useState(1); // 1 = 100%
|
||||||
const MIN_ZOOM = 0.1; // 10%
|
const MIN_ZOOM = 0.1; // 10%
|
||||||
const MAX_ZOOM = 3; // 300%
|
const MAX_ZOOM = 3; // 300%
|
||||||
|
const zoomRafRef = useRef<number | null>(null); // 줌 RAF throttle용
|
||||||
|
|
||||||
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
|
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
|
||||||
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
|
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
|
||||||
|
|
@ -1647,7 +1662,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
panState.innerScrollTop,
|
panState.innerScrollTop,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 마우스 휠로 줌 제어
|
// 마우스 휠로 줌 제어 (RAF throttle 적용으로 깜빡임 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWheel = (e: WheelEvent) => {
|
const handleWheel = (e: WheelEvent) => {
|
||||||
// 캔버스 컨테이너 내에서만 동작
|
// 캔버스 컨테이너 내에서만 동작
|
||||||
|
|
@ -1660,9 +1675,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
const delta = e.deltaY;
|
const delta = e.deltaY;
|
||||||
const zoomFactor = 0.001; // 줌 속도 조절
|
const zoomFactor = 0.001; // 줌 속도 조절
|
||||||
|
|
||||||
setZoomLevel((prevZoom) => {
|
// RAF throttle: 프레임당 한 번만 상태 업데이트
|
||||||
const newZoom = prevZoom - delta * zoomFactor;
|
if (zoomRafRef.current !== null) {
|
||||||
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
|
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;
|
const containerRef = canvasContainerRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
containerRef?.removeEventListener("wheel", handleWheel);
|
containerRef?.removeEventListener("wheel", handleWheel);
|
||||||
|
if (zoomRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(zoomRafRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [MIN_ZOOM, MAX_ZOOM]);
|
}, [MIN_ZOOM, MAX_ZOOM]);
|
||||||
|
|
||||||
|
|
@ -1785,6 +1810,103 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`);
|
toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`);
|
||||||
}, [layout, screenResolution, saveToHistory]);
|
}, [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 () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!selectedScreen?.screenId) {
|
if (!selectedScreen?.screenId) {
|
||||||
|
|
@ -5359,6 +5481,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}
|
}
|
||||||
return false;
|
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 레벨에서 캡처 단계에서 가장 먼저 처리
|
// window 레벨에서 캡처 단계에서 가장 먼저 처리
|
||||||
|
|
@ -5376,6 +5597,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
groupState.selectedComponents,
|
groupState.selectedComponents,
|
||||||
layout,
|
layout,
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
|
handleNudge,
|
||||||
|
handleGroupAlign,
|
||||||
|
handleGroupDistribute,
|
||||||
|
handleMatchSize,
|
||||||
|
handleToggleAllLabels,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
|
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
|
||||||
|
|
@ -5503,6 +5729,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
|
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
|
||||||
isPanelOpen={panelStates.v2?.isOpen || false}
|
isPanelOpen={panelStates.v2?.isOpen || false}
|
||||||
onTogglePanel={() => togglePanel("v2")}
|
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">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|
@ -6013,8 +6245,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
|
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */}
|
||||||
<div ref={canvasContainerRef} className="bg-muted relative flex-1 overflow-auto px-16 py-6">
|
<div
|
||||||
|
ref={canvasContainerRef}
|
||||||
|
className="bg-muted relative flex-1 overflow-auto px-16 py-6"
|
||||||
|
style={{ willChange: "scroll-position" }}
|
||||||
|
>
|
||||||
{/* Pan 모드 안내 - 제거됨 */}
|
{/* 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">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
|
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
||||||
<div
|
<div
|
||||||
className="flex justify-center"
|
className="flex justify-center"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
minHeight: screenResolution.height * zoomLevel,
|
minHeight: screenResolution.height * zoomLevel,
|
||||||
|
contain: "layout style", // 레이아웃 재계산 범위 제한
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
|
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
|
||||||
|
|
@ -6141,8 +6378,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
maxWidth: `${screenResolution.width}px`,
|
maxWidth: `${screenResolution.width}px`,
|
||||||
minHeight: `${screenResolution.height}px`,
|
minHeight: `${screenResolution.height}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transform: `scale(${zoomLevel})`,
|
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
|
||||||
transformOrigin: "top center", // 중앙 기준으로 스케일
|
transformOrigin: "top center", // 중앙 기준으로 스케일
|
||||||
|
willChange: "transform", // GPU 가속 레이어 생성
|
||||||
|
backfaceVisibility: "hidden" as const, // 리페인트 최적화
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -6842,6 +7081,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* 단축키 도움말 모달 */}
|
||||||
|
<KeyboardShortcutsModal
|
||||||
|
isOpen={showShortcutsModal}
|
||||||
|
onClose={() => setShowShortcutsModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
</LayerProvider>
|
</LayerProvider>
|
||||||
|
|
|
||||||
|
|
@ -365,7 +365,7 @@ export function ScreenSettingModal({
|
||||||
|
|
||||||
return (
|
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">
|
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||||
|
|
@ -525,34 +525,30 @@ export function ScreenSettingModal({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ScreenDesigner 전체 화면 모달 */}
|
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
|
||||||
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
|
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
|
||||||
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
{showDesignerModal && (
|
||||||
<DialogTitle className="sr-only">화면 디자이너</DialogTitle>
|
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
|
||||||
<div className="flex flex-col h-full">
|
<ScreenDesigner
|
||||||
<ScreenDesigner
|
selectedScreen={{
|
||||||
selectedScreen={{
|
screenId: currentScreenId,
|
||||||
screenId: currentScreenId,
|
screenCode: `screen_${currentScreenId}`,
|
||||||
screenCode: `screen_${currentScreenId}`,
|
screenName: currentScreenName,
|
||||||
screenName: currentScreenName,
|
tableName: currentMainTable || "",
|
||||||
tableName: currentMainTable || "",
|
companyCode: companyCode || "*",
|
||||||
companyCode: companyCode || "*",
|
description: "",
|
||||||
description: "",
|
isActive: "Y" as const,
|
||||||
isActive: "Y" as const,
|
createdDate: new Date(),
|
||||||
createdDate: new Date(),
|
updatedDate: new Date(),
|
||||||
updatedDate: new Date(),
|
}}
|
||||||
}}
|
onBackToList={async () => {
|
||||||
onBackToList={async () => {
|
setShowDesignerModal(false);
|
||||||
setShowDesignerModal(false);
|
await loadData();
|
||||||
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
|
setIframeKey(prev => prev + 1);
|
||||||
await loadData();
|
}}
|
||||||
// 데이터 로드 완료 후 iframe 갱신
|
/>
|
||||||
setIframeKey(prev => prev + 1);
|
</div>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* TableSettingModal */}
|
{/* TableSettingModal */}
|
||||||
{tableSettingTarget && (
|
{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,
|
Settings2,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
|
AlignStartVertical,
|
||||||
|
AlignCenterVertical,
|
||||||
|
AlignEndVertical,
|
||||||
|
AlignStartHorizontal,
|
||||||
|
AlignCenterHorizontal,
|
||||||
|
AlignEndHorizontal,
|
||||||
|
AlignHorizontalSpaceAround,
|
||||||
|
AlignVerticalSpaceAround,
|
||||||
|
RulerIcon,
|
||||||
|
Tag,
|
||||||
|
Keyboard,
|
||||||
|
Equal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
|
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
|
||||||
import {
|
import {
|
||||||
|
|
@ -50,6 +62,10 @@ interface GridSettings {
|
||||||
gridOpacity?: number;
|
gridOpacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
|
||||||
|
type DistributeDirection = "horizontal" | "vertical";
|
||||||
|
type MatchSizeMode = "width" | "height" | "both";
|
||||||
|
|
||||||
interface SlimToolbarProps {
|
interface SlimToolbarProps {
|
||||||
screenName?: string;
|
screenName?: string;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
|
|
@ -67,6 +83,13 @@ interface SlimToolbarProps {
|
||||||
// 패널 토글 기능
|
// 패널 토글 기능
|
||||||
isPanelOpen?: boolean;
|
isPanelOpen?: boolean;
|
||||||
onTogglePanel?: () => void;
|
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> = ({
|
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
|
|
@ -85,6 +108,12 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
onOpenMultilangSettings,
|
onOpenMultilangSettings,
|
||||||
isPanelOpen = false,
|
isPanelOpen = false,
|
||||||
onTogglePanel,
|
onTogglePanel,
|
||||||
|
selectedCount = 0,
|
||||||
|
onAlign,
|
||||||
|
onDistribute,
|
||||||
|
onMatchSize,
|
||||||
|
onToggleLabels,
|
||||||
|
onShowShortcuts,
|
||||||
}) => {
|
}) => {
|
||||||
// 사용자 정의 해상도 상태
|
// 사용자 정의 해상도 상태
|
||||||
const [customWidth, setCustomWidth] = useState("");
|
const [customWidth, setCustomWidth] = useState("");
|
||||||
|
|
@ -325,8 +354,100 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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 && (
|
{onPreview && (
|
||||||
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
|
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
|
||||||
<Smartphone className="h-4 w-4" />
|
<Smartphone className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,13 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
disabled={disabled}
|
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}
|
style={style}
|
||||||
>
|
>
|
||||||
<span className="truncate flex-1 text-left">
|
<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