diff --git a/client/vue/package.json b/client/vue/package.json index 282e3ef..77f0037 100644 --- a/client/vue/package.json +++ b/client/vue/package.json @@ -12,6 +12,8 @@ "@microsoft/signalr": "^9.0.0", "@syncfusion/ej2-collaborator": "35.1.37", "@syncfusion/ej2-spreadsheet": "35.1.37", + "@syncfusion/ej2-buttons": "35.1.37", + "@syncfusion/ej2-popups": "35.1.37", "@syncfusion/ej2-tailwind3-theme": "35.1.37", "@syncfusion/ej2-vue-spreadsheet": "35.1.37", "vue": "^3.5.22" diff --git a/client/vue/src/App.vue b/client/vue/src/App.vue index bb6b0fc..bb0a34b 100644 --- a/client/vue/src/App.vue +++ b/client/vue/src/App.vue @@ -1,102 +1,296 @@ diff --git a/client/vue/src/SpreadsheetEditorAdapter.ts b/client/vue/src/SpreadsheetEditorAdapter.ts index 16b703a..88dce89 100644 --- a/client/vue/src/SpreadsheetEditorAdapter.ts +++ b/client/vue/src/SpreadsheetEditorAdapter.ts @@ -1,83 +1,100 @@ import type { - ICollaborationActionData, - ICollaborationProvider + ICollaborationActionData, + ICollaborationProvider } from '@syncfusion/ej2-collaborator'; import type { Spreadsheet } from '@syncfusion/ej2-spreadsheet'; interface ImportFileResponse { - sfdt: string; - version: number; + sfdt: string; + version: number; } +/** Connects the Spreadsheet with the Collaboration Client and Server. */ export class SpreadsheetEditorAdapter implements ICollaborationProvider { - public currentRoomName: string = ''; + public currentRoomName: string = ''; - public constructor( - private spreadsheet: Spreadsheet, - private serviceUrl: string, - private currentUser: string - ) { - this.serviceUrl = serviceUrl.endsWith('/') - ? serviceUrl - : serviceUrl + '/'; - } - - public async loadFromServer( - fileName: string, - roomName: string - ): Promise { - const response: Response = await fetch( - this.serviceUrl + 'api/CollaborativeEditing/ImportFile', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ fileName, roomName }) - } - ); - - if (!response.ok) { - throw new Error('Failed to load the workbook.'); + /** + * Initializes a new instance of the Spreadsheet collaboration adapter. + * + * @param spreadsheet - The Spreadsheet instance used for collaborative editing. + * @param serviceUrl - The Collaboration Server URL. + * @param currentUser - The display name of the current participant. + */ + public constructor( + private spreadsheet: Spreadsheet, + private serviceUrl: string, + private currentUser: string + ) { + this.serviceUrl = serviceUrl.endsWith('/') + ? serviceUrl + : serviceUrl + '/'; } - const data: ImportFileResponse = JSON.parse( - await response.text() - ); + /** + * Loads the latest synchronized workbook and initializes the collaboration room. + * + * @param fileName - The name of the workbook to load. + * @param roomName - The unique collaboration room name. + */ + public async loadFromServer(fileName: string, roomName: string): Promise { + const response: Response = await fetch( + this.serviceUrl + 'api/CollaborativeEditing/ImportFile', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + fileName, + roomName + }) + } + ); - this.currentRoomName = roomName; - this.spreadsheet.collaborativeEditingModule.updateRoomInfo( - roomName, - data.version, - this.serviceUrl + 'api/CollaborativeEditing/' - ); - this.spreadsheet.collaborativeEditingModule.setLocalUser( - this.currentUser - ); - this.spreadsheet.openFromJson({ file: data.sfdt }); - } + if (!response.ok) { + throw new Error('Failed to load the workbook.'); + } - public sendActionToServer(action: unknown): void { - if (!action) { - return; - } + const data: ImportFileResponse = JSON.parse(await response.text()); - this.spreadsheet.collaborativeEditingModule.sendActionToServer( - action - ); - } + this.currentRoomName = roomName; + this.spreadsheet.collaborativeEditingModule.updateRoomInfo( + roomName, + data.version, + this.serviceUrl + 'api/CollaborativeEditing/' + ); + this.spreadsheet.collaborativeEditingModule.setLocalUser(this.currentUser); + this.spreadsheet.openFromJson({ + file: data.sfdt + }); + } - public applyRemoteAction( - action: string, - data: ICollaborationActionData - ): void { - if (!data) { - return; + /** + * Sends a completed local Spreadsheet action to the Collaboration Server. + * + * @param action - The completed Spreadsheet action. + */ + public sendActionToServer(action: unknown): void { + if (action) { + this.spreadsheet.collaborativeEditingModule.sendActionToServer(action); + } } - this.spreadsheet.collaborativeEditingModule.applyRemoteAction( - action, - data.payload - ); - } + /** + * Applies an action received from another participant to the Spreadsheet. + * + * @param action - The collaborative action name. + * @param data - The collaborative action data received from the server. + */ + public applyRemoteAction( + action: string, + data: ICollaborationActionData + ): void { + if (data) { + this.spreadsheet.collaborativeEditingModule.applyRemoteAction( + action, + data.payload + ); + } + } } diff --git a/client/vue/src/style.css b/client/vue/src/style.css index 997bf1a..9cf0cd7 100644 --- a/client/vue/src/style.css +++ b/client/vue/src/style.css @@ -3,6 +3,161 @@ html, body, #app { - height: 100%; - margin: 0; + width: 100%; + height: 100%; + margin: 0; + overflow-x: hidden; +} + +.collaborative-sample, +.collaborative-sample * { + box-sizing: border-box; +} + +.collaborative-sample { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; + overflow: hidden; + color: #242424; + font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, + "Helvetica Neue", Arial, sans-serif; +} + +.collaboration-info-bar { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 50px; + min-height: 50px; + max-height: 50px; + margin: 0; + padding: 6px 12px; + border: 1px solid #d6d6d6; + border-bottom: 0; + background-color: #f8f9fa; +} + +.collaboration-info-text { + min-width: 0; + margin: 0; + padding-right: 16px; + overflow: hidden; + color: #424242; + font-family: inherit; + font-size: 14px; + font-weight: 400; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.share-tooltip-host { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + height: 36px; +} + +.share-button.e-btn, +.copy-url-button.e-btn { + display: flex !important; + align-items: center !important; + justify-content: center !important; + height: 36px; + font-family: inherit; + line-height: normal !important; +} + +.share-button.e-btn { + min-width: 88px; + margin: 0; + padding: 0 16px !important; + font-size: 14px; + font-weight: 600; +} + +.spreadsheet-container { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +.spreadsheet-container .e-spreadsheet { + width: 100% !important; + max-width: 100% !important; +} + +.collaboration-share-tooltip.e-tooltip-wrap { + max-width: none; + font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, + "Helvetica Neue", Arial, sans-serif; +} + +.collaboration-share-tooltip .e-tip-content { + padding: 12px; +} + +.share-tooltip-content { + width: 420px; + max-width: calc(100vw - 48px); +} + +.share-url-label { + display: block; + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +.share-url-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.share-url-input { + flex: 1 1 auto; + min-width: 0; + height: 36px; + font-family: inherit; + font-size: 13px; +} + +.copy-url-button.e-btn { + flex: 0 0 auto; + min-width: 106px; + padding: 0 12px !important; + white-space: nowrap; +} + +@media (max-width: 600px) { + .collaboration-info-bar { + padding-right: 8px; + padding-left: 8px; + } + + .collaboration-info-text { + font-size: 13px; + } + + .share-tooltip-content { + width: calc(100vw - 64px); + } + + .share-url-row { + align-items: stretch; + flex-direction: column; + } + + .copy-url-button.e-btn { + width: 100%; + } } diff --git a/server/aspnet-core/Controllers/CollaborativeEditingController.cs b/server/aspnet-core/Controllers/CollaborativeEditingController.cs index ed826f9..ab93d23 100644 --- a/server/aspnet-core/Controllers/CollaborativeEditingController.cs +++ b/server/aspnet-core/Controllers/CollaborativeEditingController.cs @@ -19,6 +19,9 @@ namespace EJ2SpreadsheetServer.Controllers { + /// + /// Provides APIs for Spreadsheet collaborative editing actions and participant selections. + /// [Route("api/[controller]")] [ApiController] public class CollaborativeEditingController : ControllerBase @@ -26,20 +29,17 @@ public class CollaborativeEditingController : ControllerBase private static readonly ConcurrentDictionary< string, ConcurrentDictionary> - RoomSelections = - new ConcurrentDictionary< - string, - ConcurrentDictionary< - string, - SpreadsheetSelectionInfo>>(); + RoomSelections = new ConcurrentDictionary< + string, + ConcurrentDictionary>(); private readonly IWebHostEnvironment hostingEnvironment; private readonly IActionService actionService; private readonly ICollaborationAdapter adapter; private readonly IActiveTransport transport; - private static readonly JsonSerializerSettings - ControllerJsonSettings = new JsonSerializerSettings + private static readonly JsonSerializerSettings ControllerJsonSettings = + new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, ContractResolver = new DefaultContractResolver @@ -48,6 +48,13 @@ private static readonly JsonSerializerSettings } }; + /// + /// Initializes a new instance of the class. + /// + /// Provides access to the server hosting environment. + /// Manages collaborative editing actions and versions. + /// Maps Spreadsheet actions to and from collaboration actions. + /// Broadcasts collaboration updates to connected participants. public CollaborativeEditingController( IWebHostEnvironment hostingEnvironment, IActionService actionService, @@ -60,14 +67,17 @@ public CollaborativeEditingController( this.transport = transport; } + /// + /// Loads the source workbook, applies pending room actions, and returns the synchronized workbook. + /// + /// Contains the workbook name and collaboration room name. + /// The serialized workbook content and current collaboration version. [HttpPost] [Route("ImportFile")] [EnableCors("AllowAllOrigins")] - public async Task ImportFile( - [FromBody] FileInfo param) + public async Task ImportFile([FromBody] FileInfo param) { - if (param == null || - string.IsNullOrWhiteSpace(param.roomName)) + if (param == null || string.IsNullOrWhiteSpace(param.roomName)) { return null; } @@ -91,61 +101,42 @@ await actionService.GetPendingOperationsAsync( 0, -1 ); + List spreadsheetActions = collaborationActions == null + ? new List() + : collaborationActions + .Select(action => + adapter.MapGenericToControlAction(action) as ActionInfo + ) + .Where(action => action != null) + .OrderBy(action => action.Version) + .ToList(); - List spreadsheetActions = - collaborationActions == null - ? new List() - : collaborationActions - .Select(action => - adapter.MapGenericToControlAction( - action - ) as ActionInfo - ) - .Where(action => action != null) - .OrderBy(action => action.Version) - .ToList(); - - using (ExcelEngine temporaryExcelEngine = - new ExcelEngine()) + using (ExcelEngine temporaryExcelEngine = new ExcelEngine()) { - IApplication application = - temporaryExcelEngine.Excel; - - IWorkbook temporaryWorkbook = - application.Workbooks.Open(filePath); + IApplication application = temporaryExcelEngine.Excel; + IWorkbook temporaryWorkbook = application.Workbooks.Open(filePath); try { if (spreadsheetActions.Count > 0) { CollaborativeEditingHandler handler = - new CollaborativeEditingHandler( - temporaryWorkbook - ); + new CollaborativeEditingHandler(temporaryWorkbook); - foreach (ActionInfo action in - spreadsheetActions) + foreach (ActionInfo action in spreadsheetActions) { handler.UpdateAction(action); } } - using (MemoryStream workbookStream = - new MemoryStream()) + using (MemoryStream workbookStream = new MemoryStream()) { - temporaryWorkbook.SaveAs( - workbookStream - ); - + temporaryWorkbook.SaveAs(workbookStream); workbookStream.Position = 0; - string clientFileName = - string.IsNullOrWhiteSpace( - param.fileName - ) - ? "Sample" - : param.fileName; - + string clientFileName = string.IsNullOrWhiteSpace(param.fileName) + ? "Sample" + : param.fileName; IFormFile formFile = new FormFile( workbookStream, 0, @@ -153,33 +144,21 @@ await actionService.GetPendingOperationsAsync( clientFileName, "Sample.xlsx" ); + OpenRequest openRequest = new OpenRequest + { + File = formFile + }; + string workbookJson = Workbook.Open(openRequest); + int currentVersion = spreadsheetActions.Count > 0 + ? spreadsheetActions.Max(action => action.Version) + : 0; + DocumentContent content = new DocumentContent + { + sfdt = workbookJson, + version = currentVersion + }; - OpenRequest openRequest = - new OpenRequest - { - File = formFile - }; - - string workbookJson = - Workbook.Open(openRequest); - - int currentVersion = - spreadsheetActions.Count > 0 - ? spreadsheetActions.Max( - action => action.Version - ) - : 0; - - DocumentContent content = - new DocumentContent - { - sfdt = workbookJson, - version = currentVersion - }; - - return JsonConvert.SerializeObject( - content - ); + return JsonConvert.SerializeObject(content); } } finally @@ -190,42 +169,33 @@ await actionService.GetPendingOperationsAsync( } catch (Exception exception) { - Console.WriteLine( - "Spreadsheet import failed: " + - exception - ); - + Console.WriteLine("Spreadsheet import failed: " + exception); return null; } } + /// + /// Stores a Spreadsheet action and broadcasts the processed action to the collaboration room. + /// + /// Contains the Spreadsheet action and collaboration room information. + /// The serialized action after collaboration processing. [HttpPost] [Route("UpdateAction")] [EnableCors("AllowAllOrigins")] - public async Task UpdateAction( - [FromBody] ActionInfo param) + public async Task UpdateAction([FromBody] ActionInfo param) { - if (param == null || - string.IsNullOrWhiteSpace(param.RoomName)) + if (param == null || string.IsNullOrWhiteSpace(param.RoomName)) { return null; } CollaborationAction collaborationAction = adapter.MapControlToGenericAction(param); - CollaborationAction modifiedAction = - await actionService.AddOperationAsync( - collaborationAction, - adapter - ); - - ActionInfo updatedAction = - modifiedAction == null - ? null - : adapter.MapGenericToControlAction( - modifiedAction - ) as ActionInfo; + await actionService.AddOperationAsync(collaborationAction, adapter); + ActionInfo updatedAction = modifiedAction == null + ? null + : adapter.MapGenericToControlAction(modifiedAction) as ActionInfo; if (updatedAction == null) { @@ -236,7 +206,6 @@ await actionService.AddOperationAsync( updatedAction, ControllerJsonSettings ); - await transport.SendToGroupAsync( param.RoomName, "action", @@ -246,12 +215,16 @@ await transport.SendToGroupAsync( return payload; } + /// + /// Updates a participant selection and broadcasts it to other users in the room. + /// + /// Contains the participant selection and connection details. + /// The updated participant selection. [HttpPost] [Route("UpdateSelection")] [EnableCors("AllowAllOrigins")] - public async Task - UpdateSelection( - [FromBody] SpreadsheetSelectionInfo param) + public async Task UpdateSelection( + [FromBody] SpreadsheetSelectionInfo param) { if (param == null || string.IsNullOrWhiteSpace(param.RoomName) || @@ -260,22 +233,16 @@ public async Task return param; } - ConcurrentDictionary< - string, - SpreadsheetSelectionInfo> selections = - RoomSelections.GetOrAdd( - param.RoomName, - _ => new ConcurrentDictionary< - string, - SpreadsheetSelectionInfo>() - ); - + ConcurrentDictionary selections = + RoomSelections.GetOrAdd( + param.RoomName, + _ => new ConcurrentDictionary() + ); selections.AddOrUpdate( param.ConnectionId, param, (_, _) => param ); - await transport.SendToGroupExceptAsync( param.RoomName, param.ConnectionId, @@ -286,28 +253,34 @@ await transport.SendToGroupExceptAsync( return param; } + /// + /// Returns the active participant selections for a collaboration room. + /// + /// The collaboration room name. + /// The active participant selections in the room. [HttpGet] [Route("GetRoomSelections/{roomName}")] [EnableCors("AllowAllOrigins")] - public ActionResult> - GetRoomSelections(string roomName) + public ActionResult> GetRoomSelections( + string roomName) { if (string.IsNullOrWhiteSpace(roomName) || !RoomSelections.TryGetValue( roomName, - out ConcurrentDictionary< - string, - SpreadsheetSelectionInfo> selections + out ConcurrentDictionary selections )) { - return Ok( - new List() - ); + return Ok(new List()); } return Ok(selections.Values.ToList()); } + /// + /// Removes a disconnected participant selection from the collaboration room. + /// + /// Contains the collaboration room and connection identifiers. + /// An HTTP success result. [HttpPost] [Route("RemoveUserSelection")] [EnableCors("AllowAllOrigins")] @@ -315,79 +288,61 @@ public ActionResult RemoveUserSelection( [FromBody] RemoveSelectionRequest request) { if (request == null || - string.IsNullOrWhiteSpace( - request.RoomName - ) || - string.IsNullOrWhiteSpace( - request.ConnectionId - )) + string.IsNullOrWhiteSpace(request.RoomName) || + string.IsNullOrWhiteSpace(request.ConnectionId)) { return Ok(); } if (RoomSelections.TryGetValue( request.RoomName, - out ConcurrentDictionary< - string, - SpreadsheetSelectionInfo> selections + out ConcurrentDictionary selections )) { - selections.TryRemove( - request.ConnectionId, - out _ - ); + selections.TryRemove(request.ConnectionId, out _); if (selections.IsEmpty) { - RoomSelections.TryRemove( - request.RoomName, - out _ - ); + RoomSelections.TryRemove(request.RoomName, out _); } } return Ok(); } + /// + /// Returns collaboration actions newer than the client's last synchronized version. + /// + /// Contains the room name and last synchronized version. + /// The serialized list of pending Spreadsheet actions. [HttpPost] [Route("GetActionsFromServer")] [EnableCors("AllowAllOrigins")] - public async Task>> - GetActionsFromServer( - [FromBody] ActionInfo param) + public async Task>> GetActionsFromServer( + [FromBody] ActionInfo param) { - if (param == null || - string.IsNullOrWhiteSpace(param.RoomName)) + if (param == null || string.IsNullOrWhiteSpace(param.RoomName)) { return Ok(new List()); } int lastSyncedVersion = param.Version; - List collaborationActions = - await actionService - .GetEffectivePendingVersionAsync( - param.RoomName, - lastSyncedVersion - ); - - List actions = - collaborationActions == null - ? new List() - : collaborationActions - .Select(action => - adapter.MapGenericToControlAction( - action - ) as ActionInfo - ) - .Where(action => - action != null && - action.Version > - lastSyncedVersion - ) - .OrderBy(action => action.Version) - .ToList(); - + await actionService.GetEffectivePendingVersionAsync( + param.RoomName, + lastSyncedVersion + ); + List actions = collaborationActions == null + ? new List() + : collaborationActions + .Select(action => + adapter.MapGenericToControlAction(action) as ActionInfo + ) + .Where(action => + action != null && action.Version > lastSyncedVersion + ) + .OrderBy(action => action.Version) + .ToList(); string payload = JsonConvert.SerializeObject( actions, ControllerJsonSettings @@ -396,24 +351,51 @@ await actionService return Ok(payload); } + /// + /// Represents a request to remove a participant selection. + /// public class RemoveSelectionRequest { + /// + /// Gets or sets the collaboration room name. + /// public string RoomName { get; set; } + /// + /// Gets or sets the participant connection identifier. + /// public string ConnectionId { get; set; } } + /// + /// Represents the synchronized workbook content and collaboration version. + /// public class DocumentContent { + /// + /// Gets or sets the current collaboration version. + /// public int version { get; set; } + /// + /// Gets or sets the serialized workbook content. + /// public string sfdt { get; set; } } + /// + /// Represents a workbook import request. + /// public class FileInfo { + /// + /// Gets or sets the workbook file name. + /// public string fileName { get; set; } + /// + /// Gets or sets the collaboration room name. + /// public string roomName { get; set; } } }