markdown editor

a multiplayer markdown editor with cyxth colab.

In this guide we are going to build a multiplayer markdown editor based on the monaco-editor. This will be done in three parts. first we create a bare editor without any multiplayer features.we are using monaco as our editor and svelte as our framework of choice. You can use pure js or a framework of your choice, we’ll ensure to explain the alternative when we use a few svelte features here.

In part two we introduce cyxth and add realtime collaboration to ensure consistent state across all the editors. and in the final part we add presence and improve the overall collaboration experience by sharing cursors and user selections.

This guide aims to introduce you to the cyxth collaborative text editing concepts with a hands on example. parts 2 and 3 cover most cyxth concepts so feel free to skip to that or follow along.

intro

let start by creating a new svelte app. from the svelte website we just copy and run the commands in order. we chose the skeleton app and use typscript in the sv command.

bashpnpx sv create markleft
cd markleft
pnpm install
pnpm run dev

open the browser on the given port to make sure everything is working fine, mine is localhost:5173 yours may vary.

we will also need the monaco-editor package so let’s install that.

bashpnpm install monaco-editor

And we now we are ready to get started.

editor component

In the routes folder we edit the +page.svelte file, This file contains our editor component. starting with the following simple markup.

svelte<script lang="ts">
    let container: HTMLDivElement;
</script>
<div id="editor" bind:this={container}></div>

<style>
  #editor {
    height: calc(100vh - 4rem);
    padding-block-start: 4rem;
  }
</style>

Note the bind:this={container} in svelte is same as document.querySelector for our editor div.

Then we add monaco editor functionality to the script.

tsimport type { editor, IRange } from "monaco-editor";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import { onMount } from "svelte";

let monaco;
let model: editor.IModel;
let writer: editor.IStandaloneCodeEditor;
let src = "";
let container: HTMLDivElement;

const load = async () => {
    self.MonacoEnvironment = {
      getWorker: (_workerId, _label) => new editorWorker()
    };

    monaco = await import("monaco-editor");
    model = monaco.editor.createModel(src, "markdown");
    writer = monaco.editor.create(container, {
      value: src,
      language: "markdown",
      model,
      automaticLayout: true,
      minimap: { enabled: false },
      scrollbar: {
        useShadows: false,
        horizontal: "hidden",
      },
    });
};

onMount(async () => {
    await load();
});

you should now have an editor similar to this.

a single player editor

Test to make sure everything works. we have an editor component that only supports markdown editing.

multiple users

For a single user this is all we need, But our editor app should support multiple users i guess that is why we want to make it multiplayer. So before we jump to collaboration lets add a simple login feature so we can have the user id.

In a real application the user is probably already logged in with cookies, tokens …etc, here we are going to use localStorage. if the user is not in localStorage we show a login prompt else we show the editor component. This will be important later when we add multiplayer functionality in the next section.

Lets add the simple login functionality to script.

tslet loggedIn = $state(true);

let inputUserId: string | undefined = $state(undefined);
const login = async () => {
    if (!newUserId || !newUserId.length) return;
    localStorage.setItem("userId", newUserId);
    userId = inputUserId;
    loggedIn = true;
    await load();
};

And the markup for the same.

svelte{#if !loggedIn}
  <div class="loginstuff">
    <input type="text" placeholder="userId" bind:value={newUserId} />
    <button onclick={login}>login</button>
  </div>
{:else}
    <div id="editor" bind:this={container}></div>
{/if}

onMount let’s also check if the user is logged in if not we return early and show the login prompt

tsonMount(async () => {
    let userId = localStorage.getItem("userId"); 
    if (!userId) { 
      loggedIn = false; 
      return; 
    } 

    await load();
});

now we are ready to implement real-time collaboration with cyxth in the next part.

cyxth

Before we get started first ensure you have a cyxth account. and that you have created atleast one instance with collaboration features enabled.

Authorization

Ensure you have correctly set the public key for authorization. follow this quick tutorial to do so, it will take a minute or two to setup a simple authorization server, for our usecase i just copied the simple auth example.

user authorization guide
~3 minutes

once we have the simple auth server running, we can now fetch the authorization token on login.

tsconst AUTH_URL = "http://localhost:8902/authorize";

const getToken = async (userId: string) => {
 let stream = await fetch(AUTH_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ id: userId }),
  });

  let token = await stream.json();
  return token;
}

going multiplayer

install @cyxth/core and @cyxth/colab from npm.

bashpnpm install @cyxth/core @cyxth/colab

And now back in the +page.svelte script we bring in the packages.

tsimport Cyxth, {type TokenData} from '@cyxth/core';
import Colab, {type Text} from '@cyxth/colab';

const APP_URL = "my-app.apps.cyxth.com";
const stateId = "our-docs";
let doc: Text;

const multiplayer = async (token: TokenData) => {
    const cyxth = await new Cyxth(APP_URL, [Colab]).connect(token);
    const colab = await cyxth.colab();
    await colab.createOrJoin(stateId);

    doc = colab.changeContext().text("doc");
}

what happened there, here is a breakdown

first we import our packages cyxth and colab

Cyxth takes in the app url from the cyxth console and the plugins we are using in this case only Colab is used. we then connect to cyxth using the token we got earlier from getToken.

if connection is successiful we get a colab instance from cyxth and create or join a state with the id our-docs. stateId is analogous to a channel users on the same stateId can collaborate with each other on the same documents.

Next from changeContext we get a text node on the key doc, a single state can have multiple text nodes, tree nodes and counter nodes. we are focusing on the text node for this guide.

modifying state

The text node gives us methods to modify the text node and listen to the changes in the text node. to add this to our editor app we have listen to on content change to get insertions and deletions.

ts//...

model.onDidChangeContent((e) => {
  for (let change of e.changes) {
    let { startLineNumber: lineNumber, startColumn: column } = change.range;
    let index = model.getOffsetAt({ lineNumber, column });
    let value = change.text;
    let delCount = change.rangeLength;

    if (value === "" && delCount > 0) doc.delete(index,delCount);
    if (delCount === 0 && value !== "") doc.insert(index, value);
  }
});

in monaco-editor using onDidChangeContent we listen for content changes. each change is actually a text replacement action with index, the text inserted and the delete count.

here we use two Text methods delete and insert. the delete method takes in an index and delete count and the insert method takes in the index and value to insert. this are two of the only four methods in the text node for modifying and quering text.

  • insert(index,value) => insert text
  • delete(index,length) => delete text
  • replace(index, length, value) => replace text basically combining delete and insert
  • value() => get value of the text node

so we can now replace delete and insert with replace above 👌🏾.

tsif (value === "" && delCount > 0) doc.delete(index,delCount); 
if (delCount === 0 && value !== "") doc.insert(index, value); 
text.replace(index, value, delCount); 

that is all to collaboratively modify the state. cyxth will automatically send and apply the changes in the remote users mantaining consistent while doing so.

if you open another browser profile or on a separate device and login in with another user you will not see the changes yet. that is because we need to show these changes in the ui on the other side too. keep a second profile open or have another device for this next section.

incoming

now we show the remote changes in the ui. To do this we just handle all the actions performed which are insert, delete and replace. the incoming changes are accompanied with a context showing the user that triggered the change and more info.

To handle inserts

tslet IS_REMOTE = false;

doc.handler = {
  insert(index:number, value: string, ctx: Hctx){
   if (ctx.isLocal) return;
   console.log(ctx.userId);
   let { lineNumber, column } = model.getPositionAt(index);
   let edits = [
      {
        range: {
          startLineNumber: lineNumber,
          endLineNumber: lineNumber,
          endColumn: column,
          startColumn: column,
        },
        text: value,
        forceMoveMarkers: true,
      },
    ];

    IS_REMOTE = true;
    writer.executeEdits("remotes", edits);
    IS_REMOTE = false;
  },
}

we check if the context is local so we don’t execute our own edits. both local and remote changes will trigger the handlers, since we have already written to the model we don’t do it here. We then convert the index to editor position and execute the edits.

the IS_REMOTE flag is used to ensure we dont resend the incoming changes in onDidChangeContent again due to how monaco-editor works. so let’s fix that with.

tsmodel.onDidChangeContent((e) => {
    if (IS_REMOTE) return;

    // ...
}

lets handle deletions too.

tsdoc.handlers{
    // ..
    delete(fragments: TextFragment[], ctx: Hctx){
    if (ctx.isLocal) return;

    let edits = fragments.map(({ index, length }) => {
      let { lineNumber: sl, column: sc } = model.getPositionAt(index);
      let { lineNumber: el, column: ec } = model.getPositionAt(index + length);
      return {
        range: {
          startLineNumber: sl,
          startColumn: sc,
          endLineNumber: el,
          endColumn: ec,
        },
        text: "",
        forceMoveMarkers: true,
      };
    });

    IS_REMOTE = true;
    writer.executeEdits("remotes", edits);
    IS_REMOTE = false;
}

you will notice that delete has multiple fragments ({index,length} pairs). this due to how text works under the hood.

let’s say you have a text hello world. one user deletes the whole text thus having (an empty string) and another user inserts in between hello and world the word good thus having hello good world. if this actions happen concurrently though the first user deleted [0,11] when synced the deletion takes hello and world [0-6],[12-17] instead of just [0-11] as the word has been already split by good. leaving behind good for both users.

delete fragments

And finally replace.

replace combines both insert and delete hence it is either the only handler we need or we don’t need it at all. infact if you try the app right now it will work with both users seeing each other’s changes. cyxth will convert replace to delete and insert or use replace if both delete and insert handlers are not provided.

so the above can be combined together in one replace handler.

tsdoc.handlers = {
    replace(fragments: TextFragment[], index: number, value: string, ctx: Hctx){
        if (ctx.isLocal) return;

        let remotes = fragments.map(({index,length}) => {
            return {index, length: value: ""}
        });

        if (index !== undefined && value !== undefined) {
            remotes.push({index, length: value.length, value});
        }

        let edits = remotes.map(({index, length,value}) =>
            let { lineNumber: sl, column: sc } = model.getPositionAt(index);
            let { lineNumber: el, column: ec } = model.getPositionAt(index + length);

            return {
                range: {
                startLineNumber: sl,
                startColumn: sc,
                endLineNumber: el,
                endColumn: ec,
            },
            text: value,
            forceMoveMarkers: true,
            };
        });

        IS_REMOTE = true;
        writer.executeEdits("remotes", edits);
        IS_REMOTE = false;
    }
}

Big reveal, now let’s test across multiple devices, you’ll need someone or two to type at the same time with you it is always consistent as expected no matter the actions.

But something seems to be missing. text just appears and disappears we need to see the remote user selections and their cursors we need to know their intent, we need a way to convey presence!.

presence

presence is an integral part for any real-time collaboration application. it serves to improve user experience avoid unnecessary conflicts and convey user intent. having consistency alone will literally render your application unusable or a pain to use. for a great collaboration experience users need to know what the other user is doing or what they are about to do and anticipate the actions. when users think about collaboration they normally don’t think about consistency they think about multiple cursors and such.

This can be achieved in many ways for example sharing cursors and selections (which we are going to do here), sharing active tool icons and positions in design apps and more. this is only the first level, we can also add realtime audio chat or even video to suppliment it all, the possibilities are endless.

cyxth gives you options to seamlessly send presence data both directly in colab and secondary with calls for video and audio or chat for messaging.

the simplest way to share presence is using colab’s presence method to send binary, JSON or text data and listen them with on("presence") event on the colab instance. for this demo we are going to share selections and cursor positions.

let’s set it up.

users

First let’s listen for user events user:join and user:left. when a user joins they are added to the users object and when they leave well, they are removed. listening for user events is done with the on event handler.

tslet users = $state({});

colab.on("user:join", (e) => {
    users[e.userId] = {
        mode: e.mode,
        color: randomColor(e.userId),
        selction: []
    }
});

colab.on("user:left", (e) => {
    delete users[e.userId]
});

we are using svelte’s $state rune to make the users variable reactive i.e update ui automatically on change.

onMount we get the users

tsonMount(async () => {
    // ...
    let activeUsers = await colab.getUsers();
    users = activeUsers.reduce((users, user) => {
        if (user.userId === userId) return users;

        users[user.userId] = {
            mode: user.mode,
            color: randomColor(e.userId),
            selection: []
        };
        return users;
    }, {});
})

the users variable holds the remote user’s state that should not be necessarily consistent, this state is updated by presence channels. here we store the user’s random color generated by user id as seed, the user mode which shows the user’s access levels and remote user selection. in a design application we could store the user’s active tool or multiple selections. Note cyxth only works with ids it is up to your application to store user avatars names and such.

with that out of the way we can now render the users in the top right corner.

svelte<div class="users">
    {#each Object.entries(users) as [user, info]}
      <div class="user" style="--color: {info.color};">
        {user[0]}
      </div>
    {/each}
</div>

style the users however you want, if your app has users avatars or images show them there instead for this demo i just show the first letter and color the user circle with its color.

now let’s send those cursors and selections.

cursors & selections

to get user selections in the monaco-editor we use onDidChangeCursorSelection this returns a selection and other secondary selections. we just use the primary selection here. the selection has cursor positions too so we don’t have to get those separately.

using the colab presence method we send the selection line number and colum and position line number and column.

tswriter.onDidChangeCursorSelection((e) => {
  let {
    selectionStartColumn: selCol,
    selectionStartLineNumber: selLin,
    positionColumn: posCol,
    positionLineNumber: posLin,
  } = e.selection;

  colab.presence({ selection: [selLin, selCol, posLin, posCol] });
});

listen for presence events.

tscolab.on("presence", (e) => {
    if (!e.data.sel) return;
    users[e.userId].selection = e.data.selection;
})

And now to show the selections in the monaco-editor we use decorations which we update everytime the user selections change. the svelte $effect rune will re run the code inside when the user selections change.

tslet decorations: editor.IEditorDecorationsCollection;

$effect(() => {
    if (!decorations) decorations = writer.createDecorationsCollection([]);
    let decos = Object.entries(users).reduce((decos, [userId, info]) => {
        let [sl, sc, pl, pc] = info.selection;
        decos.push(
            {
                range: new monaco.Range(sl, sc, pl, pc),
                options: {
                  className: `remote-selection ${user}`,
                  shouldFillLineOnLineBreak: true,
                  stickiness: 1,
                },
              },
              {
                range: new monaco.Range(pl, pc, pl, pc),
                options: {
                  className: `remote-cursor ${user}`,
                  zIndex: 45,
                },
              },
        );

        return decos;
    },[]);

    decorations.set(decos);
});

then style the remote-cursor and remote-selection.

css.remote-selection {
    background: var(--highlight);
}

.remote-cursor {
    background: var(--color);
    width: 4px !important;

    &::after {
      /*content: "remote";*/
      position: absolute;
      top: -18px;
      background: var(--color);
      padding: 1px 8px;
      z-index: 999;
      color: white;
    }
}

for the selection we use a highlight color with some opacity and for the cursor we use the user color. you can not set a unique content name for the user cursor in monaco. For that we inject custom css for each user on user:join and getUsers to set the highlight and color and content using this hack. there are probably better ways to do it with monaco but this will do it for now

tsconst updateStyle = () => {
     let style = document.getElementById("injected-styles");
      if (!style) {
        style = document.createElement("style");
        style.type = "text/css";
        style.id = "injected-styles";
        document.head.appendChild(style);
      }

    style.textContent = Object.entries(users)
      .map(([user, info]: any) => `.${user} {
        --color: ${info.color}; --highlight: ${alphaColor(info.color, 0.4)};
        &.remote-cursor::after {content: "${user}"}
      }`
      )
      .join(" ");
};

You can now go ahead and try it on multiple devices with multiple users. the experience is way better than with just consistency as users can now anticipate the next actions. that is all we need to make a collaborative editor based on monaco.

conclusion

in this guide we explored how to to use the Text node in colab to add real-time collaborative text editing capabilities to your application.

first we created a bare app with no multiplayer features and then added the multiplayer functionality with cyxth by using four simple methods in the Text class we were able to send modify the state concurrently while also mantaining consistency.

After modifying the state we then handled the incoming changes and showed them in the ui. We quickly realized that consistency alone gave a poor user experience which we fixed with colab presence features by sending and showing cursors and selections.

This guide only shows a part of cyxth colab for Text editing only, there is more to explore for Tree. checkout the other guides in this series i.e the one where we build a collaborative svg editor.

-- ✌🏾