Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ dist-ssr
*.sw?

.env
test
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Pick-Px</title>
</head>
<body>
<div id="root"></div>
Expand Down
76 changes: 76 additions & 0 deletions src/components/canvas/PixelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 44 additions & 1 deletion src/components/chat/MessageItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { useAuthStore } from '../../store/authStrore';
import { useCanvasUiStore } from '../../store/canvasUiStore';

export type Message = {
messageId: string;
Expand All @@ -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(
<span
key={match.index}
className="text-blue-400 hover:underline cursor-pointer"
onClick={() => handleCoordinateClick(x, y)}
>
{fullMatch}
</span>
);
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'
Expand All @@ -32,7 +75,7 @@ const MessageItem = React.memo(({ message }: { message: Message }) => {
</div>
)}
<div className={messageBubbleClasses}>
<div>{message.content}</div>
<div>{renderMessageContent(message.content)}</div>
{message.timestamp && (
<div
className={`mt-1 text-right text-xs ${isMyMessage ? 'text-blue-200' : 'text-gray-400'}`}
Expand Down
4 changes: 2 additions & 2 deletions src/components/modal/CanvasModalContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,8 @@ const CanvasModalContent = ({ onClose }: CanvasModalContentProps) => {
onClose();
}

// 2. 페이지 이동 (새로고침 없음)
navigate(getCanvasUrl(canvasId));
// 2. 페이지 이동 (새로고침 포함)
window.location.href = getCanvasUrl(canvasId);
};

// URL 생성 함수 (Query parameter 방식 사용)
Expand Down
2 changes: 1 addition & 1 deletion src/services/socketService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}

window.ResizeObserver = ResizeObserver;
6 changes: 6 additions & 0 deletions src/store/canvasUiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,6 +47,9 @@ export const useCanvasUiStore = create<CanvasUiState>((set, get) => ({
hoverPos: null,
setHoverPos: (hoverPos) => set({ hoverPos }),

targetPixel: null,
setTargetPixel: (targetPixel) => set({ targetPixel }),

timeLeft: 0,
setTimeLeft: (newTimeLeft) =>
set((state) => ({
Expand Down
11 changes: 11 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
});