+ Copy and share this demo URL with others to collaborate in real time.
+ The link contains a unique session ID that connects all participants
+ to the same workbook.
+
+
+
+
+
+
+
+
+
+
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; }
}
}