Added back Remote Cursors
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user