realtimecursor
Version:
Real-time collaboration system with cursor tracking and approval workflow
360 lines (303 loc) • 10.6 kB
text/typescript
import React, { useState, useEffect, useRef } from 'react';
import { RealtimeCursorSDK, RealtimeCursorConfig, User, Project, Comment, StagedChange, HistoryEntry } from './legacy';
export interface UseRealtimeCursorOptions {
autoConnect?: boolean;
}
export function useRealtimeCursor(config: RealtimeCursorConfig, options: UseRealtimeCursorOptions = {}) {
const [client] = useState(() => new RealtimeCursorSDK(config));
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!config.token);
// Check authentication status on mount
useEffect(() => {
if (config.token && options.autoConnect !== false) {
setLoading(true);
client.getCurrentUser()
.then(user => {
setUser(user);
setIsAuthenticated(true);
})
.catch(err => {
setError(err.message);
setIsAuthenticated(false);
})
.finally(() => {
setLoading(false);
});
}
}, []);
const login = async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const user = await client.login(email, password);
setUser(user);
setIsAuthenticated(true);
return user;
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
};
const register = async (userData: { email: string; password: string; firstName: string; lastName: string }) => {
setLoading(true);
setError(null);
try {
const user = await client.register(userData);
setUser(user);
setIsAuthenticated(true);
return user;
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
};
const logout = () => {
client.logout();
setUser(null);
setIsAuthenticated(false);
};
return {
client,
user,
loading,
error,
isAuthenticated,
login,
register,
logout
};
}
export function useProject(client: RealtimeCursorSDK, projectId?: string, user?: User) {
const [project, setProject] = useState<Project | null>(null);
const [content, setContent] = useState<string>('');
const [comments, setComments] = useState<Comment[]>([]);
const [stagedChanges, setStagedChanges] = useState<StagedChange[]>([]);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [collaborators, setCollaborators] = useState<any[]>([]);
const [cursors, setCursors] = useState<Record<string, any>>({});
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const socketConnected = useRef<boolean>(false);
// Load project data when projectId changes
useEffect(() => {
if (!projectId) return;
const loadProjectData = async () => {
setLoading(true);
setError(null);
try {
const project = await client.getProject(projectId);
setProject(project);
setContent(project.content || '');
// Load comments
const comments = await client.getComments(projectId);
setComments(comments);
// Load history
const history = await client.getHistory(projectId);
setHistory(history);
// If user is superadmin, load staged changes
try {
const stagedChanges = await client.getStagedChanges(projectId);
setStagedChanges(stagedChanges);
} catch (err) {
// Ignore errors - user might not have permission
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
loadProjectData();
}, [projectId, client]);
// Connect to real-time collaboration
useEffect(() => {
if (!projectId || !project || socketConnected.current) return;
try {
// Create a default user object if none is provided
const userInfo = user || { id: 'anonymous', email: 'anonymous@example.com' };
client.connectToProject(projectId, {
id: userInfo.id,
name: userInfo.fullName || userInfo.email
});
socketConnected.current = true;
// Set up event listeners
client.onRoomUsers(users => {
setCollaborators(users);
});
client.onUserJoined(({ user }) => {
setCollaborators(prev => [...prev, user]);
});
client.onUserLeft(({ socketId }) => {
setCollaborators(prev => prev.filter(u => u.socketId !== socketId));
setCursors(prev => {
const newCursors = { ...prev };
delete newCursors[socketId];
return newCursors;
});
});
client.onContentUpdate(({ content: newContent, user }) => {
setContent(newContent);
});
client.onCursorUpdate((data) => {
const { user, x, y, textPosition } = data;
// The socketId is added by the server, but not in the type definition
const socketId = (data as any).socketId;
setCursors(prev => ({
...prev,
[socketId]: { x, y, user, textPosition }
}));
});
return () => {
client.disconnect();
socketConnected.current = false;
};
} catch (err: any) {
setError(`Failed to connect to real-time collaboration: ${err.message}`);
}
}, [projectId, project, client]);
const updateContent = async (newContent: string) => {
setContent(newContent);
// Update content in real-time
client.updateContent(newContent);
// Save content to server
try {
const result = await client.updateProjectContent(projectId!, newContent);
if (result.staged) {
// Content was staged for approval
return { staged: true, changeId: result.changeId };
}
// Content was saved directly
// Create a default user object if none is provided
const userInfo = user || { id: 'anonymous', email: 'anonymous@example.com' };
client.notifyContentSaved(userInfo.fullName || userInfo.email);
client.notifyHistoryUpdate();
// Refresh history
const history = await client.getHistory(projectId!);
setHistory(history);
return { staged: false };
} catch (err: any) {
setError(err.message);
throw err;
}
};
const updateCursor = (position: { x: number; y: number; textPosition?: number }) => {
client.updateCursor(position);
};
const addComment = async (commentData: { text: string; selectedText: string; startPosition?: number; endPosition?: number }) => {
try {
const comment = await client.addComment(projectId!, commentData);
setComments(prev => [...prev, comment]);
return comment;
} catch (err: any) {
setError(err.message);
throw err;
}
};
const reviewChange = async (changeId: string, approve: boolean, feedback?: string) => {
try {
const result = await client.reviewChange(changeId, approve, feedback);
// Refresh staged changes
const stagedChanges = await client.getStagedChanges(projectId!);
setStagedChanges(stagedChanges);
// Refresh project data if change was approved
if (approve) {
const project = await client.getProject(projectId!);
setProject(project);
setContent(project.content || '');
}
// Refresh history
const history = await client.getHistory(projectId!);
setHistory(history);
return result;
} catch (err: any) {
setError(err.message);
throw err;
}
};
return {
project,
content,
comments,
stagedChanges,
history,
collaborators,
cursors,
loading,
error,
updateContent,
updateCursor,
addComment,
reviewChange
};
}
export function useCursorTracking(editorRef: React.RefObject<HTMLTextAreaElement>, client: RealtimeCursorSDK) {
useEffect(() => {
if (!editorRef.current) return;
const handleMouseMove = (e: MouseEvent) => {
if (!editorRef.current) return;
const rect = editorRef.current.getBoundingClientRect();
const relativeX = Math.max(0, e.clientX - rect.left);
const relativeY = Math.max(0, e.clientY - rect.top);
// Calculate text position
const textPosition = getTextPositionFromCoords(editorRef.current, relativeX, relativeY);
client.updateCursor({
x: e.clientX,
y: e.clientY,
textPosition
});
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!editorRef.current) return;
const cursorPosition = editorRef.current.selectionStart;
client.updateCursorPosition(cursorPosition);
};
const handleFocus = () => {
client.setTyping(true);
};
const handleBlur = () => {
client.setTyping(false);
};
editorRef.current.addEventListener('mousemove', handleMouseMove);
editorRef.current.addEventListener('keyup', handleKeyUp);
editorRef.current.addEventListener('focus', handleFocus);
editorRef.current.addEventListener('blur', handleBlur);
return () => {
if (editorRef.current) {
editorRef.current.removeEventListener('mousemove', handleMouseMove);
editorRef.current.removeEventListener('keyup', handleKeyUp);
editorRef.current.removeEventListener('focus', handleFocus);
editorRef.current.removeEventListener('blur', handleBlur);
}
};
}, [editorRef, client]);
}
// Helper function to calculate text position from coordinates
function getTextPositionFromCoords(element: HTMLTextAreaElement, x: number, y: number): number {
const content = element.value;
if (!content) return 0;
try {
const lineHeight = 24; // Approximate line height in pixels
const charWidth = 8.5; // Approximate character width in pixels
const line = Math.max(0, Math.floor(y / lineHeight));
const char = Math.max(0, Math.floor(x / charWidth));
const lines = content.split('\n');
if (lines.length === 0) return 0;
let position = 0;
for (let i = 0; i < line && i < lines.length; i++) {
position += (lines[i] || '').length + 1;
}
if (line < lines.length && lines[line]) {
position += Math.min(char, lines[line].length);
}
return Math.max(0, Math.min(position, content.length));
} catch (error) {
console.warn('Error calculating text position:', error);
return 0;
}
}