From e3f94a6cddfc3e2eb6ec02defb2aecfedfa53022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=EC=9B=85?= Date: Wed, 22 Jul 2026 09:47:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=ED=8C=80=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=97=85=EB=AC=B4=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B2=B9=EC=B9=A8=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../edit-layout/model/useDashboardLayout.ts | 7 ++- src/shared/dashboard/lib/normalize-layout.ts | 60 +++++++++++++++++++ src/views/dashboard/ui/DashboardGrid.tsx | 8 ++- 3 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 src/shared/dashboard/lib/normalize-layout.ts diff --git a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts index 89e720f..1123d0a 100644 --- a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts +++ b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import type { Layout, LayoutItem } from 'react-grid-layout'; import { saveDashboardLayout } from '@/entities/dashboard-layout/api/save-dashboard-layout'; +import { normalizeLayout } from '@/shared/dashboard/lib/normalize-layout'; import type { DashboardLayoutState } from '@/entities/dashboard-layout/model/dashboard-layout.types'; // 저장·상태로 남기는 값은 위치(i,x,y,w,h)만 — minW/minH 등 위젯 제약은 카탈로그가 소유하며 @@ -26,7 +27,7 @@ export function useDashboardLayout({ pageType, initialLayout, }: UseDashboardLayoutParams) { - const [layout, setLayout] = useState(initialLayout.layout); + const [layout, setLayout] = useState(() => normalizeLayout(initialLayout.layout)); const [editMode, setEditMode] = useState(false); const didMountRef = useRef(false); const saveQueueRef = useRef(Promise.resolve()); @@ -51,7 +52,7 @@ export function useDashboardLayout({ }, [layout, workspaceId, pageType]); const handleLayoutChange = useCallback((next: Layout) => { - const positions = next.map(toPosition); + const positions = normalizeLayout(next.map(toPosition)); setLayout(positions); }, []); @@ -59,7 +60,7 @@ export function useDashboardLayout({ (item: LayoutItem) => setLayout((prev) => { if (prev.some((entry) => entry.i === item.i)) return prev; - return [...prev, toPosition(item)]; + return normalizeLayout([...prev, toPosition(item)]); }), [], ); diff --git a/src/shared/dashboard/lib/normalize-layout.ts b/src/shared/dashboard/lib/normalize-layout.ts new file mode 100644 index 0000000..d21505d --- /dev/null +++ b/src/shared/dashboard/lib/normalize-layout.ts @@ -0,0 +1,60 @@ +import type { Layout, LayoutItem } from 'react-grid-layout'; + +interface LayoutRect { + x: number; + y: number; + w: number; + h: number; +} + +function isOverlapping(left: LayoutRect, right: LayoutRect) { + return ( + left.x < right.x + right.w && + left.x + left.w > right.x && + left.y < right.y + right.h && + left.y + left.h > right.y + ); +} + +function clampLayoutItem(item: LayoutItem, cols: number): LayoutItem { + const width = Math.max(1, Math.min(item.w, cols)); + const height = Math.max(1, item.h); + const x = Math.max(0, Math.min(item.x, cols - width)); + const y = Math.max(0, item.y); + + return { ...item, x, y, w: width, h: height }; +} + +/** + * 저장된 레이아웃이 오래된 기본값/위젯 크기 변경과 충돌해도 + * 렌더 시점에 서로 겹치지 않도록 y축으로 밀어내며 정규화한다. + */ +export function normalizeLayout(layout: Layout, cols = 12): Layout { + const working = layout + .map((item, index) => ({ ...clampLayoutItem(item, cols), _index: index })) + .sort((left, right) => left.y - right.y || left.x - right.x || left._index - right._index); + + const placed: Array = []; + + working.forEach((item) => { + const nextItem = { ...item }; + + while (true) { + const overlapping = placed.filter((placedItem) => isOverlapping(nextItem, placedItem)); + + if (overlapping.length === 0) break; + + nextItem.y = Math.max(...overlapping.map((placedItem) => placedItem.y + placedItem.h)); + } + + placed.push(nextItem); + }); + + return placed + .sort((left, right) => left._index - right._index) + .map((item) => { + const { _index: removedIndex, ...normalizedItem } = item; + void removedIndex; + return normalizedItem; + }); +} diff --git a/src/views/dashboard/ui/DashboardGrid.tsx b/src/views/dashboard/ui/DashboardGrid.tsx index 004299c..31f8ea6 100644 --- a/src/views/dashboard/ui/DashboardGrid.tsx +++ b/src/views/dashboard/ui/DashboardGrid.tsx @@ -13,6 +13,7 @@ import type { Layout, ResizeHandleAxis } from 'react-grid-layout'; import { Maximize2, GripVertical, Trash2 } from 'lucide-react'; import { getWidgetSize } from '@/shared/dashboard/lib/widget-size'; +import { normalizeLayout } from '@/shared/dashboard/lib/normalize-layout'; import type { WidgetDefinition } from '@/shared/dashboard/model/widget.types'; import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types'; @@ -60,10 +61,11 @@ export default function DashboardGrid({ const visibleLayout = layout .filter((item) => byId.has(item.i)) .map((item) => ({ ...byId.get(item.i)!.layout, ...item })); + const normalizedVisibleLayout = normalizeLayout(visibleLayout); return (
- {visibleLayout.length === 0 ? ( + {normalizedVisibleLayout.length === 0 ? (

아직 추가된 위젯이 없습니다.

필요한 위젯을 추가해 워크스페이스를 구성해보세요.

@@ -74,14 +76,14 @@ export default function DashboardGrid({ - {visibleLayout.map((item) => { + {normalizedVisibleLayout.map((item) => { const id = item.i; const widget = byId.get(id)!; const size = getWidgetSize(item.w, item.h); From 634a74a5f59d146eee21babba4781f28776f45c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A7=80=EC=9B=85?= Date: Wed, 22 Jul 2026 15:31:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../edit-layout/model/useDashboardLayout.ts | 26 ++++++---- src/shared/dashboard/lib/normalize-layout.ts | 49 ++++++++++++++++--- src/views/dashboard/ui/DashboardGrid.tsx | 6 ++- src/views/dashboard/ui/DashboardView.tsx | 10 +++- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts index 1123d0a..80304b3 100644 --- a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts +++ b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts @@ -7,7 +7,7 @@ import { toast } from 'sonner'; import type { Layout, LayoutItem } from 'react-grid-layout'; import { saveDashboardLayout } from '@/entities/dashboard-layout/api/save-dashboard-layout'; -import { normalizeLayout } from '@/shared/dashboard/lib/normalize-layout'; +import { areLayoutsEqual, normalizeLayout } from '@/shared/dashboard/lib/normalize-layout'; import type { DashboardLayoutState } from '@/entities/dashboard-layout/model/dashboard-layout.types'; // 저장·상태로 남기는 값은 위치(i,x,y,w,h)만 — minW/minH 등 위젯 제약은 카탈로그가 소유하며 @@ -20,14 +20,22 @@ interface UseDashboardLayoutParams { pageType: string; /** 서버(RSC)에서 조회한 초기 레이아웃 */ initialLayout: DashboardLayoutState; + constraintsById: Partial>>; } export function useDashboardLayout({ workspaceId, pageType, initialLayout, + constraintsById, }: UseDashboardLayoutParams) { - const [layout, setLayout] = useState(() => normalizeLayout(initialLayout.layout)); + const initialNormalizedLayout = normalizeLayout(initialLayout.layout, 12, constraintsById); + const shouldPersistInitialNormalization = !areLayoutsEqual( + initialLayout.layout, + initialNormalizedLayout, + ); + + const [layout, setLayout] = useState(initialNormalizedLayout); const [editMode, setEditMode] = useState(false); const didMountRef = useRef(false); const saveQueueRef = useRef(Promise.resolve()); @@ -35,7 +43,7 @@ export function useDashboardLayout({ useEffect(() => { if (!didMountRef.current) { didMountRef.current = true; - return; + if (!shouldPersistInitialNormalization) return; } const timeoutId = window.setTimeout(() => { @@ -46,23 +54,23 @@ export function useDashboardLayout({ console.error(error); toast.error('대시보드 레이아웃 저장에 실패했습니다.'); }); - }, 400); + }, shouldPersistInitialNormalization ? 0 : 400); return () => window.clearTimeout(timeoutId); - }, [layout, workspaceId, pageType]); + }, [layout, workspaceId, pageType, shouldPersistInitialNormalization]); const handleLayoutChange = useCallback((next: Layout) => { - const positions = normalizeLayout(next.map(toPosition)); + const positions = normalizeLayout(next.map(toPosition), 12, constraintsById); setLayout(positions); - }, []); + }, [constraintsById]); const addWidget = useCallback( (item: LayoutItem) => setLayout((prev) => { if (prev.some((entry) => entry.i === item.i)) return prev; - return normalizeLayout([...prev, toPosition(item)]); + return normalizeLayout([...prev, toPosition(item)], 12, constraintsById); }), - [], + [constraintsById], ); const removeWidget = useCallback( diff --git a/src/shared/dashboard/lib/normalize-layout.ts b/src/shared/dashboard/lib/normalize-layout.ts index d21505d..c0c3194 100644 --- a/src/shared/dashboard/lib/normalize-layout.ts +++ b/src/shared/dashboard/lib/normalize-layout.ts @@ -16,22 +16,59 @@ function isOverlapping(left: LayoutRect, right: LayoutRect) { ); } -function clampLayoutItem(item: LayoutItem, cols: number): LayoutItem { - const width = Math.max(1, Math.min(item.w, cols)); - const height = Math.max(1, item.h); +type LayoutConstraintMap = Partial>>; + +function getBoundedSize( + value: number, + minValue: number | undefined, + maxValue: number | undefined, + fallbackMin = 1, +) { + const lowerBound = Math.max(fallbackMin, minValue ?? fallbackMin); + const upperBound = Math.max(lowerBound, maxValue ?? value); + + return Math.min(Math.max(value, lowerBound), upperBound); +} + +function clampLayoutItem( + item: LayoutItem, + cols: number, + constraints?: Partial, +): LayoutItem { + const width = getBoundedSize(item.w, constraints?.minW, Math.min(constraints?.maxW ?? cols, cols)); + const height = getBoundedSize(item.h, constraints?.minH, constraints?.maxH); const x = Math.max(0, Math.min(item.x, cols - width)); const y = Math.max(0, item.y); - return { ...item, x, y, w: width, h: height }; + return { ...constraints, ...item, x, y, w: width, h: height }; +} + +export function areLayoutsEqual(left: Layout, right: Layout) { + return ( + left.length === right.length && + left.every((item, index) => { + const other = right[index]; + return ( + item.i === other?.i && + item.x === other.x && + item.y === other.y && + item.w === other.w && + item.h === other.h + ); + }) + ); } /** * 저장된 레이아웃이 오래된 기본값/위젯 크기 변경과 충돌해도 * 렌더 시점에 서로 겹치지 않도록 y축으로 밀어내며 정규화한다. */ -export function normalizeLayout(layout: Layout, cols = 12): Layout { +export function normalizeLayout(layout: Layout, cols = 12, constraintsById: LayoutConstraintMap = {}): Layout { const working = layout - .map((item, index) => ({ ...clampLayoutItem(item, cols), _index: index })) + .map((item, index) => ({ + ...clampLayoutItem(item, cols, constraintsById[item.i]), + _index: index, + })) .sort((left, right) => left.y - right.y || left.x - right.x || left._index - right._index); const placed: Array = []; diff --git a/src/views/dashboard/ui/DashboardGrid.tsx b/src/views/dashboard/ui/DashboardGrid.tsx index 31f8ea6..05129fe 100644 --- a/src/views/dashboard/ui/DashboardGrid.tsx +++ b/src/views/dashboard/ui/DashboardGrid.tsx @@ -61,7 +61,11 @@ export default function DashboardGrid({ const visibleLayout = layout .filter((item) => byId.has(item.i)) .map((item) => ({ ...byId.get(item.i)!.layout, ...item })); - const normalizedVisibleLayout = normalizeLayout(visibleLayout); + const normalizedVisibleLayout = normalizeLayout( + visibleLayout, + 12, + Object.fromEntries(visibleLayout.map((item) => [item.i, byId.get(item.i)!.layout])), + ); return (
diff --git a/src/views/dashboard/ui/DashboardView.tsx b/src/views/dashboard/ui/DashboardView.tsx index 20acecf..b388680 100644 --- a/src/views/dashboard/ui/DashboardView.tsx +++ b/src/views/dashboard/ui/DashboardView.tsx @@ -20,6 +20,9 @@ import AddWidgetBar from './AddWidgetBar'; import DashboardGrid from './DashboardGrid'; const CATALOG_WIDGETS = Object.values(WIDGET_CATALOG); +const WIDGET_CONSTRAINTS_BY_ID = Object.fromEntries( + CATALOG_WIDGETS.map((widget) => [widget.layout.i, widget.layout]), +); interface DashboardViewProps { /** 레이아웃 영속화 키 */ @@ -42,7 +45,12 @@ export default function DashboardView({ pageType = 'dashboard', }: DashboardViewProps) { const { layout, editMode, handleLayoutChange, addWidget, removeWidget, toggleEdit } = - useDashboardLayout({ workspaceId, pageType, initialLayout }); + useDashboardLayout({ + workspaceId, + pageType, + initialLayout, + constraintsById: WIDGET_CONSTRAINTS_BY_ID, + }); // 이 템플릿이 허용하는 위젯 중, 아직 배치되지 않은 것 = 추가 가능 목록 // TEMPLATE_WIDGETS[purpose]는 WidgetId[]라 카탈로그에 항상 존재한다.