Build a Realtime Collaborative Code Editor with Svelte

Build a Realtime Collaborative Code Editor with Svelte

For this article, I decided to do things differently. ChatGPT gave me this headline, and I decided to stick with it.

So, today we will look further into the different aspects of a real-time code editor. This includes conflict resolution with operation transforms, syncing data with WebSockets, and user authentication.

This article will cover a range of topics, using different technologies. Feel free to follow along, or check out the GitHub repo linked at the end.

Initial Thoughts

Users of this application will want to create their own "projects" which would include multiple files. Within each file, we can store the filename and content. Users can have the basic functionality of opening, reading, editing and deleting project files. We can store all this data in a MongoDB database. Then, we can use Socket.IO to sync code changes to other users in the project.

Setting Up

Install Dependencies

First, we need to create a SvelteKit project, and then install the following packages from NPM:

npm create svelte@latest my-app
cd my-app

# Dependencies
# Auth
npm i simple-oauth2

# Database
npm i mongoose @typegoose/typegoose

# Sockets
npm i socket.io socket.io-client

# Editing
npm i svelte-simple-code-editor quill-delta prism-js

Styling

Run npm i svelte-feather-icons to provide icon components.

Our frontend will be styled with TailwindCSS, on top of flowbite-svelte , a UI library built on top of Tailwind classes. Follow the steps to get started with Flowbite Svelte.

Connecting to the Database

If you haven't already, create a MongoDB database, and save the connection URI in an environment variable. Remember to fill the connection string with the database user password, if needed.

Create a file named dbConnect.ts in the $lib/server folder which exports a dbConnect function, like in the NextJS Mongoose example. But make sure to import the connection string instead of using process.env :

Before -> process.env.MONGODB_URI

After -> import { MONGODB_URI } from '$env/static/private';

Then, we can add code to call this function inside of a server hook:

import dbConnect from '$lib/server/dbConnect';

await dbConnect();

Authentication (GitHub)

We will be using simple-oauth2 to authenticate users with the GitHub API. We need to register a new OAuth application.

OAuth Client Setup

Now, generate a client secret and copy the client ID and secret into .env . Then, create a file named authClient.ts inside $lib/server , exporting an auth client and a function to get the currently authenticated user.

import { AuthorizationCode } from 'simple-oauth2';
import { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } from '$env/static/private';

export const client = new AuthorizationCode({
    client: {
        id: GITHUB_CLIENT_ID,
        secret: GITHUB_CLIENT_SECRET
    },
    auth: {
        tokenHost: 'https://github.com',
        tokenPath: '/login/oauth/access_token',
        authorizePath: '/login/oauth/authorize'
    }
});

export const getUser = async (accessToken: string) => {
    const res = await fetch('https://api.github.com/user', {
        method: 'GET',
        headers: {
            Authorization: `Bearer ${accessToken}`
        }
    });
    const data = await res.json();
    return data;
};

Auth Routes

The auth client provides methods to generate authorization URLs and get access tokens. We can first define the login page with an endpoint file, like so:

import { client } from '$lib/server/authClient';
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

const authUrl = client.authorizeURL({
    redirect_uri: 'http://127.0.0.1:5173/auth/redirect'
});

export const GET = (() => {
    console.log(authUrl);
    throw redirect(302, authUrl);
}) satisfies RequestHandler;
  1. The user is redirected to a GitHub login page

  2. After authorising, they are taken to our redirect page

Now, we can use data returned from GitHub to get the user's info:

import { client, getUser } from '$lib/server/authClient';
import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET = (async ({ url, locals }) => {
    const code = url.searchParams.get('code');
    const err = url.searchParams.get('error');

    if (err) throw error(400, err);
    if (!code) throw error(400, 'no code specified');
    const { token } = await client.getToken({
        code,
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        //@ts-ignore
        redirect_uri: undefined
    });

    if (token.error) {
        throw error(400, token.error);
    }

    const accessToken = token.access_token;

    // Get the authenticated user
    const user = await getUser(accessToken);

    locals.user = {
        avatar: user.avatar_url,
        username: user.login
    };

    throw redirect(307, '/');
}) satisfies RequestHandler;

Persist User Data

We can also add a handle function within our hook file to store the user in an HTTP cookie, as explained at Rodney Lab.

import type { Handle } from '@sveltejs/kit';
import type { RequestEvent } from './$types';

// ...

export const handle = (async ({ event, resolve }) => {
    const loggingOut = event.route.id === '/logout';

    const userString = event.cookies.get('user');

    event.locals.user = userString && JSON.parse(userString);

    const response = await resolve(event);

    const user = loggingOut ? '' : JSON.stringify(event.locals.user);

    const secure = process.env.NODE_ENV === 'production';
    const maxAge = 7_200; // (3600 seconds / hour) * 2 hours
    const sameSite = 'Strict';
    const cookieHeader = `user=${user || ''}; Max-Age=${maxAge}; Path=/; ${
        secure ? 'Secure;' : ''
    } HttpOnly; SameSite=${sameSite}`;
    response.headers.set('Set-Cookie', cookieHeader);

    return response;
}) satisfies Handle;

And then, the user data can be shared across all pages within a top-level layout file. Add the following code to src/+layout.server.ts :

import type { LayoutServerLoad } from './$types';

export const load = (async ({ locals }) => {
    return {
        session: {
            user: locals.user
        }
    };
}) satisfies LayoutServerLoad;

So, now we can access the session data in our routes, what's next?

Database Models

Since we installed typegoose, we can create mongoose models in one step. The library uses the ES6 classes to generate type interfaces automatically.

import type { Types } from 'mongoose';
import {
    getModelForClass,
    prop,
    type DocumentType,
    defaultClasses,
    PropType
} from '@typegoose/typegoose';

The first of 3 classes will be to represent users of our application.

export class User {
    @prop({ type: String, required: true })
    public username!: string;
    @prop({ type: String })
    public avatar?: string;
}

The next will represent users' files. For reasons explained later, we will store the file content as an array of operations.

export class File {
    @prop({ required: true, type: () => String })
    public name!: string;

    @prop({ type: () => [Object] })
    public content!: { [key: string]: unknown }[];
}

And now we can define and compile the model to represent a code project.

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ProjectClass extends defaultClasses.Base {}
export class ProjectClass {
    @prop({ required: true, type: () => User })
    public creator!: User;

    @prop({ required: true, type: String })
    public name!: string;

    @prop({ type: () => [FileClass], required: true }, PropType.ARRAY)
    public files!: Types.DocumentArray<DocumentType<FileClass>>;
}

export const Project = getModelForClass(ProjectClass);

Note the interface defined above the class. The Base interface adds the _id fields to the class.

SocketIO

Any changes made to code files will be sent to other users through WebSockets, namely SocketIO.

To add real-time communication to our app, we can create a Vite plugin. The configureServer hook provides access to Vite's internal connect app, which we then wrap with a SocketIO server.

import { Server } from 'socket.io';
import type { PluginOption } from 'vite';
import handler from './handler';

const plugin: PluginOption = {
    name: 'sveltekit-socket-io',
    configureServer: (server) => {
        if (!server.httpServer) return;

        const io = new Server(server.httpServer);

        handler(io);
    }
};

export default plugin;

And now we can add our new plugin to vite.config.ts:

import { sveltekit } from '@sveltejs/kit/vite';
import type { UserConfig } from 'vite';
import socketio from './src/lib/server/socket/plugin';

const config: UserConfig = {
    plugins: [sveltekit(), socketio]
};

export default config;

Socket Events

We will now lay out handler functions for the SocketIO server.

import Delta from 'quill-delta';
import { Project } from '../models';
import type { Server } from './types';

export default function handler(io: Server) {
    io.use(async (socket, next) => {
        // Before a client connects
    });

    io.on('connection', async (socket) => {
        // When a client connects
    });
}

First, clients will need to send the ID for the project they want to work on. To validate whether the given project exists, we can use a middleware function.

io.use(async (socket, next) => {
    const user = socket.handshake.auth.user;
    const projectId = socket.handshake.auth.projectId;
    const project = await Project.findById(projectId);

    if (!project) {
        const error = new Error('project not found');
        return next(error);
    }

    socket.data.project = project;
    socket.data.user = user;
    next();
});

And once a client does connect, we can retrieve the stored project document, and add them to a room.

io.on('connection', async (socket) => {
    const project = socket.data.project;
    const user = socket.data.user;

    if (!(project && user)) {
        return socket.disconnect();
    }

    const projectId = project._id.toString();
    socket.join(projectId);

    // ...
});

User Presences

Once a user connects, we can send them data about every other user connected to the same project.

const otherSockets = await io.in(projectId).fetchSockets();
const users = otherSockets.map((socket) => socket.data.user);

socket.emit('users', users);

We can also send the current user's data so that all other users are aware.

socket.to(projectId).emit('join', user);

socket.on('disconnect', () => {
    socket.to(projectId).emit('leave', user.username);
});

Operational Transforms

One of the main issues when building collaborative editing systems is resolving conflicts. Many different solutions exist, but the one we will use, also used very widely today, is operational transforms.

For this to work, once users make any change, their update is "transformed" against awaited remote changes. This means every user's code will have the same content.

We can begin by implementing the way changes will be received on the server.

socket.on('change', async (filename, change) => {
    const file = project.files.find((file) => file.name === filename);

    if (!file) {
        return;
    }

    let doc = new Delta(file.content);
    doc = doc.compose(change);

    file.content = doc.ops;

    await project.save();

    socket.to(projectId).emit('change', filename, change);
});

Here, we take the change which is a Delta object and "apply" it to a Delta of the file content from the database. Remember how we defined the file contents as an array of objects?

Note that we use the Delta class from quill-delta , a library that implements the features of operational transforms.

Index Page

Our main page will include options to either create a new project or join an existing project. We will use form actions to take input from the user.

Form Actions

So, we can start by adding the following code to the top level +page.server.ts. In both actions, we redirect the user to the project's route:

import { Project } from '$lib/server/models';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions = {
    create: async ({ request, locals }) => {
        const data = await request.formData();
        const value = data.get('value');

        const project = new Project({
            creator: locals.user,
            name: value,
            files: [],
        });

        await project.save();

        throw redirect(303, `/project/${project._id}`);
    },
    join: async ({ request }) => {
        const data = await request.formData();
        const value = data.get('value');

        throw redirect(303, `/project/${value}`);
    }
} satisfies Actions;

Svelte Code

Here is the starter code for the +page.svelte. For now, we render a header.

<script lang="ts">
    import { Avatar, Button, Heading, Input } from 'flowbite-svelte';
    import { GithubIcon, ArrowRightIcon, PlusCircleIcon, LogInIcon } from 'svelte-feather-icons';
    import type { PageData } from './$types';

    export let data: PageData;
</script>

<main class="w-full dark h-full flex flex-col justify-center items-center">
    <header class="text-center mb-5">
        <Heading tag="h1">Code Editor</Heading>
        <Heading tag="h6">Made with Svelte</Heading>
    </header>
    <!-- ... -->
</main>

<style lang="postcss">
    :global(body, html) {
        width: 100%;
        height: 100%;
        background: theme(backgroundColor.stone.800);
    }
</style>

And now we can check if the user has authenticated, to either show the buttons described earlier or a login button.

{#if data.session.user}
    {@const user = data.session.user}
    <!-- ... -->
{:else}
    <Button href="auth" color="light">
        <GithubIcon size="24" />
        <span class="ml-1">Login</span>
    </Button>
{/if}

And as for the markup rendered when there is a user...

We can render their name and avatar, as provided by GitHub:

<div class="text-gray-800 p-3 rounded-xl border-2 border-white bg-gray-300 flex flex-row space-x-2">
    <Avatar src={user.avatar} rounded />
    <span>{user.username}</span>
</div>

Then add a button to toggle between entering the name for a new project and the ID for an existing one:

<Button on:click={toggleCreate} outline gradient color="greenToBlue" btnClass="mt-6">
    <svelte:component this={ActionIcon} />
    <span class="ml-2">{label}</span>
</Button>

And later the actual form to submit to the server:

<form method="POST" action={`?/${formaction}`} class="space-y-2 mt-5 flex flex-col">
    <Input type="text" name="value" bind:value required {placeholder} size="lg" />
    {#if value.length > 0}
        <Button type="submit" gradient class="!p-2" color="tealToLime"><ArrowRightIcon /></Button>
    {/if}
</form>

Now, back to the <script> at the top of the file. We need to know when either creating or joining has been toggled, to know which label, icon or placeholder to render.

let value = '';
let creating = false;

const toggleCreate = () => (creating = !creating);

$: placeholder = creating ? 'Enter new project name' : 'Enter project ID';
$: label = creating ? 'Join Project' : 'New Project';
$: ActionIcon = creating ? LogInIcon : PlusCircleIcon;
$: formaction = creating ? 'create' : 'join';

Project Route

As hinted at before, the files for this route will be put in src/routes/project/[project] .

Again, here we will use form actions. But first, we can create a server load function. With this, the front end can receive the project's data.

import { Project, ProjectClass } from '$lib/server/models';
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { EJSON } from 'bson';

export const load = (async ({ params, locals }) => {
    const project = await Project.findById(params.project).lean();

    if (!locals.user) {
        throw redirect(307, '/');
    }

    if (!project) {
        throw error(404, 'not found');
    }

    return {
        project: EJSON.serialize(project) as ProjectClass
    };
}) satisfies PageServerLoad;

Note that we use EJSON to handle serializing ObjectIDs from mongoose.

Actions

We will need form actions to create, rename and delete files.

Create File

When creating a file, we first check if the project exists, and then if a valid and unique filename has been entered.

createFile: async ({ request, params }) => {
    const project = await Project.findById(params.project);
    if (!project) {
        return fail(404, { message: 'not found' });
    }

    const data = await request.formData();
    const filename = data.get('filename');

    if (typeof filename !== 'string') {
        return fail(400, { message: 'invalid filename type' });
    }

    if (project.files.map((file) => file.name).includes(filename)) {
        return fail(400, { filename, message: 'file already exists' });
    }

    project.files.push({ name: filename, content: [] });
    await project.save();
},

Rename File

Renaming files requires a file's ID, as well as the new value.

renameFile: async ({ request, params }) => {
    const project = await Project.findById(params.project);
    if (!project) {
        return fail(404, { message: 'not found' });
    }

    const data = await request.formData();

    const id = data.get('id');
    const value = data.get('value');

    if (!(typeof id === 'string' && typeof value === 'string')) {
        return fail(400, { message: 'invalid id type' });
    }

    const file = project.files.id(id);

    if (!file) {
        return fail(404, { message: 'not found' });
    }

    file.name = value;

    await project.save();
},

Delete File

Here, we get the file by its ID so that we can remove the file.

deleteFile: async ({ request, params }) => {
    const project = await Project.findById(params.project);
    if (!project) {
        return fail(404, { message: 'not found' });
    }

    const data = await request.formData();

    const id = data.get('id');

    if (typeof id !== 'string') {
        return fail(400, { message: 'invalid id type' });
    }

    project.files.id(id)?.remove();
    await project.save();
}

Svelte Code

Add the following code to the adjacent +page.svelte:

<script lang="ts">
    import { setContext } from 'svelte';
    import type { PageData } from './$types';

    import { Types } from 'mongoose';
    import io from 'socket.io-client';

    import FileTabs from '$lib/components/FileTabs.svelte';
    import RenameFile from '$lib/components/RenameFile.svelte';
    import DeleteFile from '$lib/components/DeleteFile.svelte';
    import CreateFile from '$lib/components/CreateFile.svelte';
    import UsersList from '$lib/components/UsersList.svelte';
    import { project } from '$lib/stores';

    export let data: PageData;

    // ...
</script>

<main class="w-full h-full flex flex-col justify-center items-center">
    <div class="w-5/6 flex flex-row justify-around mb-10">
        <div>
            <RenameFile />
            <DeleteFile />
        </div>
        <UsersList />
        <CreateFile />
    </div>
    <div class="w-5/6 h-3/4 relative">
        <FileTabs />
    </div>
</main>

<style global>
    :global(html, body) {
        width: 100%;
        height: 100%;
    }
</style>

The imports and markup structure here pretty much describes how the page may look.

The Top Row of the Project Page

This represents the components in the first row, but we use the FileTabs component to render the input area and show syntax highlighting.

Deserializing Page Data

Note that we can't just use the data prop to access the project data. When creating or renaming files, we may want to add progressive enhancements. This allows client-side JavaScript to submit the form, i.e. fetch. This means thepage store will update, but the data prop won't also update. Therefore, we will only access the project data using stores.

We also may need to access the deserialised data in multiple components. So let's create a stores.ts file in the $lib folder. It will contain a store that derives from the page store.

import { derived } from 'svelte/store';
import type { ProjectClass } from '$lib/server/models';
import { page } from '$app/stores';
import { EJSON } from 'bson';

export const project = derived<typeof page, ProjectClass>(
    page,
    ($page) => EJSON.deserialize($page.data.project) as ProjectClass
);

SocketIO Client

Now we can initialize a client for our Socket server. Note that we need to do a handshake with the project ID and user data.

const socket = io({
    auth: {
        projectId: project._id.toString(),
        user: data.session.user
    }
});

Context

Data can be passed to children of the page component via context. We can socket client instance.

setContext('socket', socket);

Main Application State

// Currently opened file
let fileId = project.files[0]?._id ?? new Types.ObjectId();

// Used to access the file's other properties
$: file = project.files.find((file) => file._id.equals(fileId));

// id (id of file to rename), value (new value)
let editing = {
    id: new Types.ObjectId(),
    value: ''
};

So now we can update our markup to include these state variables:

<main class="w-full h-full flex flex-col justify-center items-center">
    <div class="w-5/6 flex flex-row justify-around mb-10">
        <div>
            <RenameFile on:click={() => (editing = { id: fileId, value: file?.name ?? '' })} />
            <DeleteFile {fileId} />
        </div>
        <UsersList />
        <CreateFile />
    </div>
    <div class="w-5/6 h-3/4 relative">
        <FileTabs {editing} {fileId} />
    </div>
</main>

Application Elements

Rename File

An icon button that forwards an on:click handler:

<script lang="ts">
    import { Button } from 'flowbite-svelte';
    import { EditIcon } from 'svelte-feather-icons';
</script>

<Button on:click title="Rename file" color="yellow" pill><EditIcon /></Button>

Delete File

Uses the active file to fill a form that would submit data to the deleteFile action:

<script lang="ts">
    import { Button } from 'flowbite-svelte';
    import type { Types } from 'mongoose';
    import { TrashIcon } from 'svelte-feather-icons';

    export let fileId: Types.ObjectId;
</script>

<form method="POST" action="?/deleteFile" class="inline">
    <Button type="submit" title="Delete file" shadow="pink" color="red" pill>
        <TrashIcon />
    </Button>
    <input type="text" hidden name="id" value={fileId.toString()} />
</form>

Users List

Here, we listen to the user management events from the server. The avatar for each user is displayed, along with a tooltip showing their name.

<script lang="ts">
    import type { User } from '$lib/server/models';
    import type { Client } from '$lib/server/socket/types';
    import { Avatar, Tooltip } from 'flowbite-svelte';
    import { getContext, onMount } from 'svelte';

    const socket = getContext<Client>('socket');

    let users: User[] = [];

    onMount(() => {
        socket.on('users', (data) => (users = data));
        socket.on('join', (user) => {
            users = [...users, user]
        });
        socket.on('leave', (username) => {
            users = users.filter((user) => user.username !== username);
        });
    });
</script>

<div class="flex flex-row px-4 py-2 max-w-3xl space-x-3">
    {#each users as user (user.username)}
        <Avatar src={user.avatar} data-name={user.username} rounded border />
    {/each}

    {#if users.length > 0}
        <Tooltip triggeredBy="[data-name]" on:show={(e) => (name = e.target.dataset.name)}>
            {name}
        </Tooltip>
    {/if}
</div>

The file containing the Client interface is available in the GitHub repository, for reference.

Create File

A button that triggers a popover. The popover contains an input to enter the new filename.

<script lang="ts">
    import { Button, Popover, Input } from 'flowbite-svelte';
    import { enhance } from '$app/forms';
    import { PlusCircleIcon } from 'svelte-feather-icons';
</script>

<Button id="create-file" title="Create file" shadow="cyan" pill>
    <PlusCircleIcon />
</Button>
<Popover triggeredBy="#create-file" placement="bottom" class="w-48 h-16" trigger="click">
    <form method="POST" use:enhance action="?/createFile">
        <Input type="text" name="filename" placeholder="Name" />
    </form>
</Popover>

File Tabs

Separate tab items for each file within the project. Each tab item will render an Editor component which contains the contents of the file, for editing.

For every tab item, we use the title slot to control whether an input for renaming or the regular filename should be rendered.

<script lang="ts">
    import { Tabs, TabItem, Input, Heading } from 'flowbite-svelte';
    import Editor from './Editor.svelte';
    import { applyAction, deserialize } from '$app/forms';
    import { invalidateAll } from '$app/navigation';
    import type { ActionResult } from '@sveltejs/kit';
    import { Types } from 'mongoose';
    import { project } from '$lib/stores';

    export let fileId: Types.ObjectId;

    export let editing: {
        id: Types.ObjectId;
        value: string;
    };
</script>

<Tabs contentClass="h-full overflow-auto text-lg bg-gray-800 text-white rounded-2xl relative">
    {#each $project.files as file (file._id.toString())}
        <TabItem open={file._id.equals(fileId)} on:click={() => (fileId = file._id)} title={file.name}>
            <div slot="title">
                {#if editing.id.equals(file._id)}
                    <Input type="text" on:blur={() => renameFile()} name="value" bind:value={editing.value} />
                {:else}
                    {file.name}
                {/if}
            </div>
            <Editor {file} />
        </TabItem>
    {:else}
        <TabItem open title="New">
            <div class="flex flex-col space-y-6 justify-center items-center">
                <Heading tag="h3" color="white" class="text-center mt-24">
                    Create a new File
                </Heading>
                <img src="/favicon.png" alt="Svelte Icon" />
            </div>
        </TabItem>
    {/each}
</Tabs>

Notice the renameFile function called, which we have not yet defined. Before we were able to make use of a <form> component. But now, the <input> is inside a <TabItem> , which is a button. This prevents users from submitting forms by pressing 'Enter' within the <input>.

The implementation for renameFile comes from the SvelteKit docs. The difference is that we construct a FormData object ourselves.

const renameFile = async () => {
    const data = new FormData();
    data.set('id', editing.id.toString());
    data.set('value', editing.value);

    const response = await fetch('?/renameFile', {
        method: 'POST',
        body: data
    });

    const result: ActionResult = deserialize(await response.text());

    if (result.type === 'success') {
        await invalidateAll();
        editing = {
            id: new Types.ObjectId(),
            value: ''
        };
    }

    applyAction(result);
};

Editor

This component ties in with the change events from the server. Then, it implements the process for operational transforms on code content. We also use a SimpleCodeEditor component to render the code and syntax highlighting.

Add the following imports to the component script:

<script lang="ts">
    import { getContext, onMount } from 'svelte';

    import type { Client } from '$lib/server/socket/types';
    import type { File } from '$lib/server/models';

    import { SimpleCodeEditor } from 'svelte-simple-code-editor';
    import Delta from 'quill-delta';

    import Prism from 'prismjs';
    import 'prismjs/themes/prism-tomorrow.css';

    export let file: File;
</script>

<div>
    <SimpleCodeEditor
        bind:value={code}
        highlight={(code) => Prism.highlight(code, Prism.languages.javascript, 'javascript')}
        tabSize={4}
        on:value-change={onChange}
    />
</div>

Note that we're only highlighting using "javascript". In reality, not all files created are going to be JavaScript files. As a suggestion, you could use guess-lang to detect the language. Then, you would pass the language name and grammar into the highlight function. This would allow the coding language to be detected without relying on its file extension. A post on this topic may come soon.

Now we can get the socket client, provided by context.

const socket = getContext<Client>('socket');

And then define some important state variables.

// Code content reflected in the DOM
let code = file.content.length ? (file.content[0].insert as string) : '';

// Used to compare with new code, during updates
let previousCode = code;

// Stores all the changes applied
let localChanges: Delta[] = [];

let timeout = setTimeout(() => {}, 0);

// Stores the initial change
// e.g. an update from the server
// or the last local change applied
let initialChange = new Delta();

Once the component mounts, we can listen for the change event. Here, we set the initialChange to this remote change. Then, applyLocalChanges applies the changes, along with any pending local changes.

onMount(() => {
    socket.on('change', (filename, change) => {
        if (filename !== file.name) return;
        initialChange = new Delta(change);

        applyLocalChanges();
    });
});

Before calling applyLocalChanges we can wait for any updates from the server.

const onChange = () => {
    localChanges = [
        ...localChanges,
        new Delta().insert(previousCode).diff(new Delta().insert(code))
    ];
    previousCode = code;

    clearTimeout(timeout);
    timeout = setTimeout(applyLocalChanges, 1000);
};

To transform the local changes against the remote update, we must first compose each local change into one Delta object.

const applyLocalChanges = () => {
    let final = initialChange;

    if (localChanges.length) {
        let cumulated = localChanges.reduce((prev, cur) => prev.compose(cur));

        const transformed = final.transform(cumulated, true);

        // initial + cumulated
        final = final.compose(transformed);

        final = cumulated.invert(new Delta()).compose(final);
    }

    // ...
};

As the user types into the editor, they see their local changes since the code variable is bound to the input component. So, we have to "invert" the cumulated change first so that those local changes are removed from code.

Now, at the end of the function, we apply these composed updates:

// Apply final change
const newDoc = new Delta().insert(code).compose(final);

// If new code is empty after operations,
// newDoc.ops will be empty
if (newDoc.length() === 0) {
    code = '';
} else {
    code = (newDoc.ops[0]?.insert as string) ?? '';
}

previousCode = code;

initialChange = new Delta();
localChanges = [];

Summary

In short, we built a code editor site with Svelte (and SvelteKit). Then, using quill-delta and SocketIO, we were able to handle conflict resolution.

As a takeaway from this article, here are some suggestions:

  • Uploading files to the project, check out GridFS

  • Displaying each user's cursor position

  • Find and Replace features

  • Syntax highlighting support for multiple languages, as discussed earlier

If you've read this far, I want to thank you. But if you found anything new or useful, share and stay tuned for more content.

And before you leave, here's the GitHub repository.

References

Collaborative Editing in JavaScript: An Intro to Operational Transformation (davidwalsh.name)

Flowbite-Svelte

Building a real-time collaborative editor (mahfuz.info)

Welcome to typegoose | typegoose

GitHub Documentation

Get Started with Atlas — MongoDB Atlas

Introduction • Docs • SvelteKit

Docs • Svelte

Introduction | Socket.IO

Did you find this article valuable?

Support Wool Doughnut by becoming a sponsor. Any amount is appreciated!