-
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
type SyncRequestMessage,
|
||||
type SyncDataMessage,
|
||||
type DataSavedMessage,
|
||||
type TemplateSyncMessage,
|
||||
type UserLeftMessage,
|
||||
getOrCreateUserName,
|
||||
getColorForUser,
|
||||
@@ -93,6 +94,7 @@ export interface CollaborationContextValue {
|
||||
requestSync: () => void;
|
||||
sendSync: (targetConnectionId: string, dataJson: string) => void;
|
||||
sendDataSaved: () => void;
|
||||
broadcastTemplateSync: (templateJson: string, version: number) => void;
|
||||
|
||||
// Event subscriptions for component-specific handlers
|
||||
onDataChanged: (callback: (msg: DataChangeMessage) => void) => () => void;
|
||||
@@ -104,6 +106,7 @@ export interface CollaborationContextValue {
|
||||
onSyncRequested: (callback: (msg: SyncRequestMessage) => void) => () => void;
|
||||
onSyncReceived: (callback: (msg: SyncDataMessage) => void) => () => void;
|
||||
onDataSaved: (callback: (msg: DataSavedMessage) => void) => () => void;
|
||||
onTemplateSync: (callback: (msg: TemplateSyncMessage) => void) => () => void;
|
||||
}
|
||||
|
||||
const CollaborationContext = createContext<CollaborationContextValue | null>(
|
||||
@@ -409,6 +412,13 @@ export function CollaborationProvider({
|
||||
collaborationService.sendDataSaved();
|
||||
}, []);
|
||||
|
||||
const broadcastTemplateSync = useCallback(
|
||||
(templateJson: string, version: number) => {
|
||||
collaborationService.broadcastTemplateSync(templateJson, version);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Event subscription pass-through
|
||||
const onDataChanged = useCallback(
|
||||
(callback: (msg: DataChangeMessage) => void) => {
|
||||
@@ -459,6 +469,13 @@ export function CollaborationProvider({
|
||||
[],
|
||||
);
|
||||
|
||||
const onTemplateSync = useCallback(
|
||||
(callback: (msg: TemplateSyncMessage) => void) => {
|
||||
return collaborationService.onTemplateSync(callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value: CollaborationContextValue = {
|
||||
// Connection state
|
||||
isConnected,
|
||||
@@ -499,6 +516,7 @@ export function CollaborationProvider({
|
||||
requestSync,
|
||||
sendSync,
|
||||
sendDataSaved,
|
||||
broadcastTemplateSync,
|
||||
|
||||
// Event subscriptions
|
||||
onDataChanged,
|
||||
@@ -508,6 +526,7 @@ export function CollaborationProvider({
|
||||
onSyncRequested,
|
||||
onSyncReceived,
|
||||
onDataSaved,
|
||||
onTemplateSync,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -178,6 +178,9 @@ export default function ReportEditorPage() {
|
||||
// Auto-save feature - enabled by default
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
|
||||
|
||||
// Template version for collaboration sync (increments on each save)
|
||||
const templateVersionRef = useRef(0);
|
||||
|
||||
// ============ COLLABORATION (using global context) ============
|
||||
// Room key format: "report-template:{id}"
|
||||
const roomKey = id ? `report-template:${id}` : null;
|
||||
@@ -315,19 +318,68 @@ export default function ReportEditorPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
// Template saved by remote user
|
||||
// Template sync received - apply template directly without server reload
|
||||
unsubscribers.push(
|
||||
collaboration.onTemplateSync((message) => {
|
||||
console.log(
|
||||
`[Collaboration] Received TemplateSync, version ${message.version}, size: ${message.templateJson.length} bytes`,
|
||||
);
|
||||
|
||||
// Only apply if the received version is newer
|
||||
if (message.version <= templateVersionRef.current) {
|
||||
console.log(
|
||||
`[Collaboration] Ignoring older template version ${message.version} (current: ${templateVersionRef.current})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isApplyingRemoteChange.current = true;
|
||||
const receivedTemplate = JSON.parse(
|
||||
message.templateJson,
|
||||
) as AprtTemplate;
|
||||
|
||||
// Update version to received version
|
||||
templateVersionRef.current = message.version;
|
||||
|
||||
// Apply template without adding to history (it's a sync, not a user action)
|
||||
historyActions.setWithoutHistory(() => receivedTemplate);
|
||||
|
||||
// Show notification
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: "Template aggiornato da un altro utente",
|
||||
severity: "success",
|
||||
});
|
||||
|
||||
// Also update the query cache so it stays in sync
|
||||
queryClient.setQueryData(["report-template", id], (old: unknown) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...(old as Record<string, unknown>),
|
||||
templateJson: message.templateJson,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Collaboration] Error parsing received template:", e);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isApplyingRemoteChange.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Legacy DataSaved handler (for backwards compatibility - will be removed)
|
||||
unsubscribers.push(
|
||||
collaboration.onDataSaved((message) => {
|
||||
console.log(
|
||||
"[Collaboration] Received DataSaved from:",
|
||||
message.savedBy,
|
||||
"(legacy - should use TemplateSync instead)",
|
||||
);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: `${message.savedBy} ha salvato il template`,
|
||||
severity: "success",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["report-template", id] });
|
||||
// Only reload from server if we haven't received a TemplateSync
|
||||
// This is a fallback for older clients
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -529,16 +581,30 @@ export default function ReportEditorPage() {
|
||||
// Mark current state as saved
|
||||
setLastSavedUndoCount(templateHistory.undoCount);
|
||||
|
||||
// Notify collaborators of save
|
||||
console.log(
|
||||
"[AutoSave] Save success, collaboration.isConnected:",
|
||||
collaboration.isConnected,
|
||||
"currentRoom:",
|
||||
collaboration.currentRoom,
|
||||
);
|
||||
// Broadcast template to collaborators (instant sync without server reload)
|
||||
if (collaboration.isConnected && collaboration.currentRoom) {
|
||||
console.log("[AutoSave] Sending DataSaved notification");
|
||||
collaboration.sendDataSaved();
|
||||
// Update dataSources in template for broadcasting
|
||||
const updatedTemplateForSync = {
|
||||
...template,
|
||||
dataSources: selectedDatasets.reduce(
|
||||
(acc, ds) => {
|
||||
acc[ds.id] = { type: "object" as const, schema: ds.id };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { type: "object"; schema: string }>,
|
||||
),
|
||||
};
|
||||
|
||||
// Increment version and broadcast
|
||||
templateVersionRef.current += 1;
|
||||
const templateJson = JSON.stringify(updatedTemplateForSync);
|
||||
console.log(
|
||||
`[AutoSave] Broadcasting template sync, version ${templateVersionRef.current}, size: ${templateJson.length} bytes`,
|
||||
);
|
||||
collaboration.broadcastTemplateSync(
|
||||
templateJson,
|
||||
templateVersionRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
@@ -1452,38 +1518,49 @@ export default function ReportEditorPage() {
|
||||
selectedElementId,
|
||||
]);
|
||||
|
||||
// Auto-save effect - saves after 1 second of inactivity when there are unsaved changes
|
||||
// Use refs to avoid the effect re-running on every render due to saveMutation changing
|
||||
// Auto-save: simple debounced save on every template change
|
||||
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveMutationRef = useRef(saveMutation);
|
||||
saveMutationRef.current = saveMutation;
|
||||
|
||||
const templateRef = useRef(template);
|
||||
templateRef.current = template;
|
||||
|
||||
const templateInfoRef = useRef(templateInfo);
|
||||
templateInfoRef.current = templateInfo;
|
||||
const templateForSaveRef = useRef(template);
|
||||
templateForSaveRef.current = template;
|
||||
const templateInfoForSaveRef = useRef(templateInfo);
|
||||
templateInfoForSaveRef.current = templateInfo;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!autoSaveEnabled ||
|
||||
!hasUnsavedChanges ||
|
||||
isNew ||
|
||||
saveMutationRef.current.isPending
|
||||
) {
|
||||
// Skip if disabled or new template
|
||||
if (!autoSaveEnabled || isNew) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
// Skip if no unsaved changes
|
||||
if (!hasUnsavedChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous timeout (debounce)
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Save after 500ms debounce
|
||||
autoSaveTimeoutRef.current = setTimeout(() => {
|
||||
// Check if not already saving at the moment of execution
|
||||
if (!saveMutationRef.current.isPending) {
|
||||
console.log("[AutoSave] Saving...");
|
||||
saveMutationRef.current.mutate({
|
||||
template: templateRef.current,
|
||||
info: templateInfoRef.current,
|
||||
template: templateForSaveRef.current,
|
||||
info: templateInfoForSaveRef.current,
|
||||
});
|
||||
}
|
||||
}, 1000); // 1 second debounce
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [autoSaveEnabled, hasUnsavedChanges, isNew]);
|
||||
return () => {
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoSaveEnabled, isNew, hasUnsavedChanges, templateHistory.undoCount]);
|
||||
|
||||
if (isLoadingTemplate && id) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import axios from "axios";
|
||||
|
||||
// Usa variabile ambiente o fallback a window.location per accesso esterno
|
||||
const getApiBaseUrl = (): string => {
|
||||
// Prima controlla la variabile ambiente (deve essere non-vuota)
|
||||
const envUrl = import.meta.env.VITE_API_URL;
|
||||
if (envUrl && envUrl.trim() !== "") {
|
||||
return `${envUrl}/api`;
|
||||
}
|
||||
// Fallback: usa lo stesso host del frontend ma porta 5000
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:5000/api`;
|
||||
};
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "http://localhost:5000/api",
|
||||
baseURL: getApiBaseUrl(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const API_URL = "http://localhost:5000";
|
||||
// Usa variabile ambiente o fallback a window.location per accesso esterno
|
||||
const getApiUrl = (): string => {
|
||||
// Prima controlla la variabile ambiente (deve essere non-vuota)
|
||||
const envUrl = import.meta.env.VITE_API_URL;
|
||||
if (envUrl && envUrl.trim() !== "") {
|
||||
return envUrl;
|
||||
}
|
||||
// Fallback: usa lo stesso host del frontend ma porta 5000
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:5000`;
|
||||
};
|
||||
|
||||
const CURSOR_THROTTLE_MS = 50;
|
||||
|
||||
// ==================== TYPES ====================
|
||||
@@ -108,6 +120,113 @@ export interface DataSavedMessage {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TemplateSyncMessage {
|
||||
templateJson: string;
|
||||
senderConnectionId: string;
|
||||
version: number;
|
||||
timestamp: string;
|
||||
compressed?: boolean; // Whether the templateJson is base64-encoded gzip
|
||||
}
|
||||
|
||||
// ==================== COMPRESSION UTILITIES ====================
|
||||
|
||||
const COMPRESSION_THRESHOLD = 10000; // Compress if JSON is > 10KB
|
||||
|
||||
/**
|
||||
* Compress a string using gzip and return base64-encoded result
|
||||
*/
|
||||
async function compressString(input: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
|
||||
const cs = new CompressionStream("gzip");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(data);
|
||||
writer.close();
|
||||
|
||||
const compressedChunks: Uint8Array[] = [];
|
||||
const reader = cs.readable.getReader();
|
||||
|
||||
let result = await reader.read();
|
||||
while (!result.done) {
|
||||
compressedChunks.push(result.value);
|
||||
result = await reader.read();
|
||||
}
|
||||
|
||||
// Combine chunks
|
||||
const totalLength = compressedChunks.reduce(
|
||||
(acc, arr) => acc + arr.length,
|
||||
0,
|
||||
);
|
||||
const compressed = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of compressedChunks) {
|
||||
compressed.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
return btoa(String.fromCharCode(...compressed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a base64-encoded gzip string
|
||||
*/
|
||||
async function decompressString(base64Input: string): Promise<string> {
|
||||
// Convert base64 to Uint8Array
|
||||
const binaryString = atob(base64Input);
|
||||
const compressed = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
compressed[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const ds = new DecompressionStream("gzip");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(compressed);
|
||||
writer.close();
|
||||
|
||||
const decompressedChunks: Uint8Array[] = [];
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
let result = await reader.read();
|
||||
while (!result.done) {
|
||||
decompressedChunks.push(result.value);
|
||||
result = await reader.read();
|
||||
}
|
||||
|
||||
// Combine chunks
|
||||
const totalLength = decompressedChunks.reduce(
|
||||
(acc, arr) => acc + arr.length,
|
||||
0,
|
||||
);
|
||||
const decompressed = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of decompressedChunks) {
|
||||
decompressed.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decompressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CompressionStream is available (modern browsers)
|
||||
*/
|
||||
function isCompressionAvailable(): boolean {
|
||||
return (
|
||||
typeof CompressionStream !== "undefined" &&
|
||||
typeof DecompressionStream !== "undefined"
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
compressString,
|
||||
decompressString,
|
||||
isCompressionAvailable,
|
||||
COMPRESSION_THRESHOLD,
|
||||
};
|
||||
|
||||
export interface UserLeftMessage {
|
||||
connectionId: string;
|
||||
userName: string;
|
||||
@@ -220,6 +339,7 @@ class CollaborationService {
|
||||
private syncRequestedListeners = new Set<EventCallback<SyncRequestMessage>>();
|
||||
private syncReceivedListeners = new Set<EventCallback<SyncDataMessage>>();
|
||||
private dataSavedListeners = new Set<EventCallback<DataSavedMessage>>();
|
||||
private templateSyncListeners = new Set<EventCallback<TemplateSyncMessage>>();
|
||||
private connectionStateListeners = new Set<
|
||||
EventCallback<{ connected: boolean; connecting: boolean }>
|
||||
>();
|
||||
@@ -295,8 +415,10 @@ class CollaborationService {
|
||||
this.notifyConnectionState();
|
||||
|
||||
try {
|
||||
const apiUrl = getApiUrl(); // Calcola URL al momento della connessione
|
||||
console.log(`[Collaboration] Connecting to ${apiUrl}/hubs/collaboration`);
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${API_URL}/hubs/collaboration`)
|
||||
.withUrl(`${apiUrl}/hubs/collaboration`)
|
||||
.withAutomaticReconnect([0, 1000, 2000, 5000, 10000, 30000])
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
@@ -639,6 +761,63 @@ class CollaborationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the full template to all collaborators in the room
|
||||
* This is more efficient than having each client reload from the server
|
||||
* Uses compression for large templates (>10KB)
|
||||
* @param templateJson The full template JSON string
|
||||
* @param version Incrementing version number for conflict detection
|
||||
*/
|
||||
async broadcastTemplateSync(
|
||||
templateJson: string,
|
||||
version: number,
|
||||
): Promise<void> {
|
||||
if (!this.canSend()) {
|
||||
console.log("[Collaboration] broadcastTemplateSync: canSend is false");
|
||||
return;
|
||||
}
|
||||
|
||||
let dataToSend = templateJson;
|
||||
let compressed = false;
|
||||
|
||||
// Compress if large enough and compression is available
|
||||
if (
|
||||
templateJson.length > COMPRESSION_THRESHOLD &&
|
||||
isCompressionAvailable()
|
||||
) {
|
||||
try {
|
||||
const compressedData = await compressString(templateJson);
|
||||
// Only use compression if it actually reduces size
|
||||
if (compressedData.length < templateJson.length * 0.9) {
|
||||
dataToSend = compressedData;
|
||||
compressed = true;
|
||||
console.log(
|
||||
`[Collaboration] Compressed template: ${templateJson.length} -> ${compressedData.length} bytes (${Math.round((1 - compressedData.length / templateJson.length) * 100)}% reduction)`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[Collaboration] Compression failed, sending uncompressed:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Collaboration] Broadcasting template sync, version ${version}, size: ${dataToSend.length} bytes, compressed: ${compressed}`,
|
||||
);
|
||||
|
||||
this.connection!.invoke(
|
||||
"BroadcastTemplateSync",
|
||||
this.currentRoom,
|
||||
dataToSend,
|
||||
version,
|
||||
compressed,
|
||||
).catch((err) =>
|
||||
console.error("[Collaboration] Error broadcasting template sync:", err),
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== SUBSCRIPTIONS ====================
|
||||
|
||||
onUserJoined(callback: EventCallback<Collaborator>): () => void {
|
||||
@@ -713,6 +892,11 @@ class CollaborationService {
|
||||
return () => this.dataSavedListeners.delete(callback);
|
||||
}
|
||||
|
||||
onTemplateSync(callback: EventCallback<TemplateSyncMessage>): () => void {
|
||||
this.templateSyncListeners.add(callback);
|
||||
return () => this.templateSyncListeners.delete(callback);
|
||||
}
|
||||
|
||||
onConnectionStateChanged(
|
||||
callback: EventCallback<{ connected: boolean; connecting: boolean }>,
|
||||
): () => void {
|
||||
@@ -1006,6 +1190,40 @@ class CollaborationService {
|
||||
});
|
||||
});
|
||||
|
||||
// Template sync (full template broadcast)
|
||||
this.connection.on("TemplateSync", async (message: TemplateSyncMessage) => {
|
||||
console.log(
|
||||
`[Collaboration] Received TemplateSync, version ${message.version}, size: ${message.templateJson.length} bytes, compressed: ${message.compressed}`,
|
||||
);
|
||||
|
||||
// Decompress if needed
|
||||
let finalMessage = message;
|
||||
if (message.compressed) {
|
||||
try {
|
||||
const decompressedJson = await decompressString(message.templateJson);
|
||||
console.log(
|
||||
`[Collaboration] Decompressed template: ${message.templateJson.length} -> ${decompressedJson.length} bytes`,
|
||||
);
|
||||
finalMessage = {
|
||||
...message,
|
||||
templateJson: decompressedJson,
|
||||
compressed: false,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[Collaboration] Decompression failed:", e);
|
||||
return; // Don't notify listeners with corrupted data
|
||||
}
|
||||
}
|
||||
|
||||
this.templateSyncListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(finalMessage);
|
||||
} catch (e) {
|
||||
console.error("[Collaboration] Listener error:", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reconnection handling
|
||||
this.connection.onreconnecting(() => {
|
||||
console.log("[Collaboration] Reconnecting...");
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
|
||||
const API_URL = "http://localhost:5000";
|
||||
// Usa variabile ambiente o fallback a window.location per accesso esterno
|
||||
const getApiUrl = (): string => {
|
||||
// Prima controlla la variabile ambiente (deve essere non-vuota)
|
||||
const envUrl = import.meta.env.VITE_API_URL;
|
||||
if (envUrl && envUrl.trim() !== "") {
|
||||
return envUrl;
|
||||
}
|
||||
// Fallback: usa lo stesso host del frontend ma porta 5000
|
||||
const protocol = window.location.protocol;
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}:5000`;
|
||||
};
|
||||
|
||||
class SignalRService {
|
||||
private connection: signalR.HubConnection | null = null;
|
||||
@@ -24,8 +35,10 @@ class SignalRService {
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
const apiUrl = getApiUrl(); // Calcola URL al momento della connessione
|
||||
console.log(`[SignalR Data] Connecting to ${apiUrl}/hubs/data`);
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`${API_URL}/hubs/data`)
|
||||
.withUrl(`${apiUrl}/hubs/data`)
|
||||
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
|
||||
.configureLogging(signalR.LogLevel.Information)
|
||||
.build();
|
||||
|
||||
Reference in New Issue
Block a user