-
This commit is contained in:
45
CLAUDE.md
45
CLAUDE.md
@@ -46,12 +46,25 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
|
|||||||
|
|
||||||
## Quick Start - Session Recovery
|
## Quick Start - Session Recovery
|
||||||
|
|
||||||
**Ultima sessione:** 28 Novembre 2025 (tarda notte)
|
**Ultima sessione:** 28 Novembre 2025 (pomeriggio - sera)
|
||||||
|
|
||||||
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
|
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
|
||||||
|
|
||||||
**Lavoro completato nell'ultima sessione:**
|
**Lavoro completato nell'ultima sessione:**
|
||||||
|
|
||||||
|
- **NUOVA FEATURE: Sistema Collaborazione Real-Time Globale** - IN CORSO
|
||||||
|
- Migrato da sistema report-specific a sistema globale per tutta l'app
|
||||||
|
- `CollaborationHub.cs` - Hub SignalR generico per qualsiasi entità/pagina
|
||||||
|
- `collaboration.ts` - Service singleton per gestione connessione
|
||||||
|
- `CollaborationContext.tsx` - React Context con hooks (`useCollaboration`, `useCollaborationRoom`)
|
||||||
|
- `CollaborationIndicator.tsx` - Indicatore globale nella UI
|
||||||
|
- Room-based collaboration con formato `{entityType}:{entityId}`
|
||||||
|
- **FIX: Incompatibilità versione SignalR** - Rimosso package `Microsoft.AspNetCore.SignalR.Common` v10.0.0 incompatibile con .NET 9, downgrade client `@microsoft/signalr` a v8.0.7
|
||||||
|
- **FIX: Auto-save non funzionante** - Usati `useRef` per evitare re-run dell'effect causato da `saveMutation` nelle dependencies
|
||||||
|
- **IN DEBUG:** Sincronizzazione real-time tra sessioni - Il salvataggio manuale sincronizza, l'auto-save invia la notifica ma la sessione 2 non la riceve ancora
|
||||||
|
|
||||||
|
**Lavoro completato nelle sessioni precedenti (28 Novembre 2025 tarda notte):**
|
||||||
|
|
||||||
- **NUOVA FEATURE: Toolbar Report Designer Migliorata Drasticamente** - COMPLETATO
|
- **NUOVA FEATURE: Toolbar Report Designer Migliorata Drasticamente** - COMPLETATO
|
||||||
- Design moderno stile Canva/Figma con gradient buttons e animazioni fluide
|
- Design moderno stile Canva/Figma con gradient buttons e animazioni fluide
|
||||||
- **Sezioni etichettate** su desktop (INSERISCI, MODIFICA, CRONOLOGIA, VISTA, ZOOM, PAGINA)
|
- **Sezioni etichettate** su desktop (INSERISCI, MODIFICA, CRONOLOGIA, VISTA, ZOOM, PAGINA)
|
||||||
@@ -1050,6 +1063,36 @@ frontend/src/
|
|||||||
- **Type aggiunto:** `textDecoration` in `AprtStyle`
|
- **Type aggiunto:** `textDecoration` in `AprtStyle`
|
||||||
- **File:** `EditorToolbar.tsx`, `types/report.ts`
|
- **File:** `EditorToolbar.tsx`, `types/report.ts`
|
||||||
|
|
||||||
|
20. **Incompatibilità Versione SignalR (FIX 28/11/2025 pomeriggio):**
|
||||||
|
- **Problema:** Errore "Method not found: 'System.String Microsoft.AspNetCore.SignalR.IInvocationBinder.GetTarget(System.ReadOnlySpan`1<Byte>)'." - connessioni SignalR fallivano immediatamente
|
||||||
|
- **Causa:** Package `Microsoft.AspNetCore.SignalR.Common` v10.0.0 nel backend incompatibile con .NET 9 (è per .NET 10 preview)
|
||||||
|
- **Soluzione:**
|
||||||
|
- Rimosso `Microsoft.AspNetCore.SignalR.Common` dal .csproj (SignalR è già incluso in ASP.NET Core)
|
||||||
|
- Downgrade frontend `@microsoft/signalr` da v10.0.0 a v8.0.7
|
||||||
|
- **File:** `Apollinare.API.csproj`, `frontend/package.json`
|
||||||
|
|
||||||
|
21. **Auto-Save Non Funzionante (FIX 28/11/2025 pomeriggio):**
|
||||||
|
- **Problema:** Con auto-save attivo, le modifiche non venivano salvate (l'indicatore mostrava "Non salvato" sempre)
|
||||||
|
- **Causa:** `saveMutation` nell'array di dipendenze dell'`useEffect` causava il reset del timeout ad ogni render (React Query crea un nuovo oggetto ad ogni render)
|
||||||
|
- **Soluzione:** Usati `useRef` per `saveMutation`, `template`, e `templateInfo` per evitare che l'effect si ri-esegua inutilmente
|
||||||
|
- **File:** `ReportEditorPage.tsx`
|
||||||
|
|
||||||
|
22. **Sistema Collaborazione Real-Time (IN CORSO 28/11/2025):**
|
||||||
|
- **Obiettivo:** Collaborazione stile Google Docs su tutto l'applicativo
|
||||||
|
- **Architettura implementata:**
|
||||||
|
- `CollaborationHub.cs` - Hub SignalR generico con room-based collaboration
|
||||||
|
- `collaboration.ts` - Service singleton frontend
|
||||||
|
- `CollaborationContext.tsx` - React Context con `useCollaborationRoom` hook
|
||||||
|
- Room key format: `{entityType}:{entityId}` (es. `report-template:2`)
|
||||||
|
- **Stato attuale:**
|
||||||
|
- Connessione e join room funzionanti
|
||||||
|
- Salvataggio manuale notifica le altre sessioni correttamente
|
||||||
|
- Auto-save invia `sendDataSaved()` ma le altre sessioni non ricevono la notifica
|
||||||
|
- **Debug in corso:** Aggiunto logging dettagliato per tracciare il flusso dei messaggi
|
||||||
|
- **File principali:**
|
||||||
|
- Backend: `CollaborationHub.cs`
|
||||||
|
- Frontend: `collaboration.ts`, `CollaborationContext.tsx`, `ReportEditorPage.tsx`
|
||||||
|
|
||||||
### Schema Database Report System
|
### Schema Database Report System
|
||||||
|
|
||||||
Le tabelle sono già nel DbContext (`AppollinareDbContext.cs`):
|
Le tabelle sono già nel DbContext (`AppollinareDbContext.cs`):
|
||||||
|
|||||||
750
PLAN.md
750
PLAN.md
@@ -1,425 +1,343 @@
|
|||||||
# Piano: Sistema di Report PDF con Editor Visuale
|
# Piano: Collaborazione Real-Time nel Report Designer
|
||||||
|
|
||||||
## Obiettivo
|
## Obiettivo
|
||||||
Creare un sistema completo di generazione report PDF con:
|
|
||||||
- Editor grafico drag-and-drop (stile Canva)
|
|
||||||
- Potenza di JasperReports (data binding, paginazione, formule)
|
|
||||||
- Metalinguaggio esportabile/importabile (tipo LaTeX)
|
|
||||||
- Salvataggio template riutilizzabili
|
|
||||||
- Supporto immagini e font personalizzati
|
|
||||||
|
|
||||||
## Architettura Proposta
|
Implementare la collaborazione in tempo reale nel Report Designer, simile a Google Docs o Excel Online. Quando un utente modifica un template, tutti gli altri utenti collegati allo stesso template vedono le modifiche istantaneamente.
|
||||||
|
|
||||||
### 1. Metalinguaggio Template (APRT - Apollinare Report Template)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"meta": {
|
|
||||||
"name": "Scheda Evento",
|
|
||||||
"description": "Template per stampa evento",
|
|
||||||
"author": "admin",
|
|
||||||
"createdAt": "2025-01-15",
|
|
||||||
"pageSize": "A4",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"margins": { "top": 20, "right": 15, "bottom": 20, "left": 15 }
|
|
||||||
},
|
|
||||||
"resources": {
|
|
||||||
"fonts": [
|
|
||||||
{ "id": "font1", "name": "Roboto", "url": "/fonts/roboto.ttf" }
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{ "id": "logo", "name": "Logo Aziendale", "url": "/images/logo.png" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dataSources": {
|
|
||||||
"evento": { "type": "object", "schema": "Evento" },
|
|
||||||
"ospiti": { "type": "array", "schema": "EventoDettaglioOspiti" },
|
|
||||||
"costi": { "type": "array", "schema": "EventoAltroCosto" }
|
|
||||||
},
|
|
||||||
"sections": [
|
|
||||||
{
|
|
||||||
"type": "header",
|
|
||||||
"height": 80,
|
|
||||||
"repeatOnPages": true,
|
|
||||||
"elements": [...]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "body",
|
|
||||||
"elements": [...]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "detail",
|
|
||||||
"dataSource": "ospiti",
|
|
||||||
"elements": [...]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"height": 40,
|
|
||||||
"repeatOnPages": true,
|
|
||||||
"elements": [...]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"id": "elem1",
|
|
||||||
"type": "text",
|
|
||||||
"position": { "x": 10, "y": 10, "width": 200, "height": 30 },
|
|
||||||
"style": {
|
|
||||||
"fontFamily": "font1",
|
|
||||||
"fontSize": 24,
|
|
||||||
"fontWeight": "bold",
|
|
||||||
"color": "#333333",
|
|
||||||
"textAlign": "left"
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"type": "static",
|
|
||||||
"value": "SCHEDA EVENTO"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "elem2",
|
|
||||||
"type": "text",
|
|
||||||
"position": { "x": 10, "y": 50, "width": 150, "height": 20 },
|
|
||||||
"content": {
|
|
||||||
"type": "binding",
|
|
||||||
"expression": "{{evento.codice}}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "elem3",
|
|
||||||
"type": "image",
|
|
||||||
"position": { "x": 450, "y": 10, "width": 100, "height": 60 },
|
|
||||||
"content": {
|
|
||||||
"type": "resource",
|
|
||||||
"resourceId": "logo"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "elem4",
|
|
||||||
"type": "table",
|
|
||||||
"position": { "x": 10, "y": 200, "width": 550, "height": "auto" },
|
|
||||||
"dataSource": "ospiti",
|
|
||||||
"columns": [
|
|
||||||
{ "field": "tipoOspite.descrizione", "header": "Tipo", "width": 150 },
|
|
||||||
{ "field": "numero", "header": "Quantità", "width": 100 },
|
|
||||||
{ "field": "costoUnitario", "header": "Costo Unit.", "width": 100, "format": "currency" },
|
|
||||||
{ "field": "costoTotale", "header": "Totale", "width": 100, "format": "currency" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "elem5",
|
|
||||||
"type": "shape",
|
|
||||||
"position": { "x": 10, "y": 180, "width": 550, "height": 2 },
|
|
||||||
"style": {
|
|
||||||
"backgroundColor": "#000000"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pageNum",
|
|
||||||
"type": "text",
|
|
||||||
"section": "footer",
|
|
||||||
"position": { "x": 250, "y": 10, "width": 100, "height": 20 },
|
|
||||||
"content": {
|
|
||||||
"type": "expression",
|
|
||||||
"value": "Pagina {{$pageNumber}} di {{$totalPages}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Struttura Backend
|
|
||||||
|
|
||||||
#### Nuove Entità
|
|
||||||
```
|
|
||||||
ReportTemplate
|
|
||||||
├── Id
|
|
||||||
├── Nome
|
|
||||||
├── Descrizione
|
|
||||||
├── Categoria (Evento, Cliente, Articoli, etc.)
|
|
||||||
├── TemplateJson (il metalinguaggio APRT)
|
|
||||||
├── Thumbnail (preview del template)
|
|
||||||
├── Attivo
|
|
||||||
├── CreatedAt/By, UpdatedAt/By
|
|
||||||
|
|
||||||
ReportFont
|
|
||||||
├── Id
|
|
||||||
├── Nome
|
|
||||||
├── FontFamily
|
|
||||||
├── FontData (BLOB - file TTF/OTF)
|
|
||||||
├── MimeType
|
|
||||||
|
|
||||||
ReportImage
|
|
||||||
├── Id
|
|
||||||
├── Nome
|
|
||||||
├── Categoria
|
|
||||||
├── ImageData (BLOB)
|
|
||||||
├── MimeType
|
|
||||||
├── Width, Height
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Nuovi Controller
|
|
||||||
```
|
|
||||||
ReportTemplatesController
|
|
||||||
├── GET /api/report-templates # Lista template
|
|
||||||
├── GET /api/report-templates/{id} # Dettaglio
|
|
||||||
├── POST /api/report-templates # Crea
|
|
||||||
├── PUT /api/report-templates/{id} # Aggiorna
|
|
||||||
├── DELETE /api/report-templates/{id} # Elimina
|
|
||||||
├── POST /api/report-templates/{id}/clone # Duplica
|
|
||||||
├── GET /api/report-templates/{id}/export # Esporta .aprt
|
|
||||||
├── POST /api/report-templates/import # Importa .aprt
|
|
||||||
|
|
||||||
ReportResourcesController
|
|
||||||
├── GET /api/report-resources/fonts # Lista font
|
|
||||||
├── POST /api/report-resources/fonts # Upload font
|
|
||||||
├── DELETE /api/report-resources/fonts/{id}
|
|
||||||
├── GET /api/report-resources/images # Lista immagini
|
|
||||||
├── POST /api/report-resources/images # Upload immagine
|
|
||||||
├── DELETE /api/report-resources/images/{id}
|
|
||||||
|
|
||||||
ReportGeneratorController
|
|
||||||
├── POST /api/reports/generate # Genera PDF
|
|
||||||
│ Body: { templateId, dataContext: { eventoId, ... } }
|
|
||||||
├── POST /api/reports/preview # Anteprima (PNG/HTML)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Servizio Generazione PDF
|
|
||||||
Useremo **QuestPDF** per la generazione:
|
|
||||||
- Supporto nativo .NET
|
|
||||||
- API fluent per layout complessi
|
|
||||||
- Font personalizzati
|
|
||||||
- Immagini
|
|
||||||
- Paginazione automatica
|
|
||||||
- Performance eccellenti
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class ReportGeneratorService
|
|
||||||
{
|
|
||||||
public byte[] GeneratePdf(ReportTemplate template, object dataContext)
|
|
||||||
{
|
|
||||||
var parsed = ParseTemplate(template.TemplateJson);
|
|
||||||
var document = Document.Create(container =>
|
|
||||||
{
|
|
||||||
container.Page(page =>
|
|
||||||
{
|
|
||||||
page.Size(parsed.PageSize);
|
|
||||||
page.Margin(parsed.Margins);
|
|
||||||
|
|
||||||
if (parsed.Header != null)
|
|
||||||
page.Header().Element(c => RenderSection(c, parsed.Header, dataContext));
|
|
||||||
|
|
||||||
page.Content().Element(c => RenderContent(c, parsed, dataContext));
|
|
||||||
|
|
||||||
if (parsed.Footer != null)
|
|
||||||
page.Footer().Element(c => RenderSection(c, parsed.Footer, dataContext));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return document.GeneratePdf();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Frontend - Editor Visuale
|
|
||||||
|
|
||||||
#### Componenti Principali
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
├── pages/
|
|
||||||
│ ├── ReportEditorPage.tsx # Editor principale
|
|
||||||
│ └── ReportTemplatesPage.tsx # Lista template
|
|
||||||
├── components/
|
|
||||||
│ └── reportEditor/
|
|
||||||
│ ├── ReportEditor.tsx # Container principale
|
|
||||||
│ ├── Canvas.tsx # Area di disegno (Fabric.js o Konva)
|
|
||||||
│ ├── Toolbar.tsx # Barra strumenti (text, image, shape, table)
|
|
||||||
│ ├── PropertiesPanel.tsx # Pannello proprietà elemento selezionato
|
|
||||||
│ ├── DataBindingPanel.tsx # Pannello per mappare dati
|
|
||||||
│ ├── LayersPanel.tsx # Gestione livelli/elementi
|
|
||||||
│ ├── ResourcesPanel.tsx # Font e immagini disponibili
|
|
||||||
│ ├── PageSettings.tsx # Impostazioni pagina
|
|
||||||
│ ├── PreviewModal.tsx # Anteprima PDF
|
|
||||||
│ └── elements/
|
|
||||||
│ ├── TextElement.tsx
|
|
||||||
│ ├── ImageElement.tsx
|
|
||||||
│ ├── ShapeElement.tsx
|
|
||||||
│ ├── TableElement.tsx
|
|
||||||
│ └── BarcodeElement.tsx
|
|
||||||
├── services/
|
|
||||||
│ └── reportService.ts
|
|
||||||
└── types/
|
|
||||||
└── report.ts # Tipi TypeScript per APRT
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Libreria Canvas
|
|
||||||
**Fabric.js** è la scelta migliore:
|
|
||||||
- Drag & drop nativo
|
|
||||||
- Selezione multipla
|
|
||||||
- Ridimensionamento con handle
|
|
||||||
- Rotazione elementi
|
|
||||||
- Serializzazione JSON
|
|
||||||
- Supporto testo, immagini, forme
|
|
||||||
- Griglia e snap
|
|
||||||
- Undo/redo
|
|
||||||
|
|
||||||
#### Flusso Editor
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Toolbar: [Text] [Image] [Shape] [Table] [Line] [Barcode] │
|
|
||||||
├─────────────┬───────────────────────────────────┬───────────────┤
|
|
||||||
│ │ │ │
|
|
||||||
│ Layers │ CANVAS │ Properties │
|
|
||||||
│ Panel │ ┌─────────────────┐ │ Panel │
|
|
||||||
│ │ │ HEADER │ │ │
|
|
||||||
│ □ Logo │ │ [Logo] [Titolo]│ │ Position │
|
|
||||||
│ □ Titolo │ ├─────────────────┤ │ x: 10 y: 10 │
|
|
||||||
│ □ Data │ │ │ │ w: 200 h: 30 │
|
|
||||||
│ □ Tabella │ │ BODY │ │ │
|
|
||||||
│ □ Footer │ │ │ │ Style │
|
|
||||||
│ │ │ [Data Evento] │ │ Font: Roboto │
|
|
||||||
│ │ │ [Cliente] │ │ Size: 24 │
|
|
||||||
│ │ │ [Tabella] │ │ Color: #333 │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ ├─────────────────┤ │ Data Binding │
|
|
||||||
│ │ │ FOOTER │ │ {{evento. │
|
|
||||||
│ │ │ [Pag X di Y] │ │ codice}} │
|
|
||||||
│ │ └─────────────────┘ │ │
|
|
||||||
│ │ │ │
|
|
||||||
├─────────────┴───────────────────────────────────┴───────────────┤
|
|
||||||
│ Data Sources: [evento] [ospiti] [costi] [risorse] │
|
|
||||||
│ Available Fields: codice, dataEvento, cliente.ragioneSociale...│
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Implementazione Step-by-Step
|
|
||||||
|
|
||||||
#### Fase 1: Backend Foundation
|
|
||||||
1. Creare entità `ReportTemplate`, `ReportFont`, `ReportImage`
|
|
||||||
2. Aggiornare DbContext e migrare database
|
|
||||||
3. Creare `ReportTemplatesController` con CRUD base
|
|
||||||
4. Creare `ReportResourcesController` per upload font/immagini
|
|
||||||
5. Installare e configurare QuestPDF
|
|
||||||
6. Creare `ReportGeneratorService` base
|
|
||||||
|
|
||||||
#### Fase 2: Metalinguaggio Parser
|
|
||||||
1. Definire classi C# per il metalinguaggio APRT
|
|
||||||
2. Implementare parser JSON → oggetti
|
|
||||||
3. Implementare renderer elementi → QuestPDF
|
|
||||||
4. Gestire binding dati con espressioni {{campo}}
|
|
||||||
5. Implementare paginazione e sezioni ripetute
|
|
||||||
|
|
||||||
#### Fase 3: Frontend Editor Base
|
|
||||||
1. Installare Fabric.js (`fabric`)
|
|
||||||
2. Creare pagina `ReportEditorPage`
|
|
||||||
3. Implementare `Canvas` con Fabric.js
|
|
||||||
4. Implementare `Toolbar` per aggiungere elementi
|
|
||||||
5. Implementare `PropertiesPanel` per editing proprietà
|
|
||||||
6. Implementare serializzazione canvas → APRT
|
|
||||||
|
|
||||||
#### Fase 4: Data Binding
|
|
||||||
1. Creare `DataBindingPanel` con schema dati disponibili
|
|
||||||
2. Implementare drag-drop campi su elementi
|
|
||||||
3. Supportare espressioni {{campo.sottocampo}}
|
|
||||||
4. Implementare formattazione (currency, date, number)
|
|
||||||
5. Supportare espressioni condizionali
|
|
||||||
|
|
||||||
#### Fase 5: Tabelle e Repeater
|
|
||||||
1. Implementare `TableElement` con colonne configurabili
|
|
||||||
2. Supportare data source array per righe ripetute
|
|
||||||
3. Implementare auto-height per tabelle
|
|
||||||
4. Gestire page break automatici
|
|
||||||
|
|
||||||
#### Fase 6: Risorse e Upload
|
|
||||||
1. Implementare upload font custom
|
|
||||||
2. Implementare upload immagini
|
|
||||||
3. Creare libreria risorse condivise
|
|
||||||
4. Preview font e immagini
|
|
||||||
|
|
||||||
#### Fase 7: Preview e Generazione
|
|
||||||
1. Implementare preview real-time (canvas → PNG)
|
|
||||||
2. Implementare generazione PDF finale
|
|
||||||
3. Download PDF
|
|
||||||
4. Stampa diretta
|
|
||||||
|
|
||||||
#### Fase 8: Import/Export
|
|
||||||
1. Implementare export .aprt (JSON + risorse embedded base64)
|
|
||||||
2. Implementare import .aprt
|
|
||||||
3. Validazione template importati
|
|
||||||
|
|
||||||
### 5. Template Esempio: Scheda Evento
|
|
||||||
|
|
||||||
Creeremo un template predefinito per la stampa eventi con:
|
|
||||||
- Header con logo aziendale e titolo
|
|
||||||
- Dati evento (codice, data, cliente, location)
|
|
||||||
- Tabella ospiti con subtotali
|
|
||||||
- Tabella costi aggiuntivi
|
|
||||||
- Riepilogo totali
|
|
||||||
- Note
|
|
||||||
- Footer con paginazione
|
|
||||||
|
|
||||||
### 6. Dipendenze da Aggiungere
|
|
||||||
|
|
||||||
**Backend (NuGet):**
|
|
||||||
```xml
|
|
||||||
<PackageReference Include="QuestPDF" Version="2024.12.0" />
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend (npm):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fabric": "^6.0.0",
|
|
||||||
"file-saver": "^2.0.5",
|
|
||||||
"@types/fabric": "^5.3.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Routes Frontend
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// App.tsx - nuove routes
|
|
||||||
<Route path="/report-templates" element={<ReportTemplatesPage />} />
|
|
||||||
<Route path="/report-editor/:id?" element={<ReportEditorPage />} />
|
|
||||||
<Route path="/report-preview/:templateId/:entityId" element={<ReportPreviewPage />} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Stima Componenti
|
|
||||||
|
|
||||||
| Componente | File | Complessità |
|
|
||||||
|------------|------|-------------|
|
|
||||||
| Entità + DbContext | 3 file | Bassa |
|
|
||||||
| Controllers | 3 file | Media |
|
|
||||||
| ReportGeneratorService | 1 file | Alta |
|
|
||||||
| APRT Parser | 1 file | Media |
|
|
||||||
| ReportEditorPage | 1 file | Alta |
|
|
||||||
| Canvas (Fabric.js) | 1 file | Alta |
|
|
||||||
| Toolbar | 1 file | Bassa |
|
|
||||||
| PropertiesPanel | 1 file | Media |
|
|
||||||
| DataBindingPanel | 1 file | Media |
|
|
||||||
| LayersPanel | 1 file | Bassa |
|
|
||||||
| Elementi (5 tipi) | 5 file | Media |
|
|
||||||
| Services frontend | 1 file | Bassa |
|
|
||||||
| Types | 1 file | Bassa |
|
|
||||||
|
|
||||||
**Totale: ~20 file, complessità alta**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decisioni Architetturali
|
## Architettura Proposta
|
||||||
|
|
||||||
1. **QuestPDF** invece di iTextSharp (licenza più permissiva, API moderna)
|
### Concetti Chiave
|
||||||
2. **Fabric.js** invece di Konva (più features per editing)
|
|
||||||
3. **JSON** come metalinguaggio (leggibile, facile da parsare)
|
|
||||||
4. **Embedded resources** negli export (portabilità completa)
|
|
||||||
5. **Real-time preview** via canvas (no round-trip server)
|
|
||||||
|
|
||||||
## Note Implementative
|
1. **Room-based Collaboration**: Ogni template ha una "room" SignalR dedicata
|
||||||
|
2. **Operational Transformation Semplificata**: Invece di OT completo (complesso), usiamo un modello "last-write-wins" con sync frequente
|
||||||
|
3. **Presence Awareness**: Gli utenti vedono chi altro sta modificando il template
|
||||||
|
4. **Cursor/Selection Sharing**: Mostra quale elemento sta selezionando ogni utente
|
||||||
|
|
||||||
- Il canvas Fabric.js lavora in pixel, convertiremo in mm per la stampa
|
### Flusso Dati
|
||||||
- I font custom vanno registrati in QuestPDF all'avvio
|
|
||||||
- Le immagini BLOB vanno convertite in base64 per Fabric.js
|
```
|
||||||
- La paginazione è gestita lato server da QuestPDF
|
User A modifica elemento
|
||||||
- L'editor salva solo il JSON, la generazione PDF è on-demand
|
↓
|
||||||
|
Template locale aggiornato (come ora)
|
||||||
|
↓
|
||||||
|
SignalR invia delta alla room
|
||||||
|
↓
|
||||||
|
Backend riceve e ritrasmette a tutti nella room (eccetto mittente)
|
||||||
|
↓
|
||||||
|
User B/C/... ricevono delta
|
||||||
|
↓
|
||||||
|
Applicano delta al loro template locale
|
||||||
|
↓
|
||||||
|
Canvas si aggiorna automaticamente (già funziona così)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementazione Dettagliata
|
||||||
|
|
||||||
|
### FASE 1: Backend - ReportCollaborationHub
|
||||||
|
|
||||||
|
**File:** `/src/Apollinare.API/Hubs/ReportCollaborationHub.cs`
|
||||||
|
|
||||||
|
Nuovo Hub dedicato per la collaborazione sui report:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class ReportCollaborationHub : Hub
|
||||||
|
{
|
||||||
|
// Stato in-memory degli utenti per template
|
||||||
|
private static ConcurrentDictionary<string, HashSet<CollaboratorInfo>> _templateUsers = new();
|
||||||
|
|
||||||
|
// Join a template room
|
||||||
|
public async Task JoinTemplate(int templateId, string userName, string userColor)
|
||||||
|
|
||||||
|
// Leave template room
|
||||||
|
public async Task LeaveTemplate(int templateId)
|
||||||
|
|
||||||
|
// Broadcast element change to room
|
||||||
|
public async Task ElementChanged(int templateId, ElementChangeDto change)
|
||||||
|
|
||||||
|
// Broadcast element added
|
||||||
|
public async Task ElementAdded(int templateId, AprtElement element)
|
||||||
|
|
||||||
|
// Broadcast element deleted
|
||||||
|
public async Task ElementDeleted(int templateId, string elementId)
|
||||||
|
|
||||||
|
// Broadcast page changes
|
||||||
|
public async Task PageChanged(int templateId, PageChangeDto change)
|
||||||
|
|
||||||
|
// Broadcast selection change (which element user is editing)
|
||||||
|
public async Task SelectionChanged(int templateId, string? elementId)
|
||||||
|
|
||||||
|
// Broadcast cursor position (optional, for live cursors)
|
||||||
|
public async Task CursorMoved(int templateId, float x, float y)
|
||||||
|
|
||||||
|
// Request full template sync (when joining late or after reconnect)
|
||||||
|
public async Task RequestSync(int templateId)
|
||||||
|
|
||||||
|
// Send full template state (host responds to sync requests)
|
||||||
|
public async Task SendSync(int templateId, string connectionId, AprtTemplate template)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DTOs:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record CollaboratorInfo(string ConnectionId, string UserName, string Color, string? SelectedElementId);
|
||||||
|
|
||||||
|
public record ElementChangeDto(
|
||||||
|
string ElementId,
|
||||||
|
string ChangeType, // "position", "style", "content", "visibility", etc.
|
||||||
|
object NewValue
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PageChangeDto(
|
||||||
|
string PageId,
|
||||||
|
string ChangeType, // "added", "deleted", "reordered", "renamed", "settings"
|
||||||
|
object? Data
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### FASE 2: Frontend - Servizio Collaborazione
|
||||||
|
|
||||||
|
**File:** `/frontend/src/services/reportCollaboration.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ReportCollaborationService {
|
||||||
|
private connection: HubConnection | null = null;
|
||||||
|
private templateId: number | null = null;
|
||||||
|
private listeners: Map<string, Set<Function>> = new Map();
|
||||||
|
|
||||||
|
// Connessione e gestione room
|
||||||
|
async joinTemplate(
|
||||||
|
templateId: number,
|
||||||
|
userName: string,
|
||||||
|
userColor: string,
|
||||||
|
): Promise<void>;
|
||||||
|
async leaveTemplate(): Promise<void>;
|
||||||
|
|
||||||
|
// Invio modifiche (chiamati dal ReportEditorPage)
|
||||||
|
sendElementChange(elementId: string, changeType: string, newValue: any): void;
|
||||||
|
sendElementAdded(element: AprtElement): void;
|
||||||
|
sendElementDeleted(elementId: string): void;
|
||||||
|
sendPageChange(pageId: string, changeType: string, data?: any): void;
|
||||||
|
sendSelectionChange(elementId: string | null): void;
|
||||||
|
|
||||||
|
// Sottoscrizione eventi (per ricevere modifiche da altri)
|
||||||
|
onElementChanged(callback: (change: ElementChange) => void): () => void;
|
||||||
|
onElementAdded(callback: (element: AprtElement) => void): () => void;
|
||||||
|
onElementDeleted(callback: (elementId: string) => void): () => void;
|
||||||
|
onPageChanged(callback: (change: PageChange) => void): () => void;
|
||||||
|
onCollaboratorsChanged(
|
||||||
|
callback: (collaborators: Collaborator[]) => void,
|
||||||
|
): () => void;
|
||||||
|
onSelectionChanged(
|
||||||
|
callback: (userId: string, elementId: string | null) => void,
|
||||||
|
): () => void;
|
||||||
|
onSyncRequested(callback: (requesterId: string) => void): () => void;
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
requestSync(): void;
|
||||||
|
sendSync(connectionId: string, template: AprtTemplate): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FASE 3: Frontend - Integrazione in ReportEditorPage
|
||||||
|
|
||||||
|
**Modifiche a:** `/frontend/src/pages/ReportEditorPage.tsx`
|
||||||
|
|
||||||
|
1. **Nuovo State per collaborazione:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
|
||||||
|
const [remoteSelections, setRemoteSelections] = useState<Map<string, string>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
const [isCollaborating, setIsCollaborating] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Join/Leave room al mount/unmount:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew && id) {
|
||||||
|
const userName = getCurrentUserName(); // Da auth context
|
||||||
|
const userColor = generateUserColor(userName);
|
||||||
|
|
||||||
|
reportCollaborationService
|
||||||
|
.joinTemplate(Number(id), userName, userColor)
|
||||||
|
.then(() => setIsCollaborating(true));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
reportCollaborationService.leaveTemplate();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [id, isNew]);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Sottoscrizione eventi remoti:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCollaborating) return;
|
||||||
|
|
||||||
|
const unsubscribers = [
|
||||||
|
reportCollaborationService.onElementChanged((change) => {
|
||||||
|
// Applica modifica senza creare history entry
|
||||||
|
historyActions.setWithoutHistory((prev) => {
|
||||||
|
// ... applica change.newValue a element con change.elementId
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
reportCollaborationService.onElementAdded((element) => {
|
||||||
|
historyActions.setWithoutHistory((prev) => ({
|
||||||
|
...prev,
|
||||||
|
elements: [...prev.elements, element],
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ... altri handler
|
||||||
|
|
||||||
|
reportCollaborationService.onCollaboratorsChanged(setCollaborators),
|
||||||
|
];
|
||||||
|
|
||||||
|
return () => unsubscribers.forEach((unsub) => unsub());
|
||||||
|
}, [isCollaborating]);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Invio modifiche locali:**
|
||||||
|
|
||||||
|
Modificare `handleUpdateElement` per inviare anche via SignalR:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleUpdateElement = useCallback(
|
||||||
|
(elementId: string, updates: Partial<AprtElement>) => {
|
||||||
|
// Aggiorna stato locale (come ora)
|
||||||
|
historyActions.set((prev) => ({...}));
|
||||||
|
|
||||||
|
// Invia a collaboratori
|
||||||
|
if (isCollaborating) {
|
||||||
|
reportCollaborationService.sendElementChange(elementId, "update", updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[historyActions, isCollaborating],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### FASE 4: UI Collaborazione
|
||||||
|
|
||||||
|
**File:** `/frontend/src/components/reportEditor/CollaboratorsBar.tsx`
|
||||||
|
|
||||||
|
Barra che mostra gli utenti connessi:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CollaboratorsBarProps {
|
||||||
|
collaborators: Collaborator[];
|
||||||
|
remoteSelections: Map<string, string>; // userId -> elementId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostra:
|
||||||
|
// - Avatar circolari colorati per ogni collaboratore
|
||||||
|
// - Tooltip con nome utente
|
||||||
|
// - Indicatore "sta modificando [elemento]"
|
||||||
|
// - Badge con conteggio totale collaboratori
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modifiche a EditorCanvas:** Evidenziare elementi selezionati da altri utenti con bordo colorato.
|
||||||
|
|
||||||
|
### FASE 5: Gestione Conflitti
|
||||||
|
|
||||||
|
Per semplicità, usiamo strategia **"last-write-wins"** con alcune ottimizzazioni:
|
||||||
|
|
||||||
|
1. **Elementi diversi**: Nessun conflitto, modifiche applicate indipendentemente
|
||||||
|
2. **Stesso elemento, proprietà diverse**: Merge delle proprietà
|
||||||
|
3. **Stesso elemento, stessa proprietà**: Ultima modifica vince
|
||||||
|
4. **Lock visivo**: Quando un utente seleziona un elemento, gli altri vedono un indicatore
|
||||||
|
|
||||||
|
**Opzionale (Fase futura):** Lock pessimistico - solo un utente alla volta può modificare un elemento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File da Creare/Modificare
|
||||||
|
|
||||||
|
### Nuovi File
|
||||||
|
|
||||||
|
| File | Descrizione |
|
||||||
|
| ----------------------------------------------------------- | ----------------------------------- |
|
||||||
|
| `src/Apollinare.API/Hubs/ReportCollaborationHub.cs` | Hub SignalR per collaborazione |
|
||||||
|
| `src/Apollinare.API/Models/CollaborationDtos.cs` | DTOs per messaggi collaborazione |
|
||||||
|
| `frontend/src/services/reportCollaboration.ts` | Client SignalR per collaborazione |
|
||||||
|
| `frontend/src/components/reportEditor/CollaboratorsBar.tsx` | UI collaboratori connessi |
|
||||||
|
| `frontend/src/types/collaboration.ts` | Types TypeScript per collaborazione |
|
||||||
|
|
||||||
|
### File da Modificare
|
||||||
|
|
||||||
|
| File | Modifiche |
|
||||||
|
| -------------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| `src/Apollinare.API/Program.cs` | Registrare nuovo hub `/hubs/report-collaboration` |
|
||||||
|
| `frontend/src/pages/ReportEditorPage.tsx` | Integrare collaborazione, stato collaboratori |
|
||||||
|
| `frontend/src/components/reportEditor/EditorCanvas.tsx` | Mostrare selezioni remote |
|
||||||
|
| `frontend/src/components/reportEditor/EditorToolbar.tsx` | Mostrare CollaboratorsBar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stima Complessità
|
||||||
|
|
||||||
|
| Fase | Complessità | Note |
|
||||||
|
| ---------------------- | ----------- | ---------------------------------------- |
|
||||||
|
| 1. Backend Hub | Media | SignalR groups, gestione stato in-memory |
|
||||||
|
| 2. Frontend Service | Media | Gestione connessione, eventi |
|
||||||
|
| 3. Integrazione Editor | Alta | Molti handler da modificare |
|
||||||
|
| 4. UI Collaboratori | Bassa | Componente semplice |
|
||||||
|
| 5. Gestione Conflitti | Media | Merge logic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Considerazioni Aggiuntive
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Throttling degli aggiornamenti durante drag (ogni 50-100ms invece di ogni frame)
|
||||||
|
- Debounce per modifiche testo (300ms)
|
||||||
|
- Batch di modifiche multiple in singolo messaggio
|
||||||
|
|
||||||
|
### Scalabilità
|
||||||
|
|
||||||
|
- Per deployment multi-server: usare Redis backplane per SignalR
|
||||||
|
- Considerare Azure SignalR Service per produzione
|
||||||
|
|
||||||
|
### Autenticazione
|
||||||
|
|
||||||
|
- Aggiungere autenticazione al hub (verificare che utente abbia accesso al template)
|
||||||
|
- Usare JWT token per identificare utente
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Reconnessione dopo disconnessione: richiedere sync completo
|
||||||
|
- Template eliminato mentre utenti connessi: notificare e chiudere
|
||||||
|
- Conflitto salvataggio: merge o "force save" con conferma
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordine di Implementazione Consigliato
|
||||||
|
|
||||||
|
1. **Backend Hub base** - Join/Leave room, broadcast semplice
|
||||||
|
2. **Frontend service base** - Connessione, invio/ricezione messaggi
|
||||||
|
3. **Integrazione minima** - Solo sync modifiche elementi (no UI collaboratori)
|
||||||
|
4. **Test funzionale** - Verificare che modifiche si propaghino
|
||||||
|
5. **UI Collaboratori** - Mostrare chi è connesso
|
||||||
|
6. **Selezioni remote** - Evidenziare elementi selezionati da altri
|
||||||
|
7. **Ottimizzazioni** - Throttling, batching, gestione conflitti
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domande per l'Utente
|
||||||
|
|
||||||
|
Prima di procedere, confermare:
|
||||||
|
|
||||||
|
1. **Autenticazione**: Il sistema ha già autenticazione utenti? Devo usare un sistema mock per ora?
|
||||||
|
2. **Persistenza stato**: Le modifiche devono essere salvate automaticamente o solo quando l'utente clicca "Salva"?
|
||||||
|
3. **Lock elementi**: Vuoi che solo un utente alla volta possa modificare un elemento (lock pessimistico)?
|
||||||
|
4. **Cursori live**: Vuoi vedere il cursore degli altri utenti in tempo reale (come Google Docs)?
|
||||||
|
5. **Nome utente**: Da dove prendo il nome utente da mostrare? (localStorage, auth context, prompt?)
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -14,7 +14,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.19",
|
"@fullcalendar/interaction": "^6.1.19",
|
||||||
"@fullcalendar/react": "^6.1.19",
|
"@fullcalendar/react": "^6.1.19",
|
||||||
"@fullcalendar/timegrid": "^6.1.19",
|
"@fullcalendar/timegrid": "^6.1.19",
|
||||||
"@microsoft/signalr": "^10.0.0",
|
"@microsoft/signalr": "^8.0.7",
|
||||||
"@mui/icons-material": "^7.3.5",
|
"@mui/icons-material": "^7.3.5",
|
||||||
"@mui/material": "^7.3.5",
|
"@mui/material": "^7.3.5",
|
||||||
"@mui/x-data-grid": "^8.20.0",
|
"@mui/x-data-grid": "^8.20.0",
|
||||||
@@ -1263,16 +1263,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@microsoft/signalr": {
|
"node_modules/@microsoft/signalr": {
|
||||||
"version": "10.0.0",
|
"version": "8.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz",
|
||||||
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
|
"integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"abort-controller": "^3.0.0",
|
"abort-controller": "^3.0.0",
|
||||||
"eventsource": "^2.0.2",
|
"eventsource": "^2.0.2",
|
||||||
"fetch-cookie": "^2.0.3",
|
"fetch-cookie": "^2.0.3",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
"ws": "^7.5.10"
|
"ws": "^7.4.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mui/core-downloads-tracker": {
|
"node_modules/@mui/core-downloads-tracker": {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@fullcalendar/interaction": "^6.1.19",
|
"@fullcalendar/interaction": "^6.1.19",
|
||||||
"@fullcalendar/react": "^6.1.19",
|
"@fullcalendar/react": "^6.1.19",
|
||||||
"@fullcalendar/timegrid": "^6.1.19",
|
"@fullcalendar/timegrid": "^6.1.19",
|
||||||
"@microsoft/signalr": "^10.0.0",
|
"@microsoft/signalr": "^8.0.7",
|
||||||
"@mui/icons-material": "^7.3.5",
|
"@mui/icons-material": "^7.3.5",
|
||||||
"@mui/material": "^7.3.5",
|
"@mui/material": "^7.3.5",
|
||||||
"@mui/x-data-grid": "^8.20.0",
|
"@mui/x-data-grid": "^8.20.0",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import CalendarioPage from "./pages/CalendarioPage";
|
|||||||
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
|
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
|
||||||
import ReportEditorPage from "./pages/ReportEditorPage";
|
import ReportEditorPage from "./pages/ReportEditorPage";
|
||||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||||
|
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -59,6 +60,7 @@ function App() {
|
|||||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<CollaborationProvider>
|
||||||
<RealTimeProvider>
|
<RealTimeProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
@@ -74,7 +76,10 @@ function App() {
|
|||||||
path="report-templates"
|
path="report-templates"
|
||||||
element={<ReportTemplatesPage />}
|
element={<ReportTemplatesPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="report-editor" element={<ReportEditorPage />} />
|
<Route
|
||||||
|
path="report-editor"
|
||||||
|
element={<ReportEditorPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="report-editor/:id"
|
path="report-editor/:id"
|
||||||
element={<ReportEditorPage />}
|
element={<ReportEditorPage />}
|
||||||
@@ -82,6 +87,7 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</RealTimeProvider>
|
</RealTimeProvider>
|
||||||
|
</CollaborationProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Print as PrintIcon,
|
Print as PrintIcon,
|
||||||
Close as CloseIcon,
|
Close as CloseIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
|
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
||||||
|
|
||||||
const DRAWER_WIDTH = 240;
|
const DRAWER_WIDTH = 240;
|
||||||
const DRAWER_WIDTH_COLLAPSED = 64;
|
const DRAWER_WIDTH_COLLAPSED = 64;
|
||||||
@@ -158,6 +159,9 @@ export default function Layout() {
|
|||||||
>
|
>
|
||||||
{isMobile ? "Apollinare" : "Catering & Banqueting Management"}
|
{isMobile ? "Apollinare" : "Catering & Banqueting Management"}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{/* Collaboration Indicator */}
|
||||||
|
<CollaborationIndicator compact={isMobile} />
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
||||||
|
|||||||
461
frontend/src/components/collaboration/CollaborationIndicator.tsx
Normal file
461
frontend/src/components/collaboration/CollaborationIndicator.tsx
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarGroup,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Collapse,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemAvatar,
|
||||||
|
ListItemText,
|
||||||
|
Paper,
|
||||||
|
Popover,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
Circle as CircleIcon,
|
||||||
|
ExpandLess,
|
||||||
|
ExpandMore,
|
||||||
|
History as HistoryIcon,
|
||||||
|
People as PeopleIcon,
|
||||||
|
SignalWifiOff as DisconnectedIcon,
|
||||||
|
Wifi as ConnectedIcon,
|
||||||
|
WifiFind as ConnectingIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { useCollaboration } from "../../contexts/CollaborationContext";
|
||||||
|
import type { Collaborator } from "../../services/collaboration";
|
||||||
|
|
||||||
|
interface CollaborationIndicatorProps {
|
||||||
|
/** Show only when in a room */
|
||||||
|
showOnlyInRoom?: boolean;
|
||||||
|
/** Show change history button */
|
||||||
|
showHistory?: boolean;
|
||||||
|
/** Compact mode for mobile */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Click handler for collaborator avatar */
|
||||||
|
onCollaboratorClick?: (collaborator: Collaborator) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollaborationIndicator({
|
||||||
|
showOnlyInRoom = true,
|
||||||
|
showHistory = true,
|
||||||
|
compact = false,
|
||||||
|
onCollaboratorClick,
|
||||||
|
}: CollaborationIndicatorProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
currentRoom,
|
||||||
|
localUserName,
|
||||||
|
localUserColor,
|
||||||
|
collaborators,
|
||||||
|
remoteSelections,
|
||||||
|
changeHistory,
|
||||||
|
} = useCollaboration();
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const [historyAnchorEl, setHistoryAnchorEl] = useState<HTMLElement | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [showHistoryList, setShowHistoryList] = useState(true);
|
||||||
|
|
||||||
|
// Don't show if not in a room and showOnlyInRoom is true
|
||||||
|
if (showOnlyInRoom && !currentRoom) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCollaboratorsClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHistoryClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setHistoryAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHistoryClose = () => {
|
||||||
|
setHistoryAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const historyOpen = Boolean(historyAnchorEl);
|
||||||
|
const totalUsers = collaborators.length + 1;
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const formatRelativeTime = (date: Date): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
|
||||||
|
if (diffSec < 5) return "ora";
|
||||||
|
if (diffSec < 60) return `${diffSec}s fa`;
|
||||||
|
if (diffMin < 60) return `${diffMin}m fa`;
|
||||||
|
if (diffHour < 24) return `${diffHour}h fa`;
|
||||||
|
return date.toLocaleDateString("it-IT");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get initials from name
|
||||||
|
const getInitials = (name: string): string => {
|
||||||
|
const parts = name.split(" ");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
isConnecting
|
||||||
|
? "Connessione in corso..."
|
||||||
|
: isConnected
|
||||||
|
? currentRoom
|
||||||
|
? `Collaborazione attiva: ${currentRoom}`
|
||||||
|
: "Connesso"
|
||||||
|
: "Disconnesso"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
color: isConnecting
|
||||||
|
? theme.palette.warning.main
|
||||||
|
: isConnected
|
||||||
|
? theme.palette.success.main
|
||||||
|
: theme.palette.error.main,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<ConnectingIcon fontSize="small" />
|
||||||
|
) : isConnected ? (
|
||||||
|
<ConnectedIcon fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<DisconnectedIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Collaborators avatars */}
|
||||||
|
{currentRoom && (
|
||||||
|
<Tooltip
|
||||||
|
title={`${totalUsers} utente${totalUsers > 1 ? "i" : ""} connesso${totalUsers > 1 ? "i" : ""}`}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
badgeContent={totalUsers}
|
||||||
|
color="primary"
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
onClick={handleCollaboratorsClick}
|
||||||
|
>
|
||||||
|
<AvatarGroup
|
||||||
|
max={compact ? 3 : 4}
|
||||||
|
sx={{
|
||||||
|
"& .MuiAvatar-root": {
|
||||||
|
width: compact ? 24 : 28,
|
||||||
|
height: compact ? 24 : 28,
|
||||||
|
fontSize: compact ? "0.65rem" : "0.75rem",
|
||||||
|
border: `2px solid ${theme.palette.background.paper}`,
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Local user avatar */}
|
||||||
|
<Tooltip title={`${localUserName} (tu)`}>
|
||||||
|
<Avatar sx={{ bgcolor: localUserColor }}>
|
||||||
|
{getInitials(localUserName)}
|
||||||
|
</Avatar>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Remote collaborators */}
|
||||||
|
{collaborators.map((collab) => (
|
||||||
|
<Tooltip
|
||||||
|
key={collab.connectionId}
|
||||||
|
title={`${collab.userName}${remoteSelections.get(collab.connectionId) ? " - sta modificando" : ""}`}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
bgcolor: collab.color,
|
||||||
|
boxShadow: remoteSelections.get(collab.connectionId)
|
||||||
|
? `0 0 0 2px ${collab.color}`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getInitials(collab.userName)}
|
||||||
|
</Avatar>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History button */}
|
||||||
|
{showHistory && changeHistory.length > 0 && (
|
||||||
|
<Tooltip title="Cronologia modifiche">
|
||||||
|
<IconButton size="small" onClick={handleHistoryClick}>
|
||||||
|
<Badge
|
||||||
|
badgeContent={changeHistory.length}
|
||||||
|
color="secondary"
|
||||||
|
max={99}
|
||||||
|
>
|
||||||
|
<HistoryIcon fontSize="small" />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collaborators popover */}
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||||
|
>
|
||||||
|
<Paper sx={{ minWidth: 280, maxWidth: 350, p: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
|
||||||
|
<PeopleIcon color="primary" />
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
Collaboratori ({totalUsers})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{currentRoom && (
|
||||||
|
<Chip
|
||||||
|
label={currentRoom}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<List dense disablePadding>
|
||||||
|
{/* Local user */}
|
||||||
|
<ListItem
|
||||||
|
sx={{
|
||||||
|
bgcolor: theme.palette.action.selected,
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{ bgcolor: localUserColor, width: 32, height: 32 }}>
|
||||||
|
{getInitials(localUserName)}
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
{localUserName}
|
||||||
|
<Chip label="Tu" size="small" color="primary" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary="Connesso"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
{/* Remote collaborators */}
|
||||||
|
{collaborators.map((collab) => {
|
||||||
|
const isEditing = remoteSelections.get(collab.connectionId);
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={collab.connectionId}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 0.5,
|
||||||
|
cursor: onCollaboratorClick ? "pointer" : "default",
|
||||||
|
"&:hover": onCollaboratorClick
|
||||||
|
? { bgcolor: theme.palette.action.hover }
|
||||||
|
: {},
|
||||||
|
}}
|
||||||
|
onClick={() => onCollaboratorClick?.(collab)}
|
||||||
|
>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Badge
|
||||||
|
overlap="circular"
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
badgeContent={
|
||||||
|
isEditing ? (
|
||||||
|
<CircleIcon
|
||||||
|
sx={{
|
||||||
|
color: collab.color,
|
||||||
|
fontSize: 12,
|
||||||
|
animation: "pulse 1.5s infinite",
|
||||||
|
"@keyframes pulse": {
|
||||||
|
"0%": { opacity: 1 },
|
||||||
|
"50%": { opacity: 0.5 },
|
||||||
|
"100%": { opacity: 1 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
sx={{ bgcolor: collab.color, width: 32, height: 32 }}
|
||||||
|
>
|
||||||
|
{getInitials(collab.userName)}
|
||||||
|
</Avatar>
|
||||||
|
</Badge>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={collab.userName}
|
||||||
|
secondary={
|
||||||
|
isEditing ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: collab.color, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
Sta modificando...
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
"Online"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{collaborators.length === 0 && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
secondary="Nessun altro collaboratore connesso"
|
||||||
|
sx={{ textAlign: "center", color: "text.secondary" }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* History popover */}
|
||||||
|
<Popover
|
||||||
|
open={historyOpen}
|
||||||
|
anchorEl={historyAnchorEl}
|
||||||
|
onClose={handleHistoryClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
transformOrigin={{ vertical: "top", horizontal: "right" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
minWidth: 320,
|
||||||
|
maxWidth: 400,
|
||||||
|
maxHeight: 400,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
p: 2,
|
||||||
|
pb: 1,
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<HistoryIcon color="primary" />
|
||||||
|
<Typography variant="subtitle1" fontWeight="bold">
|
||||||
|
Modifiche Recenti
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowHistoryList(!showHistoryList)}
|
||||||
|
>
|
||||||
|
{showHistoryList ? <ExpandLess /> : <ExpandMore />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Collapse in={showHistoryList}>
|
||||||
|
<List dense disablePadding sx={{ px: 1, pb: 1 }}>
|
||||||
|
{changeHistory.slice(0, 30).map((entry, index) => (
|
||||||
|
<Box key={entry.id}>
|
||||||
|
<ListItem
|
||||||
|
sx={{
|
||||||
|
py: 0.5,
|
||||||
|
borderLeft: `3px solid ${entry.userColor}`,
|
||||||
|
pl: 1,
|
||||||
|
ml: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontWeight={500}>
|
||||||
|
{entry.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ ml: 1, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(entry.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ color: entry.userColor }}
|
||||||
|
>
|
||||||
|
{entry.userName}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
{index < changeHistory.length - 1 && (
|
||||||
|
<Divider variant="inset" component="li" sx={{ ml: 2 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{changeHistory.length === 0 && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
secondary="Nessuna modifica recente"
|
||||||
|
sx={{ textAlign: "center" }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changeHistory.length > 30 && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
secondary={`...e altre ${changeHistory.length - 30} modifiche`}
|
||||||
|
sx={{ textAlign: "center", fontStyle: "italic" }}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
</Popover>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
frontend/src/components/collaboration/RemoteCursors.tsx
Normal file
177
frontend/src/components/collaboration/RemoteCursors.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { Box, Typography, Fade } from "@mui/material";
|
||||||
|
import { Navigation as CursorIcon } from "@mui/icons-material";
|
||||||
|
import { useCollaboration, useRemoteCursorsForView } from "../../contexts/CollaborationContext";
|
||||||
|
|
||||||
|
interface RemoteCursorsProps {
|
||||||
|
/** Current view ID to filter cursors */
|
||||||
|
viewId?: string | null;
|
||||||
|
/** Zoom level for coordinate transformation */
|
||||||
|
zoom?: number;
|
||||||
|
/** Container offset X (for absolute positioning) */
|
||||||
|
offsetX?: number;
|
||||||
|
/** Container offset Y (for absolute positioning) */
|
||||||
|
offsetY?: number;
|
||||||
|
/** Whether cursors are in mm coordinates (needs conversion to px) */
|
||||||
|
coordinatesInMm?: boolean;
|
||||||
|
/** MM to PX ratio if coordinatesInMm is true */
|
||||||
|
mmToPxRatio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay component that displays remote collaborator cursors
|
||||||
|
* Should be placed in a relative positioned container
|
||||||
|
*/
|
||||||
|
export default function RemoteCursors({
|
||||||
|
viewId = null,
|
||||||
|
zoom = 1,
|
||||||
|
offsetX = 0,
|
||||||
|
offsetY = 0,
|
||||||
|
coordinatesInMm = false,
|
||||||
|
mmToPxRatio = 3.7795275591,
|
||||||
|
}: RemoteCursorsProps) {
|
||||||
|
const cursors = useRemoteCursorsForView(viewId);
|
||||||
|
|
||||||
|
if (cursors.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
pointerEvents: "none",
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cursors.map((cursor) => {
|
||||||
|
// Convert coordinates if needed
|
||||||
|
let x = cursor.x;
|
||||||
|
let y = cursor.y;
|
||||||
|
|
||||||
|
if (coordinatesInMm) {
|
||||||
|
x = cursor.x * mmToPxRatio;
|
||||||
|
y = cursor.y * mmToPxRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply zoom and offset
|
||||||
|
x = x * zoom + offsetX;
|
||||||
|
y = y * zoom + offsetY;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fade key={cursor.connectionId} in={true} timeout={200}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
transform: "translate(-2px, -2px)",
|
||||||
|
transition: "left 0.05s linear, top 0.05s linear",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cursor icon */}
|
||||||
|
<CursorIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: cursor.color,
|
||||||
|
filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))",
|
||||||
|
transform: "rotate(-45deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User name label */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 16,
|
||||||
|
top: 12,
|
||||||
|
bgcolor: cursor.color,
|
||||||
|
color: "white",
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
||||||
|
maxWidth: 120,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cursor.userName}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to highlight items selected by remote users
|
||||||
|
*/
|
||||||
|
interface RemoteSelectionHighlightProps {
|
||||||
|
itemId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteSelectionHighlight({ itemId, children }: RemoteSelectionHighlightProps) {
|
||||||
|
const { remoteSelections, collaborators } = useCollaboration();
|
||||||
|
|
||||||
|
// Find if any collaborator has this item selected
|
||||||
|
let selectedBy: { userName: string; color: string } | null = null;
|
||||||
|
|
||||||
|
for (const [connectionId, selectedId] of remoteSelections.entries()) {
|
||||||
|
if (selectedId === itemId) {
|
||||||
|
const collaborator = collaborators.find((c) => c.connectionId === connectionId);
|
||||||
|
if (collaborator) {
|
||||||
|
selectedBy = { userName: collaborator.userName, color: collaborator.color };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedBy) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
outline: `2px solid ${selectedBy.color}`,
|
||||||
|
outlineOffset: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Selection indicator */}
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: -20,
|
||||||
|
left: 0,
|
||||||
|
bgcolor: selectedBy.color,
|
||||||
|
color: "white",
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
fontSize: "0.6rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedBy.userName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,15 @@ export interface ContextMenuEvent {
|
|||||||
elementId: string | null;
|
elementId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remote cursor info
|
||||||
|
export interface RemoteCursor {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
pageId: string | null;
|
||||||
|
color: string;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorCanvasProps {
|
interface EditorCanvasProps {
|
||||||
template: AprtTemplate;
|
template: AprtTemplate;
|
||||||
selectedElementId: string | null;
|
selectedElementId: string | null;
|
||||||
@@ -44,6 +53,16 @@ interface EditorCanvasProps {
|
|||||||
gridSize: number;
|
gridSize: number;
|
||||||
snapOptions: SnapOptions;
|
snapOptions: SnapOptions;
|
||||||
onContextMenu?: (event: ContextMenuEvent) => void;
|
onContextMenu?: (event: ContextMenuEvent) => void;
|
||||||
|
/** Callback when cursor moves on canvas (for collaboration) */
|
||||||
|
onCursorMove?: (x: number, y: number) => void;
|
||||||
|
/** Remote cursors from other collaborators */
|
||||||
|
remoteCursors?: Map<string, RemoteCursor>;
|
||||||
|
/** Remote selections from other collaborators (connectionId -> elementId) */
|
||||||
|
remoteSelections?: Map<string, string | null>;
|
||||||
|
/** Map of connectionId -> collaborator info for colors */
|
||||||
|
collaboratorColors?: Map<string, { color: string; userName: string }>;
|
||||||
|
/** Current page ID for filtering remote cursors */
|
||||||
|
currentPageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorCanvasRef {
|
export interface EditorCanvasRef {
|
||||||
@@ -68,6 +87,12 @@ const EditorCanvas = forwardRef<EditorCanvasRef, EditorCanvasProps>(
|
|||||||
gridSize,
|
gridSize,
|
||||||
snapOptions,
|
snapOptions,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
onCursorMove,
|
||||||
|
// These props are reserved for future remote cursor rendering on canvas
|
||||||
|
// remoteCursors,
|
||||||
|
// remoteSelections,
|
||||||
|
// collaboratorColors,
|
||||||
|
// currentPageId,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@@ -639,12 +664,16 @@ const EditorCanvas = forwardRef<EditorCanvasRef, EditorCanvasProps>(
|
|||||||
(e: { e: MouseEvent }) => {
|
(e: { e: MouseEvent }) => {
|
||||||
if (!fabricRef.current) return;
|
if (!fabricRef.current) return;
|
||||||
const pointer = fabricRef.current.getScenePoint(e.e);
|
const pointer = fabricRef.current.getScenePoint(e.e);
|
||||||
setCursorPosition({
|
const xMm = Math.round((pxToMm(pointer.x) / zoom) * 10) / 10;
|
||||||
x: Math.round((pxToMm(pointer.x) / zoom) * 10) / 10,
|
const yMm = Math.round((pxToMm(pointer.y) / zoom) * 10) / 10;
|
||||||
y: Math.round((pxToMm(pointer.y) / zoom) * 10) / 10,
|
setCursorPosition({ x: xMm, y: yMm });
|
||||||
});
|
|
||||||
|
// Send cursor position for collaboration
|
||||||
|
if (onCursorMove) {
|
||||||
|
onCursorMove(xMm, yMm);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[zoom],
|
[zoom, onCursorMove],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
|
|||||||
632
frontend/src/contexts/CollaborationContext.tsx
Normal file
632
frontend/src/contexts/CollaborationContext.tsx
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
collaborationService,
|
||||||
|
type Collaborator,
|
||||||
|
type RoomState,
|
||||||
|
type DataChangeMessage,
|
||||||
|
type ItemCreatedMessage,
|
||||||
|
type ItemDeletedMessage,
|
||||||
|
type BatchOperationMessage,
|
||||||
|
type SelectionChangedMessage,
|
||||||
|
type CursorMovedMessage,
|
||||||
|
type ChangeHistoryEntry,
|
||||||
|
type SyncRequestMessage,
|
||||||
|
type SyncDataMessage,
|
||||||
|
type DataSavedMessage,
|
||||||
|
type UserLeftMessage,
|
||||||
|
getOrCreateUserName,
|
||||||
|
getColorForUser,
|
||||||
|
} from "../services/collaboration";
|
||||||
|
|
||||||
|
// ==================== TYPES ====================
|
||||||
|
|
||||||
|
export interface RemoteCursor {
|
||||||
|
connectionId: string;
|
||||||
|
userName: string;
|
||||||
|
color: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
viewId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollaborationContextValue {
|
||||||
|
// Connection state
|
||||||
|
isConnected: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
currentRoom: string | null;
|
||||||
|
|
||||||
|
// Local user
|
||||||
|
localUserName: string;
|
||||||
|
localUserColor: string;
|
||||||
|
connectionId: string | null;
|
||||||
|
|
||||||
|
// Collaborators
|
||||||
|
collaborators: Collaborator[];
|
||||||
|
remoteSelections: Map<string, string | null>;
|
||||||
|
remoteCursors: Map<string, RemoteCursor>;
|
||||||
|
|
||||||
|
// Change history
|
||||||
|
changeHistory: ChangeHistoryEntry[];
|
||||||
|
|
||||||
|
// Room management
|
||||||
|
joinRoom: (roomKey: string, metadata?: unknown) => Promise<void>;
|
||||||
|
leaveRoom: () => Promise<void>;
|
||||||
|
switchRoom: (roomKey: string, metadata?: unknown) => Promise<void>;
|
||||||
|
|
||||||
|
// Data operations (generic)
|
||||||
|
sendDataChanged: (
|
||||||
|
itemId: string,
|
||||||
|
itemType: string,
|
||||||
|
changeType: string,
|
||||||
|
newValue: unknown,
|
||||||
|
fieldPath?: string,
|
||||||
|
) => void;
|
||||||
|
sendItemCreated: (
|
||||||
|
itemId: string,
|
||||||
|
itemType: string,
|
||||||
|
item: unknown,
|
||||||
|
parentId?: string,
|
||||||
|
index?: number,
|
||||||
|
) => void;
|
||||||
|
sendItemDeleted: (itemId: string, itemType: string) => void;
|
||||||
|
sendBatchOperation: (
|
||||||
|
operationType: string,
|
||||||
|
itemType: string,
|
||||||
|
data: unknown,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
// Presence
|
||||||
|
sendSelectionChanged: (itemId: string | null) => void;
|
||||||
|
sendCursorMoved: (x: number, y: number, viewId?: string | null) => void;
|
||||||
|
sendViewChanged: (viewId: string) => void;
|
||||||
|
sendUserTyping: (itemId: string | null, isTyping: boolean) => void;
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
requestSync: () => void;
|
||||||
|
sendSync: (targetConnectionId: string, dataJson: string) => void;
|
||||||
|
sendDataSaved: () => void;
|
||||||
|
|
||||||
|
// Event subscriptions for component-specific handlers
|
||||||
|
onDataChanged: (callback: (msg: DataChangeMessage) => void) => () => void;
|
||||||
|
onItemCreated: (callback: (msg: ItemCreatedMessage) => void) => () => void;
|
||||||
|
onItemDeleted: (callback: (msg: ItemDeletedMessage) => void) => () => void;
|
||||||
|
onBatchOperation: (
|
||||||
|
callback: (msg: BatchOperationMessage) => void,
|
||||||
|
) => () => void;
|
||||||
|
onSyncRequested: (callback: (msg: SyncRequestMessage) => void) => () => void;
|
||||||
|
onSyncReceived: (callback: (msg: SyncDataMessage) => void) => () => void;
|
||||||
|
onDataSaved: (callback: (msg: DataSavedMessage) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollaborationContext = createContext<CollaborationContextValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== PROVIDER ====================
|
||||||
|
|
||||||
|
interface CollaborationProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Auto-connect on mount */
|
||||||
|
autoConnect?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollaborationProvider({
|
||||||
|
children,
|
||||||
|
autoConnect = true,
|
||||||
|
}: CollaborationProviderProps) {
|
||||||
|
// Connection state
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [currentRoom, setCurrentRoom] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Local user
|
||||||
|
const localUserName = useRef(getOrCreateUserName());
|
||||||
|
const localUserColor = useRef(getColorForUser(localUserName.current));
|
||||||
|
const [connectionId, setConnectionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Collaborators
|
||||||
|
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
|
||||||
|
const [remoteSelections, setRemoteSelections] = useState<
|
||||||
|
Map<string, string | null>
|
||||||
|
>(new Map());
|
||||||
|
const [remoteCursors, setRemoteCursors] = useState<Map<string, RemoteCursor>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change history
|
||||||
|
const [changeHistory, setChangeHistory] = useState<ChangeHistoryEntry[]>([]);
|
||||||
|
|
||||||
|
// Auto-connect on mount
|
||||||
|
// Note: We don't disconnect on unmount because:
|
||||||
|
// 1. React Strict Mode double-mounts components, causing connection interruption
|
||||||
|
// 2. The collaboration service is a singleton that persists across navigations
|
||||||
|
// 3. SignalR has built-in reconnection handling
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoConnect) {
|
||||||
|
collaborationService.connect().catch(() => {
|
||||||
|
// Connection errors are logged in the service, ignore here
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// No cleanup - connection persists for the app lifetime
|
||||||
|
}, [autoConnect]);
|
||||||
|
|
||||||
|
// Subscribe to connection state changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = collaborationService.onConnectionStateChanged(
|
||||||
|
({ connected, connecting }) => {
|
||||||
|
setIsConnected(connected);
|
||||||
|
setIsConnecting(connecting);
|
||||||
|
setConnectionId(collaborationService.connectionId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to room state (on join)
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = collaborationService.onRoomState((state: RoomState) => {
|
||||||
|
setCurrentRoom(state.roomKey);
|
||||||
|
setCollaborators(state.collaborators);
|
||||||
|
|
||||||
|
// Initialize remote selections and cursors
|
||||||
|
const selections = new Map<string, string | null>();
|
||||||
|
const cursors = new Map<string, RemoteCursor>();
|
||||||
|
|
||||||
|
state.collaborators.forEach((c) => {
|
||||||
|
selections.set(c.connectionId, c.selectedItemId);
|
||||||
|
if (c.cursorX != null && c.cursorY != null) {
|
||||||
|
cursors.set(c.connectionId, {
|
||||||
|
connectionId: c.connectionId,
|
||||||
|
userName: c.userName,
|
||||||
|
color: c.color,
|
||||||
|
x: c.cursorX,
|
||||||
|
y: c.cursorY,
|
||||||
|
viewId: c.currentViewId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setRemoteSelections(selections);
|
||||||
|
setRemoteCursors(cursors);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to user join/leave
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubJoin = collaborationService.onUserJoined(
|
||||||
|
(collaborator: Collaborator) => {
|
||||||
|
setCollaborators((prev) => {
|
||||||
|
if (prev.some((c) => c.connectionId === collaborator.connectionId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, collaborator];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsubLeave = collaborationService.onUserLeft(
|
||||||
|
(message: UserLeftMessage) => {
|
||||||
|
setCollaborators((prev) =>
|
||||||
|
prev.filter((c) => c.connectionId !== message.connectionId),
|
||||||
|
);
|
||||||
|
setRemoteSelections((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.delete(message.connectionId);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.delete(message.connectionId);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubJoin();
|
||||||
|
unsubLeave();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = collaborationService.onSelectionChanged(
|
||||||
|
(message: SelectionChangedMessage) => {
|
||||||
|
setRemoteSelections((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(message.connectionId, message.itemId);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to cursor movements
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = collaborationService.onCursorMoved(
|
||||||
|
(message: CursorMovedMessage) => {
|
||||||
|
const collaborator = collaborationService.getCollaborator(
|
||||||
|
message.connectionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(message.connectionId, {
|
||||||
|
connectionId: message.connectionId,
|
||||||
|
userName: collaborator?.userName || "Utente",
|
||||||
|
color: collaborator?.color || "#888",
|
||||||
|
x: message.x,
|
||||||
|
y: message.y,
|
||||||
|
viewId: message.viewId,
|
||||||
|
});
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to change history
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = collaborationService.onChangeHistoryUpdated(
|
||||||
|
(history) => {
|
||||||
|
setChangeHistory(history);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Room management callbacks
|
||||||
|
const joinRoom = useCallback(async (roomKey: string, metadata?: unknown) => {
|
||||||
|
await collaborationService.joinRoom(
|
||||||
|
roomKey,
|
||||||
|
localUserName.current,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
setCurrentRoom(roomKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const leaveRoom = useCallback(async () => {
|
||||||
|
await collaborationService.leaveRoom();
|
||||||
|
setCurrentRoom(null);
|
||||||
|
setCollaborators([]);
|
||||||
|
setRemoteSelections(new Map());
|
||||||
|
setRemoteCursors(new Map());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchRoom = useCallback(
|
||||||
|
async (roomKey: string, metadata?: unknown) => {
|
||||||
|
await collaborationService.switchRoom(roomKey, metadata);
|
||||||
|
setCurrentRoom(roomKey);
|
||||||
|
setCollaborators([]);
|
||||||
|
setRemoteSelections(new Map());
|
||||||
|
setRemoteCursors(new Map());
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Data operation callbacks
|
||||||
|
const sendDataChanged = useCallback(
|
||||||
|
(
|
||||||
|
itemId: string,
|
||||||
|
itemType: string,
|
||||||
|
changeType: string,
|
||||||
|
newValue: unknown,
|
||||||
|
fieldPath?: string,
|
||||||
|
) => {
|
||||||
|
collaborationService.sendDataChanged(
|
||||||
|
itemId,
|
||||||
|
itemType,
|
||||||
|
changeType,
|
||||||
|
newValue,
|
||||||
|
fieldPath,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendItemCreated = useCallback(
|
||||||
|
(
|
||||||
|
itemId: string,
|
||||||
|
itemType: string,
|
||||||
|
item: unknown,
|
||||||
|
parentId?: string,
|
||||||
|
index?: number,
|
||||||
|
) => {
|
||||||
|
collaborationService.sendItemCreated(
|
||||||
|
itemId,
|
||||||
|
itemType,
|
||||||
|
item,
|
||||||
|
parentId,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendItemDeleted = useCallback((itemId: string, itemType: string) => {
|
||||||
|
collaborationService.sendItemDeleted(itemId, itemType);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendBatchOperation = useCallback(
|
||||||
|
(operationType: string, itemType: string, data: unknown) => {
|
||||||
|
collaborationService.sendBatchOperation(operationType, itemType, data);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Presence callbacks
|
||||||
|
const sendSelectionChanged = useCallback((itemId: string | null) => {
|
||||||
|
collaborationService.sendSelectionChanged(itemId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendCursorMoved = useCallback(
|
||||||
|
(x: number, y: number, viewId?: string | null) => {
|
||||||
|
collaborationService.sendCursorMoved(x, y, viewId);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendViewChanged = useCallback((viewId: string) => {
|
||||||
|
collaborationService.sendViewChanged(viewId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendUserTyping = useCallback(
|
||||||
|
(itemId: string | null, isTyping: boolean) => {
|
||||||
|
collaborationService.sendUserTyping(itemId, isTyping);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync callbacks
|
||||||
|
const requestSync = useCallback(() => {
|
||||||
|
collaborationService.requestSync();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendSync = useCallback(
|
||||||
|
(targetConnectionId: string, dataJson: string) => {
|
||||||
|
collaborationService.sendSync(targetConnectionId, dataJson);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendDataSaved = useCallback(() => {
|
||||||
|
collaborationService.sendDataSaved();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Event subscription pass-through
|
||||||
|
const onDataChanged = useCallback(
|
||||||
|
(callback: (msg: DataChangeMessage) => void) => {
|
||||||
|
return collaborationService.onDataChanged(callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onItemCreated = useCallback(
|
||||||
|
(callback: (msg: ItemCreatedMessage) => void) => {
|
||||||
|
return collaborationService.onItemCreated(callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onItemDeleted = useCallback(
|
||||||
|
(callback: (msg: ItemDeletedMessage) => void) => {
|
||||||
|
return collaborationService.onItemDeleted(callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBatchOperation = useCallback(
|
||||||
|
(callback: (msg: BatchOperationMessage) => void) => {
|
||||||
|
return collaborationService.onBatchOperation(callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSyncRequested = useCallback(
|
||||||
|
(callback: (msg: SyncRequestMessage) => void) => {
|
||||||
|
return collaborationService.onSyncRequested(callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSyncReceived = useCallback(
|
||||||
|
(callback: (msg: SyncDataMessage) => void) => {
|
||||||
|
return collaborationService.onSyncReceived(callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDataSaved = useCallback(
|
||||||
|
(callback: (msg: DataSavedMessage) => void) => {
|
||||||
|
return collaborationService.onDataSaved(callback);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: CollaborationContextValue = {
|
||||||
|
// Connection state
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
currentRoom,
|
||||||
|
|
||||||
|
// Local user
|
||||||
|
localUserName: localUserName.current,
|
||||||
|
localUserColor: localUserColor.current,
|
||||||
|
connectionId,
|
||||||
|
|
||||||
|
// Collaborators
|
||||||
|
collaborators,
|
||||||
|
remoteSelections,
|
||||||
|
remoteCursors,
|
||||||
|
|
||||||
|
// Change history
|
||||||
|
changeHistory,
|
||||||
|
|
||||||
|
// Room management
|
||||||
|
joinRoom,
|
||||||
|
leaveRoom,
|
||||||
|
switchRoom,
|
||||||
|
|
||||||
|
// Data operations
|
||||||
|
sendDataChanged,
|
||||||
|
sendItemCreated,
|
||||||
|
sendItemDeleted,
|
||||||
|
sendBatchOperation,
|
||||||
|
|
||||||
|
// Presence
|
||||||
|
sendSelectionChanged,
|
||||||
|
sendCursorMoved,
|
||||||
|
sendViewChanged,
|
||||||
|
sendUserTyping,
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
requestSync,
|
||||||
|
sendSync,
|
||||||
|
sendDataSaved,
|
||||||
|
|
||||||
|
// Event subscriptions
|
||||||
|
onDataChanged,
|
||||||
|
onItemCreated,
|
||||||
|
onItemDeleted,
|
||||||
|
onBatchOperation,
|
||||||
|
onSyncRequested,
|
||||||
|
onSyncReceived,
|
||||||
|
onDataSaved,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollaborationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</CollaborationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HOOKS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to access the collaboration context
|
||||||
|
*/
|
||||||
|
export function useCollaboration(): CollaborationContextValue {
|
||||||
|
const context = useContext(CollaborationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useCollaboration must be used within a CollaborationProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to automatically join a room when a component mounts
|
||||||
|
*/
|
||||||
|
export function useCollaborationRoom(
|
||||||
|
roomKey: string | null,
|
||||||
|
options?: {
|
||||||
|
metadata?: unknown;
|
||||||
|
enabled?: boolean;
|
||||||
|
},
|
||||||
|
): CollaborationContextValue {
|
||||||
|
const collaboration = useCollaboration();
|
||||||
|
const { enabled = true, metadata } = options || {};
|
||||||
|
|
||||||
|
// Use refs to avoid dependency changes causing re-runs
|
||||||
|
const joinRoomRef = useRef(collaboration.joinRoom);
|
||||||
|
const leaveRoomRef = useRef(collaboration.leaveRoom);
|
||||||
|
const metadataRef = useRef(metadata);
|
||||||
|
|
||||||
|
// Keep refs updated
|
||||||
|
joinRoomRef.current = collaboration.joinRoom;
|
||||||
|
leaveRoomRef.current = collaboration.leaveRoom;
|
||||||
|
metadataRef.current = metadata;
|
||||||
|
|
||||||
|
// Track if we successfully joined a room
|
||||||
|
const joinedRoomRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !roomKey) return;
|
||||||
|
|
||||||
|
// Use a flag to prevent race conditions
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const doJoin = async () => {
|
||||||
|
try {
|
||||||
|
if (!cancelled) {
|
||||||
|
await joinRoomRef.current(roomKey, metadataRef.current);
|
||||||
|
if (!cancelled) {
|
||||||
|
joinedRoomRef.current = roomKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error("[useCollaborationRoom] Failed to join room:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doJoin();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
// Only leave if we actually joined this room
|
||||||
|
if (joinedRoomRef.current === roomKey) {
|
||||||
|
joinedRoomRef.current = null;
|
||||||
|
leaveRoomRef.current().catch((err) => {
|
||||||
|
// Silently ignore leave errors - connection may already be closed
|
||||||
|
console.log(
|
||||||
|
"[useCollaborationRoom] Leave room:",
|
||||||
|
err?.message || "ok",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [roomKey, enabled]); // Only re-run when roomKey or enabled changes
|
||||||
|
|
||||||
|
return collaboration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get cursors filtered by current view
|
||||||
|
*/
|
||||||
|
export function useRemoteCursorsForView(viewId: string | null): RemoteCursor[] {
|
||||||
|
const { remoteCursors } = useCollaboration();
|
||||||
|
|
||||||
|
return Array.from(remoteCursors.values()).filter(
|
||||||
|
(cursor) => viewId === null || cursor.viewId === viewId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if an item is selected by a remote user
|
||||||
|
*/
|
||||||
|
export function useRemoteSelection(itemId: string): {
|
||||||
|
isSelected: boolean;
|
||||||
|
selectedBy: Collaborator | null;
|
||||||
|
} {
|
||||||
|
const { remoteSelections, collaborators } = useCollaboration();
|
||||||
|
|
||||||
|
for (const [connectionId, selectedId] of remoteSelections.entries()) {
|
||||||
|
if (selectedId === itemId) {
|
||||||
|
const collaborator =
|
||||||
|
collaborators.find((c) => c.connectionId === connectionId) || null;
|
||||||
|
return { isSelected: true, selectedBy: collaborator };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isSelected: false, selectedBy: null };
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -8,6 +8,12 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { useHistory } from "../hooks/useHistory";
|
import { useHistory } from "../hooks/useHistory";
|
||||||
|
import { useCollaborationRoom } from "../contexts/CollaborationContext";
|
||||||
|
import type {
|
||||||
|
DataChangeMessage,
|
||||||
|
ItemCreatedMessage,
|
||||||
|
ItemDeletedMessage,
|
||||||
|
} from "../services/collaboration";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
@@ -172,6 +178,16 @@ export default function ReportEditorPage() {
|
|||||||
// Auto-save feature - enabled by default
|
// Auto-save feature - enabled by default
|
||||||
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
|
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
|
||||||
|
|
||||||
|
// ============ COLLABORATION (using global context) ============
|
||||||
|
// Room key format: "report-template:{id}"
|
||||||
|
const roomKey = id ? `report-template:${id}` : null;
|
||||||
|
const collaboration = useCollaborationRoom(roomKey, {
|
||||||
|
enabled: !isNew && !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flag to prevent re-broadcasting received changes
|
||||||
|
const isApplyingRemoteChange = useRef(false);
|
||||||
|
|
||||||
// Update zoom on screen size change
|
// Update zoom on screen size change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -183,6 +199,171 @@ export default function ReportEditorPage() {
|
|||||||
}
|
}
|
||||||
}, [isMobile, isTablet]);
|
}, [isMobile, isTablet]);
|
||||||
|
|
||||||
|
// ============ COLLABORATION EFFECTS ============
|
||||||
|
// The collaboration context handles connection, room joining, and presence automatically.
|
||||||
|
// We only need to subscribe to data change events and send our changes.
|
||||||
|
|
||||||
|
// Subscribe to remote data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!collaboration.isConnected || !collaboration.currentRoom) return;
|
||||||
|
|
||||||
|
const unsubscribers: (() => void)[] = [];
|
||||||
|
|
||||||
|
// Element/data changed by remote user
|
||||||
|
unsubscribers.push(
|
||||||
|
collaboration.onDataChanged((message: DataChangeMessage) => {
|
||||||
|
if (message.itemType !== "element") return;
|
||||||
|
isApplyingRemoteChange.current = true;
|
||||||
|
historyActions.setWithoutHistory((prev) => ({
|
||||||
|
...prev,
|
||||||
|
elements: prev.elements.map((el) =>
|
||||||
|
el.id === message.itemId
|
||||||
|
? { ...el, ...(message.newValue as Partial<AprtElement>) }
|
||||||
|
: el,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
setTimeout(() => {
|
||||||
|
isApplyingRemoteChange.current = false;
|
||||||
|
}, 0);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Element added by remote user
|
||||||
|
unsubscribers.push(
|
||||||
|
collaboration.onItemCreated((message: ItemCreatedMessage) => {
|
||||||
|
if (message.itemType !== "element") return;
|
||||||
|
isApplyingRemoteChange.current = true;
|
||||||
|
historyActions.setWithoutHistory((prev) => ({
|
||||||
|
...prev,
|
||||||
|
elements: [...prev.elements, message.item as AprtElement],
|
||||||
|
}));
|
||||||
|
setTimeout(() => {
|
||||||
|
isApplyingRemoteChange.current = false;
|
||||||
|
}, 0);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Element deleted by remote user
|
||||||
|
unsubscribers.push(
|
||||||
|
collaboration.onItemDeleted((message: ItemDeletedMessage) => {
|
||||||
|
if (message.itemType !== "element") return;
|
||||||
|
isApplyingRemoteChange.current = true;
|
||||||
|
historyActions.setWithoutHistory((prev) => ({
|
||||||
|
...prev,
|
||||||
|
elements: prev.elements.filter((el) => el.id !== message.itemId),
|
||||||
|
}));
|
||||||
|
// Clear selection if deleted element was selected
|
||||||
|
if (selectedElementId === message.itemId) {
|
||||||
|
setSelectedElementId(null);
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
isApplyingRemoteChange.current = false;
|
||||||
|
}, 0);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Page changes by remote user
|
||||||
|
unsubscribers.push(
|
||||||
|
collaboration.onDataChanged((message: DataChangeMessage) => {
|
||||||
|
if (message.itemType !== "page") return;
|
||||||
|
isApplyingRemoteChange.current = true;
|
||||||
|
historyActions.setWithoutHistory((prev) => {
|
||||||
|
switch (message.changeType) {
|
||||||
|
case "added":
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: [...prev.pages, message.newValue as AprtPage],
|
||||||
|
};
|
||||||
|
case "deleted":
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.filter((p) => p.id !== message.itemId),
|
||||||
|
elements: prev.elements.filter(
|
||||||
|
(e) => e.pageId !== message.itemId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "renamed":
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.map((p) =>
|
||||||
|
p.id === message.itemId
|
||||||
|
? { ...p, name: message.newValue as string }
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case "reordered":
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: message.newValue as AprtPage[],
|
||||||
|
};
|
||||||
|
case "settings":
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: prev.pages.map((p) =>
|
||||||
|
p.id === message.itemId
|
||||||
|
? { ...p, ...(message.newValue as Partial<AprtPage>) }
|
||||||
|
: p,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
isApplyingRemoteChange.current = false;
|
||||||
|
}, 0);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Template saved by remote user
|
||||||
|
unsubscribers.push(
|
||||||
|
collaboration.onDataSaved((message) => {
|
||||||
|
console.log(
|
||||||
|
"[Collaboration] Received DataSaved from:",
|
||||||
|
message.savedBy,
|
||||||
|
);
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: `${message.savedBy} ha salvato il template`,
|
||||||
|
severity: "success",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["report-template", id] });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync requested - send current template to requester
|
||||||
|
unsubscribers.push(
|
||||||
|
collaboration.onSyncRequested((request) => {
|
||||||
|
collaboration.sendSync(request.requesterId, JSON.stringify(template));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribers.forEach((unsub) => unsub());
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
collaboration,
|
||||||
|
historyActions,
|
||||||
|
selectedElementId,
|
||||||
|
template,
|
||||||
|
queryClient,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send selection changes to collaborators
|
||||||
|
useEffect(() => {
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendSelectionChanged(selectedElementId);
|
||||||
|
}
|
||||||
|
}, [collaboration, selectedElementId]);
|
||||||
|
|
||||||
|
// Send view/page navigation to collaborators
|
||||||
|
useEffect(() => {
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendViewChanged(currentPageId);
|
||||||
|
}
|
||||||
|
}, [collaboration, currentPageId]);
|
||||||
|
|
||||||
// Load existing template
|
// Load existing template
|
||||||
const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({
|
const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({
|
||||||
queryKey: ["report-template", id],
|
queryKey: ["report-template", id],
|
||||||
@@ -347,6 +528,19 @@ export default function ReportEditorPage() {
|
|||||||
setSaveDialog(false);
|
setSaveDialog(false);
|
||||||
// Mark current state as saved
|
// Mark current state as saved
|
||||||
setLastSavedUndoCount(templateHistory.undoCount);
|
setLastSavedUndoCount(templateHistory.undoCount);
|
||||||
|
|
||||||
|
// Notify collaborators of save
|
||||||
|
console.log(
|
||||||
|
"[AutoSave] Save success, collaboration.isConnected:",
|
||||||
|
collaboration.isConnected,
|
||||||
|
"currentRoom:",
|
||||||
|
collaboration.currentRoom,
|
||||||
|
);
|
||||||
|
if (collaboration.isConnected && collaboration.currentRoom) {
|
||||||
|
console.log("[AutoSave] Sending DataSaved notification");
|
||||||
|
collaboration.sendDataSaved();
|
||||||
|
}
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
navigate(`/report-editor/${result.id}`, { replace: true });
|
navigate(`/report-editor/${result.id}`, { replace: true });
|
||||||
}
|
}
|
||||||
@@ -415,10 +609,15 @@ export default function ReportEditorPage() {
|
|||||||
pages: [...prev.pages, newPage],
|
pages: [...prev.pages, newPage],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Send to collaborators
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendDataChanged(newPageId, "page", "added", newPage);
|
||||||
|
}
|
||||||
|
|
||||||
// Switch to the new page
|
// Switch to the new page
|
||||||
setCurrentPageId(newPageId);
|
setCurrentPageId(newPageId);
|
||||||
setSelectedElementId(null);
|
setSelectedElementId(null);
|
||||||
}, [template.pages.length, historyActions]);
|
}, [template.pages.length, historyActions, collaboration]);
|
||||||
|
|
||||||
// Duplicate page with all its elements
|
// Duplicate page with all its elements
|
||||||
const handleDuplicatePage = useCallback(
|
const handleDuplicatePage = useCallback(
|
||||||
@@ -465,6 +664,11 @@ export default function ReportEditorPage() {
|
|||||||
|
|
||||||
const pageIndex = template.pages.findIndex((p) => p.id === pageId);
|
const pageIndex = template.pages.findIndex((p) => p.id === pageId);
|
||||||
|
|
||||||
|
// Send to collaborators before deleting
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendDataChanged(pageId, "page", "deleted", null);
|
||||||
|
}
|
||||||
|
|
||||||
historyActions.set((prev) => ({
|
historyActions.set((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
pages: prev.pages.filter((p) => p.id !== pageId),
|
pages: prev.pages.filter((p) => p.id !== pageId),
|
||||||
@@ -481,7 +685,7 @@ export default function ReportEditorPage() {
|
|||||||
}
|
}
|
||||||
setSelectedElementId(null);
|
setSelectedElementId(null);
|
||||||
},
|
},
|
||||||
[template.pages, historyActions],
|
[template.pages, historyActions, collaboration],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rename page
|
// Rename page
|
||||||
@@ -493,8 +697,13 @@ export default function ReportEditorPage() {
|
|||||||
p.id === pageId ? { ...p, name: newName } : p,
|
p.id === pageId ? { ...p, name: newName } : p,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Send to collaborators
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendDataChanged(pageId, "page", "renamed", newName);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[historyActions],
|
[historyActions, collaboration],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Move page up or down
|
// Move page up or down
|
||||||
@@ -578,12 +787,17 @@ export default function ReportEditorPage() {
|
|||||||
}));
|
}));
|
||||||
setSelectedElementId(newElement.id);
|
setSelectedElementId(newElement.id);
|
||||||
|
|
||||||
|
// Send to collaborators
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendItemCreated(newElement.id, "element", newElement);
|
||||||
|
}
|
||||||
|
|
||||||
// On mobile, open properties panel after adding element
|
// On mobile, open properties panel after adding element
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setMobilePanel("properties");
|
setMobilePanel("properties");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[historyActions, currentPageId, isMobile],
|
[historyActions, currentPageId, isMobile, collaboration],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update element without history (for continuous updates like dragging)
|
// Update element without history (for continuous updates like dragging)
|
||||||
@@ -608,8 +822,13 @@ export default function ReportEditorPage() {
|
|||||||
el.id === elementId ? { ...el, ...updates } : el,
|
el.id === elementId ? { ...el, ...updates } : el,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Send to collaborators
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendDataChanged(elementId, "element", "full", updates);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[historyActions],
|
[historyActions, collaboration],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle image selection from dialog
|
// Handle image selection from dialog
|
||||||
@@ -696,12 +915,18 @@ export default function ReportEditorPage() {
|
|||||||
// Delete element
|
// Delete element
|
||||||
const handleDeleteElement = useCallback(() => {
|
const handleDeleteElement = useCallback(() => {
|
||||||
if (!selectedElementId) return;
|
if (!selectedElementId) return;
|
||||||
|
|
||||||
|
// Send to collaborators before deleting
|
||||||
|
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||||
|
collaboration.sendItemDeleted(selectedElementId, "element");
|
||||||
|
}
|
||||||
|
|
||||||
historyActions.set((prev) => ({
|
historyActions.set((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
elements: prev.elements.filter((el) => el.id !== selectedElementId),
|
elements: prev.elements.filter((el) => el.id !== selectedElementId),
|
||||||
}));
|
}));
|
||||||
setSelectedElementId(null);
|
setSelectedElementId(null);
|
||||||
}, [selectedElementId, historyActions]);
|
}, [selectedElementId, historyActions, collaboration]);
|
||||||
|
|
||||||
// Copy element
|
// Copy element
|
||||||
const handleCopyElement = useCallback(() => {
|
const handleCopyElement = useCallback(() => {
|
||||||
@@ -1228,29 +1453,37 @@ export default function ReportEditorPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-save effect - saves after 1 second of inactivity when there are unsaved changes
|
// 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
|
||||||
|
const saveMutationRef = useRef(saveMutation);
|
||||||
|
saveMutationRef.current = saveMutation;
|
||||||
|
|
||||||
|
const templateRef = useRef(template);
|
||||||
|
templateRef.current = template;
|
||||||
|
|
||||||
|
const templateInfoRef = useRef(templateInfo);
|
||||||
|
templateInfoRef.current = templateInfo;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!autoSaveEnabled ||
|
!autoSaveEnabled ||
|
||||||
!hasUnsavedChanges ||
|
!hasUnsavedChanges ||
|
||||||
isNew ||
|
isNew ||
|
||||||
saveMutation.isPending
|
saveMutationRef.current.isPending
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
saveMutation.mutate({ template, info: templateInfo });
|
if (!saveMutationRef.current.isPending) {
|
||||||
|
saveMutationRef.current.mutate({
|
||||||
|
template: templateRef.current,
|
||||||
|
info: templateInfoRef.current,
|
||||||
|
});
|
||||||
|
}
|
||||||
}, 1000); // 1 second debounce
|
}, 1000); // 1 second debounce
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [
|
}, [autoSaveEnabled, hasUnsavedChanges, isNew]);
|
||||||
autoSaveEnabled,
|
|
||||||
hasUnsavedChanges,
|
|
||||||
isNew,
|
|
||||||
template,
|
|
||||||
templateInfo,
|
|
||||||
saveMutation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isLoadingTemplate && id) {
|
if (isLoadingTemplate && id) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
1047
frontend/src/services/collaboration.ts
Normal file
1047
frontend/src/services/collaboration.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="10.0.0" />
|
|
||||||
<PackageReference Include="QuestPDF" Version="2024.12.2" />
|
<PackageReference Include="QuestPDF" Version="2024.12.2" />
|
||||||
<PackageReference Include="SkiaSharp" Version="3.116.1" />
|
<PackageReference Include="SkiaSharp" Version="3.116.1" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||||
|
|||||||
527
src/Apollinare.API/Hubs/CollaborationHub.cs
Normal file
527
src/Apollinare.API/Hubs/CollaborationHub.cs
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Apollinare.API.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hub SignalR generico per la collaborazione in tempo reale su qualsiasi entità/pagina
|
||||||
|
/// Supporta: presenza utenti, cursori, selezioni, modifiche dati, chat
|
||||||
|
/// </summary>
|
||||||
|
public class CollaborationHub : Hub
|
||||||
|
{
|
||||||
|
// Stato in-memory: roomKey -> lista collaboratori
|
||||||
|
// roomKey format: "{entityType}:{entityId}" es. "report-template:123", "evento:456", "page:dashboard"
|
||||||
|
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, CollaboratorInfo>> _rooms = new();
|
||||||
|
|
||||||
|
// Mapping connectionId -> roomKey per cleanup su disconnect
|
||||||
|
private static readonly ConcurrentDictionary<string, string> _connectionRooms = new();
|
||||||
|
|
||||||
|
#region Room Management
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Un utente entra in una room (pagina/entità)
|
||||||
|
/// </summary>
|
||||||
|
public async Task JoinRoom(string roomKey, string userName, string userColor, object? metadata = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (string.IsNullOrEmpty(roomKey))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Collaboration] JoinRoom failed: roomKey is null or empty");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiungi alla room SignalR
|
||||||
|
await Groups.AddToGroupAsync(connectionId, roomKey);
|
||||||
|
|
||||||
|
// Crea info collaboratore
|
||||||
|
var collaborator = new CollaboratorInfo
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
UserName = userName ?? "Anonymous",
|
||||||
|
Color = userColor ?? "#888888",
|
||||||
|
JoinedAt = DateTime.UtcNow,
|
||||||
|
SelectedItemId = null,
|
||||||
|
CursorX = null,
|
||||||
|
CursorY = null,
|
||||||
|
CurrentViewId = null,
|
||||||
|
Metadata = metadata,
|
||||||
|
IsActive = true,
|
||||||
|
LastActivityAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggiungi al dizionario collaboratori della room
|
||||||
|
var roomCollabs = _rooms.GetOrAdd(roomKey, _ => new ConcurrentDictionary<string, CollaboratorInfo>());
|
||||||
|
roomCollabs[connectionId] = collaborator;
|
||||||
|
|
||||||
|
// Traccia mapping connection -> room
|
||||||
|
_connectionRooms[connectionId] = roomKey;
|
||||||
|
|
||||||
|
// Notifica tutti gli altri nella room
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("UserJoined", collaborator);
|
||||||
|
|
||||||
|
// Invia al nuovo utente la lista dei collaboratori già presenti
|
||||||
|
var existingCollaborators = roomCollabs.Values
|
||||||
|
.Where(c => c.ConnectionId != connectionId)
|
||||||
|
.ToList();
|
||||||
|
await Clients.Caller.SendAsync("RoomState", new RoomStateMessage
|
||||||
|
{
|
||||||
|
RoomKey = roomKey,
|
||||||
|
Collaborators = existingCollaborators,
|
||||||
|
JoinedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine($"[Collaboration] {userName} joined room {roomKey}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Collaboration] ERROR in JoinRoom: {ex.Message}");
|
||||||
|
Console.WriteLine($"[Collaboration] Stack: {ex.StackTrace}");
|
||||||
|
throw; // Re-throw to let SignalR handle it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Un utente esce da una room
|
||||||
|
/// </summary>
|
||||||
|
public async Task LeaveRoom(string roomKey)
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
await RemoveFromRoom(connectionId, roomKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cambia room (es. navigazione tra pagine)
|
||||||
|
/// </summary>
|
||||||
|
public async Task SwitchRoom(string newRoomKey, string userName, string userColor, object? metadata = null)
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
|
||||||
|
// Esci dalla room corrente se presente
|
||||||
|
if (_connectionRooms.TryGetValue(connectionId, out var currentRoom) && currentRoom != newRoomKey)
|
||||||
|
{
|
||||||
|
await RemoveFromRoom(connectionId, currentRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entra nella nuova room
|
||||||
|
await JoinRoom(newRoomKey, userName, userColor, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Data Sync
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica modifica di un oggetto/campo
|
||||||
|
/// </summary>
|
||||||
|
public async Task DataChanged(string roomKey, DataChangeMessage change)
|
||||||
|
{
|
||||||
|
change.SenderConnectionId = Context.ConnectionId;
|
||||||
|
change.Timestamp = DateTime.UtcNow;
|
||||||
|
|
||||||
|
UpdateUserActivity(Context.ConnectionId);
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("DataChanged", change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica creazione di un nuovo oggetto
|
||||||
|
/// </summary>
|
||||||
|
public async Task ItemCreated(string roomKey, ItemCreatedMessage message)
|
||||||
|
{
|
||||||
|
message.SenderConnectionId = Context.ConnectionId;
|
||||||
|
message.Timestamp = DateTime.UtcNow;
|
||||||
|
|
||||||
|
UpdateUserActivity(Context.ConnectionId);
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("ItemCreated", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica eliminazione di un oggetto
|
||||||
|
/// </summary>
|
||||||
|
public async Task ItemDeleted(string roomKey, ItemDeletedMessage message)
|
||||||
|
{
|
||||||
|
message.SenderConnectionId = Context.ConnectionId;
|
||||||
|
message.Timestamp = DateTime.UtcNow;
|
||||||
|
|
||||||
|
UpdateUserActivity(Context.ConnectionId);
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("ItemDeleted", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica operazione batch (es. riordino, bulk update)
|
||||||
|
/// </summary>
|
||||||
|
public async Task BatchOperation(string roomKey, BatchOperationMessage message)
|
||||||
|
{
|
||||||
|
message.SenderConnectionId = Context.ConnectionId;
|
||||||
|
message.Timestamp = DateTime.UtcNow;
|
||||||
|
|
||||||
|
UpdateUserActivity(Context.ConnectionId);
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("BatchOperation", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Presence & Awareness
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica cambio selezione (quale elemento sta modificando l'utente)
|
||||||
|
/// </summary>
|
||||||
|
public async Task SelectionChanged(string roomKey, string? itemId)
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
|
||||||
|
// Aggiorna stato locale
|
||||||
|
UpdateCollaboratorState(roomKey, connectionId, c => c.SelectedItemId = itemId);
|
||||||
|
UpdateUserActivity(connectionId);
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("SelectionChanged", new SelectionChangedMessage
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
ItemId = itemId,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica movimento cursore
|
||||||
|
/// </summary>
|
||||||
|
public async Task CursorMoved(string roomKey, float x, float y, string? viewId = null)
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
|
||||||
|
// Aggiorna stato locale
|
||||||
|
UpdateCollaboratorState(roomKey, connectionId, c =>
|
||||||
|
{
|
||||||
|
c.CursorX = x;
|
||||||
|
c.CursorY = y;
|
||||||
|
c.CurrentViewId = viewId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non aggiorniamo LastActivityAt per ogni movimento cursore (troppo frequente)
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("CursorMoved", new CursorMovedMessage
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
X = x,
|
||||||
|
Y = y,
|
||||||
|
ViewId = viewId,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica cambio vista/sezione (es. cambio tab, pagina, scroll)
|
||||||
|
/// </summary>
|
||||||
|
public async Task ViewChanged(string roomKey, string viewId)
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
|
||||||
|
UpdateCollaboratorState(roomKey, connectionId, c => c.CurrentViewId = viewId);
|
||||||
|
UpdateUserActivity(connectionId);
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("ViewChanged", new ViewChangedMessage
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
ViewId = viewId,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indica che l'utente sta digitando/modificando
|
||||||
|
/// </summary>
|
||||||
|
public async Task UserTyping(string roomKey, string? itemId, bool isTyping)
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
UpdateUserActivity(connectionId);
|
||||||
|
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("UserTyping", new UserTypingMessage
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
ItemId = itemId,
|
||||||
|
IsTyping = isTyping,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Sync & Recovery
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Richiesta sync completo (per nuovo utente o dopo reconnect)
|
||||||
|
/// </summary>
|
||||||
|
public async Task RequestSync(string roomKey)
|
||||||
|
{
|
||||||
|
// Chiedi al primo collaboratore (host) di inviare lo stato completo
|
||||||
|
if (_rooms.TryGetValue(roomKey, out var collabs))
|
||||||
|
{
|
||||||
|
var host = collabs.Values
|
||||||
|
.Where(c => c.ConnectionId != Context.ConnectionId && c.IsActive)
|
||||||
|
.OrderBy(c => c.JoinedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (host != null)
|
||||||
|
{
|
||||||
|
await Clients.Client(host.ConnectionId).SendAsync("SyncRequested", new SyncRequestMessage
|
||||||
|
{
|
||||||
|
RequesterId = Context.ConnectionId,
|
||||||
|
RoomKey = roomKey,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invio sync completo a un utente specifico
|
||||||
|
/// </summary>
|
||||||
|
public async Task SendSync(string roomKey, string targetConnectionId, string dataJson)
|
||||||
|
{
|
||||||
|
await Clients.Client(targetConnectionId).SendAsync("SyncReceived", new SyncDataMessage
|
||||||
|
{
|
||||||
|
RoomKey = roomKey,
|
||||||
|
DataJson = dataJson,
|
||||||
|
SenderConnectionId = Context.ConnectionId,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notifica salvataggio dati
|
||||||
|
/// </summary>
|
||||||
|
public async Task DataSaved(string roomKey, string savedBy)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Collaboration] DataSaved received from {savedBy} for room {roomKey}");
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("DataSaved", new DataSavedMessage
|
||||||
|
{
|
||||||
|
SavedBy = savedBy,
|
||||||
|
RoomKey = roomKey,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
Console.WriteLine($"[Collaboration] DataSaved sent to others in room {roomKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Lifecycle
|
||||||
|
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Collaboration] Client connected: {Context.ConnectionId}");
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
Console.WriteLine($"[Collaboration] Client connected (done): {Context.ConnectionId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
var connectionId = Context.ConnectionId;
|
||||||
|
|
||||||
|
if (exception != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Collaboration] Client disconnected with ERROR: {connectionId} - {exception.Message}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Collaboration] Client disconnected: {connectionId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rimuovi da eventuali room
|
||||||
|
if (_connectionRooms.TryRemove(connectionId, out var roomKey))
|
||||||
|
{
|
||||||
|
await RemoveFromRoom(connectionId, roomKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helpers
|
||||||
|
|
||||||
|
private async Task RemoveFromRoom(string connectionId, string roomKey)
|
||||||
|
{
|
||||||
|
// Rimuovi dalla room SignalR
|
||||||
|
await Groups.RemoveFromGroupAsync(connectionId, roomKey);
|
||||||
|
|
||||||
|
// Rimuovi dal dizionario
|
||||||
|
if (_rooms.TryGetValue(roomKey, out var collabs))
|
||||||
|
{
|
||||||
|
if (collabs.TryRemove(connectionId, out var removedCollab))
|
||||||
|
{
|
||||||
|
// Notifica gli altri
|
||||||
|
await Clients.OthersInGroup(roomKey).SendAsync("UserLeft", new UserLeftMessage
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
UserName = removedCollab.UserName,
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine($"[Collaboration] {removedCollab.UserName} left room {roomKey}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulisci dizionario se vuoto
|
||||||
|
if (collabs.IsEmpty)
|
||||||
|
{
|
||||||
|
_rooms.TryRemove(roomKey, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_connectionRooms.TryRemove(connectionId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCollaboratorState(string roomKey, string connectionId, Action<CollaboratorInfo> update)
|
||||||
|
{
|
||||||
|
if (_rooms.TryGetValue(roomKey, out var collabs) &&
|
||||||
|
collabs.TryGetValue(connectionId, out var collaborator))
|
||||||
|
{
|
||||||
|
update(collaborator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateUserActivity(string connectionId)
|
||||||
|
{
|
||||||
|
if (_connectionRooms.TryGetValue(connectionId, out var roomKey) &&
|
||||||
|
_rooms.TryGetValue(roomKey, out var collabs) &&
|
||||||
|
collabs.TryGetValue(connectionId, out var collaborator))
|
||||||
|
{
|
||||||
|
collaborator.LastActivityAt = DateTime.UtcNow;
|
||||||
|
collaborator.IsActive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
#region DTOs / Models
|
||||||
|
|
||||||
|
public class CollaboratorInfo
|
||||||
|
{
|
||||||
|
public string ConnectionId { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string Color { get; set; } = string.Empty;
|
||||||
|
public DateTime JoinedAt { get; set; }
|
||||||
|
public string? SelectedItemId { get; set; }
|
||||||
|
public float? CursorX { get; set; }
|
||||||
|
public float? CursorY { get; set; }
|
||||||
|
public string? CurrentViewId { get; set; }
|
||||||
|
public object? Metadata { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTime LastActivityAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RoomStateMessage
|
||||||
|
{
|
||||||
|
public string RoomKey { get; set; } = string.Empty;
|
||||||
|
public List<CollaboratorInfo> Collaborators { get; set; } = new();
|
||||||
|
public DateTime JoinedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DataChangeMessage
|
||||||
|
{
|
||||||
|
public string ItemId { get; set; } = string.Empty;
|
||||||
|
public string ItemType { get; set; } = string.Empty;
|
||||||
|
public string ChangeType { get; set; } = string.Empty; // "update", "partial", "field"
|
||||||
|
public string? FieldPath { get; set; } // es. "position.x", "style.color"
|
||||||
|
public object? NewValue { get; set; }
|
||||||
|
public object? OldValue { get; set; }
|
||||||
|
public string? SenderConnectionId { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ItemCreatedMessage
|
||||||
|
{
|
||||||
|
public string ItemId { get; set; } = string.Empty;
|
||||||
|
public string ItemType { get; set; } = string.Empty;
|
||||||
|
public object Item { get; set; } = null!;
|
||||||
|
public string? ParentId { get; set; }
|
||||||
|
public int? Index { get; set; }
|
||||||
|
public string? SenderConnectionId { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ItemDeletedMessage
|
||||||
|
{
|
||||||
|
public string ItemId { get; set; } = string.Empty;
|
||||||
|
public string ItemType { get; set; } = string.Empty;
|
||||||
|
public string? SenderConnectionId { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BatchOperationMessage
|
||||||
|
{
|
||||||
|
public string OperationType { get; set; } = string.Empty; // "reorder", "bulk-update", "bulk-delete"
|
||||||
|
public string ItemType { get; set; } = string.Empty;
|
||||||
|
public object Data { get; set; } = null!;
|
||||||
|
public string? SenderConnectionId { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SelectionChangedMessage
|
||||||
|
{
|
||||||
|
public string ConnectionId { get; set; } = string.Empty;
|
||||||
|
public string? ItemId { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CursorMovedMessage
|
||||||
|
{
|
||||||
|
public string ConnectionId { get; set; } = string.Empty;
|
||||||
|
public float X { get; set; }
|
||||||
|
public float Y { get; set; }
|
||||||
|
public string? ViewId { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ViewChangedMessage
|
||||||
|
{
|
||||||
|
public string ConnectionId { get; set; } = string.Empty;
|
||||||
|
public string ViewId { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UserTypingMessage
|
||||||
|
{
|
||||||
|
public string ConnectionId { get; set; } = string.Empty;
|
||||||
|
public string? ItemId { get; set; }
|
||||||
|
public bool IsTyping { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SyncRequestMessage
|
||||||
|
{
|
||||||
|
public string RequesterId { get; set; } = string.Empty;
|
||||||
|
public string RoomKey { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SyncDataMessage
|
||||||
|
{
|
||||||
|
public string RoomKey { get; set; } = string.Empty;
|
||||||
|
public string DataJson { get; set; } = string.Empty;
|
||||||
|
public string SenderConnectionId { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DataSavedMessage
|
||||||
|
{
|
||||||
|
public string SavedBy { get; set; } = string.Empty;
|
||||||
|
public string RoomKey { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UserLeftMessage
|
||||||
|
{
|
||||||
|
public string ConnectionId { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
@@ -66,5 +66,6 @@ app.UseWebSockets();
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHub<DataHub>("/hubs/data");
|
app.MapHub<DataHub>("/hubs/data");
|
||||||
|
app.MapHub<CollaborationHub>("/hubs/collaboration");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
Binary file not shown.
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user