-
This commit is contained in:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.19",
|
||||
"@fullcalendar/react": "^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/material": "^7.3.5",
|
||||
"@mui/x-data-grid": "^8.20.0",
|
||||
@@ -1263,16 +1263,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
|
||||
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz",
|
||||
"integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^2.0.2",
|
||||
"fetch-cookie": "^2.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"ws": "^7.5.10"
|
||||
"ws": "^7.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.19",
|
||||
"@fullcalendar/react": "^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/material": "^7.3.5",
|
||||
"@mui/x-data-grid": "^8.20.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ import CalendarioPage from "./pages/CalendarioPage";
|
||||
import ReportTemplatesPage from "./pages/ReportTemplatesPage";
|
||||
import ReportEditorPage from "./pages/ReportEditorPage";
|
||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -59,29 +60,34 @@ function App() {
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<RealTimeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="calendario" element={<CalendarioPage />} />
|
||||
<Route path="eventi" element={<EventiPage />} />
|
||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||
<Route path="clienti" element={<ClientiPage />} />
|
||||
<Route path="location" element={<LocationPage />} />
|
||||
<Route path="articoli" element={<ArticoliPage />} />
|
||||
<Route path="risorse" element={<RisorsePage />} />
|
||||
<Route
|
||||
path="report-templates"
|
||||
element={<ReportTemplatesPage />}
|
||||
/>
|
||||
<Route path="report-editor" element={<ReportEditorPage />} />
|
||||
<Route
|
||||
path="report-editor/:id"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</RealTimeProvider>
|
||||
<CollaborationProvider>
|
||||
<RealTimeProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="calendario" element={<CalendarioPage />} />
|
||||
<Route path="eventi" element={<EventiPage />} />
|
||||
<Route path="eventi/:id" element={<EventoDetailPage />} />
|
||||
<Route path="clienti" element={<ClientiPage />} />
|
||||
<Route path="location" element={<LocationPage />} />
|
||||
<Route path="articoli" element={<ArticoliPage />} />
|
||||
<Route path="risorse" element={<RisorsePage />} />
|
||||
<Route
|
||||
path="report-templates"
|
||||
element={<ReportTemplatesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
<Route
|
||||
path="report-editor/:id"
|
||||
element={<ReportEditorPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</RealTimeProvider>
|
||||
</CollaborationProvider>
|
||||
</BrowserRouter>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Print as PrintIcon,
|
||||
Close as CloseIcon,
|
||||
} from "@mui/icons-material";
|
||||
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
const DRAWER_WIDTH_COLLAPSED = 64;
|
||||
@@ -158,6 +159,9 @@ export default function Layout() {
|
||||
>
|
||||
{isMobile ? "Apollinare" : "Catering & Banqueting Management"}
|
||||
</Typography>
|
||||
|
||||
{/* Collaboration Indicator */}
|
||||
<CollaborationIndicator compact={isMobile} />
|
||||
</Toolbar>
|
||||
</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;
|
||||
}
|
||||
|
||||
// Remote cursor info
|
||||
export interface RemoteCursor {
|
||||
x: number;
|
||||
y: number;
|
||||
pageId: string | null;
|
||||
color: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
interface EditorCanvasProps {
|
||||
template: AprtTemplate;
|
||||
selectedElementId: string | null;
|
||||
@@ -44,6 +53,16 @@ interface EditorCanvasProps {
|
||||
gridSize: number;
|
||||
snapOptions: SnapOptions;
|
||||
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 {
|
||||
@@ -68,6 +87,12 @@ const EditorCanvas = forwardRef<EditorCanvasRef, EditorCanvasProps>(
|
||||
gridSize,
|
||||
snapOptions,
|
||||
onContextMenu,
|
||||
onCursorMove,
|
||||
// These props are reserved for future remote cursor rendering on canvas
|
||||
// remoteCursors,
|
||||
// remoteSelections,
|
||||
// collaboratorColors,
|
||||
// currentPageId,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -639,12 +664,16 @@ const EditorCanvas = forwardRef<EditorCanvasRef, EditorCanvasProps>(
|
||||
(e: { e: MouseEvent }) => {
|
||||
if (!fabricRef.current) return;
|
||||
const pointer = fabricRef.current.getScenePoint(e.e);
|
||||
setCursorPosition({
|
||||
x: Math.round((pxToMm(pointer.x) / zoom) * 10) / 10,
|
||||
y: Math.round((pxToMm(pointer.y) / zoom) * 10) / 10,
|
||||
});
|
||||
const xMm = Math.round((pxToMm(pointer.x) / zoom) * 10) / 10;
|
||||
const yMm = 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
|
||||
|
||||
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 {
|
||||
useQuery,
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { useHistory } from "../hooks/useHistory";
|
||||
import { useCollaborationRoom } from "../contexts/CollaborationContext";
|
||||
import type {
|
||||
DataChangeMessage,
|
||||
ItemCreatedMessage,
|
||||
ItemDeletedMessage,
|
||||
} from "../services/collaboration";
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
@@ -172,6 +178,16 @@ export default function ReportEditorPage() {
|
||||
// Auto-save feature - enabled by default
|
||||
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
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
@@ -183,6 +199,171 @@ export default function ReportEditorPage() {
|
||||
}
|
||||
}, [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
|
||||
const { data: existingTemplate, isLoading: isLoadingTemplate } = useQuery({
|
||||
queryKey: ["report-template", id],
|
||||
@@ -347,6 +528,19 @@ export default function ReportEditorPage() {
|
||||
setSaveDialog(false);
|
||||
// Mark current state as saved
|
||||
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) {
|
||||
navigate(`/report-editor/${result.id}`, { replace: true });
|
||||
}
|
||||
@@ -415,10 +609,15 @@ export default function ReportEditorPage() {
|
||||
pages: [...prev.pages, newPage],
|
||||
}));
|
||||
|
||||
// Send to collaborators
|
||||
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||
collaboration.sendDataChanged(newPageId, "page", "added", newPage);
|
||||
}
|
||||
|
||||
// Switch to the new page
|
||||
setCurrentPageId(newPageId);
|
||||
setSelectedElementId(null);
|
||||
}, [template.pages.length, historyActions]);
|
||||
}, [template.pages.length, historyActions, collaboration]);
|
||||
|
||||
// Duplicate page with all its elements
|
||||
const handleDuplicatePage = useCallback(
|
||||
@@ -465,6 +664,11 @@ export default function ReportEditorPage() {
|
||||
|
||||
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) => ({
|
||||
...prev,
|
||||
pages: prev.pages.filter((p) => p.id !== pageId),
|
||||
@@ -481,7 +685,7 @@ export default function ReportEditorPage() {
|
||||
}
|
||||
setSelectedElementId(null);
|
||||
},
|
||||
[template.pages, historyActions],
|
||||
[template.pages, historyActions, collaboration],
|
||||
);
|
||||
|
||||
// Rename page
|
||||
@@ -493,8 +697,13 @@ export default function ReportEditorPage() {
|
||||
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
|
||||
@@ -578,12 +787,17 @@ export default function ReportEditorPage() {
|
||||
}));
|
||||
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
|
||||
if (isMobile) {
|
||||
setMobilePanel("properties");
|
||||
}
|
||||
},
|
||||
[historyActions, currentPageId, isMobile],
|
||||
[historyActions, currentPageId, isMobile, collaboration],
|
||||
);
|
||||
|
||||
// Update element without history (for continuous updates like dragging)
|
||||
@@ -608,8 +822,13 @@ export default function ReportEditorPage() {
|
||||
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
|
||||
@@ -696,12 +915,18 @@ export default function ReportEditorPage() {
|
||||
// Delete element
|
||||
const handleDeleteElement = useCallback(() => {
|
||||
if (!selectedElementId) return;
|
||||
|
||||
// Send to collaborators before deleting
|
||||
if (collaboration.isConnected && !isApplyingRemoteChange.current) {
|
||||
collaboration.sendItemDeleted(selectedElementId, "element");
|
||||
}
|
||||
|
||||
historyActions.set((prev) => ({
|
||||
...prev,
|
||||
elements: prev.elements.filter((el) => el.id !== selectedElementId),
|
||||
}));
|
||||
setSelectedElementId(null);
|
||||
}, [selectedElementId, historyActions]);
|
||||
}, [selectedElementId, historyActions, collaboration]);
|
||||
|
||||
// Copy element
|
||||
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
|
||||
// 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(() => {
|
||||
if (
|
||||
!autoSaveEnabled ||
|
||||
!hasUnsavedChanges ||
|
||||
isNew ||
|
||||
saveMutation.isPending
|
||||
saveMutationRef.current.isPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
autoSaveEnabled,
|
||||
hasUnsavedChanges,
|
||||
isNew,
|
||||
template,
|
||||
templateInfo,
|
||||
saveMutation,
|
||||
]);
|
||||
}, [autoSaveEnabled, hasUnsavedChanges, isNew]);
|
||||
|
||||
if (isLoadingTemplate && id) {
|
||||
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
Reference in New Issue
Block a user