160 lines
4.1 KiB
TypeScript
160 lines
4.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useBuilder } from '../store/builderStore';
|
|
import { useAuth } from '../auth/AuthContext';
|
|
|
|
const colors = [
|
|
'#38bdf8',
|
|
'#3d7bfa',
|
|
'#34d399',
|
|
'#3b7a57',
|
|
'#fbbf24',
|
|
'#f97316',
|
|
'#e0564f',
|
|
'#a78bfa',
|
|
];
|
|
|
|
const stringToColor = (str: string) => {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const index = Math.abs(hash) % colors.length;
|
|
return colors[index];
|
|
};
|
|
|
|
export interface RemoteCursor {
|
|
position: [number, number, number];
|
|
userName: string;
|
|
color: string;
|
|
activeRoomId: string;
|
|
}
|
|
|
|
export function useCollab() {
|
|
const { user } = useAuth();
|
|
const parts = useBuilder((s) => s.parts);
|
|
const projectName = useBuilder((s) => s.projectName);
|
|
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const localPartsRef = useRef(parts);
|
|
const isIncomingUpdateRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsPort = window.location.port === '5173' ? '3000' : window.location.port;
|
|
const wsUrl = `${wsProto}//${window.location.hostname}${wsPort ? `:${wsPort}` : ''}`;
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
const color = stringToColor(user.email);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'join',
|
|
projectRoom: projectName,
|
|
userId: user.id,
|
|
userName: user.email,
|
|
color: color,
|
|
activeRoomId: useBuilder.getState().activeRoomId,
|
|
})
|
|
);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'mutation') {
|
|
isIncomingUpdateRef.current = true;
|
|
localPartsRef.current = data.parts;
|
|
useBuilder.getState().syncParts(data.parts);
|
|
} else if (data.type === 'cursor') {
|
|
setRemoteCursors((prev) => ({
|
|
...prev,
|
|
[data.userId]: {
|
|
position: data.position,
|
|
userName: data.userName,
|
|
color: data.color,
|
|
activeRoomId: data.activeRoomId,
|
|
},
|
|
}));
|
|
} else if (data.type === 'leave') {
|
|
setRemoteCursors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[data.userId];
|
|
return next;
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error handling WebSocket message:', err);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log('Collaboration WebSocket disconnected');
|
|
};
|
|
|
|
ws.onerror = (err) => {
|
|
console.error('Collaboration WebSocket error:', err);
|
|
};
|
|
|
|
return () => {
|
|
ws.close();
|
|
wsRef.current = null;
|
|
};
|
|
}, [user, projectName]);
|
|
|
|
// Sync local changes to remote
|
|
useEffect(() => {
|
|
if (isIncomingUpdateRef.current) {
|
|
isIncomingUpdateRef.current = false;
|
|
localPartsRef.current = parts;
|
|
return;
|
|
}
|
|
|
|
if (parts !== localPartsRef.current) {
|
|
localPartsRef.current = parts;
|
|
const ws = wsRef.current;
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'mutation',
|
|
parts,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}, [parts]);
|
|
|
|
// Sync cursor movement
|
|
useEffect(() => {
|
|
let lastSent = 0;
|
|
const handleCursor = (e: Event) => {
|
|
const now = Date.now();
|
|
if (now - lastSent < 50) return; // rate limit to 20fps
|
|
lastSent = now;
|
|
|
|
const pos = (e as CustomEvent).detail;
|
|
const ws = wsRef.current;
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'cursor',
|
|
position: [pos.x, pos.y, pos.z],
|
|
activeRoomId: useBuilder.getState().activeRoomId,
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('collab:cursor', handleCursor);
|
|
return () => {
|
|
window.removeEventListener('collab:cursor', handleCursor);
|
|
};
|
|
}, []);
|
|
|
|
return { remoteCursors };
|
|
}
|