드래그해서 위젯 선택 가능
This commit is contained in:
parent
1d0c4fe503
commit
63553e23b1
|
|
@ -129,10 +129,17 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열
|
||||||
|
allElements?: DashboardElement[]; // 🔥 모든 요소 배열
|
||||||
|
multiDragOffset?: { x: number; y: number }; // 🔥 다중 드래그 시 이 요소의 오프셋
|
||||||
cellSize: number;
|
cellSize: number;
|
||||||
subGridSize: number;
|
subGridSize: number;
|
||||||
canvasWidth?: number;
|
canvasWidth?: number;
|
||||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||||
|
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
|
||||||
|
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void; // 🔥 다중 드래그 시작
|
||||||
|
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // 🔥 다중 드래그 중
|
||||||
|
onMultiDragEnd?: () => void; // 🔥 다중 드래그 종료
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
onConfigure?: (element: DashboardElement) => void;
|
onConfigure?: (element: DashboardElement) => void;
|
||||||
|
|
@ -147,10 +154,17 @@ interface CanvasElementProps {
|
||||||
export function CanvasElement({
|
export function CanvasElement({
|
||||||
element,
|
element,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
selectedElements = [],
|
||||||
|
allElements = [],
|
||||||
|
multiDragOffset,
|
||||||
cellSize,
|
cellSize,
|
||||||
subGridSize,
|
subGridSize,
|
||||||
canvasWidth = 1560,
|
canvasWidth = 1560,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onUpdateMultiple,
|
||||||
|
onMultiDragStart,
|
||||||
|
onMultiDragMove,
|
||||||
|
onMultiDragEnd,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSelect,
|
onSelect,
|
||||||
onConfigure,
|
onConfigure,
|
||||||
|
|
@ -205,9 +219,27 @@ export function CanvasElement({
|
||||||
elementX: element.position.x,
|
elementX: element.position.x,
|
||||||
elementY: element.position.y,
|
elementY: element.position.y,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산
|
||||||
|
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) {
|
||||||
|
const offsets: Record<string, { x: number; y: number }> = {};
|
||||||
|
selectedElements.forEach((id) => {
|
||||||
|
if (id !== element.id) {
|
||||||
|
const targetElement = allElements.find((el) => el.id === id);
|
||||||
|
if (targetElement) {
|
||||||
|
offsets[id] = {
|
||||||
|
x: targetElement.position.x - element.position.x,
|
||||||
|
y: targetElement.position.y - element.position.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onMultiDragStart(element.id, offsets);
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
[element.id, element.position.x, element.position.y, onSelect, isSelected],
|
[element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 리사이즈 핸들 마우스다운
|
// 리사이즈 핸들 마우스다운
|
||||||
|
|
@ -263,6 +295,11 @@ export function CanvasElement({
|
||||||
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
|
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
|
||||||
|
|
||||||
setTempPosition({ x: snappedX, y: snappedY });
|
setTempPosition({ x: snappedX, y: snappedY });
|
||||||
|
|
||||||
|
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
|
||||||
|
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
|
||||||
|
onMultiDragMove(element, { x: snappedX, y: snappedY });
|
||||||
|
}
|
||||||
} else if (isResizing) {
|
} else if (isResizing) {
|
||||||
const deltaX = e.clientX - resizeStart.x;
|
const deltaX = e.clientX - resizeStart.x;
|
||||||
const deltaY = e.clientY - resizeStart.y;
|
const deltaY = e.clientY - resizeStart.y;
|
||||||
|
|
@ -345,12 +382,14 @@ export function CanvasElement({
|
||||||
isResizing,
|
isResizing,
|
||||||
dragStart,
|
dragStart,
|
||||||
resizeStart,
|
resizeStart,
|
||||||
element.size.width,
|
element,
|
||||||
element.type,
|
|
||||||
element.subtype,
|
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
cellSize,
|
cellSize,
|
||||||
subGridSize,
|
subGridSize,
|
||||||
|
selectedElements,
|
||||||
|
allElements,
|
||||||
|
onUpdateMultiple,
|
||||||
|
onMultiDragMove,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -370,7 +409,43 @@ export function CanvasElement({
|
||||||
position: { x: finalX, y: finalY },
|
position: { x: finalX, y: finalY },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔥 다중 선택된 요소들도 함께 업데이트
|
||||||
|
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onUpdateMultiple) {
|
||||||
|
const updates = selectedElements
|
||||||
|
.filter((id) => id !== element.id) // 현재 요소 제외
|
||||||
|
.map((id) => {
|
||||||
|
const targetElement = allElements.find((el) => el.id === id);
|
||||||
|
if (!targetElement) return null;
|
||||||
|
|
||||||
|
// 현재 요소와의 상대적 위치 유지
|
||||||
|
const relativeX = targetElement.position.x - dragStart.elementX;
|
||||||
|
const relativeY = targetElement.position.y - dragStart.elementY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
updates: {
|
||||||
|
position: {
|
||||||
|
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
|
||||||
|
y: Math.max(0, finalY + relativeY),
|
||||||
|
z: targetElement.position.z,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((update): update is { id: string; updates: Partial<DashboardElement> } => update !== null);
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
console.log("🔥 다중 선택 요소 함께 이동:", updates);
|
||||||
|
onUpdateMultiple(updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTempPosition(null);
|
setTempPosition(null);
|
||||||
|
|
||||||
|
// 🔥 다중 드래그 종료
|
||||||
|
if (onMultiDragEnd) {
|
||||||
|
onMultiDragEnd();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResizing && tempPosition && tempSize) {
|
if (isResizing && tempPosition && tempSize) {
|
||||||
|
|
@ -396,7 +471,23 @@ export function CanvasElement({
|
||||||
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]);
|
}, [
|
||||||
|
isDragging,
|
||||||
|
isResizing,
|
||||||
|
tempPosition,
|
||||||
|
tempSize,
|
||||||
|
element.id,
|
||||||
|
element.size.width,
|
||||||
|
onUpdate,
|
||||||
|
onUpdateMultiple,
|
||||||
|
onMultiDragEnd,
|
||||||
|
cellSize,
|
||||||
|
canvasWidth,
|
||||||
|
selectedElements,
|
||||||
|
allElements,
|
||||||
|
dragStart.elementX,
|
||||||
|
dragStart.elementY,
|
||||||
|
]);
|
||||||
|
|
||||||
// 전역 마우스 이벤트 등록
|
// 전역 마우스 이벤트 등록
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -525,12 +616,18 @@ export function CanvasElement({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||||
const displayPosition = tempPosition || element.position;
|
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
|
||||||
|
const displayPosition = tempPosition || (multiDragOffset && !isDragging ? {
|
||||||
|
x: element.position.x + multiDragOffset.x,
|
||||||
|
y: element.position.y + multiDragOffset.y,
|
||||||
|
z: element.position.z,
|
||||||
|
} : element.position);
|
||||||
const displaySize = tempSize || element.size;
|
const displaySize = tempSize || element.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={elementRef}
|
ref={elementRef}
|
||||||
|
data-element-id={element.id}
|
||||||
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||||
style={{
|
style={{
|
||||||
left: displayPosition.x,
|
left: displayPosition.x,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { forwardRef, useState, useCallback, useMemo } from "react";
|
import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||||
import { CanvasElement } from "./CanvasElement";
|
import { CanvasElement } from "./CanvasElement";
|
||||||
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
|
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
|
||||||
|
|
@ -9,10 +9,12 @@ import { resolveAllCollisions } from "./collisionUtils";
|
||||||
interface DashboardCanvasProps {
|
interface DashboardCanvasProps {
|
||||||
elements: DashboardElement[];
|
elements: DashboardElement[];
|
||||||
selectedElement: string | null;
|
selectedElement: string | null;
|
||||||
|
selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열
|
||||||
onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void;
|
onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void;
|
||||||
onUpdateElement: (id: string, updates: Partial<DashboardElement>) => void;
|
onUpdateElement: (id: string, updates: Partial<DashboardElement>) => void;
|
||||||
onRemoveElement: (id: string) => void;
|
onRemoveElement: (id: string) => void;
|
||||||
onSelectElement: (id: string | null) => void;
|
onSelectElement: (id: string | null) => void;
|
||||||
|
onSelectMultiple?: (ids: string[]) => void; // 🔥 다중 선택 핸들러
|
||||||
onConfigureElement?: (element: DashboardElement) => void;
|
onConfigureElement?: (element: DashboardElement) => void;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
canvasWidth?: number;
|
canvasWidth?: number;
|
||||||
|
|
@ -31,10 +33,12 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
{
|
{
|
||||||
elements,
|
elements,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
|
selectedElements = [],
|
||||||
onCreateElement,
|
onCreateElement,
|
||||||
onUpdateElement,
|
onUpdateElement,
|
||||||
onRemoveElement,
|
onRemoveElement,
|
||||||
onSelectElement,
|
onSelectElement,
|
||||||
|
onSelectMultiple,
|
||||||
onConfigureElement,
|
onConfigureElement,
|
||||||
backgroundColor = "#f9fafb",
|
backgroundColor = "#f9fafb",
|
||||||
canvasWidth = 1560,
|
canvasWidth = 1560,
|
||||||
|
|
@ -43,6 +47,19 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
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 [multiDragOffsets, setMultiDragOffsets] = useState<Record<string, { x: number; y: number }>>({});
|
||||||
|
|
||||||
// 현재 캔버스 크기에 맞는 그리드 설정 계산
|
// 현재 캔버스 크기에 맞는 그리드 설정 계산
|
||||||
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
|
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
|
||||||
|
|
@ -182,14 +199,174 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
[ref, onCreateElement, canvasWidth, cellSize],
|
[ref, onCreateElement, canvasWidth, cellSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🔥 선택 박스 드래그 시작
|
||||||
|
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);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
// 캔버스 클릭 시 선택 해제
|
// 캔버스 클릭 시 선택 해제
|
||||||
const handleCanvasClick = useCallback(
|
const handleCanvasClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
// 🔥 방금 선택했으면 클릭 이벤트 무시 (선택 해제 방지)
|
||||||
|
if (justSelected) {
|
||||||
|
console.log("🚫 방금 선택했으므로 클릭 이벤트 무시");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
|
console.log("✅ 빈 공간 클릭 - 선택 해제");
|
||||||
onSelectElement(null);
|
onSelectElement(null);
|
||||||
|
if (onSelectMultiple) {
|
||||||
|
onSelectMultiple([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSelectElement],
|
[onSelectElement, onSelectMultiple, justSelected],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 동적 그리드 크기 계산
|
// 동적 그리드 크기 계산
|
||||||
|
|
@ -202,6 +379,23 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
// 12개 컬럼 구분선 위치 계산
|
// 12개 컬럼 구분선 위치 계산
|
||||||
const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -218,11 +412,13 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
backgroundSize: `${subGridSize}px ${subGridSize}px`,
|
backgroundSize: `${subGridSize}px ${subGridSize}px`,
|
||||||
backgroundPosition: "0 0",
|
backgroundPosition: "0 0",
|
||||||
backgroundRepeat: "repeat",
|
backgroundRepeat: "repeat",
|
||||||
|
cursor: isSelecting ? "crosshair" : "default",
|
||||||
}}
|
}}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
{/* 12개 컬럼 메인 구분선 */}
|
{/* 12개 컬럼 메인 구분선 */}
|
||||||
{columnLines.map((x, i) => (
|
{columnLines.map((x, i) => (
|
||||||
|
|
@ -249,16 +445,66 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
<CanvasElement
|
<CanvasElement
|
||||||
key={element.id}
|
key={element.id}
|
||||||
element={element}
|
element={element}
|
||||||
isSelected={selectedElement === element.id}
|
isSelected={selectedElement === element.id || selectedElements.includes(element.id)}
|
||||||
|
selectedElements={selectedElements}
|
||||||
|
allElements={elements}
|
||||||
|
multiDragOffset={multiDragOffsets[element.id]}
|
||||||
cellSize={cellSize}
|
cellSize={cellSize}
|
||||||
subGridSize={subGridSize}
|
subGridSize={subGridSize}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
onUpdate={handleUpdateWithCollisionDetection}
|
onUpdate={handleUpdateWithCollisionDetection}
|
||||||
|
onUpdateMultiple={(updates) => {
|
||||||
|
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
|
||||||
|
updates.forEach(({ id, updates: elementUpdates }) => {
|
||||||
|
onUpdateElement(id, elementUpdates);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMultiDragStart={(draggedId, initialOffsets) => {
|
||||||
|
// 🔥 다중 드래그 시작 - 초기 오프셋 저장
|
||||||
|
setMultiDragOffsets(initialOffsets);
|
||||||
|
}}
|
||||||
|
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({});
|
||||||
|
}}
|
||||||
onRemove={onRemoveElement}
|
onRemove={onRemoveElement}
|
||||||
onSelect={onSelectElement}
|
onSelect={onSelectElement}
|
||||||
onConfigure={onConfigureElement}
|
onConfigure={onConfigureElement}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 🔥 선택 박스 렌더링 */}
|
||||||
|
{selectionBox && selectionBoxStyle && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
...selectionBoxStyle,
|
||||||
|
border: "2px dashed #3b82f6",
|
||||||
|
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const { refreshMenus } = useMenu();
|
const { refreshMenus } = useMenu();
|
||||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||||
|
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 🔥 다중 선택
|
||||||
const [elementCounter, setElementCounter] = useState(0);
|
const [elementCounter, setElementCounter] = useState(0);
|
||||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||||
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
||||||
|
|
@ -504,10 +505,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
selectedElement={selectedElement}
|
selectedElement={selectedElement}
|
||||||
|
selectedElements={selectedElements}
|
||||||
onCreateElement={createElement}
|
onCreateElement={createElement}
|
||||||
onUpdateElement={updateElement}
|
onUpdateElement={updateElement}
|
||||||
onRemoveElement={removeElement}
|
onRemoveElement={removeElement}
|
||||||
onSelectElement={setSelectedElement}
|
onSelectElement={(id) => {
|
||||||
|
setSelectedElement(id);
|
||||||
|
setSelectedElements([]); // 단일 선택 시 다중 선택 해제
|
||||||
|
}}
|
||||||
|
onSelectMultiple={(ids) => {
|
||||||
|
console.log("🎯 DashboardDesigner - onSelectMultiple 호출:", ids);
|
||||||
|
setSelectedElements(ids);
|
||||||
|
setSelectedElement(null); // 다중 선택 시 단일 선택 해제
|
||||||
|
}}
|
||||||
onConfigureElement={openConfigModal}
|
onConfigureElement={openConfigModal}
|
||||||
backgroundColor={canvasBackgroundColor}
|
backgroundColor={canvasBackgroundColor}
|
||||||
canvasWidth={canvasConfig.width}
|
canvasWidth={canvasConfig.width}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue