Conversation
- EditCardProvider에 드래그앤드랍 관련 상태 추가 - 대시보드 편집 영역 상수 및 타입 정의 - 대시보드 카드 타입에 드래그 상태 및 고스트 상태 추가
- 드래그앤드랍에서 충돌 로직을 계산할 때 grid 배열 시 코드가 복잡해지기 때문에 수정함
- 대시보드에서 카드의 위치와 크기를 계산하는 훅을 추가함 - 카드를 absolute 포지션으로 배치하고, 마우스 위치를 통해 그리드 셀을 도출해내는 로직을 위해 필요
- PlusIconButton과 TrashCanIconButton에서 클릭 이벤트가 부모 요소로 전파되지 않도록 수정 - 사용자 경험 개선을 위해 버튼 클릭 시 의도한 동작만 수행하도록 함
- 카드 밀어내기 알고리즘 구현 - 그리드 셀에 카드 위치 계산 기능 추가 - 드래그 이벤트 핸들러 구현 - dx, dy로 된 방향 객체 리터럴 선언
- 카드 편집 뷰에 드래그 앤 드롭 이벤트 핸들러 추가 - 미니 뷰에 드래그 앤 드롭 관련 상태 및 핸들러 통합 - 드래그 중인 카드의 시각적 피드백을 위한 Ghost 컴포넌트 추가
- 스로틀링을 통해 불필요한 함수 호출 방지 - 새로운 스로틀 유틸리티 함수 추가
There was a problem hiding this comment.
Code Review
대시보드 카드 편집을 위한 드래그앤드롭 기능 구현을 확인했습니다. 2차원 배열로 관리되던 그리드 상태를 카드 객체 배열(placedCards)로 변경한 점은 데이터 모델링 관점에서 매우 훌륭한 리팩토링입니다. 이를 통해 상태 관리가 더 명확해지고 확장성이 개선되었습니다. 카드 밀어내기(push) 로직을 포함한 드래그앤드롭 구현은 복잡하지만, 관련 상태와 로직을 커스텀 훅(useDragAndDropCard, useGridCellSize)으로 분리하여 구조적으로 잘 설계되었습니다. 다만, useGridCellSize 훅에서 그리드의 크기를 하드코딩한 부분은 잠재적인 문제를 야기할 수 있어 개선이 필요합니다. 해당 부분에 대한 구체적인 피드백을 리뷰 코멘트로 남겼습니다.
| const GRID_HEIGHT_SIZE = 724; | ||
| const GRID_WIDTH_SIZE = 550; | ||
| const GRID_GAP_SIZE = 20; |
There was a problem hiding this comment.
그리드 크기가 픽셀 단위로 하드코딩되어 있어 유지보수가 어렵고 잠재적인 버그를 유발할 수 있습니다. 예를 들어, MiniView.tsx 의 스타일이 변경되면 이 값들도 수동으로 업데이트해야 합니다.
EditCardContext 의 gridRef 를 사용하여 실제 DOM 요소의 크기를 동적으로 읽어오는 것이 좋습니다. useEffect 와 ResizeObserver 를 사용하면 그리드 크기가 변경될 때마다 값을 업데이트하여 항상 정확한 크기를 유지할 수 있습니다.
// 제안 예시
const [gridPxSize, setGridPxSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const gridEl = gridRef.current;
if (!gridEl) return;
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) {
setGridPxSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
resizeObserver.observe(gridEl);
return () => resizeObserver.disconnect();
}, [gridRef]);
// 이후 계산에서 gridPxSize.width, gridPxSize.height 사용|
마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다. 이 부분에 대한 의견 부탁 드립니다. |
|
백엔드 카드 위치 DTO가 rowNo, colNo인데 프론트엔드 메트릭 카드 상수에서 사이즈는 sizeX, sizeY여서 로직 상에 row/col과 x/y가 혼용되어 있습니다. 보기에 불편하지 않는지 의견 부탁 드립니다. |
|
메모이제이션 최적화가 적용되지 않은 상태입니다. 따라서 ghost 렌더링마다 전체 목록이 리렌더링 되고 있습니다. 이후에 이슈를 새로 파서 작업할 생각인데 지금 작업이 필요한지에 대해 의견 부탁 드립니다. |
lee0jae330
left a comment
There was a problem hiding this comment.
복잡한 기능 구현하시느라 고생하셨습니다 !! 군데군데 주석처리된 부분 지워주세요 ! (디버깅용 console등)
| {isOverList && ( | ||
| <div className="bg-others-negative text-grey-0 absolute top-0 right-0 bottom-0 left-0 flex items-center justify-center opacity-50"> | ||
| DROP TO DELETE | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
p3: 카드 영역으로 드래그하면 삭제 처리 되는거 좋네요. UI가 아직은 빨간 배경에 DROP TO DELETE 문구만 있어서 따로 정해지면 좋을거 같아요
| type MetricCardCode, | ||
| } from '@/constants/dashboard'; | ||
| import { useEditCard } from '@/hooks/dashboard'; | ||
| import { useDragAndDropCard } from '@/hooks/dashboard/useDragAndDropCard'; |
There was a problem hiding this comment.
p3: import 경로 단축해주세요 !
| <EditCardContext.Provider | ||
| value={{ | ||
| initPlacedCards, | ||
| placedCards, | ||
| setPlacedCards, | ||
| gridRef, | ||
| dragState, | ||
| setDragState, | ||
| ghost, | ||
| setGhost, | ||
| tempLayout, | ||
| setTempLayout, | ||
| isOverList, | ||
| setIsOverList, | ||
| }} | ||
| > |
There was a problem hiding this comment.
p3: 이젠 진짜 state Provider, action Provider로 나누어야 할 것 같네요..
useReducer를 사용해서 state provider랑 dispatch provider를 분리하면 좋을 것 같습니다
아니면 context를 쪼갠다거나...
| left: colPx, | ||
| top: rowPx, |
There was a problem hiding this comment.
p3: transform: translate 속성으로 변경할 수 있으면 좋을것같네용
| left: colPx, | ||
| top: rowPx, |
There was a problem hiding this comment.
p3: 여기도 translate 고려헤보시면 좋을것같습니다
| const centerY = rowPx + heightPx / 2; | ||
|
|
||
| const dist = Math.sqrt( | ||
| Math.pow(draggingCenterX - centerX, 2) + |
There was a problem hiding this comment.
p5: (draggingCenterX - centerX) ** 2 이렇게도 가능합니다..!
| const throttledHandleGridDragOver = throttle(handleGridDragOverFn, 100); | ||
| const handleGridDragOver = (e: React.DragEvent) => { | ||
| e.preventDefault(); | ||
| const clientX = e.clientX; | ||
| const clientY = e.clientY; | ||
|
|
||
| throttledHandleGridDragOver(clientX, clientY); | ||
| }; |
There was a problem hiding this comment.
p3: 이렇게 되면 throttle를 매 렌더링마다 재실행해서 매번 새로운 throttled 인스턴스가 생길 것 같네요..!
useRef로 함수를 감싸거나 useCallback, useMemo를 사용하는게 좋을 것 같아요 !
개선해본 코드
import { useCallback, useEffect, useRef } from 'react';
import {
DASHBOARD_EDIT_AREA,
DASHBOARD_METRIC_CARDS,
type DashboardEditArea,
DIRECTIONS,
GRID_COL_SIZE,
GRID_ROW_SIZE,
} from '@/constants/dashboard';
import type { DashboardCard } from '@/types/dashboard';
import { getConflictingCards } from '@/utils/dashboard';
import { throttle } from '@/utils/shared';
import { useEditCardContext } from './useEditCardContext';
import { useGridCellSize } from './useGridCellSize';
export const useDragAndDropCard = () => {
const {
placedCards,
setPlacedCards,
gridRef,
dragState,
setDragState,
ghost,
setGhost,
tempLayout,
setTempLayout,
setIsOverList,
} = useEditCardContext();
const { getGridPosition, getGridCardSize } = useGridCellSize();
/**
* 중심점 기준 진입 방향 계산 함수
* @param draggingCenterX 드래그 중인 카드의 중심 X 좌표 (픽셀 단위)
* @param draggingCenterY 드래그 중인 카드의 중심 Y 좌표 (픽셀 단위)
* @param conflictCard 충돌한 카드 정보
* @returns 진입 방향에 따른 밀어내기 우선순위 배열 [{dx, dy}, ...]
*/
const getPushDirectionPriority = useCallback(
(
draggingCenterX: number,
draggingCenterY: number,
conflictCard: DashboardCard,
) => {
// 충돌한 요소의 중심
const conflictCardDef = DASHBOARD_METRIC_CARDS[conflictCard.cardCode];
const { rowPx, colPx } = getGridPosition(
conflictCard.rowNo,
conflictCard.colNo,
);
const { widthPx, heightPx } = getGridCardSize(
conflictCardDef.sizeX,
conflictCardDef.sizeY,
);
const conflictCenterX = colPx + widthPx / 2;
const conflictCenterY = rowPx + heightPx / 2;
// 드래그 중인 카드의 중심과 충돌 카드의 중심을 비교하여 진입 방향 계산
const dx = draggingCenterX - conflictCenterX;
const dy = draggingCenterY - conflictCenterY;
const absDx = Math.abs(dx) / conflictCardDef.sizeX;
const absDy = Math.abs(dy) / conflictCardDef.sizeY;
// 진입방향 반대를 밀어내기 우선순위로 설정
const { LEFT, RIGHT, UP, DOWN } = DIRECTIONS;
if (absDx > absDy) {
if (dx > 0) {
// 우측 진입 -> 좌로 밀기 우선
return [LEFT, DOWN, UP, RIGHT];
} else {
// 좌측 진입 -> 우로 밀기 우선
return [RIGHT, DOWN, UP, LEFT];
}
} else {
if (dy > 0) {
// 하단 진입 -> 위로 밀기 우선
return [UP, RIGHT, LEFT, DOWN];
} else {
// 상단 진입 -> 아래로 밀기 우선
return [DOWN, RIGHT, LEFT, UP];
}
}
},
[getGridPosition, getGridCardSize],
);
/**
* 카드 밀어내기 재귀 알고리즘
* @param currentLayout 현재까지 계산된 전체 카드 배치
* @param currentCard 방금 이동시켜서 주변을 밀어내야 할 주체 카드
* @param movedCards 한 번 이동한 적이 있는 카드의 코드 Set (무한 루프 방지)
* @param draggingCenterX 드래그 중인 카드의 중심 X 좌표 (픽셀 단위)
* @param draggingCenterY 드래그 중인 카드의 중심 Y 좌표 (픽셀 단위)
* @returns 밀어내기 결과 레이아웃과 유효 여부 {cards, isValid}
*/
const getPushedLayout = useCallback(
(
currentLayout: DashboardCard[],
currentCard: DashboardCard,
movedCards: Set<string> = new Set(),
draggingCenterX: number,
draggingCenterY: number,
): { cards: DashboardCard[]; isValid: boolean } => {
// 현재 조작 중인 카드를 이동 완료 목록에 추가
movedCards.add(currentCard.cardCode);
// 현재 레이아웃에서 movedCard 위치 업데이트
let nextLayout = currentLayout.map((card) =>
card.cardCode === currentCard.cardCode ? currentCard : card,
);
// 충돌되는 카드 찾기
const conflicts = getConflictingCards(currentCard, nextLayout);
for (const conflictCard of conflicts) {
// 진입방향에 따른 밀어냄 방향 우선순위
const pushDirections = getPushDirectionPriority(
draggingCenterX,
draggingCenterY,
conflictCard,
);
for (const { dx, dy } of pushDirections) {
if (movedCards.has(conflictCard.cardCode)) {
continue; // 이미 이동한 카드면 패스
}
// 충돌 카드의 다음 좌표 계산
let conflictNextX = conflictCard.colNo;
let conflictNextY = conflictCard.rowNo;
const conflictCardDef = DASHBOARD_METRIC_CARDS[conflictCard.cardCode];
const currentCardDef = DASHBOARD_METRIC_CARDS[currentCard.cardCode];
if (dx > 0) {
// 현재 카드의 우측으로 밀어냄
conflictNextX = currentCard.colNo + currentCardDef.sizeX;
} else if (dx < 0) {
// 현재 카드의 좌측으로 밀어냄
conflictNextX = currentCard.colNo - conflictCardDef.sizeX;
}
if (dy > 0) {
// 현재 카드의 하단으로 밀어냄
conflictNextY = currentCard.rowNo + currentCardDef.sizeY;
} else if (dy < 0) {
// 현재 카드의 상단으로 밀어냄
conflictNextY = currentCard.rowNo - conflictCardDef.sizeY;
}
// 그리드 밖으로 나간다면 무효
if (
conflictNextX < 1 ||
conflictNextX + conflictCardDef.sizeX - 1 > GRID_COL_SIZE ||
conflictNextY < 1 ||
conflictNextY + conflictCardDef.sizeY - 1 > GRID_ROW_SIZE
) {
continue;
}
const movedConflictCard: DashboardCard = {
cardCode: conflictCard.cardCode,
colNo: conflictNextX,
rowNo: conflictNextY,
};
// 충돌된 카드 위치가 변했을 때 재귀적으로 밀어내기
const pushedResult = getPushedLayout(
nextLayout,
movedConflictCard,
new Set(movedCards),
draggingCenterX,
draggingCenterY,
);
if (pushedResult.isValid) {
nextLayout = pushedResult.cards;
break; // 유효한 방향을 찾았으므로 다른 방향 시도 중단
}
}
}
// 최종 충돌 검사
const isFinalConflict =
getConflictingCards(currentCard, nextLayout).length > 0;
return { cards: nextLayout, isValid: !isFinalConflict };
},
[getPushDirectionPriority],
);
/**
* 현재 마우스 위치를 기반으로 카드가 가장 가까운 그리드 셀에 붙도록 좌표 계산
* 카드의 크기를 고려하여, 카드의 중심이 가장 가까운 셀의 중심에 오도록 함
* @param clientX 마우스의 X 좌표 (픽셀 단위)
* @param clientY 마우스의 Y 좌표 (픽셀 단위)
* @returns 계산된 그리드 좌표 {row, col}
*/
const calculateGridPos = useCallback(
(clientX: number, clientY: number) => {
if (!gridRef.current || !dragState) {
return { row: 0, col: 0 };
}
// 드래그 중인 카드의 크기 계산 (픽셀단위)
const draggingCardDef =
DASHBOARD_METRIC_CARDS[dragState.draggingCard.cardCode];
const cardSizeX = draggingCardDef.sizeX;
const cardSizeY = draggingCardDef.sizeY;
const { widthPx, heightPx } = getGridCardSize(cardSizeX, cardSizeY);
// 드래그 중인 카드의 중심점 (픽셀단위)
const cardRect = gridRef.current.getBoundingClientRect();
const draggingCenterX =
clientX - cardRect.left - dragState.centerOffset.x;
const draggingCenterY = clientY - cardRect.top - dragState.centerOffset.y;
// 카드의 중심이 가장 가까운 셀의 중심에 오도록
let minDistance = Infinity;
let closest = { row: 0, col: 0 };
// 각 그리드 셀마다 유클리드 거리 계산
for (let r = 1; r <= GRID_ROW_SIZE - cardSizeY + 1; r++) {
for (let c = 1; c <= GRID_COL_SIZE - cardSizeX + 1; c++) {
// 그리드 셀의 중심 좌표
const { rowPx, colPx } = getGridPosition(r, c);
const centerX = colPx + widthPx / 2;
const centerY = rowPx + heightPx / 2;
const dist = Math.sqrt(
Math.pow(draggingCenterX - centerX, 2) +
Math.pow(draggingCenterY - centerY, 2),
);
if (dist < minDistance) {
minDistance = dist;
closest = { row: r, col: c };
}
}
}
return closest;
},
[dragState, gridRef, getGridPosition, getGridCardSize],
);
/*************** 이벤트 핸들러 ****************/
const handleGridDragOverFn = useCallback(
(clientX: number, clientY: number) => {
// console.log('handleGridDragOver');
if (!gridRef.current || !dragState) {
return;
}
const { row, col } = calculateGridPos(clientX, clientY);
// Ghost 위치가 변했을 때만 계산
if (ghost?.colNo === col && ghost?.rowNo === row) {
return;
}
// console.log('handleGridDragOver - new ghost');
// 시뮬레이션을 위한 레이아웃 구성
const ghostCard: DashboardCard = {
cardCode: dragState.draggingCard.cardCode,
colNo: col,
rowNo: row,
};
const currentLayout =
dragState.sourceArea === DASHBOARD_EDIT_AREA.LIST
? [...placedCards, ghostCard] // 리스트에서 새로 추가하는 경우
: placedCards;
// 드래그 중인 카드의 중심점 (픽셀단위)
const rect = gridRef.current?.getBoundingClientRect();
const draggingCenterX = clientX - rect.left - dragState.centerOffset.x;
const draggingCenterY = clientY - rect.top - dragState.centerOffset.y;
// 밀어내기 시뮬레이션 수행
const pushedResult = getPushedLayout(
currentLayout,
ghostCard,
new Set(),
draggingCenterX,
draggingCenterY,
);
// 밀어내기 결과 레이아웃 반영
setTempLayout(pushedResult.cards);
// ghost 유효 여부 결정
setGhost({ rowNo: row, colNo: col, isValid: pushedResult.isValid });
},
[
dragState,
ghost,
placedCards,
setGhost,
setTempLayout,
calculateGridPos,
getPushedLayout,
gridRef,
],
);
const latestGridDragOver = useRef(handleGridDragOverFn);
useEffect(() => {
latestGridDragOver.current = handleGridDragOverFn;
}, [handleGridDragOverFn]);
const throttledHandleGridDragOver = useRef(
throttle((x: number, y: number) => latestGridDragOver.current(x, y), 100),
);
const handleGridDragOver = (e: React.DragEvent) => {
e.preventDefault();
const clientX = e.clientX;
const clientY = e.clientY;
throttledHandleGridDragOver.current(clientX, clientY);
};
const handleGridDrop = (e: React.DragEvent) => {
// console.log('handleGridDrop');
e.preventDefault();
if (ghost?.isValid && tempLayout) {
// console.log('handleGridDrop - update grid');
setPlacedCards(tempLayout);
}
handleDragEnd();
};
const handleGridDragLeave = (e: React.DragEvent) => {
// console.log('handleGridDragLeave');
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
// 영역 외부로 나갔을 때만 처리 (자식 요소로 이동할 때는 무시)
// console.log('handleGridDragLeave - really');
setGhost(null);
}
};
const handleListDragEnter = () => {
// console.log('handleListDragEnter');
if (dragState?.sourceArea === DASHBOARD_EDIT_AREA.GRID) {
// console.log('handleListDragEnter - really');
setIsOverList(true);
}
};
const handleListDragLeave = (e: React.DragEvent) => {
// console.log('handleListDragLeave');
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
// 영역 외부로 나갔을 때만 처리 (자식 요소로 이동할 때는 무시)
// console.log('handleListDragLeave - really');
setIsOverList(false);
}
};
const handleListDrop = (e: React.DragEvent) => {
// console.log('handleListDrop', { dragState });
e.preventDefault();
// 카드 삭제
if (dragState?.sourceArea === DASHBOARD_EDIT_AREA.GRID) {
setPlacedCards((prev) =>
prev.filter((c) => c.cardCode !== dragState.draggingCard.cardCode),
);
// console.log('handleListDrop - remove card');
}
handleDragEnd();
};
const handleDragStart = (
e: React.DragEvent,
sourceArea: DashboardEditArea,
draggingCard: DashboardCard,
) => {
// console.log('handleDragStart');
e.dataTransfer.effectAllowed = 'move';
const cardRect = e.currentTarget.getBoundingClientRect();
setDragState({
sourceArea,
draggingCard,
centerOffset: {
// 카드 중심에서 마우스 포인터까지의 상대 좌표 (픽셀 단위)
x: e.clientX - cardRect.left - cardRect.width / 2,
y: e.clientY - cardRect.top - cardRect.height / 2,
},
});
};
const handleDragEnd = () => {
// console.log('handleDragEnd');
// 드래그 상태 초기화
setDragState(null);
setGhost(null);
setTempLayout(null);
setIsOverList(false);
};
return {
handleGridDragOver,
handleGridDrop,
handleGridDragLeave,
handleListDragEnter,
handleListDragLeave,
handleListDrop,
handleDragStart,
handleDragEnd,
};
};| if (!e.currentTarget.contains(e.relatedTarget as Node)) { | ||
| // 영역 외부로 나갔을 때만 처리 (자식 요소로 이동할 때는 무시) | ||
| // console.log('handleGridDragLeave - really'); | ||
| setGhost(null); | ||
| } | ||
| }; |
There was a problem hiding this comment.
p4: 사파리에서는 relatedTarget이 undefined로 정의되어 있는 문제가 있습니다..!
나중에 크로스브라우징 이슈가 있다면 참고하면 좋을 것 같습니다 !
(참고)
| export const isSameGrid = ( | ||
| placedCards1: DashboardCard[], | ||
| placedCards2: DashboardCard[], | ||
| ): boolean => { | ||
| if (placedCards1.length !== placedCards2.length) { | ||
| return false; | ||
| } | ||
|
|
||
| const cardMap1 = new Map<string, DashboardCard>(); | ||
| const cardMap2 = new Map<string, DashboardCard>(); | ||
|
|
||
| placedCards1.forEach((card) => cardMap1.set(card.cardCode, card)); | ||
| placedCards2.forEach((card) => cardMap2.set(card.cardCode, card)); | ||
|
|
||
| for (const [cardCode, card1] of cardMap1.entries()) { | ||
| const card2 = cardMap2.get(cardCode); | ||
| if (!card2 || card1.rowNo !== card2.rowNo || card1.colNo !== card2.colNo) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
p4: 변수명이.... 구체적으로 바꿔주시면 감사하겠습니다..!
There was a problem hiding this comment.
p4: throttle 함수 좋네요 !
Q. 디바운스랑 쓰로틀 차이점은 !?
+중간에 fn.apply(this, args)는 왜 해주는지 궁금합니다 !!
이 부분은 좋은데, 브라우저 너비가 작아지면, overflow-hidden처리는 문제가 있어서 해상도 가드를 적용하거나 overflow-scroll이 되어야할 것 같네요 |
최적화가 되면 좋을 것 같긴 하네요... |
이건 추후 맞추는것으로 할까요 ?? 기능 구현부터 해야할 것 같네요.... |
#️⃣ 변경 사항
대시보드 카드 편집 기능에 드래그 앤 드롭(Drag & Drop) 시스템을 도입하고, 효율적인 카드 배치를 위한 상태 관리 방식을 리팩터링했습니다. 기존의 2차원 배열 기반 그리드 관리 방식에서 카드 배열 기반의 좌표 관리 방식으로 전환하였으며, 카드 간 충돌 시 자동으로 위치를 조정하는 밀어내기 알고리즘을 구현했습니다.
#️⃣ 작업 상세 내용
드래그 앤 드롭(Drag & Drop) 핵심 로직 구현
useDragAndDropCard커스텀 훅을 신설하여 드래그 시작, 드래그 오버, 드롭 등 전반적인 D&D 이벤트 핸들러 통합 관리dragOver이벤트에 스로틀링(Throttling)을 적용하여 성능 최적화카드 밀어내기 재귀 알고리즘 구현
getPushedLayout: 드래그 중인 카드와 충돌하는 기존 카드들을 진입 방향에 따라 재귀적으로 밀어내는 알고리즘 구현getPushDirectionPriority: 드래그 중인 카드의 중심점과 충돌 카드의 위치를 비교하여 밀어낼 방향의 우선순위 결정상태 관리 및 데이터 구조 리팩터링
EditCardProvider: 2차원 배열((MetricCardCode | null)[][]) 대신 카드 정보 객체 배열(DashboardCard[])로 상태 관리 방식 변경EditCardContext: 드래그 상태(dragState), 고스트 상태(ghost), 임시 레이아웃(tempLayout) 관리를 위한 필드 추가 및gridRef를 통한 좌표 계산 기반 마련UI/UX 및 스타일링 개선
MiniViewGhost: 카드가 놓일 위치를 미리 보여주는 고스트 컴포넌트 추가 및 유효성(배치 가능 여부)에 따른 스타일링useGridCellSize: 컨테이너 크기를 기반으로 각 그리드 셀의 픽셀 좌표와 크기를 계산하는 훅 구현기타 개선 사항
PlusIconButton,TrashCanIconButton: 클릭 이벤트 전파 방지(stopPropagation) 처리useEditCard훅의 카드 추가/삭제 로직을 새로운 데이터 구조에 맞춰 수정카드 밀어내기 알고리즘 플로우
플로우 차트 확인
flowchart TD %% 최상단 시작점 Start([시작]) --> DragEvent["'onDragOver' 이벤트 수신"] subgraph "1. 전처리 단계" DragEvent --> Throttle["'Throttle' 적용 (100ms)"] Throttle --> CalcPos["'calculateGridPos': 마우스 → 그리드 {row, col} 변환"] CalcPos --> GhostCheck{"'ghost' 위치가 변경되었는가?"} end GhostCheck -- "아니오" --> End([종료]) GhostCheck -- "예" --> InitSim["시뮬레이션 레이아웃 구성<br/>(리스트 추가 시 새 카드 포함)"] subgraph "2. 재귀적 밀어내기 (getPushedLayout)" InitSim --> SetMoved["현재 카드를 'movedCards'에 추가"] SetMoved --> FindConflict["'getConflictingCards': 충돌 카드 검색"] FindConflict --> HasConflict{충돌 발생?} HasConflict -- "예" --> GetPriority["진입방향별 밀어낼 방향<br/>우선순위 결정<br/>({dx,dy}[]) getPushDirectionPriority"] GetPriority --> DirLoop["우선순위 방향 순회 시도"] %% 좌표 계산 로직 강조 섹션 subgraph "좌표 계산 상세 (Next Position)" DirLoop --> CalcNext["'conflictNextX/Y' 계산 시작"] CalcNext --> XDir{dx 방향 확인} XDir -- "dx > 0 (우측으로 밀어내기)" --> SetNextX_R["'conflictNextX' = current.colNo + current.sizeX"] XDir -- "dx < 0 (좌측으로 밀어내기)" --> SetNextX_L["'conflictNextX' = current.colNo - conflict.sizeX"] XDir -- "dx = 0" --> YDir SetNextX_R --> YDir{dy 방향 확인} SetNextX_L --> YDir YDir -- "dy > 0 (하단으로 밀어내기)" --> SetNextY_D["'conflictNextY' = current.rowNo + current.sizeY"] YDir -- "dy < 0 (상단으로 밀어내기)" --> SetNextY_U["'conflictNextY' = current.rowNo - conflict.sizeY"] YDir -- "dy = 0" --> BoundaryCheck SetNextY_D --> BoundaryCheck SetNextY_U --> BoundaryCheck end BoundaryCheck{"그리드 경계 내에 있는가?<br/>(1 <= X < COL / 1 <= Y < ROW)"} BoundaryCheck -- "예" --> RecursiveCall["'getPushedLayout' 재귀 호출<br/>(변경된 좌표의 conflictCard 기준)"] RecursiveCall --> Success{결과 유효?} Success -- "예" --> FinalConflict["최종 충돌 여부 재확인"] %% 실패 시 루프 백 Success -- "아니오" --> DirLoop BoundaryCheck -- "아니오" --> DirLoop end HasConflict -- "아니오" --> FinalConflict subgraph "3. 최종 반영" FinalConflict --> ApplyLayout["'setTempLayout(cards)' 반영"] ApplyLayout --> SetGhost["'setGhost(isValid)' 업데이트"] end SetGhost --> End %% 스타일링 style Start fill:#f3f4f6,stroke:#374151,stroke-width:2px style End fill:#f3f4f6,stroke:#374151,stroke-width:2px style RecursiveCall fill:#eff6ff,stroke:#1d4ed8,stroke-dasharray: 5 5 style CalcNext fill:#fff7ed,stroke:#c2410c,stroke-width:2px드래그 이벤트 핸들러 동작 시퀀스
sequenceDiagram title "드래그앤드랍 & 밀어내기 상세 시퀀스" participant User as "사용자" participant CardList as "CardEditView (리스트)" participant MiniView as "MiniView (그리드)" participant Hook as "useDragAndDropCard (훅)" participant Context as "EditCardContext (상태)" participant PushAlgo as "getPushedLayout (알고리즘)" rect rgb(240, 255, 240) Note over User, CardList: [시작] 리스트 또는 그리드에서 드래그 시작 alt "리스트에서 시작" User->>CardList: "'dragstart' (새 카드 선택)" CardList->>Hook: "'handleDragStart(e, LIST, card)'" else "그리드에서 시작" User->>MiniView: "'dragstart' (기존 카드 선택)" MiniView->>Hook: "'handleDragStart(e, GRID, card)'" Hook->>Context: "해당 카드의 'z-index' 낮춤 (숨김 처리)" end Hook->>Context: "'setDragState({ sourceArea, draggingCard, centerOffset })'" end loop "그리드 위에서 드래그 ('throttledHandleGridDragOver')" User->>MiniView: "'dragover' 이벤트 지속 발생" MiniView->>Hook: "'handleGridDragOver(e)'" Hook->>Hook: "'calculateGridPos' (마우스-셀 센터 비교)" Hook->>PushAlgo: "'getPushedLayout(currentLayout, ghostCandidate, ...)'" PushAlgo->>PushAlgo: "재귀적 충돌 계산 및 'PushPriority' 적용" PushAlgo-->>Hook: "결과: { cards, isValid }" Hook->>Context: "'setTempLayout(cards)', 'setGhost({row, col, isValid})'" Context-->>MiniView: "임시 레이아웃 및 'ghost' 시각화" end rect rgb(255, 245, 245) Note over User, CardList: [삭제 환경] 그리드 카드를 리스트로 드래그 User->>CardList: "'dragenter' (리스트 영역 진입)" CardList->>Hook: "'handleListDragEnter()'" Hook->>Context: "'setIsOverList(true)' -> '삭제하기' 오버레이 표시" end alt "그리드에 드롭 (이동 또는 추가)" User->>MiniView: "'drop' 이벤트 발생" MiniView->>Hook: "'handleGridDrop(e)'" Hook->>Context: "조건: 'ghost.isValid' 이면 'setPlacedCards(tempLayout)'" else "리스트에 드롭 (삭제)" User->>CardList: "'drop' 이벤트 발생" CardList->>Hook: "'handleListDrop(e)'" Hook->>Context: "조건: 'sourceArea === GRID' 이면 'setPlacedCards'에서 필터링 삭제" end Note over Hook, Context: [종료] 모든 드래그 상태 초기화 Hook->>Context: "'handleDragEnd()': dragState/ghost/tempLayout/isOverList 초기화"리뷰 참고사항
absolute로 두는 것으로 바꾸었습니다.grid: MetricCardCode[][]대신placedCards: DashboardCard[]를 관리하는 방식으로 변경하였습니다.#️⃣ 관련 이슈
📸 스크린샷 (선택)
2026-02-16.3.58.09.mov
📎 참고할만한 자료 (선택)
위키 작성 중에 있습니다. 아주 러프하게 써두었는데 트러블슈팅 목록을 정리해두었으니 궁금하시면 참고해주세요.