diff --git a/blazor/diagram/collaborative-editing/overview.md b/blazor/diagram/collaborative-editing/overview.md new file mode 100644 index 0000000000..41d779881e --- /dev/null +++ b/blazor/diagram/collaborative-editing/overview.md @@ -0,0 +1,44 @@ +--- +layout: post +title: Collaborative editing in Blazor Diagram Component | Syncfusion +description: Checkout and Learn all about collaborative editing in Syncfusion Blazor Diagram component and many more details. +platform: Blazor +control: Diagram +documentation: ug +--- + +# Collaborative Editing in Blazor Diagram + +Collaborative editing enables multiple users to work on the same diagram at the same time. Changes are reflected in real-time, allowing all participants to instantly see updates as they happen. This feature promotes seamless teamwork by eliminating the need to wait for others to finish their edits. As a result, teams can boost productivity, streamline workflows, and ensure everyone stays aligned throughout the design process. + +## Prerequisites + +- *Real-time Transport Protocol*: Enables instant communication between clients and the server, ensuring that updates during collaborative editing are transmitted and reflected immediately. +- *Distributed Cache or Database*: Serves as temporary storage for the queue of editing operations, helping maintain synchronization and consistency across multiple users. + +### Real time transport protocol + +- *Managing Connections*: Maintains active connections between clients and the server to enable uninterrupted real-time collaboration. This ensures smooth and consistent communication throughout the editing session. +- *Broadcasting Changes*: Instantly propagates any edits made by one user to all other collaborators. This guarantees that everyone is always working on the most up-to-date version of the diagram, fostering accuracy and teamwork. + +### Distributed cache or database + +Collaborative editing requires a reliable backing system to temporarily store and manage editing operations from all active users. This ensures real-time synchronization and conflict resolution across multiple clients. There are two primary options: + +- *Distributed Cache*: + * Designed for high throughput and low latency. + * Handles significantly more HTTP requests per second compared to a database. + * Example: A server with 2 vCPUs and 8 GB RAM can process up to 125 requests per second using a distributed cache. + +- *Database*: + * Suitable for smaller-scale collaboration scenarios. + * With the same server configuration, a database can handle approximately 50 requests per second. + +> *Recommendation*: + * If your application expects 50 or fewer requests per second, a database provides a reliable solution for managing the operation queue. + * If your application expects more than 50 requests per second, a distributed cache is highly recommended for optimal performance. + +> Tips to calculate the average requests per second of your application: +Assume the editor in your live application is actively used by 1000 users and each user's edit can trigger 2 to 5 requests per second. The total requests per second of your applications will be around 2000 to 5000. In this case, you can finalize a configuration to support around 5000 average requests per second. + +> Note: The metrics provided are for illustration purposes only. Actual throughput may vary based on additional server-side operations. It is strongly recommended to monitor your application’s traffic and performance and select a configuration that best meets your real-world requirements. diff --git a/blazor/diagram/collaborative-editing/using-redis-cache-asp-net.md b/blazor/diagram/collaborative-editing/using-redis-cache-asp-net.md new file mode 100644 index 0000000000..7ae76439d7 --- /dev/null +++ b/blazor/diagram/collaborative-editing/using-redis-cache-asp-net.md @@ -0,0 +1,589 @@ +--- +layout: post +title: Collaborative editing in Blazor Diagram Component | Syncfusion +description: Checkout and Learn all about collaborative editing in Syncfusion Blazor Diagram component and many more details. +platform: Blazor +control: Diagram +documentation: ug +--- + +# Collaborative Editing with Redis in Blazor Diagram + +Collaborative editing enables multiple users to work on the same diagram at the same time. Changes are reflected in real-time, allowing collaborators to see updates as they happen. This approach significantly improves efficiency by eliminating the need to wait for others to finish their edits, fostering seamless teamwork. + +## Prerequisites + +Following things are needed to enable collaborative editing in Diagram Component + +* SignalR +* Redis + +## NuGet packages required + +- Client (Blazor): + - Microsoft.AspNetCore.SignalR.Client + - Syncfusion.Blazor.Diagram +- Server: + - Microsoft.AspNetCore.SignalR + - Microsoft.AspNetCore.SignalR.StackExchangeRedis + - StackExchange.Redis + +## SignalR + +In collaborative editing, real-time communication is essential for users to see each other’s changes instantly. We use a real-time transport protocol to efficiently send and receive data as edits occur. For this, we utilize SignalR, which supports real-time data exchange between the client and server. SignalR ensures that updates are transmitted immediately, allowing seamless collaboration by handling the complexities of connection management and offering reliable communication channels. + +To make SignalR work in a distributed environment (with more than one server instance), it needs to be configured with either AspNetCore SignalR Service or a Redis backplane. + +### Scale-out SignalR using AspNetCore SignalR service + +AspNetCore SignalR Service is a scalable, managed service for real-time communication in web applications. It enables real-time messaging between web clients (browsers) and your server-side application(across multiple servers). + +Below is a code snippet to configure SignalR in a Blazor application using AddSignalR + +```csharp +builder.Services.AddSignalR(options => +{ + options.EnableDetailedErrors = true; +}); +``` + + + +### Scale-out SignalR using Redis + +Using a Redis backplane, you can achieve horizontal scaling of your SignalR application. The SignalR leverages Redis to efficiently broadcast messages across multiple servers. This allows your application to handle large user bases with minimal latency. + +In the SignalR app, install the following NuGet package: + +`Microsoft.AspNetCore.SignalR.StackExchangeRedis` + +Below is a code snippet to configure Redis backplane in an ASP.NET Core application using the AddStackExchangeRedis method + +```csharp +builder.Services.AddSingleton(provider => +{ + var connectionString = builder.Configuration.GetConnectionString("Redis") ?? "localhost:6379,abortConnect=false"; + return ConnectionMultiplexer.Connect(connectionString); +}); +builder.Services.AddScoped(); +``` + +## Redis + +Redis is used as a temporary data store to manage real-time collaborative editing operations. It helps queue editing actions and resolve conflicts through versioning mechanisms. + +All diagram editing operations performed during collaboration are cached in Redis. To prevent excessive memory usage, old versioning data is periodically removed from the Redis cache. + +Redis imposes limits on concurrent connections. Select an appropriate Redis configuration based on your expected user load to maintain optimal performance and avoid connection bottlenecks. + +## How to enable collaborative editing in client side + +### Step 1: Configure SignalR to send and receive changes + +To broadcast the changes made and receive changes from remote users, configure SignalR like below. + +```csharp +@code { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await InitializeSignalR(); + } + } + + private async Task InitializeSignalR() + { + if (connection == null) + { + connection = new HubConnectionBuilder() + .WithUrl(NavigationManager.ToAbsoluteUri("/diagramHub"), options => + { + options.SkipNegotiation = true; + options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets; + }) + .WithAutomaticReconnect() + .Build(); + connection.On("OnSaveDiagramState", OnSaveDiagramState); + connection.On("ShowConflict", ShowConflict); + connection.On("RevertCurrentChanges", RevertCurrentChanges); + connection.On("OnConnectedAsync", OnConnectedAsync); + connection.On("UpdateVersion", UpdateVersion); + connection.On>("CurrentUsers", CurrentUsers); + connection.On("LoadDiagramData", OnLoadDiagramData); + connection.On, long, SelectionEvent>("ReceiveData", async (diagramChanges, serverVersion, selectionEvent) => + { + await InvokeAsync(() => OnReceiveDiagramUpdate(diagramChanges, serverVersion, selectionEvent)); + }); + connection.On("UserJoined", ShowUserJoined); + connection.On("UserLeft", ShowUserLeft); + connection.On("UpdateSelectionHighlighter", SendCurrentSelectorBoundsToOtherClient); + connection.On>("PeerSelectionsBootstrap", async list => + { + foreach (var evt in list) + _peerSelections[evt.ConnectionId] = (evt.UserName ?? "User", evt.ElementIds?.ToHashSet() ?? new(), evt.SelectorBounds); + await InvokeAsync(StateHasChanged); + }); + + connection.On("PeerSelectionChanged", async (evt) => + { + await InvokeAsync(() => + { + PeerSelectionChanged(evt); + StateHasChanged(); + } + ); + }); + + connection.On("PeerSelectionCleared", async evt => + { + if (evt != null) + { + _peerSelections.Remove(evt.ConnectionId); + await InvokeAsync(StateHasChanged); + } + }); + await connection.StartAsync(); + } + } +``` + +### Step 2: Join SignalR room while opening the diagram + +When opening a diagram, we need to generate a unique ID for each diagram. These unique IDs are then used to create rooms using SignalR, which facilitates sending and receiving data from the server. + +```csharp + string diagramId = "diagram"; + string currentUser = string.Empty; + string roomName = "diagram_group"; + + private async Task OnConnectedAsync(string connectionId) + { + if(!string.IsNullOrEmpty(connectionId)) + { + this.ConnectionId = connectionId; + currentUser = string.IsNullOrEmpty(currentUser) ? $"{userCount}" : currentUser; + // Join the room after connection is established + await connection.SendAsync("JoinDiagram", roomName, diagramId, currentUser); + } + } +``` + +### Step 3: Broadcast current editing changes to remote users + +Changes made on the client-side need to be sent to the server-side to broadcast them to other connected users. To send the changes made to the server, use the method shown below from the diagram using the `HistoryChange` event. + +```razor + + + + +@code { + public async void OnHistoryChange(HistoryChangedEventArgs args) + { + if (args != null && DiagramInstance != null && !isLoadDiagram && !isRevertingCurrentChanges) + { + bool isUndo = args.ActionTrigger == HistoryChangedAction.Undo; + bool isStartGroup = args.EntryType == (isUndo ? HistoryEntryType.EndGroup : HistoryEntryType.StartGroup); + bool isEndGroup = args.EntryType == (isUndo ? HistoryEntryType.StartGroup : HistoryEntryType.EndGroup); + + if (isStartGroup) { editedElements = new(); isGroupAction = true; } + List parsedData = DiagramInstance.GetDiagramUpdates(args); + editedElements.AddRange(GetEditedElementIds(args).ToList()); + if (parsedData.Count > 0) + { + var (selectedElementIds, selectorBounds) = await UpdateOtherClientSelectorBounds(); + SelectionEvent currentSelectionDetails = new SelectionEvent() { ElementIds = selectedElementIds, SelectorBounds = selectorBounds }; + if (connection.State != HubConnectionState.Disconnected) + await connection.SendAsync("BroadcastToOtherClients", parsedData, clientVersion, editedElements, currentSelectionDetails, roomName); + } + if (isEndGroup || !isGroupAction) { editedElements = new(); isGroupAction = false; } + } + } +} +``` + +## How to enable collaborative editing in Blazor + +### Step 1: Configure SignalR hub to create room for collaborative editing session + +To manage groups for each diagram, create a folder named “Hubs” and add a file named “DiagramHub.cs” inside it. Add the following code to the file to manage SignalR groups using room names. + +Join the group by using unique id of the diagram by using `JoinGroup` method. + +```csharp +using Microsoft.AspNetCore.SignalR; +using System.Collections.Concurrent; + +namespace DiagramServerApplication.Hubs +{ + public class DiagramHub : Hub + { + private readonly IDiagramService _diagramService; + private readonly IRedisService _redisService; + private readonly ILogger _logger; + private readonly IHubContext _diagramHubContext; + private static readonly ConcurrentDictionary _diagramUsers = new(); + + public DiagramHub( + IDiagramService diagramService, IRedisService redis, + ILogger logger, IHubContext diagramHubContext) + { + _diagramService = diagramService; + _redisService = redis; + _logger = logger; + _diagramHubContext = diagramHubContext; + } + + public override Task OnConnectedAsync() + { + // Send session id to client. + Clients.Caller.SendAsync("OnConnectedAsync", Context.ConnectionId); + return base.OnConnectedAsync(); + } + + public async Task JoinDiagram(string roomName, string diagramId, string userName) + { + try + { + string userId = Context.ConnectionId; + if (_diagramUsers.TryGetValue(userId, out var existingUser)) + { + if (existingUser != null) + { + _diagramUsers.TryRemove(userId, out _); + await Groups.RemoveFromGroupAsync(userId, roomName); + _logger.LogInformation("Removed existing connection for user {UserId} before adding new one", userId); + } + } + + // Add to SignalR group + await Groups.AddToGroupAsync(userId, roomName); + + // Store connection context + Context.Items["DiagramId"] = diagramId; + Context.Items["UserId"] = userId; + Context.Items["UserName"] = userName; + Context.Items["RoomName"] = roomName; + + // Track user in diagram + var diagramUser = new DiagramUser + { + ConnectionId = Context.ConnectionId, + UserName = userName, + }; + bool userExists = _diagramUsers?.Count > 0; + if (!userExists) + await ClearConnectionsFromRedis(); + _diagramUsers.AddOrUpdate(userId, diagramUser, + (key, existingValue) => diagramUser + ); + await RequestAndLoadStateAsync(roomName, diagramId, Context.ConnectionId, Context.ConnectionAborted); + + long currentServerVersion = await GetDiagramVersion(); + await Clients.Caller.SendAsync("UpdateVersion", currentServerVersion); + await Clients.OthersInGroup(roomName).SendAsync("UserJoined", userName); + await SendCurrentSelectionsToCaller(); + List activeUsers = GetCurrentUsers(); + await Clients.Group(roomName).SendAsync("CurrentUsers", activeUsers); + _logger.LogInformation("User {UserId} ({UserName}) joined diagram {DiagramId}. Total users: {UserCount}", userId, userName, diagramId, _diagramUsers.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error joining diagram {DiagramId} for user {UserId}", diagramId, Context.ConnectionId); + await Clients.Caller.SendAsync("JoinError", "Failed to join diagram"); + } + } + + public async Task BroadcastToOtherClients(List payloads, long clientVersion, List? elementIds, SelectionEvent currentSelection, string roomName) + { + var connId = Context.ConnectionId; + var gate = GetConnectionLock(connId); + await gate.WaitAsync(); + try + { + var versionKey = "diagram:version"; + + var (acceptedSingle, serverVersionSingle) = await _redisService.CompareAndIncrementAsync(versionKey, clientVersion); + long serverVersionFinal = serverVersionSingle; + + if (!acceptedSingle) + { + var recentUpdates = await GetUpdatesSinceVersionAsync(clientVersion, maxScan: 200); + var recentlyTouched = new HashSet(StringComparer.Ordinal); + foreach (var upd in recentUpdates) + { + if (upd.ModifiedElementIds == null) continue; + foreach (var id in upd.ModifiedElementIds) + recentlyTouched.Add(id); + } + + var overlaps = elementIds?.Where(id => recentlyTouched.Contains(id)).Distinct().ToList(); + if (overlaps?.Count > 0) + { + await Clients.Caller.SendAsync("RevertCurrentChanges", elementIds); + await Clients.Caller.SendAsync("ShowConflict"); + return; + } + + var (_, newServerVersion) = await _redisService.CompareAndIncrementAsync(versionKey, serverVersionSingle); + serverVersionFinal = newServerVersion; + } + + var update = new DiagramUpdateMessage + { + SourceConnectionId = connId, + Version = serverVersionFinal, + ModifiedElementIds = elementIds + }; + + await StoreUpdateInRedis(update, connId); + SelectionEvent selectionEvent = BuildSelectedElementEvent(currentSelection.ElementIds, currentSelection.SelectorBounds); + await UpdateSelectionBoundsInRedis(selectionEvent, currentSelection.ElementIds, currentSelection.SelectorBounds); + await Clients.OthersInGroup(roomName).SendAsync("ReceiveData", payloads, serverVersionFinal, selectionEvent); + await RemoveOldUpdates(serverVersionFinal); + } + finally + { + gate.Release(); + } + } + public override async Task OnDisconnectedAsync(Exception? exception) + { + try + { + string roomName = Context.Items["RoomName"]?.ToString(); + string userName = Context.Items["UserName"]?.ToString(); + + await Clients.OthersInGroup(roomName) + .SendAsync("PeerSelectionCleared", new SelectionEvent + { + ConnectionId = Context.ConnectionId, + ElementIds = new() + }); + await Clients.OthersInGroup(roomName).SendAsync("UserLeft", userName); + + // Remove from SignalR group + await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName); + await _redisService.DeleteAsync(SelectionKey(Context.ConnectionId)); + + // Remove from diagram users tracking + if (_diagramUsers.TryGetValue(Context.ConnectionId, out var user)) + { + if (user != null) + _diagramUsers.TryRemove(Context.ConnectionId, out _); + } + List activeUsers = GetCurrentUsers(); + await Clients.Group(roomName).SendAsync("CurrentUsers", activeUsers); + // Clear context + Context.Items.Remove("DiagramId"); + Context.Items.Remove("UserId"); + Context.Items.Remove("UserName"); + await base.OnDisconnectedAsync(exception); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during disconnect cleanup for connection {ConnectionId}", Context.ConnectionId); + } + await base.OnDisconnectedAsync(exception); + } + } +} +``` + +### Step 2: Register services, Redis backplane, CORS, and map the hub (Program.cs) + +Add these registrations to your server Program.cs so clients can connect and scale via Redis. Adjust policies/connection strings to your environment. + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Redis (shared connection) +builder.Services.AddSingleton(sp => +{ + var cs = builder.Configuration.GetConnectionString("RedisConnectionString") + ?? "localhost:6379,abortConnect=false"; + return ConnectionMultiplexer.Connect(cs); +}); + +// SignalR + Redis backplane +builder.Services + .AddSignalR() + .AddStackExchangeRedis(builder.Configuration.GetConnectionString("RedisConnectionString") + ?? "localhost:6379,abortConnect=false"); + +// App services +builder.Services.AddScoped(); +builder.Services.AddScoped(); // your implementation + +var app = builder.Build(); + +app.MapHub("/diagramHub"); + +app.Run(); +``` + +Notes: +- Ensure WebSockets are enabled on the host/proxy, or remove SkipNegotiation on the client to allow fallback transports. +- Use a singleton IConnectionMultiplexer to respect Redis connection limits. + +### Step 3: Configure Redis cache connection string in application level + +Configure the Redis that stores temporary data for the collaborative editing session. Provide the Redis connection string in `appsettings.json` file. + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "RedisConnectionString": "<>" + } +} +``` + + +## Model types used in the sample (minimal) + +Define these models used by the snippets: + +```csharp +public sealed class SelectionEvent +{ + public string ConnectionId { get; set; } = string.Empty; + public string? UserName { get; set; } + public List? ElementIds { get; set; } + public Rect? SelectorBounds { get; set; } // define Rect for your app +} + +public sealed class DiagramUser +{ + public string ConnectionId { get; set; } = string.Empty; + public string UserName { get; set; } = "User"; +} + +public sealed class DiagramUpdateMessage +{ + public string SourceConnectionId { get; set; } = string.Empty; + public long Version { get; set; } + public List? ModifiedElementIds { get; set; } + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class DiagramData +{ + public string DiagramId { get; set; } = string.Empty; + public string? SerializedState { get; set; } + public long Version { get; set; } +} +``` + +## Client essentials (versioning, reconnect, and revert) + +```csharp +long clientVersion = 0; +bool isRevertingCurrentChanges = false; + +private void UpdateVersion(long serverVersion) +{ + clientVersion = serverVersion; +} + +private async Task RevertCurrentChanges(List elementIds) +{ + isRevertingCurrentChanges = true; + try + { + await ReloadElementsFromServerOrCache(elementIds); + } + finally + { + isRevertingCurrentChanges = false; + } +} + +// Rejoin the diagram room if connection drops and reconnects +connection.Reconnected += async _ => +{ + await connection.SendAsync("JoinDiagram", roomName, diagramId, currentUser); +}; +``` + +When using HistoryChange, ensure you declare: + +```csharp +List editedElements = new(); +bool isGroupAction = false; +``` + +## Per-diagram versioning keys (server) + +Avoid a global version key. Use per-diagram keys: + +```csharp +private static string VersionKey(string diagramId) => $"diagram:{diagramId}:version"; +private static string UpdateKey(long version, string diagramId) => $"diagram:{diagramId}:update:{version}"; +private static string SelectionKey(string connectionId, string diagramId) => $"diagram:{diagramId}:selection:{connectionId}"; +``` + +Read diagramId from Context.Items["DiagramId"] inside hub methods and use it for all keys. + +## Conflict policy (optimistic concurrency) + +- Client sends payload with clientVersion and edited elementIds. +- Server compares with Redis version. If stale and elements overlap, ask client to revert and show conflict. +- If stale but no overlap, server increments and accepts. +- Clients must set clientVersion to the server version after each accepted update. + +## Cleanup strategy for Redis + +- Keep only the last K versions (e.g., 200), or +- Set TTL on update keys to bound memory usage. + +## Hosting, transport, and serialization + +- Enable WebSockets on your host/reverse proxy; consider keep-alives. +- If WebSockets aren’t available, remove SkipNegotiation on the client to allow fallback transports. +- For large payloads, enable MessagePack on server (and client if applicable) and consider sending diffs. + +## Security and rooms + +- Derive roomName from diagramId (e.g., "diagram:" + diagramId) and validate/normalize on server. +- Consider authentication/authorization to join rooms. +- Rate-limit BroadcastToOtherClients if necessary. + +## App settings example + +```json +{ + "ConnectionStrings": { + "RedisConnectionString": "<>" + } +} +``` + +## CollaborationServer helper methods (required in the sample) + +Implement or verify these server helpers exist in the Hub or related services; they are invoked in the snippets above: + +- GetConnectionLock(string connectionId): returns a per-connection SemaphoreSlim for serializing updates. +- RequestAndLoadStateAsync(string roomName, string diagramId, string connectionId, CancellationToken abort): loads existing diagram state (from DB/Redis) and sends to caller via LoadDiagramData. +- GetDiagramVersion(): reads current version from Redis for the current diagram (use VersionKey(diagramId)); return 0 if missing. +- GetUpdatesSinceVersionAsync(long sinceVersion, int maxScan): reads recent DiagramUpdateMessage entries from Redis for conflict checks. +- StoreUpdateInRedis(DiagramUpdateMessage update, string connectionId): stores update under UpdateKey(update.Version, diagramId). +- UpdateSelectionBoundsInRedis(SelectionEvent evt, List? elementIds, Rect? selectorBounds): persists the caller’s selection snapshot under SelectionKey(connectionId, diagramId). +- SendCurrentSelectionsToCaller(): gathers SelectionEvent for active peers in this diagram and sends PeerSelectionsBootstrap to the caller. +- GetCurrentUsers(): returns display names of users in _diagramUsers for this diagram/group. +- RemoveOldUpdates(long latestVersion): trims old updates (keep last K versions or apply TTL) for this diagram. +- ClearConnectionsFromRedis(): clears stale selection keys for all users when the first user joins. +- SelectionKey(string connectionId): if you keep this overload, ensure it internally resolves diagramId; otherwise prefer SelectionKey(connectionId, diagramId). +- BuildSelectedElementEvent(IEnumerable? elementIds, Rect? selectorBounds): constructs SelectionEvent with Context.ConnectionId and current user name. + + +The full version of the code discussed can be found in the GitHub location below. + +GitHub Example: Collaborative editing examples