From 984dd70505a53de1c006927b4edb663199bbd625 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 1 Sep 2025 16:40:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=8B=A4=EC=A4=91=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=95=EB=A0=AC=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/screen/GroupingToolbar.tsx | 64 ++- .../components/screen/RealtimePreview.tsx | 40 +- frontend/components/screen/ScreenDesigner.tsx | 384 ++++++++++++++++-- frontend/lib/utils/groupingUtils.ts | 12 +- 4 files changed, 423 insertions(+), 77 deletions(-) diff --git a/frontend/components/screen/GroupingToolbar.tsx b/frontend/components/screen/GroupingToolbar.tsx index 24084853..642928a8 100644 --- a/frontend/components/screen/GroupingToolbar.tsx +++ b/frontend/components/screen/GroupingToolbar.tsx @@ -14,7 +14,19 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Group, Ungroup, Palette, Settings, X, Check } from "lucide-react"; +import { + Group, + Ungroup, + Palette, + Settings, + X, + Check, + AlignLeft, + AlignCenter, + AlignRight, + StretchHorizontal, + StretchVertical, +} from "lucide-react"; import { GroupState, ComponentData, ComponentStyle } from "@/types/screen"; import { createGroupStyle } from "@/lib/utils/groupingUtils"; @@ -25,6 +37,8 @@ interface GroupingToolbarProps { onGroupUngroup: (groupId: string) => void; selectedComponents: ComponentData[]; allComponents: ComponentData[]; + onGroupAlign?: (mode: "left" | "centerX" | "right" | "top" | "centerY" | "bottom") => void; + onGroupDistribute?: (orientation: "horizontal" | "vertical") => void; } export const GroupingToolbar: React.FC = ({ @@ -34,6 +48,8 @@ export const GroupingToolbar: React.FC = ({ onGroupUngroup, selectedComponents, allComponents, + onGroupAlign, + onGroupDistribute, }) => { const [showCreateDialog, setShowCreateDialog] = useState(false); const [groupTitle, setGroupTitle] = useState("새 그룹"); @@ -102,6 +118,9 @@ export const GroupingToolbar: React.FC = ({ {selectedComponents.length > 0 && ( {selectedComponents.length}개 선택됨 + {selectedComponents.length > 1 && ( + (Shift+클릭으로 다중선택, 드래그로 함께 이동) + )} )} @@ -147,6 +166,49 @@ export const GroupingToolbar: React.FC = ({ )} + + {/* 정렬/분배 도구 */} + {selectedComponents.length > 1 && ( +
+ 정렬 + + + + + + +
+ 균등 + + +
+ )}
diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 4a839817..739b2e90 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -27,7 +27,7 @@ import { interface RealtimePreviewProps { component: ComponentData; isSelected?: boolean; - onClick?: () => void; + onClick?: (e?: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기 @@ -216,11 +216,14 @@ export const RealtimePreview: React.FC = ({ style={{ left: `${component.position.x}px`, top: `${component.position.y}px`, - width: `${size.width * 80}px`, + width: `${size.width}px`, // 격자 기반 계산 제거 height: `${size.height}px`, ...style, }} - onClick={onClick} + onClick={(e) => { + e.stopPropagation(); + onClick?.(e); + }} draggable onDragStart={onDragStart} onDragEnd={onDragEnd} @@ -242,34 +245,9 @@ export const RealtimePreview: React.FC = ({ )} {type === "group" && ( -
- {/* 그룹 헤더 */} -
{ - e.stopPropagation(); - onGroupToggle?.(component.id); - }} - > -
- - {label || "그룹"} - ({children ? children.length : 0}개) -
- {component.collapsible && - (component.collapsed ? ( - - ) : ( - - ))} -
- - {/* 그룹 내용 */} - {!component.collapsed && ( -
- {children ? children :
그룹이 비어있습니다
} -
- )} +
+ {/* 그룹 박스/헤더 제거: 투명 컨테이너 */} +
{children}
)} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 1e17a203..fc5be419 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; @@ -142,8 +142,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [dragState, setDragState] = useState({ isDragging: false, draggedComponent: null as ComponentData | null, + draggedComponents: [] as ComponentData[], // 다중선택된 컴포넌트들 originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, + isMultiDrag: false, // 다중 드래그 여부 + initialMouse: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, }); const [groupState, setGroupState] = useState({ isGrouping: false, @@ -160,6 +164,92 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage] = useState(10); + // 드래그 박스(마키) 다중선택 상태 + const [selectionState, setSelectionState] = useState({ + isSelecting: false, + start: { x: 0, y: 0 }, + current: { x: 0, y: 0 }, + }); + + // 컴포넌트의 절대 좌표 계산 (그룹 자식은 부모 오프셋을 누적) + const getAbsolutePosition = useCallback( + (comp: ComponentData) => { + let x = comp.position.x; + let y = comp.position.y; + let cur: ComponentData | undefined = comp; + while (cur.parentId) { + const parent = layout.components.find((c) => c.id === cur!.parentId); + if (!parent) break; + x += parent.position.x; + y += parent.position.y; + cur = parent; + } + return { x, y }; + }, + [layout.components], + ); + + // 마키 선택 시작 (캔버스 빈 영역 마우스다운) + const handleMarqueeStart = useCallback( + (e: React.MouseEvent) => { + if (dragState.isDragging) return; // 드래그 중이면 무시 + const rect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const x = rect ? e.clientX - rect.left + scrollLeft : 0; + const y = rect ? e.clientY - rect.top + scrollTop : 0; + setSelectionState({ isSelecting: true, start: { x, y }, current: { x, y } }); + // 기존 선택 초기화 (Shift 미사용 시) + if (!e.shiftKey) { + setGroupState((prev) => ({ ...prev, selectedComponents: [] })); + } + }, + [dragState.isDragging], + ); + + // 마키 이동 + const handleMarqueeMove = useCallback( + (e: React.MouseEvent) => { + if (!selectionState.isSelecting) return; + const rect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const x = rect ? e.clientX - rect.left + scrollLeft : 0; + const y = rect ? e.clientY - rect.top + scrollTop : 0; + setSelectionState((prev) => ({ ...prev, current: { x, y } })); + }, + [selectionState.isSelecting], + ); + + // 마키 종료 -> 영역 내 컴포넌트 선택 + const handleMarqueeEnd = useCallback(() => { + if (!selectionState.isSelecting) return; + const minX = Math.min(selectionState.start.x, selectionState.current.x); + const minY = Math.min(selectionState.start.y, selectionState.current.y); + const maxX = Math.max(selectionState.start.x, selectionState.current.x); + const maxY = Math.max(selectionState.start.y, selectionState.current.y); + + const selectedIds = layout.components + // 그룹 컨테이너는 제외 + .filter((c) => c.type !== "group") + .filter((c) => { + const abs = getAbsolutePosition(c); + const left = abs.x; + const top = abs.y; + const right = abs.x + c.size.width; + const bottom = abs.y + c.size.height; + // 영역과 교차 여부 판단 (일부라도 겹치면 선택) + return right >= minX && left <= maxX && bottom >= minY && top <= maxY; + }) + .map((c) => c.id); + + setGroupState((prev) => ({ + ...prev, + selectedComponents: Array.from(new Set([...prev.selectedComponents, ...selectedIds])), + })); + setSelectionState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 } }); + }, [selectionState, layout.components, getAbsolutePosition]); + // 테이블 데이터 로드 (실제로는 API에서 가져와야 함) useEffect(() => { const fetchTables = async () => { @@ -614,37 +704,100 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } }, [layout, selectedScreen]); + // 캔버스 참조 (좌표 계산 정확도 향상) + const canvasRef = useRef(null); + const scrollContainerRef = useRef(null); + // 드래그 시작 (새 컴포넌트 추가) const startDrag = useCallback((component: Partial, e: React.DragEvent) => { + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; + const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; + setDragState({ isDragging: true, draggedComponent: component as ComponentData, + draggedComponents: [component as ComponentData], originalPosition: { x: 0, y: 0 }, - currentPosition: { x: 0, y: 0 }, + currentPosition: { x: relMouseX, y: relMouseY }, + isMultiDrag: false, + initialMouse: { x: relMouseX, y: relMouseY }, + grabOffset: { x: 0, y: 0 }, }); e.dataTransfer.setData("application/json", JSON.stringify(component)); }, []); // 기존 컴포넌트 드래그 시작 (재배치) - const startComponentDrag = useCallback((component: ComponentData, e: React.DragEvent) => { - e.stopPropagation(); - setDragState({ - isDragging: true, - draggedComponent: component, - originalPosition: component.position, - currentPosition: component.position, - }); - e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true })); - }, []); + const startComponentDrag = useCallback( + (component: ComponentData, e: React.DragEvent) => { + e.stopPropagation(); + + // 다중선택된 컴포넌트들이 있는지 확인 + const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); + + const isMultiDrag = selectedComponents.length > 1 && groupState.selectedComponents.includes(component.id); + + // 마우스-컴포넌트 그랩 오프셋 계산 (커서와 컴포넌트 좌측상단의 거리) + const canvasRect = canvasRef.current?.getBoundingClientRect(); + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; + const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; + const grabOffsetX = relMouseX - component.position.x; + const grabOffsetY = relMouseY - component.position.y; + + if (isMultiDrag) { + // 다중 드래그 + setDragState({ + isDragging: true, + draggedComponent: component, + draggedComponents: selectedComponents, + originalPosition: component.position, + currentPosition: { x: relMouseX, y: relMouseY }, + isMultiDrag: true, + initialMouse: { x: relMouseX, y: relMouseY }, + grabOffset: { x: grabOffsetX, y: grabOffsetY }, + }); + e.dataTransfer.setData( + "application/json", + JSON.stringify({ + ...component, + isMoving: true, + isMultiDrag: true, + selectedComponentIds: groupState.selectedComponents, + }), + ); + } else { + // 단일 드래그 + setDragState({ + isDragging: true, + draggedComponent: component, + draggedComponents: [component], + originalPosition: component.position, + currentPosition: { x: relMouseX, y: relMouseY }, + isMultiDrag: false, + initialMouse: { x: relMouseX, y: relMouseY }, + grabOffset: { x: grabOffsetX, y: grabOffsetY }, + }); + e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true })); + } + }, + [layout.components, groupState.selectedComponents], + ); // 드래그 중 const onDragOver = useCallback( (e: React.DragEvent) => { e.preventDefault(); if (dragState.isDragging) { - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / 80) * 80; - const y = Math.floor((e.clientY - rect.top) / 60) * 60; + const rect = canvasRef.current?.getBoundingClientRect(); + // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const x = rect ? e.clientX - rect.left + scrollLeft : 0; + const y = rect ? e.clientY - rect.top + scrollTop : 0; setDragState((prev) => ({ ...prev, @@ -665,21 +818,59 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD if (data.isMoving) { // 기존 컴포넌트 재배치 - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / 80) * 80; - const y = Math.floor((e.clientY - rect.top) / 60) * 60; + const rect = canvasRef.current?.getBoundingClientRect(); + // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const mouseX = rect ? e.clientX - rect.left + scrollLeft : 0; + const mouseY = rect ? e.clientY - rect.top + scrollTop : 0; - const newLayout = { - ...layout, - components: layout.components.map((comp) => (comp.id === data.id ? { ...comp, position: { x, y } } : comp)), - }; - setLayout(newLayout); - saveToHistory(newLayout); + if (data.isMultiDrag && data.selectedComponentIds) { + // 다중 드래그 처리 + // 그랩한 컴포넌트의 시작 위치 기준 델타 계산 (그랩 오프셋 반영) + const dropX = mouseX - dragState.grabOffset.x; + const dropY = mouseY - dragState.grabOffset.y; + const deltaX = dropX - dragState.originalPosition.x; + const deltaY = dropY - dragState.originalPosition.y; + + const newLayout = { + ...layout, + components: layout.components.map((comp) => { + if (data.selectedComponentIds.includes(comp.id)) { + return { + ...comp, + position: { + x: comp.position.x + deltaX, + y: comp.position.y + deltaY, + }, + }; + } + return comp; + }), + }; + setLayout(newLayout); + saveToHistory(newLayout); + } else { + // 단일 드래그 처리 + const x = mouseX - dragState.grabOffset.x; + const y = mouseY - dragState.grabOffset.y; + const newLayout = { + ...layout, + components: layout.components.map((comp) => + comp.id === data.id ? { ...comp, position: { x, y } } : comp, + ), + }; + setLayout(newLayout); + saveToHistory(newLayout); + } } else { // 새 컴포넌트 추가 - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.floor((e.clientX - rect.left) / 80) * 80; - const y = Math.floor((e.clientY - rect.top) / 60) * 60; + const rect = canvasRef.current?.getBoundingClientRect(); + // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) + const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; + const scrollTop = scrollContainerRef.current?.scrollTop || 0; + const x = rect ? e.clientX - rect.left + scrollLeft : 0; + const y = rect ? e.clientY - rect.top + scrollTop : 0; const newComponent: ComponentData = { ...data, @@ -701,11 +892,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setDragState({ isDragging: false, draggedComponent: null, + draggedComponents: [], originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, + isMultiDrag: false, + initialMouse: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, }); }, - [layout, saveToHistory], + [ + layout, + saveToHistory, + dragState.initialMouse.x, + dragState.initialMouse.y, + dragState.grabOffset.x, + dragState.grabOffset.y, + ], ); // 드래그 종료 @@ -713,16 +915,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD setDragState({ isDragging: false, draggedComponent: null, + draggedComponents: [], originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, + isMultiDrag: false, + initialMouse: { x: 0, y: 0 }, + grabOffset: { x: 0, y: 0 }, }); }, []); // 컴포넌트 클릭 (선택) const handleComponentClick = useCallback( - (component: ComponentData) => { - if (groupState.isGrouping) { - // 그룹화 모드에서는 다중 선택 + (component: ComponentData, event?: React.MouseEvent) => { + const isShiftPressed = event?.shiftKey || false; + + // 그룹 컨테이너는 다중선택 대상에서 제외 + const isGroupContainer = component.type === "group"; + + if (groupState.isGrouping || isShiftPressed) { + // 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택 + if (isGroupContainer) { + // 그룹 컨테이너 클릭은 다중선택에 포함하지 않고 무시 + return; + } const isSelected = groupState.selectedComponents.includes(component.id); setGroupState((prev) => ({ ...prev, @@ -730,16 +945,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ? prev.selectedComponents.filter((id) => id !== component.id) : [...prev.selectedComponents, component.id], })); + + // 시프트 키로 선택한 경우 마지막 선택된 컴포넌트를 selectedComponent로 설정 + if (isShiftPressed) { + setSelectedComponent(component); + } } else { // 일반 모드에서는 단일 선택 setSelectedComponent(component); setGroupState((prev) => ({ ...prev, - selectedComponents: [component.id], + selectedComponents: isGroupContainer ? [] : [component.id], })); } }, - [groupState.isGrouping], + [groupState.isGrouping, groupState.selectedComponents], ); // 화면이 선택되지 않았을 때 처리 @@ -773,7 +993,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD variant={groupState.isGrouping ? "default" : "outline"} size="sm" onClick={() => setGroupState((prev) => ({ ...prev, isGrouping: !prev.isGrouping }))} - title="그룹화 모드 토글" + title="그룹화 모드 토글 (일반 모드에서도 Shift+클릭으로 다중선택 가능)" > {groupState.isGrouping ? "그룹화 모드" : "일반 모드"} @@ -807,6 +1027,75 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD onGroupUngroup={handleGroupUngroup} selectedComponents={layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))} allComponents={layout.components} + onGroupAlign={(mode) => { + const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); + if (selected.length < 2) return; + + let newComponents = [...layout.components]; + const minX = Math.min(...selected.map((c) => c.position.x)); + const maxX = Math.max(...selected.map((c) => c.position.x + c.size.width)); + const minY = Math.min(...selected.map((c) => c.position.y)); + const maxY = Math.max(...selected.map((c) => c.position.y + c.size.height)); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + newComponents = newComponents.map((c) => { + if (!groupState.selectedComponents.includes(c.id)) return c; + if (mode === "left") return { ...c, position: { x: minX, y: c.position.y } }; + if (mode === "right") return { ...c, position: { x: maxX - c.size.width, y: c.position.y } }; + if (mode === "centerX") + return { ...c, position: { x: Math.round(centerX - c.size.width / 2), y: c.position.y } }; + if (mode === "top") return { ...c, position: { x: c.position.x, y: minY } }; + if (mode === "bottom") return { ...c, position: { x: c.position.x, y: maxY - c.size.height } }; + if (mode === "centerY") + return { ...c, position: { x: c.position.x, y: Math.round(centerY - c.size.height / 2) } }; + return c; + }); + + const newLayout = { ...layout, components: newComponents }; + setLayout(newLayout); + saveToHistory(newLayout); + }} + onGroupDistribute={(orientation) => { + const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); + if (selected.length < 3) return; // 균등 분배는 3개 이상 권장 + + const sorted = [...selected].sort((a, b) => + orientation === "horizontal" ? a.position.x - b.position.x : a.position.y - b.position.y, + ); + + if (orientation === "horizontal") { + const left = sorted[0].position.x; + const right = Math.max(...sorted.map((c) => c.position.x + c.size.width)); + const totalWidth = right - left; + const gaps = sorted.length - 1; + const usedWidth = sorted.reduce((sum, c) => sum + c.size.width, 0); + const gapSize = gaps > 0 ? Math.max(0, Math.round((totalWidth - usedWidth) / gaps)) : 0; + + let cursor = left; + sorted.forEach((c, idx) => { + c.position.x = cursor; + cursor += c.size.width + gapSize; + }); + } else { + const top = sorted[0].position.y; + const bottom = Math.max(...sorted.map((c) => c.position.y + c.size.height)); + const totalHeight = bottom - top; + const gaps = sorted.length - 1; + const usedHeight = sorted.reduce((sum, c) => sum + c.size.height, 0); + const gapSize = gaps > 0 ? Math.max(0, Math.round((totalHeight - usedHeight) / gaps)) : 0; + + let cursor = top; + sorted.forEach((c, idx) => { + c.position.y = cursor; + cursor += c.size.height + gapSize; + }); + } + + const newLayout = { ...layout, components: [...layout.components] }; + setLayout(newLayout); + saveToHistory(newLayout); + }} /> {/* 메인 컨텐츠 영역 */} @@ -850,7 +1139,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD type: "container", tableName: table.tableName, label: table.tableLabel, - size: { width: 12, height: 80 }, + size: { width: 200, height: 80 }, // 픽셀 단위로 변경 }, e, ) @@ -896,7 +1185,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD columnName: column.columnName, widgetType: widgetType as WebType, label: column.columnLabel || column.columnName, - size: { width: 6, height: 40 }, + size: { width: 150, height: 40 }, // 픽셀 단위로 변경 }, e, ); @@ -964,12 +1253,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 중앙: 캔버스 영역 */} -
+
{layout.components.length === 0 ? (
@@ -990,6 +1283,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
+ {/* 마키 선택 사각형 */} + {selectionState.isSelecting && ( +
+ )} + {/* 컴포넌트들 - 실시간 미리보기 */} {layout.components .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 @@ -1008,7 +1314,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id) } - onClick={() => handleComponentClick(component)} + onClick={(e) => handleComponentClick(component, e)} onDragStart={(e) => startComponentDrag(component, e)} onDragEnd={endDrag} onGroupToggle={(groupId) => { @@ -1022,7 +1328,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD key={child.id} component={child} isSelected={groupState.selectedComponents.includes(child.id)} - onClick={() => handleComponentClick(child)} + onClick={(e) => handleComponentClick(child, e)} onDragStart={(e) => startComponentDrag(child, e)} onDragEnd={endDrag} /> diff --git a/frontend/lib/utils/groupingUtils.ts b/frontend/lib/utils/groupingUtils.ts index 16108fae..adb62c3d 100644 --- a/frontend/lib/utils/groupingUtils.ts +++ b/frontend/lib/utils/groupingUtils.ts @@ -15,15 +15,15 @@ export function createGroupComponent( boundingBox?: { width: number; height: number }, style?: any, ): GroupComponent { - // 격자 기반 크기 계산 - const gridWidth = Math.max(6, Math.ceil(boundingBox?.width / 80) + 2); // 최소 6 그리드, 여백 2 - const gridHeight = Math.max(100, (boundingBox?.height || 200) + 40); // 최소 100px, 여백 40px + // 픽셀 기반 크기 계산 (격자 제거) + const groupWidth = Math.max(200, (boundingBox?.width || 200) + 40); // 최소 200px, 여백 40px + const groupHeight = Math.max(100, (boundingBox?.height || 200) + 40); // 최소 100px, 여백 40px return { id: generateComponentId(), type: "group", position, - size: { width: gridWidth, height: gridHeight }, + size: { width: groupWidth, height: groupHeight }, label: title, // title 대신 label 사용 backgroundColor: "#f8f9fa", border: "1px solid #dee2e6", @@ -39,7 +39,7 @@ export function createGroupComponent( }; } -// 선택된 컴포넌트들의 경계 박스 계산 (격자 기반) +// 선택된 컴포넌트들의 경계 박스 계산 (픽셀 기반) export function calculateBoundingBox(components: ComponentData[]): { minX: number; minY: number; @@ -54,7 +54,7 @@ export function calculateBoundingBox(components: ComponentData[]): { const minX = Math.min(...components.map((c) => c.position.x)); const minY = Math.min(...components.map((c) => c.position.y)); - const maxX = Math.max(...components.map((c) => c.position.x + c.size.width * 80)); + const maxX = Math.max(...components.map((c) => c.position.x + c.size.width)); // 격자 계산 제거 const maxY = Math.max(...components.map((c) => c.position.y + c.size.height)); return { From ff2b3c37c6448ee3293dbc977e7c8683a7accbb3 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 1 Sep 2025 17:05:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/화면관리_시스템_설계.md | 37 +++++-- .../components/screen/RealtimePreview.tsx | 54 ++++++++-- frontend/components/screen/ScreenDesigner.tsx | 98 +++++++++++++++---- frontend/components/screen/StyleEditor.tsx | 24 +---- 4 files changed, 157 insertions(+), 56 deletions(-) diff --git a/docs/화면관리_시스템_설계.md b/docs/화면관리_시스템_설계.md index 1c72c952..67e82fba 100644 --- a/docs/화면관리_시스템_설계.md +++ b/docs/화면관리_시스템_설계.md @@ -30,6 +30,17 @@ - **실시간 미리보기**: 설계한 화면을 실제 화면과 동일하게 확인 가능 - **메뉴 연동**: 각 회사의 메뉴에 화면 할당 및 관리 +### 🆕 최근 업데이트 (요약) + +- **픽셀 기반 자유 이동**: 격자 스냅을 제거하고 커서를 따라 정확히 이동하도록 구현. 그리드 라인은 시각적 가이드만 유지 +- **멀티 선택 강화**: Shift+클릭 + 드래그 박스(마키)로 다중선택 가능. 그룹 컨테이너는 선택에서 자동 제외 +- **다중 드래그 이동**: 다중선택 항목을 함께 이동(상대 위치 유지). 스크롤/그랩 오프셋 반영으로 튐 현상 제거 +- **그룹 UI 간소화**: 그룹 헤더/테두리 박스 제거(투명 컨테이너). 그룹 내부에만 집중 +- **그룹 내 정렬/분배 툴**: 좌/가로중앙/우, 상/세로중앙/하 정렬 + 가로/세로 균등 분배 추가(아이콘 UI) +- **왼쪽 목록 UX**: 검색·페이징 도입으로 대량 테이블 로딩 지연 완화 +- **Undo/Redo**: 최대 50단계, 단축키(Ctrl/Cmd+Z, Ctrl/Cmd+Y) +- **위젯 타입 렌더링 보강**: code/entity/file 포함 실제 위젯 형태로 표시 + ### 🎯 **현재 테이블 구조와 100% 호환** **기존 테이블 타입관리 시스템과 완벽 연계:** @@ -571,6 +582,8 @@ size: { width: number; height: number }; ### 2. 컴포넌트 배치 로직 +현재 배치 로직은 **픽셀 기반 자유 위치**로 동작합니다. 마우스 그랩 오프셋과 스크롤 오프셋을 반영하여 커서를 정확히 추적합니다. 아래 그리드 기반 예시는 참고용이며, 실제 런타임에서는 스냅을 적용하지 않습니다. + ```typescript // 그리드 기반 배치 function calculateGridPosition( @@ -1171,6 +1184,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }, [undo, redo]); ``` +#### 선택/이동 UX (현행) + +- Shift+클릭으로 다중선택 가능 +- 캔버스 빈 영역 드래그로 **마키 선택** 가능(Shift 누르면 기존 선택에 추가) +- 다중선택 상태에서 드래그 시 전체가 함께 이동(상대 좌표 유지) +- 그룹 컨테이너는 선택/정렬 대상에서 자동 제외 + // 컴포넌트 추가 const addComponent = (component: ComponentData) => { setLayout((prev) => ({ @@ -2265,26 +2285,31 @@ export class TableTypeIntegrationService { - **직관적 인터페이스**: 드래그앤드롭 기반 화면 설계 - **실시간 피드백**: 컴포넌트 배치 즉시 미리보기 표시 -- **키보드 지원**: Ctrl+Z/Ctrl+Y 단축키로 빠른 작업 +- **다중선택**: Shift+클릭 및 마키 선택 지원, 다중 드래그 이동 +- **정렬/분배**: 그룹 내 좌/중앙/우·상/중앙/하 정렬 및 균등 분배 +- **키보드 지원**: Ctrl/Cmd+Z, Ctrl/Cmd+Y 단축키 - **반응형 UI**: 전체 화면 활용한 효율적인 레이아웃 ## 🚀 다음 단계 계획 ### 1. 컴포넌트 그룹화 기능 -- [ ] 여러 위젯을 컨테이너로 그룹화 -- [ ] 부모-자식 관계 설정 -- [ ] 그룹 단위 이동/삭제 기능 +- [x] 여러 위젯을 컨테이너로 그룹화 +- [x] 부모-자식 관계 설정(parentId) +- [x] 그룹 단위 이동 +- [x] 그룹 UI 단순화(헤더/박스 제거) +- [x] 그룹 내 정렬/균등 분배 도구(아이콘 UI) +- [ ] 그룹 단위 삭제/복사/붙여넣기 ### 2. 레이아웃 저장/로드 -- [ ] 설계한 화면을 데이터베이스에 저장 +- [ ] 설계한 화면을 데이터베이스에 저장 (프론트 통합 진행 필요) - [ ] 저장된 화면 불러오기 기능 - [ ] 버전 관리 시스템 ### 3. 데이터 바인딩 -- [ ] 실제 데이터베이스와 연결 +- [ ] 실제 데이터베이스와 연결 (메타데이터 연동은 완료) - [ ] 폼 제출 및 데이터 저장 - [ ] 유효성 검증 시스템 diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 739b2e90..400de969 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -36,16 +36,22 @@ interface RealtimePreviewProps { // 웹 타입에 따른 위젯 렌더링 const renderWidget = (component: ComponentData) => { - const { widgetType, label, placeholder, required, readonly, columnName } = component; + const { widgetType, label, placeholder, required, readonly, columnName, style } = component; // 디버깅: 실제 widgetType 값 확인 console.log("RealtimePreview - widgetType:", widgetType, "columnName:", columnName); + // 사용자가 테두리를 설정했는지 확인 + const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); + + // 기본 테두리 제거 여부 결정 - Shadcn UI 기본 border 클래스를 덮어쓰기 + const borderClass = hasCustomBorder ? "!border-0" : ""; + const commonProps = { placeholder: placeholder || `입력하세요...`, disabled: readonly, required: required, - className: "w-full h-full", + className: `w-full h-full ${borderClass}`, }; switch (widgetType) { @@ -68,7 +74,9 @@ const renderWidget = (component: ComponentData) => {