Build an online collaborative text editor using .NET
You will need the following installed on your machine: Visual Studio Code with the C# extension, ASP.NET Core and .NET Core SDK.
Whether it’s realtime audio or video chats, or just collaborating on documents in realtime via Google Docs, there are many times when collaborating online in realtime is a huge time saver and a necessity to keep up with your productivity.
In this article, I’ll walk you through building a basic collaborative text editor using ASP.NET Core. A user can create any number of documents, view or update the document. I’ll reference these documents as pen
.
Prerequisites
This tutorial uses the following:
- JavaScript (jQuery)
- ASP.NET Core
- Visual Studio Code
- .NET Core SDK (Download and install it here if you don’t have it)
- Visual Studio Code C# extension (Install C# extension from the Visual Studio Code Marketplace if you don’t have it installed)
- A Pusher account
Verify your setup by typing the following in your command line:
dotnet --version
The command should print out the version of .NET Core you have installed.
Setting up your Pusher app
To start utilizing Pusher’s technology, you need to create a Pusher app and get the app keys. Login or signup (If you don’t have an account already) for a free account.
Once you are logged in, scroll down and click on Create new Channels app. You will see a modal, fill in the form and then click on Create my app.
After submitting the form, the next page that appears is a getting started page with code samples. Click on App Keys tab to get your Pusher app details.
Keep the keys handy, we’ll need them later:
app_id = <PUSHER_APP_ID>
key = <PUSHER_APP_KEY>
secret = <PUSHER_APP_SECRET>
cluster = <PUSHER_APP_CLUSTER>
Creating an ASP.NET Core MVC project
First, create a new folder on your system called CollaText
. Then from your command line, cd
into the folder you just created. NB: CollaText
can be any name you want.
Next, from your command line, run the following command:
dotnet new mvc
This command will create a new ASP.NET Core MVC project in your current folder.
Next,
- Open the
CollaText
folder in Visual Studio Code editor and select theStartup.cs
file.
💡 If your Visual Studio Code has been added to your system path, you can open the project by typing “code .****” (without quotes) in your command prompt.
- Select Yes to the Warn message “Required assets to build and debug are missing from ‘CollaText’. Add them?”
- Select Restore to the Info message “There are unresolved dependencies”.
Now, Press Debug (F5) to build and run the program. The address in which the project is running will open automatically if there is no error. In case it does not open automatically, navigate to http://localhost:5000/ from your browser. You should see a default page.
Next, update CollaText.csproj
with the following code:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.0.3" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />
<DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="2.0.1" />
</ItemGroup>
</Project>
Save the file and select Restore to the Info message “There are unresolved dependencies”. This will prepare the project for scaffolding.
Adding models
A model is an object that represents the data in our application. For this project, we’ll create a table that holds data for created pen
known as Pens
.
Create a new file called Pen.cs
in the Models
folder and add the following code to it:
using System;
namespace CollaText.Models
{
public class Pen
{
public int ID { get; set; }
public string Title { get; set; }
public string Content { get; set; }
}
}
Creating the database context
The database context is the main class that coordinates Entity Framework functionality for a given data model. We’ll derive from the Microsoft.EntityFrameworkCore.DbContext
to create this class.
Create a new file called CollaTextPenContext.cs
in the Models
folder and add the following code to it:
using Microsoft.EntityFrameworkCore;
namespace CollaText.Models
{
public class CollaTextPenContext : DbContext
{
public CollaTextPenContext (DbContextOptions<CollaTextPenContext> options)
: base(options)
{
}
public DbSet<Pen> Pens { get; set; }
}
}
Setting up the database and running migrations
Now that we have created our models, we can easily generate a migration file that will contain code for creating and updating our table schema.
In this article, we’ll make use of SQLite for our database.
Registering the database context
We’ll register the database context with the dependency injection container. Services (such as the DB context) that are registered with the dependency injection container are available to the controllers.
Update the code in ConfigureServices
method of /Startup.cs
file with the following code:
[...]
public void ConfigureServices(IServiceCollection services)
{
[...]
services.AddDbContext<CollaTextPenContext>(options =>
options.UseSqlite("Data Source=CollaText.db"));
[...]
}
[...]
This tells Entity Framework which model classes are included in the data model.
Finally, add the following usings to the header of Startup.cs
file:
using CollaText.Models;
using Microsoft.EntityFrameworkCore;
You can see the database context as a database connection and a set of tables, and the Dbset as a representation of the tables themselves. The database context allows us to link our model properties to our database with a connection string (in our case, we are using SQLite)
Running the migration
From your command line, run the following command:
dotnet ef migrations add CollaText
dotnet ef database update
- The first command will create a migration script that will be used for managing our database tables.
- The second command will execute the migration script, thereby applying the migration to the database to create the schema.
💡 If you got an error while running the command, stop the debugging or the server and try again.
Creating the controller
We’ll create a controller called PenController.cs
for handling browser requests.
Create a new file called PenController.cs
in the Controllers
folder and add the following code to it:
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using CollaText.Models;
using System.Net;
namespace CollaText.Controllers
{
public class PenController : Controller
{
private readonly CollaTextPenContext _context;
public PenController(CollaTextPenContext context)
{
_context = context;
}
// GET: Pen
public async Task<IActionResult> Index(int? id)
{
ViewData["Pen"] = _context.Pens.SingleOrDefault(d => d.ID == id);
return View(await _context.Pens.ToListAsync());
}
}
}
In the preceding code:
- The constructor uses dependency injection to inject the database context (
CollaTextPenContext
) into the controller. We have injected*CollaTextPenContext*
context into the class so we can have access to the context. int? id
parameter passed in theIndex
method indicates that theid
parameter is optional for the route.- Next, we fetched a single pen using the passed in
id
, which is passed to the view viaViewData
. - Lastly, with
await _context.Pens.ToListAsync()
, we fetched all pens in the database and passed it down to the view.
With that, we now have a route - localhost:xxxx/Pen/Index/{id}
.
Creating the app UI
Add the following styling to wwwroot/css/site.css
:
.vertical-center {
min-height: 80%;
min-height: 80vh;
display: grid;
align-items: center;
}
.pen > a:link, a:visited {
display: block;
text-decoration: none;
background: gray;
color:azure;
padding: 9px;
border-radius: 3px;
margin: 4px;
font-weight: bolder;
}
.pen > a:hover, a:active {
border-left: 4px solid burlywood;
}
#editor[contenteditable=true] {
min-height: 150px;
border: 1px solid lightblue;
border-radius: 4px;
padding: 3px;
}
#title[contenteditable=true] {
min-height: 40px;
border: 1px solid lightblue;
border-radius: 4px;
line-height: 2.6;
padding: 3px;
margin-bottom: 6px;
font-size: 16px;
}
[contenteditable=true]:empty:before {
content: attr(placeholder);
display: block;
}
Next, let’s add our view file. Create a new folder called Pen
in the Views
folder. Then create a Index.cshtml
file in the Views/Pen
folder.
Now, add the following code to Index.cshtml
:
@model IEnumerable<CollaText.Models.Pen>
@{
ViewData["Title"] = "Index";
Pen pen = (Pen) ViewData["Pen"];
}
<div class="container-fluid vertical-center">
<h3 class="text-center"> Realtime collaborative text editor </h3>
<div class="row">
<div class="col-md-3">
<div class="pen">
<a class="" href="#" data-toggle="modal" data-target="#myModal">
Create New Pen
</a>
</div> <br>
<div id="Pen">
@foreach (var item in Model) {
<div class="pen">
<a class="" asp-route-id="@item.ID">
@Html.DisplayFor(modelItem => item.Title)
</a>
</div>
}
</div>
</div>
@if(pen != null) {
<div class="col-md-9">
<div class="form-group">
<div id="title" contenteditable="true" placeholder="Enter title here...">@pen.Title</div>
<div id="editor" contenteditable="true" placeholder="Enter content here...">@pen.Content</div>
</div>
</div>
<input type="hidden" value="@pen.ID" id="penId">
}
else {
<p class="text-center"> Select any pen to start editing... </p>
}
</div>
</div>
<!-- Modal -->
<div id="myModal" class="modal fade" role="dialog">
<div class="modal-dialog">
<!-- Modal content-->
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">×</button>
<h4 class="modal-title">Create new pen</h4>
</div>
<div class="modal-body">
<form asp-action="Create">
<div class="form-group">
<label for="pen">Pen Name</label>
<input type="text" name="Title" class="form-control" id="Title" placeholder="Pen">
</div>
<button type="submit" class="btn btn-primary btn-block">Submit</button>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script src="https://js.pusher.com/4.1/pusher.min.js"></script>
When a user visits the route - /Pen/Index/
, The Views/Pen/Index.chtml
file will be loaded for the user.
In the preceding code:
- With
Pen pen = (Pen) ViewData["Pen"]
, we are casting the data passed via ViewData to a Pen Model Object so we can easily access data in the object. - With
@foreach (var item in Model) { …
, we are displaying all pen in the database to the view. - Finally, we included Pusher JavaScript library.
Now, visit http://localhost:5000/Pen/Index
, the page should be similar to:
Creating new pens
Let’s add a method for creating a new pen. When a user clicks on Create New Pen
, a pop up will show up which contains a form for creating new pen.
Add the following code to the PenController
class in PenController.cs
:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("ID,Title")] Pen pen)
{
if (ModelState.IsValid)
{
_context.Add(pen);
await _context.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
return View(pen);
}
With this, we now have a POST method route - /Pen/Create
for creating a new pen.
Making it realtime
So far, users can create a new pen, view the pen, and edit it. However other users are not aware of any changes done by other users in realtime. We’ll use Pusher to add realtime feature to our application.
Installing the Pusher library
Pusher has a .NET library that makes it easy to interact with its API. We need to add this to the project.
From your command line, run the following command:
dotnet add package PusherServer
Next, add the following code to the PenController
class in PenController.cs
:
public async Task<IActionResult> Trigger(object data, string channelName, string eventName)
{
var options = new PusherOptions
{
Cluster = "<PUSHER_APP_CLUSTER>",
Encrypted = true
};
var pusher = new Pusher(
"<PUSHER_APP_ID>",
"<PUSHER_APP_KEY>",
"<PUSHER_APP_SECRET>",
options
);
var result = await pusher.TriggerAsync(
channelName,
eventName,
data
);
return new OkObjectResult(data);
}
We’ll use this method to trigger events to Pusher. Make sure to update the code with your correct pusher Keys you have noted down.
Finally, add the below using to the header of PenController.cs
:
using PusherServer;
Trigger an event to Pusher when a user edits a pen’s content
When a user updates a pen’s content, we’ll trigger an event to pusher so that Pusher will broadcast the message to all other connected users.
Add the following code to to the PenController
class in PenController.cs
:
[HttpPost]
public async Task<IActionResult> ContentChange(int penId, string Content, string sessionID)
{
await Trigger(new {Content = Content, penId = penId, sessionID = sessionID}, "coll-text-editor", "contentChange");
var pen = await _context.Pens.SingleOrDefaultAsync(m => m.ID == penId);
if( pen != null) {
pen.Content = Content;
_context.SaveChanges();
}
return new OkObjectResult(new { content = Content, penId = penId, sessionID = sessionID });
}
In the preceding code:
- We are triggering an event to Pusher using the
Trigger
method we added earlier. - In the
Trigger
method, we passed along the data we want to send to Pusher, the channel name -*coll-text-editor*
, and the event name -*contentChange*
. - Then we’ll save the updated content to the database.
Trigger an event to Pusher when a user adds a new pen
Pusher assigns all connected users a unique sessionID
. We’ll use this ID to identify users. Update the parameter of the Create
method in the PenController.cs
so it includes this sessionID
:
public async Task<IActionResult> Create([Bind("ID,Title")] Pen pen, string sessionID)
Next, add the following code to the Create
method in PenController.cs
:
await Trigger(new {Title = pen.Title, penId = pen.ID, sessionID = sessionID}, "coll-text-editor", "newPen");
If you have followed closely, the Create
method will look like this:
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("ID,Title")] Pen pen, string sessionID)
{
if (ModelState.IsValid)
{
_context.Add(pen);
await _context.SaveChangesAsync();
await Trigger(new {Title = pen.Title, penId = pen.ID, sessionID = sessionID}, "coll-text-editor", "newPen");
return RedirectToAction(nameof(Index));
}
return View(pen);
}
Initializing the Pusher JavaScript client library
Add the following code to wwwroot/js/site.js
:
var pusher = new Pusher('<PUSHER_APP_KEY>', {
cluster: '<PUSHER_APP_CLUSTER>',
encrypted: true
});
Next, let’s subscribe to a channel. Add the following code to wwwroot/js/site.js
:
let channel = pusher.subscribe('coll-text-editor');
In this case, coll-text-editor
is the channel name we want to subscribe to.
Next, add the following code to wwwroot/js/site.js
:
let timeout = null;
// Sends the text to the server which in turn is sent to Pusher's server
$("#editor").keyup(function () {
let content = $("#editor").text();
clearTimeout(timeout);
timeout = setTimeout(function() {
$.post("/Pen/ContentChange", { content: content, penId: $("#penId").val(), sessionID: pusher.sessionID})
}, 300);
});
When a user updates a pen’s content, we will send a request to the ContentChange
method in Controllers/PenContoller.cs
which in turn triggers an event to Pusher.
Next, let’s listen for contentChange
event. Add the following code to wwwroot/js/style.js
:
channel.bind('contentChange', function(data) {
if ( (data.sessionID != pusher.sessionID) && (data.penId == $("#penId").val()) ) {
$("#editor").text(data.Content)
}
});
Here, when there is a contentChange
event, we’ll update the content of the pen for the user. The if
condition makes sure the current user is the user that made the change to the pen. Also if the current pen the user is viewing is what is changed so we don’t bother updating the content for that particular user.
Finally, let’s listen for newPen
event. Add the following code to wwwroot/js/site.js
:
channel.bind('newPen', function(data) {
if (data.sessionID != pusher.sessionID) {
$("#Pen").append(
`
<div class="pen">
<a class="" href="/Pen/Index/${data.penId}">
${data.Title}
</a>
</div>
`
)
}
});
When a new pen is created, we’ll append the pen for other connected users in realtime.
Well done! You have just built a realtime collaborative text editor using Pusher’s amazing technology. To test what you have built, load up the app in a different tab on your browser, then start collaborating.
Conclusion
In this tutorial, we discussed how to set up an ASP.NET Core application in Visual Studio Code. We’ve also built a realtime collaborative text editor using ASP.NET Core and Pusher. There is no limit of what you can do here, feel free to add new features to the application. You can get the full project on Github.
2 May 2018
by Gideon Onwuka