Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions src/features/dashboard/edit-layout/model/useDashboardLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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 등 위젯 제약은 카탈로그가 소유하며
Expand All @@ -19,22 +20,30 @@ interface UseDashboardLayoutParams {
pageType: string;
/** 서버(RSC)에서 조회한 초기 레이아웃 */
initialLayout: DashboardLayoutState;
constraintsById: Partial<Record<string, Partial<LayoutItem>>>;
}

export function useDashboardLayout({
workspaceId,
pageType,
initialLayout,
constraintsById,
}: UseDashboardLayoutParams) {
const [layout, setLayout] = useState<Layout>(initialLayout.layout);
const initialNormalizedLayout = normalizeLayout(initialLayout.layout, 12, constraintsById);
const shouldPersistInitialNormalization = !areLayoutsEqual(
initialLayout.layout,
initialNormalizedLayout,
);

const [layout, setLayout] = useState<Layout>(initialNormalizedLayout);
const [editMode, setEditMode] = useState(false);
const didMountRef = useRef(false);
const saveQueueRef = useRef(Promise.resolve());

useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
if (!shouldPersistInitialNormalization) return;
}

const timeoutId = window.setTimeout(() => {
Expand All @@ -45,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 = 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 [...prev, toPosition(item)];
return normalizeLayout([...prev, toPosition(item)], 12, constraintsById);
}),
[],
[constraintsById],
);

const removeWidget = useCallback(
Expand Down
97 changes: 97 additions & 0 deletions src/shared/dashboard/lib/normalize-layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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
);
}

type LayoutConstraintMap = Partial<Record<string, Partial<LayoutItem>>>;

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>,
): 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 { ...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, constraintsById: LayoutConstraintMap = {}): Layout {
const working = layout
.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<LayoutItem & { _index: number }> = [];

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;
});
}
12 changes: 9 additions & 3 deletions src/views/dashboard/ui/DashboardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -60,10 +61,15 @@ 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,
12,
Object.fromEntries(visibleLayout.map((item) => [item.i, byId.get(item.i)!.layout])),
);

return (
<div ref={containerRef} className="w-full">
{visibleLayout.length === 0 ? (
{normalizedVisibleLayout.length === 0 ? (
<div className="text-brand-muted flex min-h-[60vh] flex-col items-center justify-center gap-1 text-center">
<p className="text-lg">아직 추가된 위젯이 없습니다.</p>
<p>필요한 위젯을 추가해 워크스페이스를 구성해보세요.</p>
Expand All @@ -74,14 +80,14 @@ export default function DashboardGrid({
<ReactGridLayout
// 보기 모드에서는 RGL이 남겨두는 리사이즈 핸들이 hover 시 노출되지 않도록 숨긴다.
className={editMode ? undefined : '[&_.react-resizable-handle]:hidden!'}
layout={visibleLayout}
layout={normalizedVisibleLayout}
width={width}
onLayoutChange={onLayoutChange}
gridConfig={{ cols: 12, rowHeight: 40, margin: [16, 16], containerPadding: [0, 0] }}
dragConfig={{ enabled: editMode, handle: '.rgl-drag-handle' }}
resizeConfig={{ enabled: editMode, handleComponent: renderResizeHandle }}
>
{visibleLayout.map((item) => {
{normalizedVisibleLayout.map((item) => {
const id = item.i;
const widget = byId.get(id)!;
const size = getWidgetSize(item.w, item.h);
Expand Down
10 changes: 9 additions & 1 deletion src/views/dashboard/ui/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/** 레이아웃 영속화 키 */
Expand All @@ -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[]라 카탈로그에 항상 존재한다.
Expand Down