자동스크롤, 다중선택 하고 휠로 위아래 이동 가능 #128
|
|
@ -172,6 +172,10 @@ export function CanvasElement({
|
|||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
||||
const dragStartRef = useRef({ x: 0, y: 0, elementX: 0, elementY: 0, initialScrollY: 0 }); // 🔥 스크롤 조정용 ref
|
||||
const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향
|
||||
const autoScrollFrameRef = useRef<number | null>(null); // 🔥 requestAnimationFrame ID
|
||||
const lastMouseYRef = useRef<number>(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간)
|
||||
const [resizeStart, setResizeStart] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
|
|
@ -213,12 +217,18 @@ export function CanvasElement({
|
|||
}
|
||||
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
const startPos = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y,
|
||||
});
|
||||
initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치
|
||||
};
|
||||
setDragStart(startPos);
|
||||
dragStartRef.current = startPos; // 🔥 ref에도 저장
|
||||
|
||||
// 🔥 드래그 시작 시 마우스 위치 초기화 (화면 중간)
|
||||
lastMouseYRef.current = window.innerHeight / 2;
|
||||
|
||||
// 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산
|
||||
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) {
|
||||
|
|
@ -269,8 +279,25 @@ export function CanvasElement({
|
|||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
|
||||
const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
||||
|
||||
if (isFirstSelectedElement) {
|
||||
const scrollThreshold = 100;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const mouseY = e.clientY;
|
||||
|
||||
// 🔥 항상 마우스 위치 업데이트
|
||||
lastMouseYRef.current = mouseY;
|
||||
// console.log("🖱️ 마우스 위치 업데이트:", { mouseY, viewportHeight, top: scrollThreshold, bottom: viewportHeight - scrollThreshold });
|
||||
}
|
||||
|
||||
// 🔥 현재 스크롤 위치를 고려한 deltaY 계산
|
||||
const currentScrollY = window.pageYOffset;
|
||||
const scrollDelta = currentScrollY - dragStartRef.current.initialScrollY;
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.x;
|
||||
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
|
||||
|
||||
// 임시 위치 계산
|
||||
let rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||
|
|
@ -356,6 +383,7 @@ export function CanvasElement({
|
|||
allElements,
|
||||
onUpdateMultiple,
|
||||
onMultiDragMove,
|
||||
// dragStartRef, autoScrollDirectionRef, autoScrollFrameRef는 ref라서 dependency 불필요
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -393,7 +421,6 @@ export function CanvasElement({
|
|||
position: {
|
||||
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
|
||||
y: Math.max(0, finalY + relativeY),
|
||||
z: targetElement.position.z,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -401,7 +428,7 @@ export function CanvasElement({
|
|||
.filter((update): update is { id: string; updates: Partial<DashboardElement> } => update !== null);
|
||||
|
||||
if (updates.length > 0) {
|
||||
console.log("🔥 다중 선택 요소 함께 이동:", updates);
|
||||
// console.log("🔥 다중 선택 요소 함께 이동:", updates);
|
||||
onUpdateMultiple(updates);
|
||||
}
|
||||
}
|
||||
|
|
@ -437,6 +464,13 @@ export function CanvasElement({
|
|||
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
|
||||
// 🔥 자동 스크롤 정리
|
||||
autoScrollDirectionRef.current = null;
|
||||
if (autoScrollFrameRef.current) {
|
||||
cancelAnimationFrame(autoScrollFrameRef.current);
|
||||
autoScrollFrameRef.current = null;
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
isResizing,
|
||||
|
|
@ -455,6 +489,60 @@ export function CanvasElement({
|
|||
dragStart.elementY,
|
||||
]);
|
||||
|
||||
// 🔥 자동 스크롤 루프 (requestAnimationFrame 사용)
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const scrollSpeed = 3; // 🔥 속도를 좀 더 부드럽게 (5 → 3)
|
||||
const scrollThreshold = 100;
|
||||
let animationFrameId: number;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const autoScrollLoop = (currentTime: number) => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const lastMouseY = lastMouseYRef.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;
|
||||
|
||||
// 🔥 10ms 간격으로 스크롤
|
||||
if (shouldScroll && deltaTime >= 10) {
|
||||
window.scrollBy(0, scrollDirection);
|
||||
// console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime });
|
||||
lastTime = currentTime;
|
||||
}
|
||||
|
||||
// 계속 반복
|
||||
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
||||
};
|
||||
|
||||
// 루프 시작
|
||||
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
||||
autoScrollFrameRef.current = animationFrameId;
|
||||
|
||||
return () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// 전역 마우스 이벤트 등록
|
||||
React.useEffect(() => {
|
||||
if (isDragging || isResizing) {
|
||||
|
|
@ -586,7 +674,6 @@ export function CanvasElement({
|
|||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -57,9 +57,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
} | 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]);
|
||||
|
|
@ -207,11 +212,11 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const isWidget = target.closest("[data-element-id]");
|
||||
|
||||
if (isWidget) {
|
||||
console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함");
|
||||
// console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("✅ 빈 공간 클릭 - 선택 박스 시작");
|
||||
// console.log("✅ 빈 공간 클릭 - 선택 박스 시작");
|
||||
|
||||
if (!ref || typeof ref === "function") return;
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
|
|
@ -246,7 +251,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
||||
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
||||
|
||||
console.log("🔍 선택 박스:", { minX, maxX, minY, maxY });
|
||||
// console.log("🔍 선택 박스:", { minX, maxX, minY, maxY });
|
||||
|
||||
// 선택 박스 안에 있는 요소들 찾기 (70% 이상 겹치면 선택)
|
||||
const selectedIds = elements
|
||||
|
|
@ -276,18 +281,18 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 70% 이상 겹치면 선택
|
||||
const overlapPercentage = overlapArea / elementArea;
|
||||
|
||||
console.log(`📦 요소 ${el.id}:`, {
|
||||
position: el.position,
|
||||
size: el.size,
|
||||
overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%",
|
||||
selected: overlapPercentage >= 0.7,
|
||||
});
|
||||
// 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);
|
||||
// console.log("✅ 선택된 요소:", selectedIds);
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
onSelectMultiple(selectedIds);
|
||||
|
|
@ -313,30 +318,33 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
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 });
|
||||
// 🔥 자동 스크롤을 위한 마우스 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 });
|
||||
// console.log("📏 이동 거리:", { deltaX, deltaY });
|
||||
|
||||
// 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분)
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
console.log("🎯 선택 박스 활성화 (5px 이상 이동)");
|
||||
// console.log("🎯 선택 박스 활성화 (5px 이상 이동)");
|
||||
setIsSelecting(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 선택 박스 업데이트
|
||||
console.log("📦 선택 박스 업데이트:", { startX: selectionBox.startX, startY: selectionBox.startY, endX: x, endY: y });
|
||||
// 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 호출");
|
||||
// console.log("🖱️ 마우스 업 - handleMouseUp 호출");
|
||||
handleMouseUp();
|
||||
};
|
||||
|
||||
|
|
@ -349,24 +357,77 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
};
|
||||
}, [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) {
|
||||
console.log("🚫 방금 선택했으므로 클릭 이벤트 무시");
|
||||
// 🔥 방금 선택했거나 드래그 중이면 클릭 이벤트 무시 (선택 해제 방지)
|
||||
if (justSelected || isDraggingAny) {
|
||||
// console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시");
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target === e.currentTarget) {
|
||||
console.log("✅ 빈 공간 클릭 - 선택 해제");
|
||||
// console.log("✅ 빈 공간 클릭 - 선택 해제");
|
||||
onSelectElement(null);
|
||||
if (onSelectMultiple) {
|
||||
onSelectMultiple([]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSelectElement, onSelectMultiple, justSelected],
|
||||
[onSelectElement, onSelectMultiple, justSelected, isDraggingAny],
|
||||
);
|
||||
|
||||
// 동적 그리드 크기 계산
|
||||
|
|
@ -462,6 +523,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
onMultiDragStart={(draggedId, initialOffsets) => {
|
||||
// 🔥 다중 드래그 시작 - 초기 오프셋 저장
|
||||
setMultiDragOffsets(initialOffsets);
|
||||
setIsDraggingAny(true);
|
||||
}}
|
||||
onMultiDragMove={(draggedElement, tempPosition) => {
|
||||
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 실시간 업데이트
|
||||
|
|
@ -486,6 +548,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
onMultiDragEnd={() => {
|
||||
// 🔥 다중 드래그 종료 - 오프셋 초기화
|
||||
setMultiDragOffsets({});
|
||||
setIsDraggingAny(false);
|
||||
}}
|
||||
onRemove={onRemoveElement}
|
||||
onSelect={onSelectElement}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
|||
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
|
||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
|
||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
|
||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
|
|
@ -214,22 +214,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
return;
|
||||
}
|
||||
|
||||
// 기본 크기 설정
|
||||
let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기
|
||||
// 기본 크기 설정 (서브그리드 기준)
|
||||
const gridConfig = calculateGridConfig(canvasConfig.width);
|
||||
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
||||
|
||||
// 서브그리드 기준 기본 크기 (픽셀)
|
||||
let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||
let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||
|
||||
if (type === "chart") {
|
||||
defaultCells = { width: 4, height: 3 }; // 차트
|
||||
defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸
|
||||
defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸
|
||||
} else if (type === "widget" && subtype === "calendar") {
|
||||
defaultCells = { width: 2, height: 3 }; // 달력 최소 크기
|
||||
defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸
|
||||
defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸
|
||||
}
|
||||
|
||||
// 현재 해상도에 맞는 셀 크기 계산
|
||||
const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
|
||||
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
|
||||
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
|
||||
|
||||
// 크기 유효성 검사
|
||||
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
||||
// console.error("Invalid size calculated:", {
|
||||
|
|
|
|||
Loading…
Reference in New Issue