diff --git a/.gitignore b/.gitignore index 50c8dda..1a30e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.sw? .env +test \ No newline at end of file diff --git a/index.html b/index.html index 12d2898..7f6b79f 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Pick-Px
diff --git a/src/components/canvas/PixelCanvas.tsx b/src/components/canvas/PixelCanvas.tsx index 9ed66de..7037ed5 100644 --- a/src/components/canvas/PixelCanvas.tsx +++ b/src/components/canvas/PixelCanvas.tsx @@ -94,6 +94,8 @@ function PixelCanvas({ const setHasError = useCanvasUiStore((state) => state.setHasError); const showCanvas = useCanvasUiStore((state) => state.showCanvas); const setShowCanvas = useCanvasUiStore((state) => state.setShowCanvas); + const targetPixel = useCanvasUiStore((state) => state.targetPixel); + const setTargetPixel = useCanvasUiStore((state) => state.setTargetPixel); const startCooldown = useCanvasUiStore((state) => state.startCooldown); @@ -584,6 +586,71 @@ function PixelCanvas({ zoomCanvas(1 / 1.2); }, [zoomCanvas]); + const centerOnWorldPixel = useCallback( + (worldX: number, worldY: number) => { + const canvas = renderCanvasRef.current; + if (!canvas) return; + + // Check if the target pixel is within canvas bounds + if ( + worldX < 0 || + worldX >= canvasSize.width || + worldY < 0 || + worldY >= canvasSize.height + ) { + console.warn( + `Target pixel (${worldX}, ${worldY}) is out of canvas bounds.` + ); + return; + } + + const viewportCenterX = canvas.clientWidth / 2; + const viewportCenterY = canvas.clientHeight / 2; + + // Calculate the target view position to center the world pixel + // (worldX + 0.5) to center on the pixel, not its top-left corner + const targetX = viewportCenterX - (worldX + 0.5) * scaleRef.current; + const targetY = viewportCenterY - (worldY + 0.5) * scaleRef.current; + + const startX = viewPosRef.current.x; + const startY = viewPosRef.current.y; + const duration = 1000; // Animation duration in ms + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + // Ease-out cubic function + const eased = 1 - Math.pow(1 - progress, 3); + + viewPosRef.current.x = startX + (targetX - startX) * eased; + viewPosRef.current.y = startY + (targetY - startY) * eased; + + draw(); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + // Ensure final position is exact + viewPosRef.current.x = targetX; + viewPosRef.current.y = targetY; + draw(); + + // Set fixedPosRef to highlight the target pixel + fixedPosRef.current = { x: worldX, y: worldY, color: 'transparent' }; // Use transparent or a default color + draw(); // Redraw to show the fixedPosRef + + // Optionally, update overlay for the centered pixel + const screenX = worldX * scaleRef.current + viewPosRef.current.x; + const screenY = worldY * scaleRef.current + viewPosRef.current.y; + updateOverlay(screenX, screenY); + } + }; + requestAnimationFrame(animate); + }, + [draw, canvasSize, updateOverlay] + ); + const handleCooltime = useCallback(() => { startCooldown(10); }, [startCooldown]); @@ -972,6 +1039,15 @@ function PixelCanvas({ } }, [imageTransparency, draw]); + // Listen for targetPixel changes from chat and center the canvas + useEffect(() => { + if (targetPixel) { + centerOnWorldPixel(targetPixel.x, targetPixel.y); + // Reset targetPixel to null after processing to prevent re-triggering + setTargetPixel(null); + } + }, [targetPixel, centerOnWorldPixel, setTargetPixel]); + useEffect(() => { const rootElement = rootRef.current; if (!rootElement) return; diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 3d3742a..3e9b413 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useAuthStore } from '../../store/authStrore'; +import { useCanvasUiStore } from '../../store/canvasUiStore'; export type Message = { messageId: string; @@ -13,8 +14,50 @@ export type Message = { 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 handleCoordinateClick = (x: number, y: number) => { + setTargetPixel({ x, y }); + }; + + const renderMessageContent = (content: string) => { + const parts: React.ReactNode[] = []; + const regex = /\((\d+),(\d+)\)/g; + let lastIndex = 0; + let match; + + while ((match = regex.exec(content)) !== null) { + const [fullMatch, xStr, yStr] = match; + const x = parseInt(xStr, 10); + const y = parseInt(yStr, 10); + + // Add the text before the coordinate + if (match.index > lastIndex) { + parts.push(content.substring(lastIndex, match.index)); + } + + // Add the clickable coordinate + parts.push( + handleCoordinateClick(x, y)} + > + {fullMatch} + + ); + lastIndex = regex.lastIndex; + } + + // Add any remaining text after the last coordinate + if (lastIndex < content.length) { + parts.push(content.substring(lastIndex)); + } + + return parts; + }; + console.log(message.user.userId); const messageBubbleClasses = isMyMessage ? 'bg-blue-500 text-white rounded-lg py-2 px-3 max-w-[70%] self-end' @@ -32,7 +75,7 @@ const MessageItem = React.memo(({ message }: { message: Message }) => { )}
-
{message.content}
+
{renderMessageContent(message.content)}
{message.timestamp && (
{ onClose(); } - // 2. 페이지 이동 (새로고침 없음) - navigate(getCanvasUrl(canvasId)); + // 2. 페이지 이동 (새로고침 포함) + window.location.href = getCanvasUrl(canvasId); }; // URL 생성 함수 (Query parameter 방식 사용) diff --git a/src/services/socketService.ts b/src/services/socketService.ts index cc84717..a395ab0 100644 --- a/src/services/socketService.ts +++ b/src/services/socketService.ts @@ -137,7 +137,7 @@ class SocketService { // 인증 에러 리스너 제거 offAuthError(callback: (error: { message: string }) => void) { if (this.socket) { - this.socket.off('autherror', callback); + this.socket.off('auth_error', callback); } } } diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..87be034 --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,7 @@ +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +window.ResizeObserver = ResizeObserver; diff --git a/src/store/canvasUiStore.ts b/src/store/canvasUiStore.ts index cd4f12c..b886b4a 100644 --- a/src/store/canvasUiStore.ts +++ b/src/store/canvasUiStore.ts @@ -9,6 +9,9 @@ interface CanvasUiState { // 캔버스 내 좌표 값 hoverPos: HoverPos; setHoverPos: (pos: HoverPos) => void; + // 채팅에서 클릭된 좌표 + targetPixel: { x: number; y: number } | null; + setTargetPixel: (pos: { x: number; y: number } | null) => void; // 잔여 쿨다운 시간 timeLeft: number; setTimeLeft: (timeLeft: number | ((prev: number) => number)) => void; @@ -44,6 +47,9 @@ export const useCanvasUiStore = create((set, get) => ({ hoverPos: null, setHoverPos: (hoverPos) => set({ hoverPos }), + targetPixel: null, + setTargetPixel: (targetPixel) => set({ targetPixel }), + timeLeft: 0, setTimeLeft: (newTimeLeft) => set((state) => ({ diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fc38606 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/setupTests.ts'], + }, +});