594 lines
22 KiB
TypeScript
594 lines
22 KiB
TypeScript
"use client";
|
|
|
|
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,
|
|
calculateVerticalGuidelines,
|
|
calculateHorizontalGuidelines,
|
|
calculateBoxSize,
|
|
magneticSnap,
|
|
} from "./gridUtils";
|
|
import { resolveAllCollisions } from "./collisionUtils";
|
|
|
|
interface DashboardCanvasProps {
|
|
elements: DashboardElement[];
|
|
selectedElement: string | null;
|
|
selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열
|
|
onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void;
|
|
onUpdateElement: (id: string, updates: Partial<DashboardElement>) => void;
|
|
onRemoveElement: (id: string) => void;
|
|
onSelectElement: (id: string | null) => void;
|
|
onSelectMultiple?: (ids: string[]) => void; // 🔥 다중 선택 핸들러
|
|
onConfigureElement?: (element: DashboardElement) => void;
|
|
backgroundColor?: string;
|
|
canvasWidth?: number;
|
|
canvasHeight?: number;
|
|
}
|
|
|
|
/**
|
|
* 대시보드 캔버스 컴포넌트
|
|
* - 드래그 앤 드롭 영역
|
|
* - 12 컬럼 그리드 배경
|
|
* - 스냅 기능
|
|
* - 요소 배치 및 관리
|
|
*/
|
|
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|
(
|
|
{
|
|
elements,
|
|
selectedElement,
|
|
selectedElements = [],
|
|
onCreateElement,
|
|
onUpdateElement,
|
|
onRemoveElement,
|
|
onSelectElement,
|
|
onSelectMultiple,
|
|
onConfigureElement,
|
|
backgroundColor = "transparent",
|
|
canvasWidth = 1560,
|
|
canvasHeight = 768,
|
|
},
|
|
ref,
|
|
) => {
|
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
|
|
// 🔥 선택 박스 상태
|
|
const [selectionBox, setSelectionBox] = useState<{
|
|
startX: number;
|
|
startY: number;
|
|
endX: number;
|
|
endY: number;
|
|
} | null>(null);
|
|
const [isSelecting, setIsSelecting] = useState(false);
|
|
const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그
|
|
const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그
|
|
|
|
// 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백)
|
|
const [multiDragOffsets, setMultiDragOffsets] = useState<Record<string, { x: number; y: number }>>({});
|
|
|
|
// 🔥 선택 박스 드래그 중 자동 스크롤
|
|
const lastMouseYForSelectionRef = React.useRef<number>(window.innerHeight / 2);
|
|
const selectionAutoScrollFrameRef = React.useRef<number | null>(null);
|
|
|
|
// 현재 캔버스 크기에 맞는 그리드 설정 계산
|
|
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<DashboardElement>) => {
|
|
// position이나 size가 아닌 다른 속성 업데이트는 충돌 감지 없이 바로 처리
|
|
if (!updates.position && !updates.size) {
|
|
onUpdateElement(id, updates);
|
|
return;
|
|
}
|
|
|
|
// 업데이트할 요소 찾기
|
|
const elementIndex = elements.findIndex((el) => el.id === id);
|
|
if (elementIndex === -1) {
|
|
onUpdateElement(id, updates);
|
|
return;
|
|
}
|
|
|
|
// position이나 size와 다른 속성이 함께 있으면 분리해서 처리
|
|
const positionSizeUpdates: any = {};
|
|
const otherUpdates: any = {};
|
|
|
|
Object.keys(updates).forEach((key) => {
|
|
if (key === "position" || key === "size") {
|
|
positionSizeUpdates[key] = (updates as any)[key];
|
|
} else {
|
|
otherUpdates[key] = (updates as any)[key];
|
|
}
|
|
});
|
|
|
|
// 다른 속성들은 먼저 바로 업데이트
|
|
if (Object.keys(otherUpdates).length > 0) {
|
|
onUpdateElement(id, otherUpdates);
|
|
}
|
|
|
|
// position/size가 없으면 여기서 종료
|
|
if (Object.keys(positionSizeUpdates).length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 임시로 업데이트된 요소 배열 생성
|
|
const updatedElements = elements.map((el) =>
|
|
el.id === id
|
|
? {
|
|
...el,
|
|
...positionSizeUpdates,
|
|
position: positionSizeUpdates.position || el.position,
|
|
size: positionSizeUpdates.size || el.size,
|
|
}
|
|
: el,
|
|
);
|
|
|
|
// 서브 그리드 크기 계산 (cellSize / 3)
|
|
const subGridSize = Math.floor(cellSize / 3);
|
|
|
|
// 충돌 해결 (서브 그리드 단위로 스냅 및 충돌 감지)
|
|
const resolvedElements = resolveAllCollisions(updatedElements, id, subGridSize, canvasWidth, cellSize);
|
|
|
|
// 변경된 요소들만 업데이트
|
|
resolvedElements.forEach((resolvedEl, idx) => {
|
|
const originalEl = elements[idx];
|
|
if (
|
|
resolvedEl.position.x !== originalEl.position.x ||
|
|
resolvedEl.position.y !== originalEl.position.y ||
|
|
resolvedEl.size.width !== originalEl.size.width ||
|
|
resolvedEl.size.height !== originalEl.size.height
|
|
) {
|
|
onUpdateElement(resolvedEl.id, {
|
|
position: resolvedEl.position,
|
|
size: resolvedEl.size,
|
|
});
|
|
}
|
|
});
|
|
},
|
|
[elements, onUpdateElement, cellSize, canvasWidth],
|
|
);
|
|
|
|
// 드래그 오버 처리
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
setIsDragOver(true);
|
|
}, []);
|
|
|
|
// 드래그 리브 처리
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
if (e.currentTarget === e.target) {
|
|
setIsDragOver(false);
|
|
}
|
|
}, []);
|
|
|
|
// 드롭 처리 (그리드 스냅 적용)
|
|
const handleDrop = useCallback(
|
|
(e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragOver(false);
|
|
|
|
try {
|
|
const dragData: DragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
|
|
|
if (!ref || typeof ref === "function") return;
|
|
|
|
const rect = ref.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
|
|
// 캔버스 스크롤을 고려한 정확한 위치 계산
|
|
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
|
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
|
|
|
// 자석 스냅 적용
|
|
let snappedX = magneticSnap(rawX, verticalGuidelines);
|
|
let snappedY = magneticSnap(rawY, horizontalGuidelines);
|
|
|
|
// 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);
|
|
} catch {
|
|
// 드롭 데이터 파싱 오류 무시
|
|
}
|
|
},
|
|
[ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines],
|
|
);
|
|
|
|
// 🔥 선택 박스 드래그 시작
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
// 🔥 위젯 내부 클릭이 아닌 경우만 (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;
|
|
|
|
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
|
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
|
|
|
// 🔥 일단 시작 위치만 저장 (아직 isSelecting은 false)
|
|
setSelectionBox({ startX: x, startY: y, endX: x, endY: y });
|
|
},
|
|
[ref],
|
|
);
|
|
|
|
// 🔥 선택 박스 드래그 종료
|
|
const handleMouseUp = useCallback(() => {
|
|
if (!isSelecting || !selectionBox) {
|
|
setIsSelecting(false);
|
|
setSelectionBox(null);
|
|
return;
|
|
}
|
|
|
|
if (!onSelectMultiple) {
|
|
setIsSelecting(false);
|
|
setSelectionBox(null);
|
|
return;
|
|
}
|
|
|
|
// 선택 박스 영역 계산
|
|
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
|
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
|
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
|
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
|
|
|
// console.log("🔍 선택 박스:", { minX, maxX, minY, maxY });
|
|
|
|
// 선택 박스 안에 있는 요소들 찾기 (70% 이상 겹치면 선택)
|
|
const selectedIds = elements
|
|
.filter((el) => {
|
|
const elLeft = el.position.x;
|
|
const elRight = el.position.x + el.size.width;
|
|
const elTop = el.position.y;
|
|
const elBottom = el.position.y + el.size.height;
|
|
|
|
// 겹치는 영역 계산
|
|
const overlapLeft = Math.max(elLeft, minX);
|
|
const overlapRight = Math.min(elRight, maxX);
|
|
const overlapTop = Math.max(elTop, minY);
|
|
const overlapBottom = Math.min(elBottom, maxY);
|
|
|
|
// 겹치는 영역이 없으면 false
|
|
if (overlapRight < overlapLeft || overlapBottom < overlapTop) {
|
|
return false;
|
|
}
|
|
|
|
// 겹치는 영역의 넓이
|
|
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);
|
|
|
|
// console.log("✅ 선택된 요소:", selectedIds);
|
|
|
|
if (selectedIds.length > 0) {
|
|
onSelectMultiple(selectedIds);
|
|
setJustSelected(true); // 🔥 방금 선택했음을 표시
|
|
setTimeout(() => setJustSelected(false), 100); // 100ms 후 플래그 해제
|
|
} else {
|
|
onSelectMultiple([]); // 빈 배열도 전달
|
|
}
|
|
|
|
setIsSelecting(false);
|
|
setSelectionBox(null);
|
|
}, [isSelecting, selectionBox, elements, onSelectMultiple]);
|
|
|
|
// 🔥 document 레벨에서 마우스 이동/해제 감지 (위젯 위에서도 작동)
|
|
useEffect(() => {
|
|
if (!selectionBox) return;
|
|
|
|
const handleDocumentMouseMove = (e: MouseEvent) => {
|
|
if (!ref || typeof ref === "function") return;
|
|
const rect = ref.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
|
|
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
|
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
|
|
|
// 🔥 자동 스크롤을 위한 마우스 Y 위치 저장
|
|
lastMouseYForSelectionRef.current = e.clientY;
|
|
|
|
// console.log("🖱️ 마우스 이동:", { x, y, startX: selectionBox.startX, startY: selectionBox.startY, isSelecting });
|
|
|
|
// 🔥 selectionBox가 있지만 아직 isSelecting이 false인 경우 (드래그 시작 대기)
|
|
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 이상 이동)");
|
|
setIsSelecting(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 🔥 선택 박스 업데이트
|
|
// console.log("📦 선택 박스 업데이트:", { startX: selectionBox.startX, startY: selectionBox.startY, endX: x, endY: y });
|
|
setSelectionBox((prev) => (prev ? { ...prev, endX: x, endY: y } : null));
|
|
};
|
|
|
|
const handleDocumentMouseUp = () => {
|
|
// console.log("🖱️ 마우스 업 - handleMouseUp 호출");
|
|
handleMouseUp();
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleDocumentMouseMove);
|
|
document.addEventListener("mouseup", handleDocumentMouseUp);
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
|
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
|
};
|
|
}, [selectionBox, isSelecting, ref, handleMouseUp]);
|
|
|
|
// 🔥 선택 박스 드래그 중 자동 스크롤
|
|
useEffect(() => {
|
|
if (!isSelecting) {
|
|
// console.log("❌ 자동 스크롤 비활성화: isSelecting =", isSelecting);
|
|
return;
|
|
}
|
|
|
|
// console.log("✅ 자동 스크롤 활성화: isSelecting =", isSelecting);
|
|
|
|
const scrollSpeed = 3;
|
|
const scrollThreshold = 100;
|
|
let animationFrameId: number;
|
|
let lastTime = performance.now();
|
|
|
|
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;
|
|
// console.log("⬆️ 위로 스크롤 (선택 박스):", { lastMouseY, scrollThreshold });
|
|
} else if (lastMouseY > viewportHeight - scrollThreshold) {
|
|
shouldScroll = true;
|
|
scrollDirection = scrollSpeed;
|
|
// console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold });
|
|
}
|
|
|
|
const deltaTime = currentTime - lastTime;
|
|
|
|
if (shouldScroll && deltaTime >= 10) {
|
|
window.scrollBy(0, scrollDirection);
|
|
// console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime });
|
|
lastTime = currentTime;
|
|
}
|
|
|
|
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
|
};
|
|
|
|
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
|
selectionAutoScrollFrameRef.current = animationFrameId;
|
|
|
|
return () => {
|
|
if (animationFrameId) {
|
|
cancelAnimationFrame(animationFrameId);
|
|
}
|
|
// console.log("🛑 자동 스크롤 정리");
|
|
};
|
|
}, [isSelecting]);
|
|
|
|
// 캔버스 클릭 시 선택 해제
|
|
const handleCanvasClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
// 🔥 방금 선택했거나 드래그 중이면 클릭 이벤트 무시 (선택 해제 방지)
|
|
if (justSelected || isDraggingAny) {
|
|
// console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시");
|
|
return;
|
|
}
|
|
|
|
if (e.target === e.currentTarget) {
|
|
// console.log("✅ 빈 공간 클릭 - 선택 해제");
|
|
onSelectElement(null);
|
|
if (onSelectMultiple) {
|
|
onSelectMultiple([]);
|
|
}
|
|
}
|
|
},
|
|
[onSelectElement, onSelectMultiple, justSelected, isDraggingAny],
|
|
);
|
|
|
|
// 동적 그리드 크기 계산
|
|
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
|
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
|
|
|
|
// 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용)
|
|
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
|
|
|
// 12개 컬럼 구분선 위치 계산
|
|
const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
|
|
|
|
// 🔥 선택 박스 스타일 계산
|
|
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);
|
|
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
|
|
|
return {
|
|
left: `${minX}px`,
|
|
top: `${minY}px`,
|
|
width: `${maxX - minX}px`,
|
|
height: `${maxY - minY}px`,
|
|
};
|
|
}, [selectionBox]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={`relative w-full ${isDragOver ? "bg-blue-50/50" : ""} `}
|
|
style={{
|
|
backgroundColor,
|
|
height: `${canvasHeight}px`,
|
|
minHeight: `${canvasHeight}px`,
|
|
cursor: isSelecting ? "crosshair" : "default",
|
|
}}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={handleCanvasClick}
|
|
onMouseDown={handleMouseDown}
|
|
>
|
|
{/* 12개 컬럼 메인 구분선 - 주석 처리 (서브그리드만 사용) */}
|
|
{/* {columnLines.map((x, i) => (
|
|
<div
|
|
key={`col-${i}`}
|
|
className="pointer-events-none absolute top-0 h-full"
|
|
style={{
|
|
left: `${x}px`,
|
|
width: "1px",
|
|
backgroundColor: i === 0 || i === GRID_CONFIG.COLUMNS ? "rgba(59, 130, 246, 0.3)" : "rgba(59, 130, 246, 0.15)",
|
|
zIndex: 0,
|
|
}}
|
|
/>
|
|
))} */}
|
|
{/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */}
|
|
{verticalGuidelines.map((x, xIdx) =>
|
|
horizontalGuidelines.slice(0, -1).map((y, yIdx) => (
|
|
<div
|
|
key={`grid-box-${xIdx}-${yIdx}`}
|
|
className="pointer-events-none absolute"
|
|
style={{
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
width: `${boxSize}px`,
|
|
height: `${boxSize}px`,
|
|
backgroundColor: "#ffffff",
|
|
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
|
zIndex: 0,
|
|
}}
|
|
/>
|
|
)),
|
|
)}
|
|
{/* 배치된 요소들 렌더링 */}
|
|
{elements.length === 0 && (
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
|
<div className="text-center">
|
|
<div className="text-sm">상단 메뉴에서 차트나 위젯을 선택하세요</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{elements.map((element) => (
|
|
<CanvasElement
|
|
key={element.id}
|
|
element={element}
|
|
isSelected={selectedElement === element.id || selectedElements.includes(element.id)}
|
|
selectedElements={selectedElements}
|
|
allElements={elements}
|
|
multiDragOffset={multiDragOffsets[element.id]}
|
|
cellSize={cellSize}
|
|
subGridSize={subGridSize}
|
|
canvasWidth={canvasWidth}
|
|
verticalGuidelines={verticalGuidelines}
|
|
horizontalGuidelines={horizontalGuidelines}
|
|
onUpdate={handleUpdateWithCollisionDetection}
|
|
onUpdateMultiple={(updates) => {
|
|
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
|
|
updates.forEach(({ id, updates: elementUpdates }) => {
|
|
onUpdateElement(id, elementUpdates);
|
|
});
|
|
}}
|
|
onMultiDragStart={(draggedId, initialOffsets) => {
|
|
// 🔥 다중 드래그 시작 - 초기 오프셋 저장
|
|
setMultiDragOffsets(initialOffsets);
|
|
setIsDraggingAny(true);
|
|
}}
|
|
onMultiDragMove={(draggedElement, tempPosition) => {
|
|
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 실시간 업데이트
|
|
if (selectedElements.length > 1 && selectedElements.includes(draggedElement.id)) {
|
|
const newOffsets: Record<string, { x: number; y: number }> = {};
|
|
selectedElements.forEach((id) => {
|
|
if (id !== draggedElement.id) {
|
|
const targetElement = elements.find((el) => el.id === id);
|
|
if (targetElement) {
|
|
const relativeX = targetElement.position.x - draggedElement.position.x;
|
|
const relativeY = targetElement.position.y - draggedElement.position.y;
|
|
newOffsets[id] = {
|
|
x: tempPosition.x + relativeX - targetElement.position.x,
|
|
y: tempPosition.y + relativeY - targetElement.position.y,
|
|
};
|
|
}
|
|
}
|
|
});
|
|
setMultiDragOffsets(newOffsets);
|
|
}
|
|
}}
|
|
onMultiDragEnd={() => {
|
|
// 🔥 다중 드래그 종료 - 오프셋 초기화
|
|
setMultiDragOffsets({});
|
|
setIsDraggingAny(false);
|
|
}}
|
|
onRemove={onRemoveElement}
|
|
onSelect={onSelectElement}
|
|
/>
|
|
))}
|
|
|
|
{/* 🔥 선택 박스 렌더링 */}
|
|
{selectionBox && selectionBoxStyle && (
|
|
<div
|
|
className="pointer-events-none absolute"
|
|
style={{
|
|
...selectionBoxStyle,
|
|
border: "2px dashed #3b82f6",
|
|
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
|
zIndex: 9999,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
DashboardCanvas.displayName = "DashboardCanvas";
|