From d818eb11c05f0727b81d3937d3111c1211a9c566 Mon Sep 17 00:00:00 2001 From: Anas Date: Thu, 10 Jul 2025 17:15:43 +0900 Subject: [PATCH 01/71] =?UTF-8?q?[SCRUM-148]:useCanvasInteradction.ts?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=B0=B4=ED=8A=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/canvas/PixelCanvas.tsx | 487 ++++-------------------- src/hooks/useCanvasInteraction.ts | 523 ++++++++++++++++++++++++++ 2 files changed, 605 insertions(+), 405 deletions(-) create mode 100644 src/hooks/useCanvasInteraction.ts diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index 7037ed5..b60c715 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -1,12 +1,12 @@ import React, { useRef, useEffect, useCallback, useState } from 'react'; import { useCanvasUiStore } from '../../store/canvasUiStore'; -import { shallow } from 'zustand/shallow'; import { usePixelSocket } from '../SocketIntegration'; import CanvasUI from './CanvasUI'; import Preloader from '../Preloader'; import { useCanvasStore } from '../../store/canvasStore'; import { toast } from 'react-toastify'; import { fetchCanvasData as fetchCanvasDataUtil } from '../../api/canvasFetch'; +import { useCanvasInteraction } from '../../hooks/useCanvasInteraction'; import { INITIAL_POSITION, @@ -28,28 +28,14 @@ function PixelCanvas({ }: PixelCanvasProps) { const { canvas_id, setCanvasId } = useCanvasStore(); - useEffect(() => { - if (initialCanvasId && initialCanvasId !== canvas_id) { - setCanvasId(initialCanvasId); - console.log('Canvas ID changed:', initialCanvasId); - } - }, [initialCanvasId, canvas_id, setCanvasId]); - const rootRef = useRef(null); const previewCanvasRef = useRef(null); const renderCanvasRef = useRef(null); const interactionCanvasRef = useRef(null); const sourceCanvasRef = useRef(null!); - const scaleRef = useRef(1); const viewPosRef = useRef<{ x: number; y: number }>(INITIAL_POSITION); - const startPosRef = useRef<{ x: number; y: number }>(INITIAL_POSITION); - const isPanningRef = useRef(false); - - const pinchDistanceRef = useRef(0); - const dragStartInfoRef = useRef<{ x: number; y: number } | null>(null); const DRAG_THRESHOLD = 5; // 5px 이상 움직이면 드래그로 간주 - const fixedPosRef = useRef<{ x: number; y: number; color: string } | null>( null ); @@ -59,18 +45,14 @@ function PixelCanvas({ color: string; } | null>(null); + const imageTransparencyRef = useRef(0.5); + // state를 각각 가져오도록 하여 불필요한 리렌더링을 방지합니다. const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const color = useCanvasUiStore((state) => state.color); - const setColor = useCanvasUiStore((state) => state.setColor); - const hoverPos = useCanvasUiStore((state) => state.hoverPos); const setHoverPos = useCanvasUiStore((state) => state.setHoverPos); const cooldown = useCanvasUiStore((state) => state.cooldown); - const setCooldown = useCanvasUiStore((state) => state.setCooldown); - const timeLeft = useCanvasUiStore((state) => state.timeLeft); - const setTimeLeft = useCanvasUiStore((state) => state.setTimeLeft); - const showPalette = useCanvasUiStore((state) => state.showPalette); const setShowPalette = useCanvasUiStore((state) => state.setShowPalette); const showImageControls = useCanvasUiStore( (state) => state.showImageControls @@ -90,8 +72,6 @@ function PixelCanvas({ ); const isLoading = useCanvasUiStore((state) => state.isLoading); const setIsLoading = useCanvasUiStore((state) => state.setIsLoading); - const hasError = useCanvasUiStore((state) => state.hasError); - const setHasError = useCanvasUiStore((state) => state.setHasError); const showCanvas = useCanvasUiStore((state) => state.showCanvas); const setShowCanvas = useCanvasUiStore((state) => state.setShowCanvas); const targetPixel = useCanvasUiStore((state) => state.targetPixel); @@ -99,9 +79,6 @@ function PixelCanvas({ const startCooldown = useCanvasUiStore((state) => state.startCooldown); - const imageTransparencyRef = useRef(0.5); - const lastTouchPosRef = useRef<{ x: number; y: number } | null>(null); - // 이미지 관련 상태 (Zustand로 이동하지 않는 부분) const imageCanvasRef = useRef(null); const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); @@ -159,7 +136,7 @@ function PixelCanvas({ } return null; }, - [imagePosition, imageSize, isImageFixed] + [imagePosition, imageSize, isImageFixed, scaleRef] ); const draw = useCallback(() => { @@ -370,7 +347,7 @@ function PixelCanvas({ img.src = URL.createObjectURL(file); }, - [canvasSize, draw] + [canvasSize, draw, setIsImageFixed, setShowImageControls, setShowPalette] ); // 이미지 확대축소 @@ -404,7 +381,7 @@ function PixelCanvas({ setIsImageFixed(true); setShowImageControls(false); toast.success('이미지가 고정되었습니다!'); - }, []); + }, [setIsImageFixed, setShowImageControls]); // 이미지 취소 const cancelImage = useCallback(() => { @@ -413,7 +390,7 @@ function PixelCanvas({ setIsImageFixed(false); toast.info('이미지가 제거되었습니다.'); draw(); - }, [draw]); + }, [draw, setIsImageFixed, setShowImageControls]); const { sendPixel } = usePixelSocket({ sourceCanvasRef, @@ -461,7 +438,7 @@ function PixelCanvas({ setHoverPos(null); } }, - [canvasSize] + [canvasSize, viewPosRef, scaleRef, setHoverPos, interactionCanvasRef] ); const clearOverlay = useCallback(() => { @@ -470,7 +447,7 @@ function PixelCanvas({ if (!overlayCanvas) return; const overlayCtx = overlayCanvas.getContext('2d'); overlayCtx?.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); - }, []); + }, [setHoverPos, interactionCanvasRef]); const resetAndCenter = useCallback(() => { const canvas = renderCanvasRef.current; @@ -479,15 +456,12 @@ function PixelCanvas({ // 화면 크기에 맞게 스케일 계산 const viewportWidth = canvas.clientWidth; const viewportHeight = canvas.clientHeight; - const isMobile = window.innerWidth < 768; const scaleFactor = 0.7; const scaleX = (viewportWidth / canvasSize.width) * scaleFactor; const scaleY = (viewportHeight / canvasSize.height) * scaleFactor; scaleRef.current = Math.max(Math.min(scaleX, scaleY), MIN_SCALE); scaleRef.current = Math.min(scaleRef.current, MAX_SCALE); - scaleRef.current = Math.min(scaleX, scaleY); - scaleRef.current = Math.min(scaleRef.current, MAX_SCALE); // 캔버스를 화면 중앙에 배치 viewPosRef.current.x = @@ -497,7 +471,7 @@ function PixelCanvas({ draw(); clearOverlay(); - }, [draw, clearOverlay, canvasSize]); + }, [draw, clearOverlay, canvasSize, scaleRef, viewPosRef, renderCanvasRef]); const centerOnPixel = useCallback( (screenX: number, screenY: number) => { @@ -549,7 +523,7 @@ function PixelCanvas({ }; requestAnimationFrame(animate); }, - [draw, updateOverlay, canvasSize] + [draw, updateOverlay, canvasSize, viewPosRef, scaleRef, renderCanvasRef] ); const zoomCanvas = useCallback( @@ -575,7 +549,7 @@ function PixelCanvas({ draw(); updateOverlay(centerX, centerY); }, - [draw, updateOverlay] + [draw, updateOverlay, viewPosRef, scaleRef, renderCanvasRef] ); const handleZoomIn = useCallback(() => { @@ -648,7 +622,7 @@ function PixelCanvas({ }; requestAnimationFrame(animate); }, - [draw, canvasSize, updateOverlay] + [draw, canvasSize, updateOverlay, viewPosRef, scaleRef, renderCanvasRef] ); const handleCooltime = useCallback(() => { @@ -679,210 +653,54 @@ function PixelCanvas({ [draw] ); - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const sx = e.nativeEvent.offsetX; - const sy = e.nativeEvent.offsetY; - const wx = (sx - viewPosRef.current.x) / scaleRef.current; - const wy = (sy - viewPosRef.current.y) / scaleRef.current; - - // 이미지 모드에서 리사이즈 핸들 또는 이미지 영역 클릭 감지 - if ( - imageMode && - !isImageFixed && - imageCanvasRef.current && - e.button === 0 - ) { - const handle = getResizeHandle(wx, wy); - - if (handle) { - // 리사이즈 핸들 클릭 - setIsResizing(true); - setResizeHandle(handle); - setResizeStart({ - x: wx, - y: wy, - width: imageSize.width, - height: imageSize.height, - }); - return; - } else if ( - wx >= imagePosition.x && - wx <= imagePosition.x + imageSize.width && - wy >= imagePosition.y && - wy <= imagePosition.y + imageSize.height - ) { - // 이미지 드래그 - setIsDraggingImage(true); - setDragStart({ x: wx - imagePosition.x, y: wy - imagePosition.y }); - return; - } - } - - if (e.button === 0) { - dragStartInfoRef.current = { x: sx, y: sy }; - } - }, - [ - imageMode, - isImageFixed, - imagePosition, - imageSize, - getResizeHandle, - setIsResizing, - setResizeHandle, - setResizeStart, - setIsDraggingImage, - setDragStart, - ] - ); - - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const { offsetX, offsetY } = e.nativeEvent; - - if (dragStartInfoRef.current && !isPanningRef.current) { - const dx = offsetX - dragStartInfoRef.current.x; - const dy = offsetY - dragStartInfoRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { - isPanningRef.current = true; - startPosRef.current = { - // 패닝 시작 위치 설정 - x: offsetX - viewPosRef.current.x, - y: offsetY - viewPosRef.current.y, - }; - dragStartInfoRef.current = null; // 대기 상태 해제 - } - } - - // 이미지 리사이즈 중 - if (isResizing && resizeHandle) { - const wx = (offsetX - viewPosRef.current.x) / scaleRef.current; - const wy = (offsetY - viewPosRef.current.y) / scaleRef.current; - - let newWidth = imageSize.width; - let newHeight = imageSize.height; - - if (resizeHandle === 'se') { - // 대각선 리사이즈 - newWidth = resizeStart.width + (wx - resizeStart.x); - newHeight = resizeStart.height + (wy - resizeStart.y); - } else if (resizeHandle === 'e') { - // 가로만 리사이즈 - newWidth = resizeStart.width + (wx - resizeStart.x); - } else if (resizeHandle === 's') { - // 세로만 리사이즈 - newHeight = resizeStart.height + (wy - resizeStart.y); - } - - if ( - newWidth > 10 && - newHeight > 10 && - newWidth < canvasSize.width * 2 && - newHeight < canvasSize.height * 2 - ) { - setImageSize({ width: newWidth, height: newHeight }); - draw(); - } - return; - } - - // 이미지 드래그 중 - if (isDraggingImage && !isImageFixed) { - const wx = (offsetX - viewPosRef.current.x) / scaleRef.current; - const wy = (offsetY - viewPosRef.current.y) / scaleRef.current; - setImagePosition({ - x: wx - dragStart.x, - y: wy - dragStart.y, - }); - draw(); - return; - } - - // 캔버스 팬닝 중 - if (isPanningRef.current) { - viewPosRef.current = { - x: offsetX - startPosRef.current.x, - y: offsetY - startPosRef.current.y, - }; - draw(); - } - updateOverlay(offsetX, offsetY); - }, - [ - draw, - updateOverlay, - isDraggingImage, - isImageFixed, - dragStart, - isResizing, - resizeHandle, - resizeStart, - imageSize, - canvasSize, - setImagePosition, - setImageSize, - ] - ); - - const handleMouseUp = useCallback( - (e: React.MouseEvent) => { - // If it was a click (not a drag/pan) - if (dragStartInfoRef.current) { - const dx = e.nativeEvent.offsetX - dragStartInfoRef.current.x; - const dy = e.nativeEvent.offsetY - dragStartInfoRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) <= DRAG_THRESHOLD) { - // This was a click, not a drag - const sx = e.nativeEvent.offsetX; - const sy = e.nativeEvent.offsetY; - const wx = (sx - viewPosRef.current.x) / scaleRef.current; - const wy = (sy - viewPosRef.current.y) / scaleRef.current; - - const pixelX = Math.floor(wx); - const pixelY = Math.floor(wy); - - if ( - pixelX >= 0 && - pixelX < canvasSize.width && - pixelY >= 0 && - pixelY < canvasSize.height && - (!imageCanvasRef.current || isImageFixed) // Only allow pixel selection if no image or image is fixed - ) { - fixedPosRef.current = { - x: pixelX, - y: pixelY, - color: 'transparent', - }; - setShowPalette(true); - centerOnPixel(sx, sy); // Call centerOnPixel here - } - } - } - - isPanningRef.current = false; - setIsDraggingImage(false); - setIsResizing(false); - setResizeHandle(null); - dragStartInfoRef.current = null; // Reset drag start info - }, - [canvasSize, isImageFixed, setShowPalette, centerOnPixel] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { - handleMouseUp(e); - clearOverlay(); - dragStartInfoRef.current = null; // Reset drag start info on mouse leave - }, - [handleMouseUp, clearOverlay] - ); + const { + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleMouseLeave, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + } = useCanvasInteraction({ + viewPosRef, + scaleRef, + imageCanvasRef, + interactionCanvasRef, + fixedPosRef, + canvasSize, + imageMode, + isImageFixed, + isDraggingImage, + setIsDraggingImage, + dragStart, + setDragStart, + isResizing, + setIsResizing, + resizeHandle, + setResizeHandle, + resizeStart, + setResizeStart, + imagePosition, + setImagePosition, + imageSize, + setImageSize, + draw, + updateOverlay, + clearOverlay, + centerOnPixel, + getResizeHandle, + handleImageScale, + setShowPalette, + DRAG_THRESHOLD, + handleConfirm, + }); // fetchCanvasData 분리 useEffect(() => { fetchCanvasDataUtil({ id: initialCanvasId, setIsLoading, - setHasError, + setHasError: (value: boolean) => {}, setCanvasId, setCanvasSize, sourceCanvasRef, @@ -895,142 +713,39 @@ function PixelCanvas({ setCanvasId, setCanvasSize, setIsLoading, - setHasError, onLoadingChange, setShowCanvas, ]); - // 투명도 상태가 변경될 때 ref 값만 업데이트하고 draw 함수 직접 호출 - const handleTransparencyChange = useCallback( - (value: number) => { - imageTransparencyRef.current = value; - setImageTransparency(value); - // 투명도가 변경되면 즉시 화면에 반영 (draw 함수 직접 호출) - if (imageCanvasRef.current) { - draw(); - } - }, - [draw, setImageTransparency] - ); - - // PixelCanvas.tsx 내부에 아래 함수들을 추가합니다. - - // --- 터치 이벤트 핸들러 --- - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - e.preventDefault(); - const touches = e.touches; - - // 두 손가락 터치: 핀치 줌 시작 - if (touches.length === 2) { - const dx = touches[0].clientX - touches[1].clientX; - const dy = touches[0].clientY - touches[1].clientY; - pinchDistanceRef.current = Math.sqrt(dx * dx + dy * dy); - isPanningRef.current = false; // 줌 할때는 패닝 방지 - dragStartInfoRef.current = null; // 두 손가락 터치 시 드래그 시작 정보 초기화 - return; - } - - // 한 손가락 터치: 이동 또는 픽셀 선택 시작 - if (touches.length === 1) { - const touch = touches[0]; - const rect = interactionCanvasRef.current!.getBoundingClientRect(); - const sx = touch.clientX - rect.left; - const sy = touch.clientY - rect.top; - - dragStartInfoRef.current = { x: sx, y: sy }; - lastTouchPosRef.current = { x: sx, y: sy }; - } - }, - [] - ); - - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - e.preventDefault(); - const touches = e.touches; - const rect = interactionCanvasRef.current!.getBoundingClientRect(); - - // 두 손가락 터치: 핀치 줌 로직 - if (touches.length === 2) { - const dx = touches[0].clientX - touches[1].clientX; - const dy = touches[0].clientY - touches[1].clientY; - const newDistance = Math.sqrt(dx * dx + dy * dy); - const oldDistance = pinchDistanceRef.current; - - if (oldDistance > 0) { - const scaleFactor = newDistance / oldDistance; - const newScale = Math.max( - MIN_SCALE, - Math.min(MAX_SCALE, scaleRef.current * scaleFactor) - ); - const centerX = - (touches[0].clientX + touches[1].clientX) / 2 - rect.left; - const centerY = - (touches[0].clientY + touches[1].clientY) / 2 - rect.top; - - const xs = (centerX - viewPosRef.current.x) / scaleRef.current; - const ys = (centerY - viewPosRef.current.y) / scaleRef.current; - - viewPosRef.current.x = centerX - xs * newScale; - viewPosRef.current.y = centerY - ys * newScale; - scaleRef.current = newScale; - - draw(); - updateOverlay(centerX, centerY); - } - pinchDistanceRef.current = newDistance; - return; - } - - // 한 손가락 터치: 이동 로직 - if (touches.length === 1) { - const touch = touches[0]; - const sx = touch.clientX - rect.left; - const sy = touch.clientY - rect.top; - - if (dragStartInfoRef.current && !isPanningRef.current) { - const dx = sx - dragStartInfoRef.current.x; - const dy = sy - dragStartInfoRef.current.y; - if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { - isPanningRef.current = true; - startPosRef.current = { - x: sx - viewPosRef.current.x, - y: sy - viewPosRef.current.y, - }; - dragStartInfoRef.current = null; - } - } + // fetchCanvasData 분리 + useEffect(() => { + fetchCanvasDataUtil({ + id: initialCanvasId, + setIsLoading, + setHasError: (value: boolean) => {}, + setCanvasId, + setCanvasSize, + sourceCanvasRef, + onLoadingChange, + setShowCanvas, + INITIAL_BACKGROUND_COLOR, + }); + }, [ + initialCanvasId, + setCanvasId, + setCanvasSize, + setIsLoading, + onLoadingChange, + setShowCanvas, + ]); - if (isPanningRef.current) { - viewPosRef.current = { - x: sx - startPosRef.current.x, - y: sy - startPosRef.current.y, - }; - draw(); - } - updateOverlay(sx, sy); - lastTouchPosRef.current = { x: sx, y: sy }; - } - }, - [draw, updateOverlay] - ); + useEffect(() => { + if (initialCanvasId && initialCanvasId !== canvas_id) { + setCanvasId(initialCanvasId); + console.log('Canvas ID changed:', initialCanvasId); + } + }, [initialCanvasId, canvas_id, setCanvasId]); - const handleTouchEnd = useCallback( - (e: React.TouchEvent) => { - // 모든 제스처 상태 초기화 - pinchDistanceRef.current = 0; - // handleMouseUp에 마지막 터치 위치를 전달하여 클릭/드래그 판단에 사용 - handleMouseUp({ - nativeEvent: { - offsetX: lastTouchPosRef.current?.x || 0, - offsetY: lastTouchPosRef.current?.y || 0, - }, - } as React.MouseEvent); - lastTouchPosRef.current = null; // 터치 종료 시 초기화 - }, - [handleMouseUp] - ); // 투명도 상태가 변경될 때 ref 값 업데이트 및 draw 함수 호출 useEffect(() => { imageTransparencyRef.current = imageTransparency; @@ -1080,44 +795,6 @@ function PixelCanvas({ return () => observer.disconnect(); }, [resetAndCenter]); - useEffect(() => { - const interactionCanvas = interactionCanvasRef.current; - if (!interactionCanvas) return; - - const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - const { offsetX, offsetY } = e; - - // 이미지 모드에서 이미지만 확대축소 - if (imageMode && !isImageFixed && imageCanvasRef.current) { - const delta = -e.deltaY; - const scaleFactor = delta > 0 ? 1.1 : 0.9; - handleImageScale(scaleFactor); - return; - } - - // 캔버스 모드 또는 이미지 확정 후 전체 확대축소 - const xs = (offsetX - viewPosRef.current.x) / scaleRef.current; - const ys = (offsetY - viewPosRef.current.y) / scaleRef.current; - const delta = -e.deltaY; - const newScale = - delta > 0 ? scaleRef.current * 1.2 : scaleRef.current / 1.2; - - if (newScale >= MIN_SCALE && newScale <= MAX_SCALE) { - scaleRef.current = newScale; - viewPosRef.current.x = offsetX - xs * scaleRef.current; - viewPosRef.current.y = offsetY - ys * scaleRef.current; - draw(); - updateOverlay(offsetX, offsetY); - } - }; - - interactionCanvas.addEventListener('wheel', handleWheel, { - passive: false, - }); - return () => interactionCanvas.removeEventListener('wheel', handleWheel); - }, [draw, updateOverlay, handleImageScale, imageMode, isImageFixed]); - return (
; + scaleRef: React.RefObject; + imageCanvasRef: React.RefObject; + interactionCanvasRef: React.RefObject; + fixedPosRef: React.RefObject<{ + x: number; + y: number; + color: string; + } | null>; + + // State and Setters from parent + canvasSize: { width: number; height: number }; + imageMode: boolean; + isImageFixed: boolean; + isDraggingImage: boolean; + setIsDraggingImage: (value: boolean) => void; + dragStart: { x: number; y: number }; + setDragStart: (value: { x: number; y: number }) => void; + isResizing: boolean; + setIsResizing: (value: boolean) => void; + resizeHandle: 'se' | 'e' | 's' | null; + setResizeHandle: (value: 'se' | 'e' | 's' | null) => void; + resizeStart: { x: number; y: number; width: number; height: number }; + setResizeStart: (value: { + x: number; + y: number; + width: number; + height: number; + }) => void; + imagePosition: { x: number; y: number }; + setImagePosition: (value: { x: number; y: number }) => void; + imageSize: { width: number; height: number }; + setImageSize: (value: { width: number; height: number }) => void; + + // Callbacks from parent + draw: () => void; + updateOverlay: (screenX: number, screenY: number) => void; + clearOverlay: () => void; + centerOnPixel: (screenX: number, screenY: number) => void; + getResizeHandle: (wx: number, wy: number) => 'se' | 'e' | 's' | null; + handleImageScale: (scaleFactor: number) => void; + + // Zustand Setters + setShowPalette: (show: boolean) => void; + + // Constants + DRAG_THRESHOLD: number; + + handleConfirm: () => void; +} + +export const useCanvasInteraction = ({ + viewPosRef, + scaleRef, + imageCanvasRef, + interactionCanvasRef, + fixedPosRef, + canvasSize, + imageMode, + isImageFixed, + isDraggingImage, + setIsDraggingImage, + dragStart, + setDragStart, + isResizing, + setIsResizing, + resizeHandle, + setResizeHandle, + resizeStart, + setResizeStart, + imagePosition, + setImagePosition, + imageSize, + setImageSize, + draw, + updateOverlay, + clearOverlay, + centerOnPixel, + getResizeHandle, + handleImageScale, + setShowPalette, + DRAG_THRESHOLD, + handleConfirm, +}: UseCanvasInteractionProps) => { + // Refs managed within the hook + const isPanningRef = useRef(false); + const startPosRef = useRef<{ x: number; y: number }>(INITIAL_POSITION); + const dragStartInfoRef = useRef<{ x: number; y: number } | null>(null); + const pinchDistanceRef = useRef(0); + const lastTouchPosRef = useRef<{ x: number; y: number } | null>(null); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const sx = e.nativeEvent.offsetX; + const sy = e.nativeEvent.offsetY; + const wx = (sx - viewPosRef.current.x) / scaleRef.current; + const wy = (sy - viewPosRef.current.y) / scaleRef.current; + + if ( + imageMode && + !isImageFixed && + imageCanvasRef.current && + e.button === 0 + ) { + const handle = getResizeHandle(wx, wy); + + if (handle) { + setIsResizing(true); + setResizeHandle(handle); + setResizeStart({ + x: wx, + y: wy, + width: imageSize.width, + height: imageSize.height, + }); + return; + } else if ( + wx >= imagePosition.x && + wx <= imagePosition.x + imageSize.width && + wy >= imagePosition.y && + wy <= imagePosition.y + imageSize.height + ) { + setIsDraggingImage(true); + setDragStart({ x: wx - imagePosition.x, y: wy - imagePosition.y }); + return; + } + } + + if (e.button === 0) { + dragStartInfoRef.current = { x: sx, y: sy }; + } + }, + [ + viewPosRef, + scaleRef, + imageMode, + isImageFixed, + imageCanvasRef, + getResizeHandle, + setIsResizing, + setResizeHandle, + setResizeStart, + imageSize.width, + imageSize.height, + imagePosition.x, + imagePosition.y, + setIsDraggingImage, + setDragStart, + ] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const { offsetX, offsetY } = e.nativeEvent; + + if (dragStartInfoRef.current && !isPanningRef.current) { + const dx = offsetX - dragStartInfoRef.current.x; + const dy = offsetY - dragStartInfoRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { + isPanningRef.current = true; + startPosRef.current = { + x: offsetX - viewPosRef.current.x, + y: offsetY - viewPosRef.current.y, + }; + dragStartInfoRef.current = null; + } + } + + if (isResizing && resizeHandle) { + const wx = (offsetX - viewPosRef.current.x) / scaleRef.current; + const wy = (offsetY - viewPosRef.current.y) / scaleRef.current; + + let newWidth = imageSize.width; + let newHeight = imageSize.height; + + if (resizeHandle === 'se') { + newWidth = resizeStart.width + (wx - resizeStart.x); + newHeight = resizeStart.height + (wy - resizeStart.y); + } else if (resizeHandle === 'e') { + newWidth = resizeStart.width + (wx - resizeStart.x); + } else if (resizeHandle === 's') { + newHeight = resizeStart.height + (wy - resizeStart.y); + } + + if ( + newWidth > 10 && + newHeight > 10 && + newWidth < canvasSize.width * 2 && + newHeight < canvasSize.height * 2 + ) { + setImageSize({ width: newWidth, height: newHeight }); + draw(); + } + return; + } + + if (isDraggingImage && !isImageFixed) { + const wx = (offsetX - viewPosRef.current.x) / scaleRef.current; + const wy = (offsetY - viewPosRef.current.y) / scaleRef.current; + setImagePosition({ + x: wx - dragStart.x, + y: wy - dragStart.y, + }); + draw(); + return; + } + + if (isPanningRef.current) { + viewPosRef.current = { + x: offsetX - startPosRef.current.x, + y: offsetY - startPosRef.current.y, + }; + draw(); + } + updateOverlay(offsetX, offsetY); + }, + [ + DRAG_THRESHOLD, + isResizing, + resizeHandle, + viewPosRef, + scaleRef, + imageSize, + resizeStart, + canvasSize, + setImageSize, + draw, + isDraggingImage, + isImageFixed, + setImagePosition, + dragStart, + updateOverlay, + ] + ); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + if (dragStartInfoRef.current) { + const dx = e.nativeEvent.offsetX - dragStartInfoRef.current.x; + const dy = e.nativeEvent.offsetY - dragStartInfoRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) <= DRAG_THRESHOLD) { + const sx = e.nativeEvent.offsetX; + const sy = e.nativeEvent.offsetY; + const wx = (sx - viewPosRef.current.x) / scaleRef.current; + const wy = (sy - viewPosRef.current.y) / scaleRef.current; + + const pixelX = Math.floor(wx); + const pixelY = Math.floor(wy); + + if ( + pixelX >= 0 && + pixelX < canvasSize.width && + pixelY >= 0 && + pixelY < canvasSize.height && + (!imageCanvasRef.current || isImageFixed) + ) { + fixedPosRef.current = { + x: pixelX, + y: pixelY, + color: 'transparent', + }; + setShowPalette(true); + centerOnPixel(sx, sy); + } + } + } + + isPanningRef.current = false; + setIsDraggingImage(false); + setIsResizing(false); + setResizeHandle(null); + dragStartInfoRef.current = null; + }, + [ + DRAG_THRESHOLD, + viewPosRef, + scaleRef, + canvasSize, + imageCanvasRef, + isImageFixed, + fixedPosRef, + setShowPalette, + centerOnPixel, + setIsDraggingImage, + setIsResizing, + setResizeHandle, + ] + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + handleMouseUp(e); + clearOverlay(); + dragStartInfoRef.current = null; + }, + [handleMouseUp, clearOverlay] + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + const touches = e.touches; + + if (touches.length === 2) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + pinchDistanceRef.current = Math.sqrt(dx * dx + dy * dy); + isPanningRef.current = false; + dragStartInfoRef.current = null; + return; + } + + if (touches.length === 1) { + const touch = touches[0]; + const rect = interactionCanvasRef.current!.getBoundingClientRect(); + const sx = touch.clientX - rect.left; + const sy = touch.clientY - rect.top; + + dragStartInfoRef.current = { x: sx, y: sy }; + lastTouchPosRef.current = { x: sx, y: sy }; + } + }, + [interactionCanvasRef] + ); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + const touches = e.touches; + const rect = interactionCanvasRef.current!.getBoundingClientRect(); + + if (touches.length === 2) { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + const newDistance = Math.sqrt(dx * dx + dy * dy); + const oldDistance = pinchDistanceRef.current; + + if (oldDistance > 0) { + const scaleFactor = newDistance / oldDistance; + const newScale = Math.max( + MIN_SCALE, + Math.min(MAX_SCALE, scaleRef.current * scaleFactor) + ); + const centerX = + (touches[0].clientX + touches[1].clientX) / 2 - rect.left; + const centerY = + (touches[0].clientY + touches[1].clientY) / 2 - rect.top; + + const xs = (centerX - viewPosRef.current.x) / scaleRef.current; + const ys = (centerY - viewPosRef.current.y) / scaleRef.current; + + viewPosRef.current.x = centerX - xs * newScale; + viewPosRef.current.y = centerY - ys * newScale; + scaleRef.current = newScale; + + draw(); + updateOverlay(centerX, centerY); + } + pinchDistanceRef.current = newDistance; + return; + } + + if (touches.length === 1) { + const touch = touches[0]; + const sx = touch.clientX - rect.left; + const sy = touch.clientY - rect.top; + + if (dragStartInfoRef.current && !isPanningRef.current) { + const dx = sx - dragStartInfoRef.current.x; + const dy = sy - dragStartInfoRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { + isPanningRef.current = true; + startPosRef.current = { + x: sx - viewPosRef.current.x, + y: sy - viewPosRef.current.y, + }; + dragStartInfoRef.current = null; + } + } + + if (isPanningRef.current) { + viewPosRef.current = { + x: sx - startPosRef.current.x, + y: sy - startPosRef.current.y, + }; + draw(); + } + updateOverlay(sx, sy); + lastTouchPosRef.current = { x: sx, y: sy }; + } + }, + [ + interactionCanvasRef, + scaleRef, + viewPosRef, + draw, + updateOverlay, + DRAG_THRESHOLD, + ] + ); + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + pinchDistanceRef.current = 0; + handleMouseUp({ + nativeEvent: { + offsetX: lastTouchPosRef.current?.x || 0, + offsetY: lastTouchPosRef.current?.y || 0, + }, + } as React.MouseEvent); + lastTouchPosRef.current = null; + }, + [handleMouseUp] + ); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!fixedPosRef.current) return; + + let moved = false; + switch (e.key) { + case 'ArrowUp': + fixedPosRef.current.y -= 1; + moved = true; + break; + case 'ArrowDown': + fixedPosRef.current.y += 1; + moved = true; + break; + case 'ArrowLeft': + fixedPosRef.current.x -= 1; + moved = true; + break; + case 'ArrowRight': + fixedPosRef.current.x += 1; + moved = true; + break; + case 'Enter': + handleConfirm(); + break; + } + + if (moved) { + // 캔버스 경계 체크 + if (fixedPosRef.current.x < 0) fixedPosRef.current.x = 0; + if (fixedPosRef.current.x >= canvasSize.width) + fixedPosRef.current.x = canvasSize.width - 1; + if (fixedPosRef.current.y < 0) fixedPosRef.current.y = 0; + if (fixedPosRef.current.y >= canvasSize.height) + fixedPosRef.current.y = canvasSize.height - 1; + + draw(); + e.preventDefault(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [draw, handleConfirm, canvasSize]); + + useEffect(() => { + const interactionCanvas = interactionCanvasRef.current; + if (!interactionCanvas) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const { offsetX, offsetY } = e; + + if (imageMode && !isImageFixed && imageCanvasRef.current) { + const delta = -e.deltaY; + const scaleFactor = delta > 0 ? 1.1 : 0.9; + handleImageScale(scaleFactor); + return; + } + + const xs = (offsetX - viewPosRef.current.x) / scaleRef.current; + const ys = (offsetY - viewPosRef.current.y) / scaleRef.current; + const delta = -e.deltaY; + const newScale = + delta > 0 ? scaleRef.current * 1.2 : scaleRef.current / 1.2; + + if (newScale >= MIN_SCALE && newScale <= MAX_SCALE) { + scaleRef.current = newScale; + viewPosRef.current.x = offsetX - xs * scaleRef.current; + viewPosRef.current.y = offsetY - ys * scaleRef.current; + draw(); + updateOverlay(offsetX, offsetY); + } + }; + + interactionCanvas.addEventListener('wheel', handleWheel, { + passive: false, + }); + return () => interactionCanvas.removeEventListener('wheel', handleWheel); + }, [ + interactionCanvasRef, + imageMode, + isImageFixed, + imageCanvasRef, + handleImageScale, + viewPosRef, + scaleRef, + draw, + updateOverlay, + ]); + + return { + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleMouseLeave, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + }; +}; From ed1879c4a1d3f30e4fe016a822d7d2fb0b289d47 Mon Sep 17 00:00:00 2001 From: yoominlee00 Date: Thu, 10 Jul 2025 17:44:24 +0900 Subject: [PATCH 02/71] SCRUM-122: imagemode-error --- src/components/canvas/PixelCanvas.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index b60c715..a18d189 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -452,7 +452,10 @@ function PixelCanvas({ const resetAndCenter = useCallback(() => { const canvas = renderCanvasRef.current; if (!canvas || canvas.clientWidth === 0 || canvasSize.width === 0) return; - + if (imageMode && !isImageFixed && imageCanvasRef.current) { + draw(); + return; + } // 화면 크기에 맞게 스케일 계산 const viewportWidth = canvas.clientWidth; const viewportHeight = canvas.clientHeight; From e375ae56edd4ed3f7a2fe8266f91d8b3f0b51feb Mon Sep 17 00:00:00 2001 From: yoominlee00 Date: Thu, 10 Jul 2025 19:55:17 +0900 Subject: [PATCH 03/71] SCRUM-150: image-edit-canvas-grid --- src/components/canvas/CanvasUIPC.tsx | 2 +- src/components/canvas/PixelCanvas.tsx | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index 8c987f0..2c61340 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -140,7 +140,7 @@ export default function CanvasUIPC({ Date: Thu, 10 Jul 2025 20:07:32 +0900 Subject: [PATCH 04/71] =?UTF-8?q?404=20Not=20Found=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/empty_box.png | Bin 0 -> 12579 bytes src/api/canvasFetch.ts | 14 ++++++++++-- src/components/canvas/PixelCanvas.tsx | 31 +++++++------------------- src/pages/NotFoundPage.tsx | 28 +++++++++++++++++++++++ 4 files changed, 48 insertions(+), 25 deletions(-) create mode 100644 public/empty_box.png create mode 100644 src/pages/NotFoundPage.tsx diff --git a/public/empty_box.png b/public/empty_box.png new file mode 100644 index 0000000000000000000000000000000000000000..b053f3aa9d3830093b6cf3f6ff52c41938e1668b GIT binary patch literal 12579 zcmeHtc{r49`~N*m86ImA!jMTS%2L@UB9tsiLe?xr$-d5*8KG2`(Mk~&ktIuH>_f5> z+4rHbZ(}#inEBnF=Y4+1?|q;5pWpZIuj81*G56fpb)DCFo!5DupYwA*xoWJ>#c`Me z006Gbmo8od0G80b4{Sg9gtM&)0sdq6x@6@G0DtiBeIUTwBq8u2#P^!MHjw*MbQb&q zy>r3n0ss`?|DfO62LSRVmoHwp9t2q&vCTO;a6x?8tS2zw&YjA_@V3Lz-ucS+5b8%v zz2Kr3ymBsW2Pqz{nNWRpH3@c0Q&{(zq-0(2*bzkqr6cU^kdLs(FE46qd&~dU@i}n8 zK}@7#o+nzq_>FDF{JS?p{?{_HXg}Yn+xlmi_?wsq?qbW&1THNO+4o>PcWK?KOl}A$ z+yDOkuLk}{8gS@+3dkEx#)yoggnqRpjz8Sd;RN)jjsk{V8+H76O>11mVR*6ROaBG5 z>Tt;HmyJ8htkuu>fCsZDzkiebVX6ml;>!ZO(x6%nWsaXkar^o!6~Vh9!y*Axx57dJ z;EWTuguJ8 zGbwG2x^8Be9kp`Q@1PypR2ioegk-W8ea3@*Cu0 z=%1D?kPCd(3AyFFU8Avp*YzBh{Xl{+gcoqQ4b=id-zc#0_Qx;4i63u?pEp4s!aJjQ6K`*xk((`TF}G~qb0nwH4(jvz2;t>jhtB&!IaOTq z?Su^Q)zfmO4^AuFtv#Z{iP?c@`qTSp`kVss7=4#SqF;P$K6>1-tlp$@c_W8RY$1|Q>;2GUBv#?|#&Me-Xmy1} zmaZ@3EUIL2d&t`Qk<12c>UY}k&gMKHG#yk>SxgVv0{{Ngc3@p;`&ze-8*8j@+OyVU ztscR&J-yr!kpJ`Cs(&dq2|JwmQwyySXj$|ySviF6G1Fb{=r8j03ndhO!C?@prD7L3 zM7D_dB51d8v)SILqxigF6$L1KP*dw!PeTLY6Xl!@?16Ddk1_nG&u;U(n# z^?>mNF)zHmFAzLY`@RJt)s2EaIKltdQ_?|h6Hc!8;FHX+972B|10MhZZr6)~OR|y3 zfir(u$#=im6?KQbv1?|^@`Z{~4ybFrX{T7ru z>%W|D`V~B>6*QquT_9rj7g=8k9CJSS2^%f7!PD7k3*i2w&)YAE2+cP^(S@||68Um0 z5(_Wjn)^Gz6Noae2CW9X9Y6#8bLV;h z6tqfT0MdU6FCi)i;XNk1s6;LmyXCz!kFhTgI_y5ri-K-IwaVyF2=f&HZI^lY*zH;LV3~kU! zI?Cr=_tLWfI=(x@`~#PF&+7~CGsiFOE1Q&jr(U=}-Ssp%gt{=#xlkq+nREAsDZcAO zQgMNf?B2n4Ebjb%(vb2+)2=c|o5MfvWXtk6PX(>1&heM3_Le%18fkUn0Xu8YfoOlc? zix~pYPLZ_94kT^GuqXxR84J5Xy-b<2YTbS7DK1O9T?5x39svUSJ)+FkCBB^4_xkz4 z(yw;%T!bzZpq>MUaR3bC*l%;Ie8~MwD+iWJu-Gu|K zg(GKEzsVQ!KjH;$W%d5?*vYb?gsnIaUUQ#oL^p#YF$Z?7NGw!3O4scBYDcQJJHbBd zqg6!3!!ffNKG~Lv10Pk!D((ng{o%DSVX#Iym`<^XXb|VI5XYRg$x}=It?cm@T~n zVR^_U$Ykz=tS`cy0rNogY;`xC_Nch8TY@Re83k>%irPHu=4(x<{C&G)c``4A@rlrz zGQwGni3Yw$n42?laq-BoTWRnb7n}Rx_gSxVeA!6R6=TWkP7URuU7bTE5|9xJvc8VX zkC9<*i^lr~H;&t>@4OCF-+zB==VV@ztWrw%vAOk$s$a%0N|>m*410l4V-^X+fmSp$ zs>j~i^O2*!`0t&?G59LX0k&1KOZou6Yefn#n34>reCSw;%3IuM8ubw_{@b7aUk{-g z+VRBz>700W4WFg59rolGIAPqkr~a;Z`!kfh!_1n?1mwu;FZY5#?FkhM^4Tt|$ZU3( zDhIS5X?+7UCcRSjh?uvP9<9PG7F=KXYM{M!Nj+EJ*jOp|d{Q(xnOTfJMpgAhumj#6 z71B9JD)`Yu%6xk4Q>kPs+ih38gPMhvCeAnc<2;-=wXZr^`{vZ|)kO_>ZdF-^^s*uf zs(Kp*yeaHn+j8GsnXBXPjHRTFLaiuLhGXWu%2;Xgw%B zl(tT&QkWljrKe9B&io#@+hGzhlo42_cf6|}r*Ka>LqASfo~5xuVEm@ovn7#z(b*iu zMf|qw2azVbyuFslFx@jAg0*gQ)mI`f)CL6nA`_OzM6L|pU4-4P4`u^}6GP>OUwb10 zB)0Tp*Qqsm4jXrHc^|IDRy{*vv-IN-(tx7p;(3p*K9ROt9nVHh0;Kik`6|$l<+AmH zCg4O{w<-BYi;Exi^V!E2`Bmh!8g{E5j@AmPE7$RsS?%Kl{rtbBI2|X5`uVO?%ul+< z-Hu^OC!KTr@%F!LjC*Cj>sjE*wa-UVEOLrTcaP|mP8xpq%S#-H%vLr#4X=+V=SyoK zc+zZ&ch#r$4&A}k{5k+UXxqOId#zi}v0*UxL1u6tyS1(V98vv5?N>FfI>n^6DN>PZ z(fqypM(rhRs#%kY_xLW&aoL>C_WEQK@%!lQ`^4)`-FCsRz6m*Y{^9wS4fzO6Oy1(+ z1yMe|?N5eUT-qNV9C!Y;^;>d|Z{l1vWt|7i{L8zsX zECQ_@OHCc$oSntAr}!nmP&>=$=BYr)Hn$NxtTdlsmo*vnQGsvQ36nA1h(?G!3(W8E zFrvgV$KX8yBmMlNu+QZqSy``-I7f%N9O_uO&Vf6TcF_8ewz74$yZ(yt+d!wL&)!z% zE6!Wgx+i$vZsn!srl~-5Deb3>*p|b$%h#UKnjR%@O)VHP11e0tGA(HDVD4A3#xcr(x%q&n1d{Ib)>IclUGo~s(*KQ(T zgBKUdBH_Db5M38_#Kxwe@AcS-)#((&s3U~a2klNdSiU?n%Kn26uepBy#SMx0D4%a9 z&Y+<4YssfghU|UbT+k@L@X6IeoYo8zIr-|NxopDsZ+%#DcJddTfrmRX%nV0EMCnpH*hWWQ9%=VK z>g9zWo41Si7oHUya;xmy?PzD%bV*b5^*eeY>${X-6aERG&GA{$c4p0;HH-I$$d9Uv zrzu0)(;n&lB<|w^I;2vwhm@TUDKLTwWtha8fI2UzOXa~a#f;jgr&8Lx_gT7(>LJ4n zQ~0!|aH)v>T`q_*#`1B0h{r7-dHk>gSg-TH!SxdMVb=!!88? zHkDk%N4BT}y10k!MSt{KP|}}Yz)M7G0TJ*!>Uq&fH`VD{;yv{lzSgq1jb{xIWLS8P zo;-QZXm)ClEZFA@a9{V(>OnLf$^U1rT5|E{x&kt+T)_@M=G8MGTIIOG^u`rMF zD2g(vw}+!F_(21w~`}%?CJ?Gr432*o1E8OPO&7Q@TAFUF7&I@%ymCu zrBkmi6KI=0!u4sOS*{&mE%V=m+M&R$t1H~Vj~RCp#!Ka}9$Ja(KhX5k#L&u3`bbqF zCvF$RRI2bn9agF|Hn3hY&H@JW*~N3%ypDr9OXSm<39$NW`LlbI{MaA|RUonP1D>I$ z=9UAchqLq{@-za8>f?4{1 zq%uzzts9kI)PP8&g~B&+s}bY=)V#_5tyFw3`z!08W`<{UoRS!8jvM#h`yNiuP||^g z%0D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 state.color); const setHoverPos = useCanvasUiStore((state) => state.setHoverPos); @@ -719,29 +721,7 @@ function PixelCanvas({ fetchCanvasDataUtil({ id: initialCanvasId, setIsLoading, - setHasError: (value: boolean) => {}, - setCanvasId, - setCanvasSize, - sourceCanvasRef, - onLoadingChange, - setShowCanvas, - INITIAL_BACKGROUND_COLOR, - }); - }, [ - initialCanvasId, - setCanvasId, - setCanvasSize, - setIsLoading, - onLoadingChange, - setShowCanvas, - ]); - - // fetchCanvasData 분리 - useEffect(() => { - fetchCanvasDataUtil({ - id: initialCanvasId, - setIsLoading, - setHasError: (value: boolean) => {}, + setHasError, setCanvasId, setCanvasSize, sourceCanvasRef, @@ -756,6 +736,7 @@ function PixelCanvas({ setIsLoading, onLoadingChange, setShowCanvas, + setHasError, ]); useEffect(() => { @@ -814,6 +795,10 @@ function PixelCanvas({ return () => observer.disconnect(); }, [resetAndCenter]); + if (hasError) { + return ; + } + return (
{ + return ( +
+
+

404

+ Not Found + +

Canvas Not Found

+

요청하신 페이지를 찾을 수 없습니다

+ + 돌아가기 + +
+
+ ); +}; + +export default NotFoundPage; From 3ff0d1bff4a14435b3ae2b964aaa977b502515fe Mon Sep 17 00:00:00 2001 From: Anas Date: Thu, 10 Jul 2025 20:13:45 +0900 Subject: [PATCH 05/71] =?UTF-8?q?SCURM-151=20:=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=94=94=EC=9E=90=EC=9D=B8=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 8 +++++++- public/main_logo.svg | 9 +++++++++ src/pages/NotFoundPage.tsx | 27 ++++++++++++++++++++------- 3 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 public/main_logo.svg diff --git a/index.html b/index.html index 7f6b79f..9151a7a 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,14 @@ - + + + + Pick-Px diff --git a/public/main_logo.svg b/public/main_logo.svg new file mode 100644 index 0000000..bfc0604 --- /dev/null +++ b/public/main_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx index f6bf93e..907dbaf 100644 --- a/src/pages/NotFoundPage.tsx +++ b/src/pages/NotFoundPage.tsx @@ -3,22 +3,35 @@ import { Link } from 'react-router-dom'; const NotFoundPage = () => { return ( -
+
-

404

Not Found - -

Canvas Not Found

-

요청하신 페이지를 찾을 수 없습니다

+

+ 404 +

+

+ Canvas Not Found +

+

요청하신 페이지를 찾을 수 없습니다

- 돌아가기 + Go to Homepage
From 4739f24345457895fc4363fc6d523885c2f9557a Mon Sep 17 00:00:00 2001 From: Anas Date: Thu, 10 Jul 2025 22:51:45 +0900 Subject: [PATCH 06/71] =?UTF-8?q?[SCRUM-154]:=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20safari=20=EA=B8=B0=EB=B3=B8=20=EB=8F=99=EC=9E=91=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20Pinch=20Zoom=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 5 ++++- src/App.tsx | 2 +- src/index.css | 15 ++++++++++++--- src/main.tsx | 10 ++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 9151a7a..00b36db 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,10 @@ - + +
{ + event.preventDefault(); + }, + { passive: false } +); + createRoot(document.getElementById('root')!).render(); From 9211dec10be9f6c4d31d3f62f1ca12cd4ec20bd9 Mon Sep 17 00:00:00 2001 From: yoominlee00 Date: Thu, 10 Jul 2025 23:01:07 +0900 Subject: [PATCH 07/71] SCRUM-152: star-background --- src/components/canvas/PixelCanvas.tsx | 12 +- src/components/canvas/StarfieldCanvas.css | 9 ++ src/components/canvas/StarfieldCanvas.tsx | 161 ++++++++++++++++++++++ 3 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 src/components/canvas/StarfieldCanvas.css create mode 100644 src/components/canvas/StarfieldCanvas.tsx diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index be7baaa..adb8b4e 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -1,4 +1,5 @@ import React, { useRef, useEffect, useCallback, useState } from 'react'; +import StarfieldCanvas from './StarfieldCanvas'; import { useCanvasUiStore } from '../../store/canvasUiStore'; import { usePixelSocket } from '../SocketIntegration'; import CanvasUI from './CanvasUI'; @@ -470,7 +471,7 @@ function PixelCanvas({ const resetAndCenter = useCallback(() => { const canvas = renderCanvasRef.current; if (!canvas || canvas.clientWidth === 0 || canvasSize.width === 0) return; - if (imageMode && !isImageFixed && imageCanvasRef.current) { + if (!isImageFixed && imageCanvasRef.current) { draw(); return; } @@ -804,16 +805,13 @@ function PixelCanvas({ ref={rootRef} className='relative h-full w-full transition-all duration-300' style={{ - backgroundImage: `url('/Creatives.png')`, - backgroundSize: 'cover', - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center center', backgroundColor: VIEWPORT_BACKGROUND_COLOR, boxShadow: cooldown ? 'inset 0 0 50px rgba(239, 68, 68, 0.3), 0 0 100px rgba(239, 68, 68, 0.2)' : 'none', }} > + {cooldown && ( <>
@@ -921,7 +919,7 @@ function PixelCanvas({ 🎨 캔버스 모드
-
• 우클릭 드래그: 캔버스 이동
+
• 좌클릭 드래그: 캔버스 이동
• 마우스 휠: 캔버스 확대/축소
• 이미지는 고정된 상태
@@ -955,4 +953,4 @@ function PixelCanvas({ ); } -export default PixelCanvas; +export default PixelCanvas; \ No newline at end of file diff --git a/src/components/canvas/StarfieldCanvas.css b/src/components/canvas/StarfieldCanvas.css new file mode 100644 index 0000000..164ae12 --- /dev/null +++ b/src/components/canvas/StarfieldCanvas.css @@ -0,0 +1,9 @@ +#starfield-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; /* 다른 모든 요소 뒤에 위치하도록 명시 */ + background-color: black; +} diff --git a/src/components/canvas/StarfieldCanvas.tsx b/src/components/canvas/StarfieldCanvas.tsx new file mode 100644 index 0000000..ec2751d --- /dev/null +++ b/src/components/canvas/StarfieldCanvas.tsx @@ -0,0 +1,161 @@ +import React, { useRef, useEffect } from 'react'; +import './StarfieldCanvas.css'; + +type StarfieldCanvasProps = { + viewPosRef: React.RefObject<{ x: number; y: number }>; +}; + +const StarfieldCanvas = ({ viewPosRef }: StarfieldCanvasProps) => { + const canvasRef = useRef(null); + const animationFrameIdRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let w = (canvas.width = window.innerWidth); + let h = (canvas.height = window.innerHeight); + + const hue = 217; + const stars: any[] = []; + let count = 0; + const maxStars = 400; // Reduced for a sparser effect + + const canvas2 = document.createElement('canvas'); + const ctx2 = canvas2.getContext('2d'); + canvas2.width = 100; + canvas2.height = 100; + const half = canvas2.width / 2; + const gradient2 = ctx2!.createRadialGradient( + half, + half, + 0, + half, + half, + half + ); + gradient2.addColorStop(0.025, '#fff'); + gradient2.addColorStop(0.1, `hsl(${hue}, 61%, 33%)`); + gradient2.addColorStop(0.25, `hsl(${hue}, 64%, 6%)`); + gradient2.addColorStop(1, 'transparent'); + + ctx2!.fillStyle = gradient2; + ctx2!.beginPath(); + ctx2!.arc(half, half, half, 0, Math.PI * 2); + ctx2!.fill(); + + function random(min: number, max?: number) { + if (max === undefined) { + max = min; + min = 0; + } + if (min > max) { + [min, max] = [max, min]; + } + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + function maxOrbit(x: number, y: number) { + const max = Math.max(x, y); + const diameter = Math.round(Math.sqrt(max * max + max * max)); + return diameter / 2; + } + + class Star { + orbitRadius: number; + radius: number; + orbitX: number; + orbitY: number; + timePassed: number; + speed: number; + alpha: number; + parallaxFactor: number; // New: for parallax effect + + constructor() { + this.orbitRadius = random(maxOrbit(w, h)); + this.radius = random(60, this.orbitRadius) / 12; + this.orbitX = w / 2; + this.orbitY = h / 2; + this.timePassed = random(0, maxStars); + this.speed = random(this.orbitRadius) / 400000; + this.alpha = random(2, 10) / 10; + this.parallaxFactor = random(2, 10) / 10; // Assign a random parallax factor + count++; + stars[count] = this; + } + + draw() { + const canvasX = + Math.sin(this.timePassed) * this.orbitRadius + this.orbitX; + const canvasY = + Math.cos(this.timePassed) * this.orbitRadius + this.orbitY; + const twinkle = random(10); + + if (twinkle === 1 && this.alpha > 0) { + this.alpha -= 0.05; + } else if (twinkle === 2 && this.alpha < 1) { + this.alpha += 0.05; + } + + // Calculate parallax offset + const parallaxX = viewPosRef.current + ? viewPosRef.current.x * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect + : 0; + const parallaxY = viewPosRef.current + ? viewPosRef.current.y * this.parallaxFactor * 0.1 // Adjust multiplier for desired effect + : 0; + + ctx!.globalAlpha = this.alpha; + ctx!.drawImage( + canvas2, + canvasX - this.radius / 2 + parallaxX, + canvasY - this.radius / 2 + parallaxY, + this.radius, + this.radius + ); + this.timePassed += this.speed; + } + } + + for (let i = 0; i < maxStars; i++) { + new Star(); + } + + const animation = () => { + ctx!.globalCompositeOperation = 'source-over'; + ctx!.globalAlpha = 0.8; + ctx!.fillStyle = 'black'; // Solid black background + ctx!.fillRect(0, 0, w, h); + + ctx!.globalCompositeOperation = 'lighter'; + for (let i = 1, l = stars.length; i < l; i++) { + stars[i].draw(); + } + + animationFrameIdRef.current = window.requestAnimationFrame(animation); + }; + + animation(); + + const handleResize = () => { + w = canvas.width = window.innerWidth; + h = canvas.height = window.innerHeight; + }; + + window.addEventListener('resize', handleResize); + + return () => { + if (animationFrameIdRef.current) { + window.cancelAnimationFrame(animationFrameIdRef.current); + } + window.removeEventListener('resize', handleResize); + }; + }, [viewPosRef]); // Add viewPosRef to dependency array + + return ; +}; + +export default StarfieldCanvas; From 8d13d71e7634d2066511bf977507427cfd992484 Mon Sep 17 00:00:00 2001 From: yoominlee00 Date: Thu, 10 Jul 2025 23:40:44 +0900 Subject: [PATCH 08/71] SCRUM-155: enter-error --- src/components/chat/Chat.tsx | 11 ++++++++--- src/hooks/useCanvasInteraction.ts | 8 ++++++-- src/store/modalStore.ts | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/components/chat/Chat.tsx b/src/components/chat/Chat.tsx index 6c1ed72..a7aa42d 100644 --- a/src/components/chat/Chat.tsx +++ b/src/components/chat/Chat.tsx @@ -27,7 +27,7 @@ function Chat() { const canvas_id = useCanvasStore((state) => state.canvas_id); const { user, isLoggedIn } = useAuthStore(); - const { openLoginModal, isGroupModalOpen } = useModalStore(); + const { openLoginModal, isGroupModalOpen, openChat, closeChat } = useModalStore(); // 채팅 소켓 연결 - 유효한 group_id가 있을 때만 const { sendMessage: sendSocketMessage, leaveChat } = useChatSocket({ @@ -97,8 +97,9 @@ function Chat() { useEffect(() => { if (isOpen && (isGroupModalOpen || !isLoggedIn)) { setIsOpen(false); + closeChat(); } - }, [isGroupModalOpen, isLoggedIn, isOpen]); + }, [isGroupModalOpen, isLoggedIn, isOpen, closeChat]); // isOpen True 시, canvasId 변경시 useEffect(() => { @@ -189,8 +190,12 @@ function Chat() { if (isOpen) { leaveChat(); + setIsOpen(false); + closeChat(); // Synchronize with modal store + } else { + setIsOpen(true); + openChat(); // Synchronize with modal store } - setIsOpen(!isOpen); }} className='flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 text-white shadow-xl transition-transform hover:bg-blue-600 active:scale-90 pointer-events-auto' > diff --git a/src/hooks/useCanvasInteraction.ts b/src/hooks/useCanvasInteraction.ts index 8dcf317..fcd863e 100644 --- a/src/hooks/useCanvasInteraction.ts +++ b/src/hooks/useCanvasInteraction.ts @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { MIN_SCALE, MAX_SCALE } from '../components/canvas/canvasConstants'; import { INITIAL_POSITION } from '../components/canvas/canvasConstants'; +import { useModalStore } from '../store/modalStore'; interface UseCanvasInteractionProps { // Refs from parent @@ -94,6 +95,7 @@ export const useCanvasInteraction = ({ const dragStartInfoRef = useRef<{ x: number; y: number } | null>(null); const pinchDistanceRef = useRef(0); const lastTouchPosRef = useRef<{ x: number; y: number } | null>(null); + const { isChatOpen } = useModalStore(); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -441,7 +443,9 @@ export const useCanvasInteraction = ({ moved = true; break; case 'Enter': - handleConfirm(); + if (!isChatOpen) { + handleConfirm(); + } break; } @@ -463,7 +467,7 @@ export const useCanvasInteraction = ({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [draw, handleConfirm, canvasSize]); + }, [draw, handleConfirm, canvasSize, isChatOpen]); useEffect(() => { const interactionCanvas = interactionCanvasRef.current; diff --git a/src/store/modalStore.ts b/src/store/modalStore.ts index 43ba4b6..3cfc50e 100644 --- a/src/store/modalStore.ts +++ b/src/store/modalStore.ts @@ -22,6 +22,10 @@ type ModalState = { isGroupModalOpen: boolean; openGroupModal: () => void; closeGroupModal: () => void; + + isChatOpen: boolean; + openChat: () => void; + closeChat: () => void; }; export const useModalStore = create((set) => ({ @@ -34,6 +38,7 @@ export const useModalStore = create((set) => ({ isAlbumModalOpen: false, isMyPageModalOpen: false, isGroupModalOpen: false, + isChatOpen: false, }), closeLoginModal: () => set({ isLoginModalOpen: false }), @@ -45,6 +50,7 @@ export const useModalStore = create((set) => ({ isAlbumModalOpen: false, isMyPageModalOpen: false, isGroupModalOpen: false, + isChatOpen: false, }), closeCanvasModal: () => set({ isCanvasModalOpen: false }), @@ -56,6 +62,7 @@ export const useModalStore = create((set) => ({ isAlbumModalOpen: true, isMyPageModalOpen: false, isGroupModalOpen: false, + isChatOpen: false, }), closeAlbumModal: () => set({ isAlbumModalOpen: false }), @@ -67,6 +74,7 @@ export const useModalStore = create((set) => ({ isAlbumModalOpen: false, isMyPageModalOpen: true, isGroupModalOpen: false, + isChatOpen: false, }), closeMyPageModal: () => set({ isMyPageModalOpen: false }), @@ -78,6 +86,20 @@ export const useModalStore = create((set) => ({ isAlbumModalOpen: false, isMyPageModalOpen: false, isGroupModalOpen: true, + isChatOpen: false, }), closeGroupModal: () => set({ isGroupModalOpen: false }), + + // 채팅 모달 상태 추가 + isChatOpen: false, + openChat: () => + set({ + isLoginModalOpen: false, + isCanvasModalOpen: false, + isAlbumModalOpen: false, + isMyPageModalOpen: false, + isGroupModalOpen: false, + isChatOpen: true, + }), + closeChat: () => set({ isChatOpen: false }), })); From 691f08ef2ce96d3dc46e94b096f16f818581b49c Mon Sep 17 00:00:00 2001 From: yoominlee00 Date: Fri, 11 Jul 2025 00:57:27 +0900 Subject: [PATCH 09/71] SCRUM-156: cooldown-pixel-flash --- src/components/canvas/PixelCanvas.tsx | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index adb8b4e..693bd28 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -46,6 +46,7 @@ function PixelCanvas({ y: number; color: string; } | null>(null); + const flashingPixelRef = useRef<{ x: number; y: number } | null>(null); const imageTransparencyRef = useRef(0.5); @@ -292,6 +293,26 @@ function PixelCanvas({ pctx.restore(); } + + // Flashing pixel effect + if (flashingPixelRef.current) { + const { x, y } = flashingPixelRef.current; + const currentTime = Date.now(); + const isVisible = Math.floor(currentTime / 500) % 2 === 0; // Blink every 500ms + + if (isVisible) { + const flashCtx = previewCanvasRef.current?.getContext('2d'); + if (flashCtx) { + flashCtx.save(); + flashCtx.translate(viewPosRef.current.x, viewPosRef.current.y); + flashCtx.scale(scaleRef.current, scaleRef.current); + flashCtx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; // Red border + flashCtx.lineWidth = 4 / scaleRef.current; + flashCtx.strokeRect(x, y, 1, 1); + flashCtx.restore(); + } + } + } }, [canvasSize, imagePosition, imageSize, isImageFixed, imageMode]); // 이미지 첨부 핸들러 @@ -657,8 +678,10 @@ function PixelCanvas({ handleCooltime(); previewPixelRef.current = { x: pos.x, y: pos.y, color }; + flashingPixelRef.current = { x: pos.x, y: pos.y }; // Set flashing pixel draw(); sendPixel({ x: pos.x, y: pos.y, color }); + // The flashingPixelRef will now be cleared when cooldown ends, not after 1 second. setTimeout(() => { previewPixelRef.current = null; pos.color = 'transparent'; @@ -764,6 +787,25 @@ function PixelCanvas({ } }, [targetPixel, centerOnWorldPixel, setTargetPixel]); + // Animation loop for flashing pixel + useEffect(() => { + let animationFrameId: number; + + const animate = () => { + draw(); + animationFrameId = requestAnimationFrame(animate); + }; + + // Start animation loop if there's a cooldown or a pixel is flashing + if (cooldown || flashingPixelRef.current) { + animationFrameId = requestAnimationFrame(animate); + } + + return () => { + cancelAnimationFrame(animationFrameId); + }; + }, [cooldown, draw]); + useEffect(() => { const rootElement = rootRef.current; if (!rootElement) return; From 48f7abef7a10facae8e109ee60c3af4f01cea2a4 Mon Sep 17 00:00:00 2001 From: yoominlee00 Date: Fri, 11 Jul 2025 01:16:44 +0900 Subject: [PATCH 10/71] SCRUM-156: cooldown-pixel-flash2 --- src/hooks/useCanvasInteraction.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useCanvasInteraction.ts b/src/hooks/useCanvasInteraction.ts index fcd863e..58402cf 100644 --- a/src/hooks/useCanvasInteraction.ts +++ b/src/hooks/useCanvasInteraction.ts @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { MIN_SCALE, MAX_SCALE } from '../components/canvas/canvasConstants'; import { INITIAL_POSITION } from '../components/canvas/canvasConstants'; import { useModalStore } from '../store/modalStore'; +import { useCanvasUiStore } from '../store/canvasUiStore'; interface UseCanvasInteractionProps { // Refs from parent @@ -96,6 +97,7 @@ export const useCanvasInteraction = ({ const pinchDistanceRef = useRef(0); const lastTouchPosRef = useRef<{ x: number; y: number } | null>(null); const { isChatOpen } = useModalStore(); + const cooldown = useCanvasUiStore((state) => state.cooldown); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -443,7 +445,7 @@ export const useCanvasInteraction = ({ moved = true; break; case 'Enter': - if (!isChatOpen) { + if (!isChatOpen && !cooldown) { handleConfirm(); } break; From 563fae7ec8698c58c252b2d81622e910cf2d3292 Mon Sep 17 00:00:00 2001 From: ChangHyun Park Date: Fri, 11 Jul 2025 13:25:50 +0900 Subject: [PATCH 11/71] =?UTF-8?q?SCRUM-158=20:=20=EB=A6=AC=EB=8D=94=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/Chat.tsx | 27 ++++++++++++++++++--------- src/components/chat/ChatAPI.tsx | 15 +++++++++++---- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/components/chat/Chat.tsx b/src/components/chat/Chat.tsx index a7aa42d..6857d88 100644 --- a/src/components/chat/Chat.tsx +++ b/src/components/chat/Chat.tsx @@ -13,6 +13,7 @@ import { useModalStore } from '../../store/modalStore'; export type Group = { group_id: string; group_title: string; + made_by: string; }; function Chat() { @@ -24,10 +25,11 @@ function Chat() { const [groups, setGroups] = useState([]); const [currentGroupId, setCurrentGroupId] = useState(null); const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가 - + const [leader, setLeader] = useState(null); // 그룹 리더 아이디 const canvas_id = useCanvasStore((state) => state.canvas_id); const { user, isLoggedIn } = useAuthStore(); - const { openLoginModal, isGroupModalOpen, openChat, closeChat } = useModalStore(); + const { openLoginModal, isGroupModalOpen, openChat, closeChat } = + useModalStore(); // 채팅 소켓 연결 - 유효한 group_id가 있을 때만 const { sendMessage: sendSocketMessage, leaveChat } = useChatSocket({ @@ -58,7 +60,9 @@ function Chat() { try { setCurrentGroupId(groupId); setIsLoading(true); // 로딩 시작 - const newMessages = await chatService.getChatMessages(groupId); + const { newMessages, madeBy } = + await chatService.getChatMessages(groupId); + setLeader(madeBy); setMessages(newMessages); // 메시지 상태 업데이트 } catch (error) { console.error( @@ -114,7 +118,6 @@ function Chat() { groups: fetchedGroups, messages: initialMessages, } = await chatService.getChatInitMessages(canvas_id); - setGroups(fetchedGroups); setCurrentGroupId(defaultGroupId); setMessages(initialMessages); @@ -130,7 +133,9 @@ function Chat() { }, [isOpen, canvas_id]); return ( -
+
{/* 채팅창 UI */}
- {group.group_title.length > 10 - ? `${group.group_title.substring(0, 10)}...` - : group.group_title} + {group.made_by === user?.userId + ? group.group_title.length > 10 + ? `👑 ${group.group_title.substring(0, 10)}...` + : group.group_title + : group.group_title.length > 10 + ? `${group.group_title.substring(0, 10)}...` + : group.group_title} ))}
@@ -197,7 +206,7 @@ function Chat() { openChat(); // Synchronize with modal store } }} - className='flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 text-white shadow-xl transition-transform hover:bg-blue-600 active:scale-90 pointer-events-auto' + className='pointer-events-auto flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 text-white shadow-xl transition-transform hover:bg-blue-600 active:scale-90' > {isOpen ? ( // 닫기 아이콘 (X) diff --git a/src/components/chat/ChatAPI.tsx b/src/components/chat/ChatAPI.tsx index 4e2a880..5d96a01 100644 --- a/src/components/chat/ChatAPI.tsx +++ b/src/components/chat/ChatAPI.tsx @@ -23,7 +23,11 @@ export const chatService = { new Date(a.timestamp || a.created_at).getTime() - new Date(b.timestamp || b.created_at).getTime() ); - return { defaultGroupId, groups, messages: sortedMessages }; + return { + defaultGroupId, + groups, + messages: sortedMessages, + }; } catch (error) { console.error(`Failed to fetch message for chat ${canvasId}:`, error); throw error; @@ -41,9 +45,8 @@ export const chatService = { params: { group_id: groupId, limit }, }); // 실제 API에서는 data.messages 형태로 올 수 있습니다. - const messages = response.data.data.messages; - // 메시지를 시간순으로 정렬 - return messages.sort( + console.log(response.data.data); + const newMessages = response.data.data.messages.sort( ( a: { timestamp: any; created_at: any }, b: { timestamp: any; created_at: any } @@ -51,6 +54,10 @@ export const chatService = { new Date(a.timestamp || a.created_at).getTime() - new Date(b.timestamp || b.created_at).getTime() ); + const madeBy = response.data.data.group.made_by; + + // 메시지를 시간순으로 정렬 + return { newMessages, madeBy }; } catch (error) { console.error(`Failed to fetch messages for group ${groupId}:`, error); throw error; From 8b48b2af43dad729bb6e19f19a91581692d8b7c6 Mon Sep 17 00:00:00 2001 From: Anas Date: Fri, 11 Jul 2025 14:26:06 +0900 Subject: [PATCH 12/71] =?UTF-8?q?[SCRUM-160]=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?started=5Fat=20=EB=B0=98=EC=98=81=ED=95=98=EC=97=AC=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=20=EC=9D=B4=EB=8F=99=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/CanvasAPI.ts | 1 + src/components/modal/CanvasModalContent.tsx | 179 ++++++++++++++++---- 2 files changed, 146 insertions(+), 34 deletions(-) diff --git a/src/api/CanvasAPI.ts b/src/api/CanvasAPI.ts index b75dabe..358e027 100644 --- a/src/api/CanvasAPI.ts +++ b/src/api/CanvasAPI.ts @@ -8,6 +8,7 @@ export interface Canvas { size_y: number; type: string; ended_at: string; + started_at?: string; // Add started_at field status?: 'active' | 'inactive' | 'archived'; // 향후 이미지 관련 필드 추가 예정 // thumbnail?: string; // 썸네일 이미지 URL diff --git a/src/components/modal/CanvasModalContent.tsx b/src/components/modal/CanvasModalContent.tsx index a2e10f0..939c754 100644 --- a/src/components/modal/CanvasModalContent.tsx +++ b/src/components/modal/CanvasModalContent.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; import { canvasService } from '../../api/CanvasAPI'; import type { Canvas } from '../../api/CanvasAPI'; import { useCanvasStore } from '../../store/canvasStore'; @@ -80,7 +81,34 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { }, []); // 캔버스가 종료되었는지 확인하는 함수 - const isCanvasExpired = (endedAt: string) => { + const isCanvasExpired = (endedAt: string, startedAt?: string) => { + const now = currentTime; + + // startedAt이 존재하고 현재 시간이 startedAt보다 이전이면 아직 만료되지 않음 (시작 전) + if (startedAt && startedAt !== 'null' && startedAt !== 'undefined') { + try { + let startTime: Date; + if (startedAt.includes('T')) { + startTime = startedAt.endsWith('Z') + ? new Date(startedAt) + : new Date(startedAt + 'Z'); + } else { + startTime = new Date(startedAt); + } + + if ( + !isNaN(startTime.getTime()) && + now.getTime() < startTime.getTime() + ) { + return false; // 아직 시작 전이므로 만료되지 않음 + } + } catch (error) { + console.error('Error checking canvas start time:', error); + // 에러 발생 시 endedAt 로직으로 폴백 + } + } + + // startedAt이 지났거나 없으면 endedAt으로 만료 여부 판단 if (!endedAt || endedAt === 'null' || endedAt === 'undefined') { return false; } @@ -100,42 +128,63 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { return false; } - return endTime.getTime() <= currentTime.getTime(); + return endTime.getTime() <= now.getTime(); } catch (error) { console.error('Error checking canvas expiration:', error); return false; } }; - const getTimeRemaining = (endedAt: string) => { + const getTimeRemaining = (endedAt: string, startedAt?: string) => { try { - // 다양한 날짜 형식 처리 - let endTime: Date; - - if (!endedAt || endedAt === 'null' || endedAt === 'undefined') { - return { text: '종료 시간 없음', isExpired: false, isUrgent: false }; + const now = currentTime; + let targetTime: Date | null = null; + let prefix: string = ''; + let isUpcomingCanvas = false; // To track if it's an upcoming canvas (startedAt in future) + + // 1. Check if startedAt is valid and in the future + if (startedAt && startedAt !== 'null' && startedAt !== 'undefined') { + let startTime: Date; + if (startedAt.includes('T')) { + startTime = startedAt.endsWith('Z') + ? new Date(startedAt) + : new Date(startedAt + 'Z'); + } else { + startTime = new Date(startedAt); + } + + if (!isNaN(startTime.getTime()) && startTime.getTime() > now.getTime()) { + targetTime = startTime; + prefix = '시작까지'; + isUpcomingCanvas = true; + } } - if (endedAt.includes('T')) { - // ISO 형식인 경우 (2024-12-31T23:59:59 또는 2024-12-31T23:59:59Z) - endTime = endedAt.endsWith('Z') - ? new Date(endedAt) - : new Date(endedAt + 'Z'); - } else { - // 다른 형식인 경우 - endTime = new Date(endedAt); + // 2. If not an upcoming canvas (startedAt not provided, or in the past/invalid), use endedAt + if (!targetTime) { // If targetTime was not set by startedAt logic + if (!endedAt || endedAt === 'null' || endedAt === 'undefined') { + return { text: '종료 시간 없음', isExpired: false, isUrgent: false, isUpcoming: false }; + } + + if (endedAt.includes('T')) { + targetTime = endedAt.endsWith('Z') + ? new Date(endedAt) + : new Date(endedAt + 'Z'); + } else { + targetTime = new Date(endedAt); + } + prefix = '종료까지'; } - // 날짜가 유효하지 않은 경우 - if (isNaN(endTime.getTime())) { - console.warn('Invalid ended_at date:', endedAt); - return { text: '날짜 오류', isExpired: false, isUrgent: false }; + // Handle invalid targetTime after all attempts + if (!targetTime || isNaN(targetTime.getTime())) { + console.warn('Invalid date:', endedAt, startedAt); + return { text: '날짜 오류', isExpired: false, isUrgent: false, isUpcoming: false }; } - const now = currentTime; - const timeDiff = endTime.getTime() - now.getTime(); + const timeDiff = targetTime.getTime() - now.getTime(); if (timeDiff <= 0) { - return { text: '종료됨', isExpired: true }; + return { text: isUpcomingCanvas ? '시작됨' : '종료됨', isExpired: true, isUpcoming: false }; } const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); @@ -149,27 +198,29 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { let isUrgent = false; if (days > 0) { - text = `${days}일 ${hours}시간 남음`; + text = `${prefix} ${days}일 ${hours}시간 남음`; } else if (hours > 0) { - text = `${hours}시간 ${minutes}분 남음`; + text = `${prefix} ${hours}시간 ${minutes}분 남음`; isUrgent = hours < 1; // 1시간 미만일 때 긴급 } else if (minutes > 0) { - text = `${minutes}분 ${seconds}초 남음`; + text = `${prefix} ${minutes}분 ${seconds}초 남음`; isUrgent = true; } else { - text = `${seconds}초 남음`; + text = `${prefix} ${seconds}초 남음`; isUrgent = true; } - return { text, isExpired: false, isUrgent }; + return { text, isExpired: false, isUrgent, isUpcoming: isUpcomingCanvas }; } catch (error) { console.error( 'Error calculating time remaining:', error, 'endedAt:', - endedAt + endedAt, + 'startedAt:', + startedAt ); - return { text: '계산 오류', isExpired: false, isUrgent: false }; + return { text: '계산 오류', isExpired: false, isUrgent: false, isUpcoming: false }; } }; @@ -225,6 +276,60 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { return; } + // 선택된 캔버스 찾기 + const selectedCanvas = canvases.find((c) => c.canvasId === canvasId); + + if (selectedCanvas && selectedCanvas.type !== 'public') { + // 이벤트 캔버스인 경우에만 체크 + const now = currentTime; + if (selectedCanvas.started_at) { + let startTime: Date; + try { + if (selectedCanvas.started_at.includes('T')) { + startTime = selectedCanvas.started_at.endsWith('Z') + ? new Date(selectedCanvas.started_at) + : new Date(selectedCanvas.started_at + 'Z'); + } else { + startTime = new Date(selectedCanvas.started_at); + } + + if ( + !isNaN(startTime.getTime()) && + now.getTime() < startTime.getTime() + ) { + // 아직 시작 전 + toast.error('아직 시작되지 않은 캔버스입니다.', { + position: 'top-center', + autoClose: 2000, + hideProgressBar: true, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: 'dark', + style: { backgroundColor: '#dc2626', color: 'white' }, // Tailwind red-600 + }); + return; // 페이지 이동 막기 + } + } catch (error) { + console.error('Error parsing started_at for toast:', error); + // 에러 발생 시에도 페이지 이동 막고 메시지 표시 + toast.error('캔버스 정보를 처리하는 중 오류가 발생했습니다.', { + position: 'top-center', + autoClose: 2000, + hideProgressBar: true, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: 'dark', + style: { backgroundColor: '#dc2626', color: 'white' }, + }); + return; + } + } + } + // 1. 모달 먼저 닫기 if (onClose) { onClose(); @@ -485,8 +590,10 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { { canvases .filter((canvas) => canvas.type !== 'public') - .filter((canvas) => !isCanvasExpired(canvas.ended_at)) - .length + .filter( + (canvas) => + !isCanvasExpired(canvas.ended_at, canvas.started_at) + ).length } 개) @@ -546,13 +653,17 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { .filter((canvas) => !isCanvasExpired(canvas.ended_at)) // 종료된 캔버스 제외 .map((canvas) => { const timeInfo = canvas.ended_at - ? getTimeRemaining(canvas.ended_at) + ? getTimeRemaining(canvas.ended_at, canvas.started_at) : null; return (
handleCanvasSelect(e, canvas.canvasId)} - className='group canvas-rainbow-border block min-w-[200px] cursor-pointer transition-all duration-300 hover:shadow-xl hover:shadow-gray-900/20' + className={`group block min-w-[200px] cursor-pointer transition-all duration-300 hover:shadow-xl hover:shadow-gray-900/20 ${ + timeInfo?.isUpcoming + ? 'grayscale opacity-50 cursor-not-allowed' + : 'canvas-rainbow-border' + }`} >

From 76a4eab04b4444354536af24e3e09c59701d91ff Mon Sep 17 00:00:00 2001 From: Anas Date: Fri, 11 Jul 2025 14:34:23 +0900 Subject: [PATCH 13/71] =?UTF-8?q?[SCRUM-160]=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=B9=A0=EB=A5=B8=20=EC=88=9C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A0=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modal/CanvasModalContent.tsx | 30 ++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/components/modal/CanvasModalContent.tsx b/src/components/modal/CanvasModalContent.tsx index 939c754..cb89530 100644 --- a/src/components/modal/CanvasModalContent.tsx +++ b/src/components/modal/CanvasModalContent.tsx @@ -162,7 +162,7 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { // 2. If not an upcoming canvas (startedAt not provided, or in the past/invalid), use endedAt if (!targetTime) { // If targetTime was not set by startedAt logic if (!endedAt || endedAt === 'null' || endedAt === 'undefined') { - return { text: '종료 시간 없음', isExpired: false, isUrgent: false, isUpcoming: false }; + return { text: '종료 시간 없음', isExpired: false, isUrgent: false, isUpcoming: false, targetDate: undefined }; } if (endedAt.includes('T')) { @@ -178,13 +178,13 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { // Handle invalid targetTime after all attempts if (!targetTime || isNaN(targetTime.getTime())) { console.warn('Invalid date:', endedAt, startedAt); - return { text: '날짜 오류', isExpired: false, isUrgent: false, isUpcoming: false }; + return { text: '날짜 오류', isExpired: false, isUrgent: false, isUpcoming: false, targetDate: undefined }; } const timeDiff = targetTime.getTime() - now.getTime(); if (timeDiff <= 0) { - return { text: isUpcomingCanvas ? '시작됨' : '종료됨', isExpired: true, isUpcoming: false }; + return { text: isUpcomingCanvas ? '시작됨' : '종료됨', isExpired: true, isUpcoming: false, targetDate: targetTime }; } const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); @@ -210,7 +210,7 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { isUrgent = true; } - return { text, isExpired: false, isUrgent, isUpcoming: isUpcomingCanvas }; + return { text, isExpired: false, isUrgent, isUpcoming: isUpcomingCanvas, targetDate: targetTime }; } catch (error) { console.error( 'Error calculating time remaining:', @@ -220,7 +220,7 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { 'startedAt:', startedAt ); - return { text: '계산 오류', isExpired: false, isUrgent: false, isUpcoming: false }; + return { text: '계산 오류', isExpired: false, isUrgent: false, isUpcoming: false, targetDate: undefined }; } }; @@ -650,11 +650,29 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => { > {canvases .filter((canvas) => canvas.type !== 'public') - .filter((canvas) => !isCanvasExpired(canvas.ended_at)) // 종료된 캔버스 제외 + .filter((canvas) => !isCanvasExpired(canvas.ended_at, canvas.started_at)) // 종료된 캔버스 제외 .map((canvas) => { const timeInfo = canvas.ended_at ? getTimeRemaining(canvas.ended_at, canvas.started_at) : null; + return { canvas, timeInfo }; // Return an object with canvas and timeInfo + }) + .sort((a, b) => { + // Sort logic + const aIsUpcoming = a.timeInfo?.isUpcoming || false; + const bIsUpcoming = b.timeInfo?.isUpcoming || false; + + // If one is upcoming and the other is not, the non-upcoming comes first + if (aIsUpcoming && !bIsUpcoming) return 1; // a is upcoming, b is not -> b comes first + if (!aIsUpcoming && bIsUpcoming) return -1; // a is not upcoming, b is -> a comes first + + // If both are upcoming or both are not upcoming, sort by targetDate + if (a.timeInfo?.targetDate && b.timeInfo?.targetDate) { + return a.timeInfo.targetDate.getTime() - b.timeInfo.targetDate.getTime(); + } + return 0; // Should not happen if targetDate is always present when timeInfo is not null + }) + .map(({ canvas, timeInfo }) => { return (
Date: Fri, 11 Jul 2025 14:41:34 +0900 Subject: [PATCH 14/71] =?UTF-8?q?SCRUM-158=20:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EB=A6=AC=EB=8D=94=20=ED=91=9C=EC=8B=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/Chat.tsx | 13 +++++++------ src/components/chat/MessageItem.tsx | 8 ++++++-- src/store/chatStore.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 src/store/chatStore.ts diff --git a/src/components/chat/Chat.tsx b/src/components/chat/Chat.tsx index 6857d88..a9d7191 100644 --- a/src/components/chat/Chat.tsx +++ b/src/components/chat/Chat.tsx @@ -7,6 +7,7 @@ import { useCanvasStore } from '../../store/canvasStore'; import { useChatSocket } from '../SocketIntegration'; import { useAuthStore } from '../../store/authStrore'; import { useModalStore } from '../../store/modalStore'; +import { useChatStore } from '../../store/chatStore'; // 임시로 사용할 가짜 메시지 데이터 @@ -25,8 +26,8 @@ function Chat() { const [groups, setGroups] = useState([]); const [currentGroupId, setCurrentGroupId] = useState(null); const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가 - const [leader, setLeader] = useState(null); // 그룹 리더 아이디 const canvas_id = useCanvasStore((state) => state.canvas_id); + const { leader, setLeader } = useChatStore(); const { user, isLoggedIn } = useAuthStore(); const { openLoginModal, isGroupModalOpen, openChat, closeChat } = useModalStore(); @@ -161,11 +162,11 @@ function Chat() { }`} > {group.made_by === user?.userId - ? group.group_title.length > 10 - ? `👑 ${group.group_title.substring(0, 10)}...` - : group.group_title - : group.group_title.length > 10 - ? `${group.group_title.substring(0, 10)}...` + ? group.group_title.length > 5 + ? `👑 ${group.group_title.substring(0, 5)}...` + : `👑 ${group.group_title}` + : group.group_title.length > 5 + ? `${group.group_title.substring(0, 5)}...` : group.group_title} ))} diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 3e9b413..6f7f9bc 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useAuthStore } from '../../store/authStrore'; import { useCanvasUiStore } from '../../store/canvasUiStore'; +import { useChatStore } from '../../store/chatStore'; export type Message = { messageId: string; @@ -16,6 +17,7 @@ const MessageItem = React.memo(({ message }: { message: Message }) => { const currentUser = useAuthStore((state) => state.user); const setTargetPixel = useCanvasUiStore((state) => state.setTargetPixel); const isMyMessage = message.user.userId === currentUser?.userId; + const { leader } = useChatStore(); const handleCoordinateClick = (x: number, y: number) => { setTargetPixel({ x, y }); @@ -41,7 +43,7 @@ const MessageItem = React.memo(({ message }: { message: Message }) => { parts.push( handleCoordinateClick(x, y)} > {fullMatch} @@ -71,7 +73,9 @@ const MessageItem = React.memo(({ message }: { message: Message }) => {
{!isMyMessage && (
- {message.user.name} + {leader === message.user.userId + ? `👑 ${message.user.name}` + : message.user.name}
)}
diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts new file mode 100644 index 0000000..ba97339 --- /dev/null +++ b/src/store/chatStore.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand'; + +interface chatState { + leader: string; + setLeader: (id: string) => void; +} + +export const useChatStore = create((set) => ({ + leader: '', + setLeader: (id) => set({ leader: id }), +})); From 1985aa20829374063331b0b707e4ea10703a7c41 Mon Sep 17 00:00:00 2001 From: ChangHyun Park Date: Fri, 11 Jul 2025 14:43:57 +0900 Subject: [PATCH 15/71] =?UTF-8?q?SCRUM-158=20:=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B0=8F=20=EC=B0=B8=EC=97=AC=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/group/GroupCreateTab.tsx | 9 +++-- src/components/group/GroupJoinTab.tsx | 54 +++++++++++++------------ 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/components/group/GroupCreateTab.tsx b/src/components/group/GroupCreateTab.tsx index 710cde0..a05b057 100644 --- a/src/components/group/GroupCreateTab.tsx +++ b/src/components/group/GroupCreateTab.tsx @@ -18,23 +18,24 @@ export default function GroupCreateTab({ return (
-
-
+ {/* 좌표 표시창 */}
{hoverPos ? `(${hoverPos.x}, ${hoverPos.y})` : 'OutSide'} From 16271d613e6403cece95b8a499935df10ab3e1f0 Mon Sep 17 00:00:00 2001 From: Anas Date: Sun, 13 Jul 2025 01:45:57 +0900 Subject: [PATCH 27/71] =?UTF-8?q?[SCRUM-174]:=20PC=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/canvas/CanvasUIPC.tsx | 41 ++++++++++++++-------------- src/index.css | 9 ++++++ 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index d4b15d9..e8c4179 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -87,24 +87,13 @@ export default function CanvasUIPC({ return ( <> - {/* 컬러 피커 */} -
- { - const newColor = e.target.value; - setColor(newColor); - onSelectColor(newColor); - }} - className='h-[40px] w-[40px] cursor-pointer rounded-[4px] border-2 border-solid border-white p-0' - title='색상 선택' - /> + {/* 이미지 업로드 */} +
{onImageAttach && (
{/* 항상 보이는 메뉴 토글 버튼 (햄버거 아이콘) */} - -
- - {/* 팔레트 */} -
-
- {colors.slice(0, 10).map((c, index) => ( -
- -
- {/*확정 버튼 */} - - -
-
- {/* 쿨타임 창 : 쿨타임 중에만 표시*/} {cooldown && (
diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index e8c4179..a67246a 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -64,9 +64,9 @@ export default function CanvasUIPC({ const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); - useEffect(() => { - showInstructionsToast(); - }, []); + // useEffect(() => { + // showInstructionsToast(); + // }, []); useEffect(() => { if (!isMenuOpen) return; diff --git a/src/components/toast/InstructionsToast.tsx b/src/components/toast/InstructionsToast.tsx index c07777a..0016d3a 100644 --- a/src/components/toast/InstructionsToast.tsx +++ b/src/components/toast/InstructionsToast.tsx @@ -6,7 +6,7 @@ export const showInstructionsToast = () => { const mobileInstructions = (
-
How to Play
+
Play
{ const desktopInstructions = (
-
How to Play
+
Play
Date: Sun, 13 Jul 2025 02:55:06 +0900 Subject: [PATCH 29/71] =?UTF-8?q?[SCRUM-174]:=20=ED=8C=94=EB=A0=88?= =?UTF-8?q?=ED=8A=B8=20=EB=84=88=EB=B9=84=20=EA=B0=92=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/canvas/CanvasUIPC.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/canvas/CanvasUIPC.tsx b/src/components/canvas/CanvasUIPC.tsx index a67246a..f0782fb 100644 --- a/src/components/canvas/CanvasUIPC.tsx +++ b/src/components/canvas/CanvasUIPC.tsx @@ -364,8 +364,8 @@ export default function CanvasUIPC({ }`} > {/* 좌표 표시창 */} -
- {hoverPos ? `(${hoverPos.x}, ${hoverPos.y})` : 'OutSide'} +
+ {hoverPos ? `(${hoverPos.x},${hoverPos.y})` : 'OutSide'}