Added back Remote Cursors

This commit is contained in:
ExilProductions
2026-01-16 17:06:43 +01:00
parent fa0762643e
commit 8eed0a0a92
10 changed files with 307 additions and 34 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useSocket } from '../context/SocketContext';
import { useAuth } from '../context/AuthContext';
import { Pixel } from '@shared/types';
import { Pixel, PaintAck } from '../types';
interface CanvasProps {
cursors: any[];
@@ -26,6 +26,7 @@ export default function Canvas({
const { user, showLoginPrompt } = useAuth();
const pixelsRef = useRef<Map<string, Pixel>>(new Map());
const pendingPixelsRef = useRef<Map<string, { pixel: Pixel; timestamp: number }>>(new Map());
const animationFrameRef = useRef<number>();
const mouseScreenRef = useRef({ x: 0, y: 0 });
const lastPanRef = useRef({ x: 0, y: 0 });
@@ -76,10 +77,26 @@ export default function Canvas({
pixelsRef.current.set(`${p.x},${p.y}`, p);
}
});
socket.on('canvas-update', (update: { pixels: Pixel[] }) => {
for (const p of update.pixels) {
const key = `${p.x},${p.y}`;
const pending = pendingPixelsRef.current.get(key);
if (pending) {
const pendingKey = `${pending.pixel.x},${pending.pixel.y}`;
pendingPixelsRef.current.delete(pendingKey);
}
pixelsRef.current.set(key, p);
}
});
}
return () => {
window.removeEventListener('resize', resize);
if (socket) {
socket.off('canvas-state');
socket.off('canvas-update');
}
};
}, [socket]);
@@ -159,6 +176,30 @@ export default function Canvas({
ctx.lineTo(sx + 5 * scale, sy + 15 * scale);
ctx.closePath();
ctx.fill();
if (c.username) {
const fontSize = Math.max(12, 14 * scale);
ctx.font = `${fontSize}px sans-serif`;
const textWidth = ctx.measureText(c.username).width;
const padding = 4 * scale;
const labelX = sx + 18 * scale;
const labelY = sy + 5 * scale;
const labelWidth = textWidth + padding * 2;
const labelHeight = fontSize + padding;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(labelX, labelY - fontSize + padding, labelWidth, labelHeight, 4 * scale);
} else {
ctx.rect(labelX, labelY - fontSize + padding, labelWidth, labelHeight);
}
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.textBaseline = 'top';
ctx.fillText(c.username, labelX + padding, labelY - fontSize + padding);
}
}
};
@@ -192,7 +233,7 @@ export default function Canvas({
};
}, []);
const paintPixel = () => {
const paintPixel = useCallback(() => {
if (!user) {
showLoginPrompt();
return;
@@ -209,8 +250,24 @@ export default function Canvas({
pixelsRef.current.set(`${p.x},${p.y}`, p);
}
}
sendPaintPixels(pixels);
};
sendPaintPixels(pixels, (ack: PaintAck) => {
if (!ack.success) {
for (const pixel of pixels) {
const key = `${pixel.x},${pixel.y}`;
if (!pendingPixelsRef.current.has(key)) {
pendingPixelsRef.current.set(key, { pixel, timestamp: Date.now() });
}
}
} else if (ack.conflicts > 0 && ack.pixels) {
for (const serverPixel of ack.pixels) {
const key = `${serverPixel.x},${serverPixel.y}`;
pixelsRef.current.set(key, serverPixel);
pendingPixelsRef.current.delete(key);
}
}
});
}, [user, showLoginPrompt, sendPaintPixels]);
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button === 1) {

View File

@@ -1,14 +1,14 @@
import { io, Socket } from 'socket.io-client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef } from 'react';
import { useAuth } from './AuthContext';
import { Pixel, Cursor } from '../types';
import { Pixel, Cursor, PaintAck, PendingPixel } from '../types';
interface SocketContextType {
socket: Socket | null;
isConnected: boolean;
cursors: Cursor[];
sendCursorMove: (x: number, y: number) => void;
sendPaintPixels: (pixels: Pixel[]) => void;
sendPaintPixels: (pixels: Pixel[], onAck?: (ack: PaintAck) => void) => void;
}
const SocketContext = createContext<SocketContextType | undefined>(undefined);
@@ -18,16 +18,15 @@ export function SocketProvider({ children }: { children: ReactNode }) {
const [isConnected, setIsConnected] = useState(false);
const [cursors, setCursors] = useState<Cursor[]>([]);
const { user } = useAuth();
const pendingPixelsRef = useRef<Map<string, PendingPixel>>(new Map());
useEffect(() => {
// Only connect sock if user is authed
if (!user) {
setSocket(null);
setIsConnected(false);
return;
}
// Get socket auth token first
fetch('/api/socket-token', {
credentials: 'include'
})
@@ -39,14 +38,12 @@ export function SocketProvider({ children }: { children: ReactNode }) {
})
.then(data => {
if (data.token) {
// Conn with token
const newSocket = io('', {
transports: ['websocket']
});
newSocket.on('connect', () => {
setIsConnected(true);
// auth when logged in
newSocket.emit('join-canvas', data.token);
});
@@ -68,11 +65,9 @@ export function SocketProvider({ children }: { children: ReactNode }) {
newSocket.on('error', (message: string) => {
console.error('Socket error:', message);
// If auth fails we disconnect and retry after delay
if (message.includes('Authentication required') || message.includes('expired')) {
newSocket.disconnect();
setTimeout(() => {
// Trigger reconn
}, 2000);
}
});
@@ -94,15 +89,74 @@ export function SocketProvider({ children }: { children: ReactNode }) {
});
}, [user]);
const resendPendingPixels = useCallback((socket: Socket) => {
const now = Date.now();
const maxRetries = 3;
const retryDelay = 1000;
for (const [key, pending] of pendingPixelsRef.current) {
if (pending.retries >= maxRetries) {
pendingPixelsRef.current.delete(key);
window.dispatchEvent(new CustomEvent('pixel-sync-failed', { detail: pending.pixel }));
continue;
}
if (now - pending.timestamp > retryDelay * (pending.retries + 1)) {
pending.retries++;
pending.timestamp = now;
socket.emit('paint-pixels', {
pixels: [pending.pixel],
timestamp: pending.timestamp
}, (ack: PaintAck) => {
if (ack.success) {
pendingPixelsRef.current.delete(key);
}
});
}
}
}, []);
useEffect(() => {
if (!socket || !isConnected) return;
const interval = setInterval(() => {
if (pendingPixelsRef.current.size > 0) {
resendPendingPixels(socket);
}
}, 500);
return () => clearInterval(interval);
}, [socket, isConnected, resendPendingPixels]);
const sendCursorMove = (x: number, y: number) => {
if (socket && isConnected) {
socket.emit('cursor-move', { x, y });
}
};
const sendPaintPixels = (pixels: Pixel[]) => {
const sendPaintPixels = (pixels: Pixel[], onAck?: (ack: PaintAck) => void) => {
if (socket && isConnected) {
socket.emit('paint-pixels', pixels);
const timestamp = Date.now();
const key = `${pixels[0]?.x},${pixels[0]?.y}`;
socket.emit('paint-pixels', { pixels, timestamp }, (ack: PaintAck) => {
if (onAck) {
onAck(ack);
}
if (ack.success) {
pendingPixelsRef.current.delete(key);
} else if (ack.conflicts > 0 && ack.pixels) {
for (const pixel of ack.pixels) {
const pixelKey = `${pixel.x},${pixel.y}`;
pendingPixelsRef.current.set(pixelKey, {
pixel,
timestamp: Date.now(),
retries: 0
});
}
}
});
}
};

View File

@@ -1,5 +1,4 @@
export interface Cursor {
userId: string;
x: number;
@@ -13,4 +12,18 @@ export interface Pixel {
color: string;
userId?: string;
timestamp?: string;
}
export interface PaintAck {
success: boolean;
saved: number;
conflicts: number;
pixels?: Pixel[];
message?: string;
}
export interface PendingPixel {
pixel: Pixel;
timestamp: number;
retries: number;
}