From 9dca73f4c4f5d10a64b57a6ffa0f2b963c094c11 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:37:14 +0900 Subject: [PATCH 01/12] =?UTF-8?q?12=EC=BB=AC=EB=9F=BC=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 47 +++++--- .../admin/dashboard/DashboardCanvas.tsx | 108 ++++++++++-------- .../components/admin/dashboard/gridUtils.ts | 68 ++++++++++- 3 files changed, 157 insertions(+), 66 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 9350642e..6214c8cb 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult, Position } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG, magneticSnap } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -135,6 +135,8 @@ interface CanvasElementProps { cellSize: number; subGridSize: number; canvasWidth?: number; + verticalGuidelines: number[]; + horizontalGuidelines: number[]; onUpdate: (id: string, updates: Partial) => void; onUpdateMultiple?: (updates: { id: string; updates: Partial }[]) => void; // 🔥 다중 업데이트 onMultiDragStart?: (draggedId: string, otherOffsets: Record) => void; @@ -159,6 +161,8 @@ export function CanvasElement({ cellSize, subGridSize, canvasWidth = 1560, + verticalGuidelines, + horizontalGuidelines, onUpdate, onUpdateMultiple, onMultiDragStart, @@ -315,15 +319,18 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - // 드래그 중 실시간 스냅 (서브그리드만 사용) - const snappedX = Math.round(rawX / subGridSize) * subGridSize; - const snappedY = Math.round(rawY / subGridSize) * subGridSize; + // 자석 스냅으로 변경 + const snappedX = magneticSnap(rawX, verticalGuidelines); + const snappedY = magneticSnap(rawY, horizontalGuidelines); - setTempPosition({ x: snappedX, y: snappedY }); + // 스냅 후 X 좌표 다시 체크 + const finalSnappedX = Math.min(snappedX, maxX); + + setTempPosition({ x: finalSnappedX, y: snappedY }); // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { - onMultiDragMove(element, { x: snappedX, y: snappedY }); + onMultiDragMove(element, { x: finalSnappedX, y: snappedY }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -367,14 +374,20 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 리사이즈 중 실시간 스냅 (서브그리드만 사용) - const snappedX = Math.round(newX / subGridSize) * subGridSize; - const snappedY = Math.round(newY / subGridSize) * subGridSize; - const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize; - const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize; + // 자석 스냅으로 변경 + const snappedX = magneticSnap(newX, verticalGuidelines); + const snappedY = magneticSnap(newY, horizontalGuidelines); + + // 크기는 12px 단위로 스냅 + const snappedWidth = Math.round(newWidth / 12) * 12; + const snappedHeight = Math.round(newHeight / 12) * 12; + + // 스냅 후 경계 체크 + const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); + const finalSnappedY = Math.max(0, snappedY); // 임시 크기/위치 저장 (스냅됨) - setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); + setTempPosition({ x: finalSnappedX, y: finalSnappedY }); setTempSize({ width: snappedWidth, height: snappedHeight }); } }, @@ -386,7 +399,8 @@ export function CanvasElement({ element, canvasWidth, cellSize, - subGridSize, + verticalGuidelines, + horizontalGuidelines, selectedElements, allElements, onUpdateMultiple, @@ -891,12 +905,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "list" ? ( // 리스트 위젯 렌더링
- { - onUpdate(element.id, { listConfig: newConfig as any }); - }} - /> +
) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( // 야드 관리 3D 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 1c2414c1..51351475 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -3,7 +3,15 @@ import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { CanvasElement } from "./CanvasElement"; -import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils"; +import { + GRID_CONFIG, + snapToGrid, + calculateGridConfig, + calculateVerticalGuidelines, + calculateHorizontalGuidelines, + calculateBoxSize, + magneticSnap, +} from "./gridUtils"; import { resolveAllCollisions } from "./collisionUtils"; interface DashboardCanvasProps { @@ -47,7 +55,7 @@ export const DashboardCanvas = forwardRef( ref, ) => { const [isDragOver, setIsDragOver] = useState(false); - + // 🔥 선택 박스 상태 const [selectionBox, setSelectionBox] = useState<{ startX: number; @@ -58,10 +66,10 @@ export const DashboardCanvas = forwardRef( const [isSelecting, setIsSelecting] = useState(false); const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그 const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그 - + // 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백) const [multiDragOffsets, setMultiDragOffsets] = useState>({}); - + // 🔥 선택 박스 드래그 중 자동 스크롤 const lastMouseYForSelectionRef = React.useRef(window.innerHeight / 2); const selectionAutoScrollFrameRef = React.useRef(null); @@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef( const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); const cellSize = gridConfig.CELL_SIZE; + // 🔥 그리드 박스 시스템 - 12개 박스가 캔버스 너비에 꽉 차게 + const verticalGuidelines = useMemo(() => calculateVerticalGuidelines(canvasWidth), [canvasWidth]); + const horizontalGuidelines = useMemo( + () => calculateHorizontalGuidelines(canvasHeight, canvasWidth), + [canvasHeight, canvasWidth], + ); + const boxSize = useMemo(() => calculateBoxSize(canvasWidth), [canvasWidth]); + // 충돌 방지 기능이 포함된 업데이트 핸들러 const handleUpdateWithCollisionDetection = useCallback( (id: string, updates: Partial) => { @@ -177,23 +193,13 @@ export const DashboardCanvas = forwardRef( const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); - // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) - const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 - const magneticThreshold = 15; + // 자석 스냅 적용 + let snappedX = magneticSnap(rawX, verticalGuidelines); + let snappedY = magneticSnap(rawY, horizontalGuidelines); - // X 좌표 스냅 - const nearestGridX = Math.round(rawX / gridSize) * gridSize; - const distToGridX = Math.abs(rawX - nearestGridX); - let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; - - // Y 좌표 스냅 - const nearestGridY = Math.round(rawY / gridSize) * gridSize; - const distToGridY = Math.abs(rawY - nearestGridY); - const snappedY = - distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; - - // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 - const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 + // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장) + const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP; + const maxX = canvasWidth - minElementWidth; snappedX = Math.max(0, Math.min(snappedX, maxX)); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); @@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef( // 드롭 데이터 파싱 오류 무시 } }, - [ref, onCreateElement, canvasWidth, cellSize], + [ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines], ); // 🔥 선택 박스 드래그 시작 @@ -210,14 +216,14 @@ export const DashboardCanvas = forwardRef( // 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우) const target = e.target as HTMLElement; const isWidget = target.closest("[data-element-id]"); - + if (isWidget) { // console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함"); return; } - + // console.log("✅ 빈 공간 클릭 - 선택 박스 시작"); - + if (!ref || typeof ref === "function") return; const rect = ref.current?.getBoundingClientRect(); if (!rect) return; @@ -274,20 +280,20 @@ export const DashboardCanvas = forwardRef( // 겹치는 영역의 넓이 const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop); - + // 요소의 전체 넓이 const elementArea = el.size.width * el.size.height; // 70% 이상 겹치면 선택 const overlapPercentage = overlapArea / elementArea; - + // console.log(`📦 요소 ${el.id}:`, { // position: el.position, // size: el.size, // overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%", // selected: overlapPercentage >= 0.7, // }); - + return overlapPercentage >= 0.7; }) .map((el) => el.id); @@ -327,9 +333,9 @@ export const DashboardCanvas = forwardRef( if (!isSelecting) { const deltaX = Math.abs(x - selectionBox.startX); const deltaY = Math.abs(y - selectionBox.startY); - + // console.log("📏 이동 거리:", { deltaX, deltaY }); - + // 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분) if (deltaX > 5 || deltaY > 5) { // console.log("🎯 선택 박스 활성화 (5px 이상 이동)"); @@ -374,10 +380,10 @@ export const DashboardCanvas = forwardRef( const autoScrollLoop = (currentTime: number) => { const viewportHeight = window.innerHeight; const lastMouseY = lastMouseYForSelectionRef.current; - + let shouldScroll = false; let scrollDirection = 0; - + if (lastMouseY < scrollThreshold) { shouldScroll = true; scrollDirection = -scrollSpeed; @@ -387,9 +393,9 @@ export const DashboardCanvas = forwardRef( scrollDirection = scrollSpeed; // console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold }); } - + const deltaTime = currentTime - lastTime; - + if (shouldScroll && deltaTime >= 10) { window.scrollBy(0, scrollDirection); // console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime }); @@ -418,7 +424,7 @@ export const DashboardCanvas = forwardRef( // console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시"); return; } - + if (e.target === e.currentTarget) { // console.log("✅ 빈 공간 클릭 - 선택 해제"); onSelectElement(null); @@ -433,7 +439,7 @@ export const DashboardCanvas = forwardRef( // 동적 그리드 크기 계산 const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; - + // 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용) const subGridSize = gridConfig.SUB_GRID_SIZE; @@ -443,7 +449,7 @@ export const DashboardCanvas = forwardRef( // 🔥 선택 박스 스타일 계산 const selectionBoxStyle = useMemo(() => { if (!selectionBox) return null; - + const minX = Math.min(selectionBox.startX, selectionBox.endX); const maxX = Math.max(selectionBox.startX, selectionBox.endX); const minY = Math.min(selectionBox.startY, selectionBox.endY); @@ -465,14 +471,6 @@ export const DashboardCanvas = forwardRef( backgroundColor, height: `${canvasHeight}px`, minHeight: `${canvasHeight}px`, - // 서브그리드 배경 (세밀한 점선) - backgroundImage: ` - linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px) - `, - backgroundSize: `${subGridSize}px ${subGridSize}px`, - backgroundPosition: "0 0", - backgroundRepeat: "repeat", cursor: isSelecting ? "crosshair" : "default", }} onDragOver={handleDragOver} @@ -494,6 +492,23 @@ export const DashboardCanvas = forwardRef( }} /> ))} */} + {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게) */} + {verticalGuidelines.map((x, xIdx) => + horizontalGuidelines.map((y, yIdx) => ( +
+ )), + )} {/* 배치된 요소들 렌더링 */} {elements.length === 0 && (
@@ -513,6 +528,8 @@ export const DashboardCanvas = forwardRef( cellSize={cellSize} subGridSize={subGridSize} canvasWidth={canvasWidth} + verticalGuidelines={verticalGuidelines} + horizontalGuidelines={horizontalGuidelines} onUpdate={handleUpdateWithCollisionDetection} onUpdateMultiple={(updates) => { // 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기) @@ -552,10 +569,9 @@ export const DashboardCanvas = forwardRef( }} onRemove={onRemoveElement} onSelect={onSelectElement} - onConfigure={onConfigureElement} /> ))} - + {/* 🔥 선택 박스 렌더링 */} {selectionBox && selectionBoxStyle && (
{ export const snapToGrid = (value: number, subGridSize?: number): number => { // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); - + // 서브 그리드 단위로 스냅 const gridIndex = Math.round(value / snapSize); return gridIndex * snapSize; @@ -198,3 +205,62 @@ export const getNearbyGridLines = (value: number): number[] => { export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => { return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD; }; + +// 박스 크기 계산 (캔버스 너비에 맞게) +export function calculateBoxSize(canvasWidth: number): number { + const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개 + const availableWidth = canvasWidth - totalGaps; + return availableWidth / 12; +} + +// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게) +export function calculateVerticalGuidelines(canvasWidth: number): number[] { + const lines: number[] = []; + const boxSize = calculateBoxSize(canvasWidth); + + for (let i = 0; i < 12; i++) { + const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP); + lines.push(x); + } + return lines; +} + +// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지) +export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] { + const lines: number[] = []; + const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용 + const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; + + for (let y = 0; y <= canvasHeight; y += cellSize) { + lines.push(y); + } + return lines; +} + +// 가장 가까운 가이드라인 찾기 +export function findNearestGuideline( + value: number, + guidelines: number[], +): { + nearest: number; + distance: number; +} { + let nearest = guidelines[0]; + let minDistance = Math.abs(value - guidelines[0]); + + for (const guideline of guidelines) { + const distance = Math.abs(value - guideline); + if (distance < minDistance) { + minDistance = distance; + nearest = guideline; + } + } + + return { nearest, distance: minDistance }; +} + +// 자석 스냅 (10px 이내면 스냅) +export function magneticSnap(value: number, guidelines: number[]): number { + const { nearest, distance } = findNearestGuideline(value, guidelines); + return distance <= GRID_CONFIG.SNAP_DISTANCE ? nearest : value; +} From 0a28445abe2f2337a7e5923ccd8eb3fb2624af87 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:45:15 +0900 Subject: [PATCH 02/12] =?UTF-8?q?12=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B0=8F=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/DashboardCanvas.tsx | 9 +++++---- .../components/admin/dashboard/DashboardDesigner.tsx | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 51351475..ac0d69a4 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -48,7 +48,7 @@ export const DashboardCanvas = forwardRef( onSelectElement, onSelectMultiple, onConfigureElement, - backgroundColor = "#f9fafb", + backgroundColor = "transparent", canvasWidth = 1560, canvasHeight = 768, }, @@ -466,7 +466,7 @@ export const DashboardCanvas = forwardRef( return (
( horizontalGuidelines.map((y, yIdx) => (
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 315179f6..36601623 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -47,7 +47,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [dashboardId, setDashboardId] = useState(initialDashboardId || null); const [dashboardTitle, setDashboardTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb"); + const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("transparent"); const canvasRef = useRef(null); // 저장 모달 상태 @@ -218,7 +218,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 기본 크기 설정 (서브그리드 기준) const gridConfig = calculateGridConfig(canvasConfig.width); const subGridSize = gridConfig.SUB_GRID_SIZE; - + // 서브그리드 기준 기본 크기 (픽셀) let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 @@ -550,7 +550,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
Date: Wed, 22 Oct 2025 16:49:57 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=EC=8A=A4=EB=83=85=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/gridUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index a9aaeec8..083f819b 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -259,8 +259,8 @@ export function findNearestGuideline( return { nearest, distance: minDistance }; } -// 자석 스냅 (10px 이내면 스냅) +// 강제 스냅 (항상 가장 가까운 가이드라인에 스냅) export function magneticSnap(value: number, guidelines: number[]): number { - const { nearest, distance } = findNearestGuideline(value, guidelines); - return distance <= GRID_CONFIG.SNAP_DISTANCE ? nearest : value; + const { nearest } = findNearestGuideline(value, guidelines); + return nearest; // 거리 체크 없이 무조건 스냅 } From 41c763c0196be01563b12101f848d4d359ecd2b1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:58:07 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=EC=8A=A4=EB=83=85=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 8 +-- .../admin/dashboard/DashboardDesigner.tsx | 36 +++++++++----- .../components/admin/dashboard/gridUtils.ts | 49 +++++++++++-------- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 6214c8cb..2ecf8245 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult, Position } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { GRID_CONFIG, magneticSnap } from "./gridUtils"; +import { GRID_CONFIG, magneticSnap, snapSizeToGrid } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -378,9 +378,9 @@ export function CanvasElement({ const snappedX = magneticSnap(newX, verticalGuidelines); const snappedY = magneticSnap(newY, horizontalGuidelines); - // 크기는 12px 단위로 스냅 - const snappedWidth = Math.round(newWidth / 12) * 12; - const snappedHeight = Math.round(newHeight / 12) * 12; + // 크기는 그리드 박스 단위로 스냅 + const snappedWidth = snapSizeToGrid(newWidth, canvasWidth || 1560); + const snappedHeight = snapSizeToGrid(newHeight, canvasWidth || 1560); // 스냅 후 경계 체크 const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 36601623..83097678 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -7,7 +7,14 @@ import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigSidebar } from "./ElementConfigSidebar"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils"; +import { + GRID_CONFIG, + snapToGrid, + snapSizeToGrid, + calculateCellSize, + calculateGridConfig, + calculateBoxSize, +} from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; @@ -89,8 +96,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 그리드에 스냅 (X, Y, 너비, 높이 모두) const snappedX = snapToGrid(scaledX, newCellSize); const snappedY = snapToGrid(el.position.y, newCellSize); - const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize); - const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize); + const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width); + const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width); return { ...el, @@ -215,22 +222,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D return; } - // 기본 크기 설정 (서브그리드 기준) - const gridConfig = calculateGridConfig(canvasConfig.width); - const subGridSize = gridConfig.SUB_GRID_SIZE; + // 기본 크기 설정 (그리드 박스 단위) + const boxSize = calculateBoxSize(canvasConfig.width); - // 서브그리드 기준 기본 크기 (픽셀) - let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 - let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 + // 그리드 박스 단위 기본 크기 + let boxesWidth = 3; // 기본 위젯: 박스 3개 + let boxesHeight = 3; // 기본 위젯: 박스 3개 if (type === "chart") { - defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸 - defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸 + boxesWidth = 4; // 차트: 박스 4개 + boxesHeight = 3; // 차트: 박스 3개 } else if (type === "widget" && subtype === "calendar") { - defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸 - defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸 + boxesWidth = 3; // 달력: 박스 3개 + boxesHeight = 4; // 달력: 박스 4개 } + // 박스 개수를 픽셀로 변환 (마지막 간격 제거) + const defaultWidth = boxesWidth * boxSize + (boxesWidth - 1) * GRID_CONFIG.GRID_BOX_GAP; + const defaultHeight = boxesHeight * boxSize + (boxesHeight - 1) * GRID_CONFIG.GRID_BOX_GAP; + // 크기 유효성 검사 if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { // console.error("Invalid size calculated:", { diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index 083f819b..6094aa0e 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -54,9 +54,11 @@ export function calculateGridConfig(canvasWidth: number) { /** * 실제 그리드 셀 크기 계산 (gap 포함) + * @param canvasWidth - 캔버스 너비 */ -export const getCellWithGap = () => { - return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; +export const getCellWithGap = (canvasWidth: number = 1560) => { + const boxSize = calculateBoxSize(canvasWidth); + return boxSize + GRID_CONFIG.GRID_BOX_GAP; }; /** @@ -70,14 +72,14 @@ export const getCanvasWidth = () => { /** * 좌표를 서브 그리드에 스냅 (세밀한 조정 가능) * @param value - 스냅할 좌표값 - * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px) + * @param subGridSize - 서브 그리드 크기 (선택사항) * @returns 스냅된 좌표값 */ export const snapToGrid = (value: number, subGridSize?: number): number => { - // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) - const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); + // 서브 그리드 크기가 지정되지 않으면 기본 박스 크기 사용 + const snapSize = subGridSize ?? calculateBoxSize(1560); - // 서브 그리드 단위로 스냅 + // 그리드 단위로 스냅 const gridIndex = Math.round(value / snapSize); return gridIndex * snapSize; }; @@ -88,8 +90,9 @@ export const snapToGrid = (value: number, subGridSize?: number): number => { * @param cellSize - 셀 크기 (선택사항) * @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값) */ -export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { - const snapped = snapToGrid(value, cellSize); +export const snapToGridWithThreshold = (value: number, cellSize?: number): number => { + const snapSize = cellSize ?? calculateBoxSize(1560); + const snapped = snapToGrid(value, snapSize); const distance = Math.abs(value - snapped); return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value; @@ -102,15 +105,7 @@ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_C * @param cellSize - 셀 크기 (선택사항) * @returns 스냅된 크기 */ -export const snapSizeToGrid = ( - size: number, - minCells: number = 2, - cellSize: number = GRID_CONFIG.CELL_SIZE, -): number => { - const cellWithGap = cellSize + GRID_CONFIG.GAP; - const cells = Math.max(minCells, Math.round(size / cellWithGap)); - return cells * cellWithGap - GRID_CONFIG.GAP; -}; +// 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음 /** * 위치와 크기를 모두 그리드에 스냅 @@ -142,9 +137,10 @@ export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canva let snappedX = snapToGrid(bounds.position.x); let snappedY = snapToGrid(bounds.position.y); - // 크기 스냅 - const snappedWidth = snapSizeToGrid(bounds.size.width); - const snappedHeight = snapSizeToGrid(bounds.size.height); + // 크기 스냅 (canvasWidth 기본값 1560) + const width = canvasWidth || 1560; + const snappedWidth = snapSizeToGrid(bounds.size.width, width); + const snappedHeight = snapSizeToGrid(bounds.size.height, width); // 캔버스 경계 체크 if (canvasWidth) { @@ -264,3 +260,16 @@ export function magneticSnap(value: number, guidelines: number[]): number { const { nearest } = findNearestGuideline(value, guidelines); return nearest; // 거리 체크 없이 무조건 스냅 } + +// 크기를 그리드 박스 단위로 스냅 (박스 크기의 배수로만 가능) +export function snapSizeToGrid(size: number, canvasWidth: number): number { + const boxSize = calculateBoxSize(canvasWidth); + const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; // 박스 + 간격 + + // 최소 1개 박스 크기 + const minBoxes = 1; + const boxes = Math.max(minBoxes, Math.round(size / cellSize)); + + // 박스 개수에서 마지막 간격 제거 + return boxes * boxSize + (boxes - 1) * GRID_CONFIG.GRID_BOX_GAP; +} From 2dd96f5a74e007153099c650c29f7a6cdd024763 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 22 Oct 2025 17:19:47 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=ED=99=94=EB=A9=B4=EA=B4=80=EB=A6=ACui?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/screens/[screenId]/page.tsx | 116 +++---- .../components/screen/DesignerToolbar.tsx | 8 - .../screen/RealtimePreviewDynamic.tsx | 4 +- frontend/components/screen/ScreenDesigner.tsx | 256 +++++++--------- frontend/components/screen/StyleEditor.tsx | 36 +-- .../screen/panels/ComponentsPanel.tsx | 45 ++- .../screen/panels/PropertiesPanel.tsx | 4 +- .../screen/panels/ResolutionPanel.tsx | 11 - .../screen/panels/UnifiedPropertiesPanel.tsx | 288 ++++++++---------- .../screen/toolbar/LeftUnifiedToolbar.tsx | 36 +-- .../lib/registry/DynamicComponentRenderer.tsx | 12 +- .../lib/registry/DynamicWebTypeRenderer.tsx | 5 +- .../AccordionBasicComponent.tsx | 21 +- .../button-primary/ButtonPrimaryComponent.tsx | 12 +- .../text-input/TextInputComponent.tsx | 32 +- frontend/lib/utils/domPropsFilter.ts | 32 ++ 16 files changed, 455 insertions(+), 463 deletions(-) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index f91831d5..7c15afff 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -10,8 +10,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; import { EditModal } from "@/components/screen/EditModal"; -import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine"; -import { useBreakpoint } from "@/hooks/useBreakpoint"; +import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; export default function ScreenViewPage() { const params = useParams(); @@ -22,13 +21,9 @@ export default function ScreenViewPage() { const [layout, setLayout] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [formData, setFormData] = useState>({}); - // 화면 너비에 따라 Y좌표 유지 여부 결정 - const [preserveYPosition, setPreserveYPosition] = useState(true); - - const breakpoint = useBreakpoint(); - // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -124,24 +119,6 @@ export default function ScreenViewPage() { } }, [screenId]); - // 윈도우 크기 변경 감지 - layout이 로드된 후에만 실행 - useEffect(() => { - if (!layout) return; - - const screenWidth = layout?.screenResolution?.width || 1200; - - const handleResize = () => { - const shouldPreserve = window.innerWidth >= screenWidth - 100; - setPreserveYPosition(shouldPreserve); - }; - - window.addEventListener("resize", handleResize); - // 초기 값도 설정 - handleResize(); - - return () => window.removeEventListener("resize", handleResize); - }, [layout]); - if (loading) { return (
@@ -172,39 +149,70 @@ export default function ScreenViewPage() { // 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용 const screenWidth = layout?.screenResolution?.width || 1200; + const screenHeight = layout?.screenResolution?.height || 800; return ( -
-
- {/* 항상 반응형 모드로 렌더링 */} - {layout && layout.components.length > 0 ? ( - { - console.log("📝 page.tsx formData 업데이트:", fieldName, value); - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - screenInfo={{ id: screenId, tableName: screen?.tableName }} - /> - ) : ( - // 빈 화면일 때 -
-
-
- 📄 -
-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

+
+ {/* 절대 위치 기반 렌더링 */} + {layout && layout.components.length > 0 ? ( +
+ {/* 최상위 컴포넌트들 렌더링 */} + {layout.components + .filter((component) => !component.parentId) + .map((component) => ( + {}} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || component.type === "container" || component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; + + return ( + {}} + /> + ); + })} + + ))} +
+ ) : ( + // 빈 화면일 때 +
+
+
+ 📄
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

- )} -
+
+ )} {/* 편집 모달 */} void; onUndo: () => void; onRedo: () => void; - onPreview: () => void; onTogglePanel: (panelId: string) => void; panelStates: Record; canUndo: boolean; @@ -45,7 +43,6 @@ export const DesignerToolbar: React.FC = ({ onSave, onUndo, onRedo, - onPreview, onTogglePanel, panelStates, canUndo, @@ -229,11 +226,6 @@ export const DesignerToolbar: React.FC = ({
- -
-
- + { + onTableDragStart={(e, table, column) => { const dragData = { type: column ? "column" : "table", table, @@ -3930,25 +3931,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
)} - {panelStates.components?.isOpen && ( -
-
-

컴포넌트

- -
-
- -
-
- )} - {panelStates.properties?.isOpen && ( -
-
-

속성

-
@@ -3962,85 +3952,49 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD currentTable={tables.length > 0 ? tables[0] : undefined} currentTableName={selectedScreen?.tableName} dragState={dragState} + onStyleChange={(style) => { + if (selectedComponent) { + updateComponentProperty(selectedComponent.id, "style", style); + } + }} + currentResolution={screenResolution} + onResolutionChange={handleResolutionChange} />
)} - {panelStates.styles?.isOpen && ( -
-
-

스타일

- -
-
- {selectedComponent ? ( - { - if (selectedComponent) { - updateComponentProperty(selectedComponent.id, "style", style); - } - }} - /> - ) : ( -
- 컴포넌트를 선택하여 스타일을 편집하세요 -
- )} -
-
- )} - - {panelStates.resolution?.isOpen && ( -
-
-

해상도

- -
-
- -
-
- )} + {/* 스타일과 해상도 패널은 속성 패널의 탭으로 통합됨 */} {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */} -
+
{/* Pan 모드 안내 - 제거됨 */} {/* 줌 레벨 표시 */} -
+
🔍 {Math.round(zoomLevel * 100)}%
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */}
{ if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { setSelectedComponent(null); @@ -4067,14 +4021,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {gridLines.map((line, index) => (
))} @@ -4286,15 +4239,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 드래그 선택 영역 */} {selectionDrag.isSelecting && (
)} @@ -4302,19 +4252,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD {/* 빈 캔버스 안내 */} {layout.components.length === 0 && (
-
- -

캔버스가 비어있습니다

-

좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요

-

- 단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정), E(해상도) -

-

- 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제) -

-

- ⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 +

+
+ +
+

캔버스가 비어있습니다

+

+ 좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요

+
+

+ 단축키: T(테이블), M(템플릿), P(속성), S(스타일), + R(격자), D(상세설정), E(해상도) +

+

+ 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), + Ctrl+Z(실행취소), Delete(삭제) +

+

+ ⚠️ + 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 +

+
)} @@ -4352,13 +4311,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD screenId={selectedScreen.screenId} /> )} - {/* 반응형 미리보기 모달 */} - setShowResponsivePreview(false)} - components={layout.components} - screenWidth={screenResolution.width} - />
); } diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index 706ee8a1..ecd405d0 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -255,45 +255,45 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
-
-
-