1048 lines
28 KiB
TypeScript
1048 lines
28 KiB
TypeScript
import * as signalR from "@microsoft/signalr";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
const API_URL = "http://localhost:5000";
|
|
const CURSOR_THROTTLE_MS = 50;
|
|
|
|
// ==================== TYPES ====================
|
|
|
|
export interface Collaborator {
|
|
connectionId: string;
|
|
userName: string;
|
|
color: string;
|
|
joinedAt: string;
|
|
selectedItemId: string | null;
|
|
cursorX: number | null;
|
|
cursorY: number | null;
|
|
currentViewId: string | null;
|
|
metadata: unknown;
|
|
isActive: boolean;
|
|
lastActivityAt: string;
|
|
}
|
|
|
|
export interface RoomState {
|
|
roomKey: string;
|
|
collaborators: Collaborator[];
|
|
joinedAt: string;
|
|
}
|
|
|
|
export interface DataChangeMessage {
|
|
itemId: string;
|
|
itemType: string;
|
|
changeType: string;
|
|
fieldPath?: string;
|
|
newValue: unknown;
|
|
oldValue?: unknown;
|
|
senderConnectionId?: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface ItemCreatedMessage {
|
|
itemId: string;
|
|
itemType: string;
|
|
item: unknown;
|
|
parentId?: string;
|
|
index?: number;
|
|
senderConnectionId?: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface ItemDeletedMessage {
|
|
itemId: string;
|
|
itemType: string;
|
|
senderConnectionId?: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface BatchOperationMessage {
|
|
operationType: string;
|
|
itemType: string;
|
|
data: unknown;
|
|
senderConnectionId?: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface SelectionChangedMessage {
|
|
connectionId: string;
|
|
itemId: string | null;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface CursorMovedMessage {
|
|
connectionId: string;
|
|
x: number;
|
|
y: number;
|
|
viewId: string | null;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface ViewChangedMessage {
|
|
connectionId: string;
|
|
viewId: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface UserTypingMessage {
|
|
connectionId: string;
|
|
itemId: string | null;
|
|
isTyping: boolean;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface SyncRequestMessage {
|
|
requesterId: string;
|
|
roomKey: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface SyncDataMessage {
|
|
roomKey: string;
|
|
dataJson: string;
|
|
senderConnectionId: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface DataSavedMessage {
|
|
savedBy: string;
|
|
roomKey: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface UserLeftMessage {
|
|
connectionId: string;
|
|
userName: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface ChangeHistoryEntry {
|
|
id: string;
|
|
type: "data_changed" | "item_created" | "item_deleted" | "batch_operation";
|
|
description: string;
|
|
userName: string;
|
|
userColor: string;
|
|
timestamp: Date;
|
|
itemId?: string;
|
|
itemType?: string;
|
|
}
|
|
|
|
// ==================== COLORS ====================
|
|
|
|
export const COLLABORATOR_COLORS = [
|
|
"#FF6B6B",
|
|
"#4ECDC4",
|
|
"#45B7D1",
|
|
"#96CEB4",
|
|
"#FFEAA7",
|
|
"#DDA0DD",
|
|
"#98D8C8",
|
|
"#F7DC6F",
|
|
"#BB8FCE",
|
|
"#85C1E9",
|
|
"#F8B500",
|
|
"#58D68D",
|
|
];
|
|
|
|
export function getColorForUser(userName: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < userName.length; i++) {
|
|
hash = userName.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
return COLLABORATOR_COLORS[Math.abs(hash) % COLLABORATOR_COLORS.length];
|
|
}
|
|
|
|
export function generateRandomUserName(): string {
|
|
const adjectives = [
|
|
"Veloce",
|
|
"Creativo",
|
|
"Brillante",
|
|
"Agile",
|
|
"Dinamico",
|
|
"Preciso",
|
|
"Elegante",
|
|
"Audace",
|
|
];
|
|
const nouns = [
|
|
"Designer",
|
|
"Editor",
|
|
"Artista",
|
|
"Creatore",
|
|
"Architetto",
|
|
"Compositore",
|
|
"Maestro",
|
|
"Inventore",
|
|
];
|
|
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
|
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
|
const num = Math.floor(Math.random() * 100);
|
|
return `${adj} ${noun} ${num}`;
|
|
}
|
|
|
|
export function getOrCreateUserName(): string {
|
|
const stored = localStorage.getItem("apollinare_username");
|
|
if (stored) return stored;
|
|
const newName = generateRandomUserName();
|
|
localStorage.setItem("apollinare_username", newName);
|
|
return newName;
|
|
}
|
|
|
|
export function saveUserName(userName: string): void {
|
|
localStorage.setItem("apollinare_username", userName);
|
|
}
|
|
|
|
// ==================== SERVICE ====================
|
|
|
|
type EventCallback<T> = (data: T) => void;
|
|
|
|
class CollaborationService {
|
|
private connection: signalR.HubConnection | null = null;
|
|
private currentRoom: string | null = null;
|
|
private userName: string = "";
|
|
private userColor: string = "";
|
|
private isConnecting = false;
|
|
private connectAbortController: AbortController | null = null;
|
|
|
|
// Event listeners
|
|
private userJoinedListeners = new Set<EventCallback<Collaborator>>();
|
|
private userLeftListeners = new Set<EventCallback<UserLeftMessage>>();
|
|
private roomStateListeners = new Set<EventCallback<RoomState>>();
|
|
private dataChangedListeners = new Set<EventCallback<DataChangeMessage>>();
|
|
private itemCreatedListeners = new Set<EventCallback<ItemCreatedMessage>>();
|
|
private itemDeletedListeners = new Set<EventCallback<ItemDeletedMessage>>();
|
|
private batchOperationListeners = new Set<
|
|
EventCallback<BatchOperationMessage>
|
|
>();
|
|
private selectionChangedListeners = new Set<
|
|
EventCallback<SelectionChangedMessage>
|
|
>();
|
|
private cursorMovedListeners = new Set<EventCallback<CursorMovedMessage>>();
|
|
private viewChangedListeners = new Set<EventCallback<ViewChangedMessage>>();
|
|
private userTypingListeners = new Set<EventCallback<UserTypingMessage>>();
|
|
private syncRequestedListeners = new Set<EventCallback<SyncRequestMessage>>();
|
|
private syncReceivedListeners = new Set<EventCallback<SyncDataMessage>>();
|
|
private dataSavedListeners = new Set<EventCallback<DataSavedMessage>>();
|
|
private connectionStateListeners = new Set<
|
|
EventCallback<{ connected: boolean; connecting: boolean }>
|
|
>();
|
|
|
|
// Change history (LIFO)
|
|
private changeHistory: ChangeHistoryEntry[] = [];
|
|
private changeHistoryListeners = new Set<
|
|
EventCallback<ChangeHistoryEntry[]>
|
|
>();
|
|
private maxHistoryEntries = 50;
|
|
|
|
// Collaborators cache for name/color lookup
|
|
private collaboratorsCache = new Map<string, Collaborator>();
|
|
|
|
// Cursor throttling
|
|
private lastCursorSend = 0;
|
|
private pendingCursorUpdate: {
|
|
x: number;
|
|
y: number;
|
|
viewId: string | null;
|
|
} | null = null;
|
|
private cursorThrottleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// ==================== GETTERS ====================
|
|
|
|
get isConnected(): boolean {
|
|
return this.connection?.state === signalR.HubConnectionState.Connected;
|
|
}
|
|
|
|
get currentUserName(): string {
|
|
return this.userName;
|
|
}
|
|
|
|
get currentUserColor(): string {
|
|
return this.userColor;
|
|
}
|
|
|
|
get connectionId(): string | null {
|
|
return this.connection?.connectionId ?? null;
|
|
}
|
|
|
|
get currentRoomKey(): string | null {
|
|
return this.currentRoom;
|
|
}
|
|
|
|
// ==================== CONNECTION ====================
|
|
|
|
async connect(): Promise<void> {
|
|
// If already connected, return immediately
|
|
if (this.isConnected) return;
|
|
|
|
// If already connecting, wait for the connection to complete
|
|
if (this.isConnecting) {
|
|
// Wait for connection state to change
|
|
return new Promise((resolve) => {
|
|
const checkConnection = () => {
|
|
if (this.isConnected) {
|
|
resolve();
|
|
} else if (!this.isConnecting) {
|
|
// Connection attempt finished but not connected - could be cancelled or failed
|
|
// Don't reject, just resolve silently (caller should check isConnected)
|
|
resolve();
|
|
} else {
|
|
setTimeout(checkConnection, 100);
|
|
}
|
|
};
|
|
setTimeout(checkConnection, 100);
|
|
});
|
|
}
|
|
|
|
this.isConnecting = true;
|
|
this.connectAbortController = new AbortController();
|
|
this.notifyConnectionState();
|
|
|
|
try {
|
|
this.connection = new signalR.HubConnectionBuilder()
|
|
.withUrl(`${API_URL}/hubs/collaboration`)
|
|
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000, 30000])
|
|
.configureLogging(signalR.LogLevel.Warning)
|
|
.build();
|
|
|
|
this.setupEventHandlers();
|
|
|
|
// Check if aborted before starting
|
|
if (this.connectAbortController.signal.aborted) {
|
|
console.log("[Collaboration] Connection aborted before start");
|
|
return;
|
|
}
|
|
|
|
await this.connection.start();
|
|
|
|
// Check if aborted after starting (disconnect was called during connection)
|
|
if (this.connectAbortController.signal.aborted) {
|
|
console.log(
|
|
"[Collaboration] Connection aborted after start, disconnecting",
|
|
);
|
|
await this.connection.stop();
|
|
return;
|
|
}
|
|
|
|
console.log("[Collaboration] Connected");
|
|
} catch (error) {
|
|
// Don't log error if it was an intentional abort
|
|
if (this.connectAbortController?.signal.aborted) {
|
|
console.log("[Collaboration] Connection aborted");
|
|
return;
|
|
}
|
|
console.error("[Collaboration] Connection failed:", error);
|
|
throw error;
|
|
} finally {
|
|
this.isConnecting = false;
|
|
this.connectAbortController = null;
|
|
this.notifyConnectionState();
|
|
}
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
// Abort any pending connection attempt
|
|
if (this.connectAbortController) {
|
|
this.connectAbortController.abort();
|
|
}
|
|
|
|
if (this.currentRoom) {
|
|
await this.leaveRoom();
|
|
}
|
|
if (this.connection) {
|
|
try {
|
|
await this.connection.stop();
|
|
} catch {
|
|
// Ignore errors when stopping
|
|
}
|
|
this.connection = null;
|
|
}
|
|
this.notifyConnectionState();
|
|
}
|
|
|
|
// ==================== ROOM MANAGEMENT ====================
|
|
|
|
async joinRoom(
|
|
roomKey: string,
|
|
userName?: string,
|
|
metadata?: unknown,
|
|
): Promise<void> {
|
|
// Ensure we're connected first
|
|
if (!this.isConnected) {
|
|
await this.connect();
|
|
}
|
|
|
|
// Check connection - if not connected (possibly due to abort), just return silently
|
|
if (!this.isConnected || !this.connection) {
|
|
console.log(
|
|
"[Collaboration] Cannot join room: not connected (connection may have been cancelled)",
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Leave current room if different
|
|
if (this.currentRoom && this.currentRoom !== roomKey) {
|
|
await this.leaveRoom();
|
|
}
|
|
|
|
if (this.currentRoom === roomKey) return;
|
|
|
|
this.userName = userName || getOrCreateUserName();
|
|
this.userColor = getColorForUser(this.userName);
|
|
|
|
try {
|
|
await this.connection.invoke(
|
|
"JoinRoom",
|
|
roomKey,
|
|
this.userName,
|
|
this.userColor,
|
|
metadata,
|
|
);
|
|
this.currentRoom = roomKey;
|
|
console.log(`[Collaboration] Joined room: ${roomKey}`);
|
|
} catch (error) {
|
|
console.error("[Collaboration] Failed to join room:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async leaveRoom(): Promise<void> {
|
|
if (!this.currentRoom) return;
|
|
|
|
// Only try to invoke if actually connected
|
|
if (this.isConnected && this.connection) {
|
|
try {
|
|
await this.connection.invoke("LeaveRoom", this.currentRoom);
|
|
console.log(`[Collaboration] Left room: ${this.currentRoom}`);
|
|
} catch (error) {
|
|
console.error("[Collaboration] Error leaving room:", error);
|
|
}
|
|
}
|
|
|
|
// Always clean up local state
|
|
this.currentRoom = null;
|
|
this.collaboratorsCache.clear();
|
|
this.changeHistory = [];
|
|
this.notifyChangeHistory();
|
|
}
|
|
|
|
async switchRoom(newRoomKey: string, metadata?: unknown): Promise<void> {
|
|
if (!this.isConnected) {
|
|
await this.connect();
|
|
}
|
|
|
|
// Check connection - if not connected (possibly due to abort), just return silently
|
|
if (!this.isConnected || !this.connection) {
|
|
console.log(
|
|
"[Collaboration] Cannot switch room: not connected (connection may have been cancelled)",
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.connection.invoke(
|
|
"SwitchRoom",
|
|
newRoomKey,
|
|
this.userName,
|
|
this.userColor,
|
|
metadata,
|
|
);
|
|
this.currentRoom = newRoomKey;
|
|
this.collaboratorsCache.clear();
|
|
console.log(`[Collaboration] Switched to room: ${newRoomKey}`);
|
|
} catch (error) {
|
|
console.error("[Collaboration] Failed to switch room:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ==================== DATA SYNC ====================
|
|
|
|
sendDataChanged(
|
|
itemId: string,
|
|
itemType: string,
|
|
changeType: string,
|
|
newValue: unknown,
|
|
fieldPath?: string,
|
|
): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke("DataChanged", this.currentRoom, {
|
|
itemId,
|
|
itemType,
|
|
changeType,
|
|
fieldPath,
|
|
newValue,
|
|
}).catch((err) =>
|
|
console.error("[Collaboration] Error sending data change:", err),
|
|
);
|
|
}
|
|
|
|
sendItemCreated(
|
|
itemId: string,
|
|
itemType: string,
|
|
item: unknown,
|
|
parentId?: string,
|
|
index?: number,
|
|
): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke("ItemCreated", this.currentRoom, {
|
|
itemId,
|
|
itemType,
|
|
item,
|
|
parentId,
|
|
index,
|
|
}).catch((err) =>
|
|
console.error("[Collaboration] Error sending item created:", err),
|
|
);
|
|
}
|
|
|
|
sendItemDeleted(itemId: string, itemType: string): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke("ItemDeleted", this.currentRoom, {
|
|
itemId,
|
|
itemType,
|
|
}).catch((err) =>
|
|
console.error("[Collaboration] Error sending item deleted:", err),
|
|
);
|
|
}
|
|
|
|
sendBatchOperation(
|
|
operationType: string,
|
|
itemType: string,
|
|
data: unknown,
|
|
): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke("BatchOperation", this.currentRoom, {
|
|
operationType,
|
|
itemType,
|
|
data,
|
|
}).catch((err) =>
|
|
console.error("[Collaboration] Error sending batch operation:", err),
|
|
);
|
|
}
|
|
|
|
// ==================== PRESENCE ====================
|
|
|
|
sendSelectionChanged(itemId: string | null): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke("SelectionChanged", this.currentRoom, itemId).catch(
|
|
(err) => console.error("[Collaboration] Error sending selection:", err),
|
|
);
|
|
}
|
|
|
|
sendCursorMoved(x: number, y: number, viewId?: string | null): void {
|
|
if (!this.canSend()) return;
|
|
|
|
const now = Date.now();
|
|
|
|
if (now - this.lastCursorSend >= CURSOR_THROTTLE_MS) {
|
|
this.doSendCursor(x, y, viewId ?? null);
|
|
this.lastCursorSend = now;
|
|
this.pendingCursorUpdate = null;
|
|
} else {
|
|
this.pendingCursorUpdate = { x, y, viewId: viewId ?? null };
|
|
|
|
if (!this.cursorThrottleTimer) {
|
|
this.cursorThrottleTimer = setTimeout(
|
|
() => {
|
|
if (this.pendingCursorUpdate) {
|
|
this.doSendCursor(
|
|
this.pendingCursorUpdate.x,
|
|
this.pendingCursorUpdate.y,
|
|
this.pendingCursorUpdate.viewId,
|
|
);
|
|
this.lastCursorSend = Date.now();
|
|
this.pendingCursorUpdate = null;
|
|
}
|
|
this.cursorThrottleTimer = null;
|
|
},
|
|
CURSOR_THROTTLE_MS - (now - this.lastCursorSend),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private doSendCursor(x: number, y: number, viewId: string | null): void {
|
|
this.connection!.invoke(
|
|
"CursorMoved",
|
|
this.currentRoom,
|
|
x,
|
|
y,
|
|
viewId,
|
|
).catch((err) =>
|
|
console.error("[Collaboration] Error sending cursor:", err),
|
|
);
|
|
}
|
|
|
|
sendViewChanged(viewId: string): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke("ViewChanged", this.currentRoom, viewId).catch(
|
|
(err) => console.error("[Collaboration] Error sending view change:", err),
|
|
);
|
|
}
|
|
|
|
sendUserTyping(itemId: string | null, isTyping: boolean): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke(
|
|
"UserTyping",
|
|
this.currentRoom,
|
|
itemId,
|
|
isTyping,
|
|
).catch((err) =>
|
|
console.error("[Collaboration] Error sending typing:", err),
|
|
);
|
|
}
|
|
|
|
// ==================== SYNC ====================
|
|
|
|
requestSync(): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke("RequestSync", this.currentRoom).catch((err) =>
|
|
console.error("[Collaboration] Error requesting sync:", err),
|
|
);
|
|
}
|
|
|
|
sendSync(targetConnectionId: string, dataJson: string): void {
|
|
if (!this.canSend()) return;
|
|
|
|
this.connection!.invoke(
|
|
"SendSync",
|
|
this.currentRoom,
|
|
targetConnectionId,
|
|
dataJson,
|
|
).catch((err) => console.error("[Collaboration] Error sending sync:", err));
|
|
}
|
|
|
|
sendDataSaved(): void {
|
|
console.log(
|
|
"[Collaboration] sendDataSaved called, canSend:",
|
|
this.canSend(),
|
|
"isConnected:",
|
|
this.isConnected,
|
|
"currentRoom:",
|
|
this.currentRoom,
|
|
);
|
|
if (!this.canSend()) {
|
|
console.log(
|
|
"[Collaboration] sendDataSaved: canSend is false, not sending",
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log("[Collaboration] sendDataSaved: invoking DataSaved on server");
|
|
this.connection!.invoke("DataSaved", this.currentRoom, this.userName).catch(
|
|
(err) => console.error("[Collaboration] Error sending data saved:", err),
|
|
);
|
|
}
|
|
|
|
// ==================== SUBSCRIPTIONS ====================
|
|
|
|
onUserJoined(callback: EventCallback<Collaborator>): () => void {
|
|
this.userJoinedListeners.add(callback);
|
|
return () => this.userJoinedListeners.delete(callback);
|
|
}
|
|
|
|
onUserLeft(callback: EventCallback<UserLeftMessage>): () => void {
|
|
this.userLeftListeners.add(callback);
|
|
return () => this.userLeftListeners.delete(callback);
|
|
}
|
|
|
|
onRoomState(callback: EventCallback<RoomState>): () => void {
|
|
this.roomStateListeners.add(callback);
|
|
return () => this.roomStateListeners.delete(callback);
|
|
}
|
|
|
|
onDataChanged(callback: EventCallback<DataChangeMessage>): () => void {
|
|
this.dataChangedListeners.add(callback);
|
|
return () => this.dataChangedListeners.delete(callback);
|
|
}
|
|
|
|
onItemCreated(callback: EventCallback<ItemCreatedMessage>): () => void {
|
|
this.itemCreatedListeners.add(callback);
|
|
return () => this.itemCreatedListeners.delete(callback);
|
|
}
|
|
|
|
onItemDeleted(callback: EventCallback<ItemDeletedMessage>): () => void {
|
|
this.itemDeletedListeners.add(callback);
|
|
return () => this.itemDeletedListeners.delete(callback);
|
|
}
|
|
|
|
onBatchOperation(callback: EventCallback<BatchOperationMessage>): () => void {
|
|
this.batchOperationListeners.add(callback);
|
|
return () => this.batchOperationListeners.delete(callback);
|
|
}
|
|
|
|
onSelectionChanged(
|
|
callback: EventCallback<SelectionChangedMessage>,
|
|
): () => void {
|
|
this.selectionChangedListeners.add(callback);
|
|
return () => this.selectionChangedListeners.delete(callback);
|
|
}
|
|
|
|
onCursorMoved(callback: EventCallback<CursorMovedMessage>): () => void {
|
|
this.cursorMovedListeners.add(callback);
|
|
return () => this.cursorMovedListeners.delete(callback);
|
|
}
|
|
|
|
onViewChanged(callback: EventCallback<ViewChangedMessage>): () => void {
|
|
this.viewChangedListeners.add(callback);
|
|
return () => this.viewChangedListeners.delete(callback);
|
|
}
|
|
|
|
onUserTyping(callback: EventCallback<UserTypingMessage>): () => void {
|
|
this.userTypingListeners.add(callback);
|
|
return () => this.userTypingListeners.delete(callback);
|
|
}
|
|
|
|
onSyncRequested(callback: EventCallback<SyncRequestMessage>): () => void {
|
|
this.syncRequestedListeners.add(callback);
|
|
return () => this.syncRequestedListeners.delete(callback);
|
|
}
|
|
|
|
onSyncReceived(callback: EventCallback<SyncDataMessage>): () => void {
|
|
this.syncReceivedListeners.add(callback);
|
|
return () => this.syncReceivedListeners.delete(callback);
|
|
}
|
|
|
|
onDataSaved(callback: EventCallback<DataSavedMessage>): () => void {
|
|
this.dataSavedListeners.add(callback);
|
|
return () => this.dataSavedListeners.delete(callback);
|
|
}
|
|
|
|
onConnectionStateChanged(
|
|
callback: EventCallback<{ connected: boolean; connecting: boolean }>,
|
|
): () => void {
|
|
this.connectionStateListeners.add(callback);
|
|
return () => this.connectionStateListeners.delete(callback);
|
|
}
|
|
|
|
onChangeHistoryUpdated(
|
|
callback: EventCallback<ChangeHistoryEntry[]>,
|
|
): () => void {
|
|
this.changeHistoryListeners.add(callback);
|
|
return () => this.changeHistoryListeners.delete(callback);
|
|
}
|
|
|
|
getChangeHistory(): ChangeHistoryEntry[] {
|
|
return [...this.changeHistory];
|
|
}
|
|
|
|
getCollaborator(connectionId: string): Collaborator | undefined {
|
|
return this.collaboratorsCache.get(connectionId);
|
|
}
|
|
|
|
// ==================== PRIVATE ====================
|
|
|
|
private canSend(): boolean {
|
|
return this.isConnected && this.currentRoom !== null;
|
|
}
|
|
|
|
private notifyConnectionState(): void {
|
|
const state = {
|
|
connected: this.isConnected,
|
|
connecting: this.isConnecting,
|
|
};
|
|
this.connectionStateListeners.forEach((cb) => {
|
|
try {
|
|
cb(state);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
}
|
|
|
|
private notifyChangeHistory(): void {
|
|
this.changeHistoryListeners.forEach((cb) => {
|
|
try {
|
|
cb([...this.changeHistory]);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
}
|
|
|
|
private addChangeHistoryEntry(
|
|
entry: Omit<ChangeHistoryEntry, "id" | "timestamp">,
|
|
): void {
|
|
const fullEntry: ChangeHistoryEntry = {
|
|
...entry,
|
|
id: uuidv4(),
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
this.changeHistory.unshift(fullEntry);
|
|
|
|
if (this.changeHistory.length > this.maxHistoryEntries) {
|
|
this.changeHistory = this.changeHistory.slice(0, this.maxHistoryEntries);
|
|
}
|
|
|
|
this.notifyChangeHistory();
|
|
}
|
|
|
|
private getCollaboratorName(connectionId?: string): string {
|
|
if (!connectionId) return "Sconosciuto";
|
|
return this.collaboratorsCache.get(connectionId)?.userName || "Sconosciuto";
|
|
}
|
|
|
|
private getCollaboratorColor(connectionId?: string): string {
|
|
if (!connectionId) return "#888888";
|
|
return this.collaboratorsCache.get(connectionId)?.color || "#888888";
|
|
}
|
|
|
|
private setupEventHandlers(): void {
|
|
if (!this.connection) return;
|
|
|
|
// Room state (received on join)
|
|
this.connection.on("RoomState", (state: RoomState) => {
|
|
state.collaborators.forEach((c) =>
|
|
this.collaboratorsCache.set(c.connectionId, c),
|
|
);
|
|
this.roomStateListeners.forEach((cb) => {
|
|
try {
|
|
cb(state);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// User joined
|
|
this.connection.on("UserJoined", (collaborator: Collaborator) => {
|
|
this.collaboratorsCache.set(collaborator.connectionId, collaborator);
|
|
this.userJoinedListeners.forEach((cb) => {
|
|
try {
|
|
cb(collaborator);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// User left
|
|
this.connection.on("UserLeft", (message: UserLeftMessage) => {
|
|
this.collaboratorsCache.delete(message.connectionId);
|
|
this.userLeftListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Data changed
|
|
this.connection.on("DataChanged", (message: DataChangeMessage) => {
|
|
this.dataChangedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
|
|
this.addChangeHistoryEntry({
|
|
type: "data_changed",
|
|
description: `Modificato ${message.itemType}`,
|
|
userName: this.getCollaboratorName(message.senderConnectionId),
|
|
userColor: this.getCollaboratorColor(message.senderConnectionId),
|
|
itemId: message.itemId,
|
|
itemType: message.itemType,
|
|
});
|
|
});
|
|
|
|
// Item created
|
|
this.connection.on("ItemCreated", (message: ItemCreatedMessage) => {
|
|
this.itemCreatedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
|
|
this.addChangeHistoryEntry({
|
|
type: "item_created",
|
|
description: `Creato ${message.itemType}`,
|
|
userName: this.getCollaboratorName(message.senderConnectionId),
|
|
userColor: this.getCollaboratorColor(message.senderConnectionId),
|
|
itemId: message.itemId,
|
|
itemType: message.itemType,
|
|
});
|
|
});
|
|
|
|
// Item deleted
|
|
this.connection.on("ItemDeleted", (message: ItemDeletedMessage) => {
|
|
this.itemDeletedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
|
|
this.addChangeHistoryEntry({
|
|
type: "item_deleted",
|
|
description: `Eliminato ${message.itemType}`,
|
|
userName: this.getCollaboratorName(message.senderConnectionId),
|
|
userColor: this.getCollaboratorColor(message.senderConnectionId),
|
|
itemId: message.itemId,
|
|
itemType: message.itemType,
|
|
});
|
|
});
|
|
|
|
// Batch operation
|
|
this.connection.on("BatchOperation", (message: BatchOperationMessage) => {
|
|
this.batchOperationListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
|
|
this.addChangeHistoryEntry({
|
|
type: "batch_operation",
|
|
description: `${message.operationType} su ${message.itemType}`,
|
|
userName: this.getCollaboratorName(message.senderConnectionId),
|
|
userColor: this.getCollaboratorColor(message.senderConnectionId),
|
|
itemType: message.itemType,
|
|
});
|
|
});
|
|
|
|
// Selection changed
|
|
this.connection.on(
|
|
"SelectionChanged",
|
|
(message: SelectionChangedMessage) => {
|
|
const collab = this.collaboratorsCache.get(message.connectionId);
|
|
if (collab) collab.selectedItemId = message.itemId;
|
|
|
|
this.selectionChangedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
// Cursor moved
|
|
this.connection.on("CursorMoved", (message: CursorMovedMessage) => {
|
|
const collab = this.collaboratorsCache.get(message.connectionId);
|
|
if (collab) {
|
|
collab.cursorX = message.x;
|
|
collab.cursorY = message.y;
|
|
collab.currentViewId = message.viewId;
|
|
}
|
|
|
|
this.cursorMovedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// View changed
|
|
this.connection.on("ViewChanged", (message: ViewChangedMessage) => {
|
|
const collab = this.collaboratorsCache.get(message.connectionId);
|
|
if (collab) collab.currentViewId = message.viewId;
|
|
|
|
this.viewChangedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// User typing
|
|
this.connection.on("UserTyping", (message: UserTypingMessage) => {
|
|
this.userTypingListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Sync requested
|
|
this.connection.on("SyncRequested", (message: SyncRequestMessage) => {
|
|
this.syncRequestedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Sync received
|
|
this.connection.on("SyncReceived", (message: SyncDataMessage) => {
|
|
this.syncReceivedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Data saved
|
|
this.connection.on("DataSaved", (message: DataSavedMessage) => {
|
|
this.dataSavedListeners.forEach((cb) => {
|
|
try {
|
|
cb(message);
|
|
} catch (e) {
|
|
console.error("[Collaboration] Listener error:", e);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Reconnection handling
|
|
this.connection.onreconnecting(() => {
|
|
console.log("[Collaboration] Reconnecting...");
|
|
this.notifyConnectionState();
|
|
});
|
|
|
|
this.connection.onreconnected(async () => {
|
|
console.log("[Collaboration] Reconnected");
|
|
this.notifyConnectionState();
|
|
|
|
// Rejoin room if we were in one - with a small delay to ensure connection is stable
|
|
if (
|
|
this.currentRoom &&
|
|
this.connection?.state === signalR.HubConnectionState.Connected
|
|
) {
|
|
try {
|
|
await this.connection.invoke(
|
|
"JoinRoom",
|
|
this.currentRoom,
|
|
this.userName,
|
|
this.userColor,
|
|
null,
|
|
);
|
|
console.log(`[Collaboration] Rejoined room: ${this.currentRoom}`);
|
|
} catch (err) {
|
|
console.error("[Collaboration] Error rejoining room:", err);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.connection.onclose(() => {
|
|
console.log("[Collaboration] Connection closed");
|
|
this.notifyConnectionState();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Singleton export
|
|
export const collaborationService = new CollaborationService();
|