자동스크롤, 다중선택 하고 휠로 위아래 이동 가능 #128

Merged
hjlee merged 1 commits from lhj into main 2025-10-22 11:24:02 +09:00
3 changed files with 189 additions and 39 deletions

View File

@ -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;

View File

@ -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}

View File

@ -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:", {