Recently we have started on a journey of teaching programming. The setup is a little bit unusual, the students are beginners and the lessons will be online. To make things easier for everyone, we need a simple environment for students to write and test code and for teachers to see it and correct mistakes.
Yes a VSCode & Google Meet solution would work, but it would require quite a bit of orchestration and distract the students from the task at hand.
Naturally, being programmers, we decided to scratch our own itch and just build a solution ourselves.
In this blog we want to show the surprising little amount of code needed for a shared text-editor. Which allows teachers to check the student’s code and to take control and fix mistakes. All in realtime.
Users: stundents and teacher Each student starts with his own document. Teacher can list all student documents and choose to open a document. A document is pure text, and has can only ever have a single writer (which is initially the student). The teacher can take control of a document becoming the writer. Thus the document becomes readonly for the student. Changes made by the writer are committed to a shared shored. Readonly documents are watched and changes are displayed to the user in realtime.
store = {
'2025-03-03': {
'karol': {
'body': { 'text': 'This is a start of a wonderful friendship' },
'mode': { 'editor': 'miro' }
}
}
}
Our json store will be a map of document name to objects. The document object has two parts. The body with the actual text and cursor position. And the mode, which just holds the editor-user - the single writer.
Why the split? This is an optimization technique. A writer (a student) does not need to be notified of his own text changes. But needs to know when the editor of the document changes - that is when the mode changes, because the teacher took over. Since we can subscribe to changes at any url, when writing we will only watch the mode nested object. When reading/observing we can observer the whole document object.
Let's take a look at the code, shall we.
Returns an object with methods to observe and/or set the document text and mode. Methods are described below.
const create_shared_editor = (userid, textarea, on_mode_changed) => {
let doc_state = {
// the id of the user
userid: userid,
// The path of the open document
path: '',
// HTMLTextArea
textarea: textarea,
// open document
body: {},
mode: {},
}
//... methods decribed below.
return { ... };
}
Returns all documents in the store. First level map is date and lessons. And each lessons is a map of user (student) and document.
const get_documents = async () => {
// read the root json store
const resp = await sse_fetch("");
const docs = await resp.json();
const paths = []
for (const dt of Object.keys(docs)) {
const names = docs[dt];
for (const name of Object.keys(names)) {
paths.push(dt + "/" + name);
}
}
return paths;
}
Adds a new document to the store. Upload changes to the textArea as they happen and starts watching for server mode changes.
const is_user_editor = () => {
return doc_state.mode.editor === doc_state.userid;
}
const make_document_body = () => {
const tb = doc_state.textarea;
return { text: tb.value, selectionStart: tb.selectionStart, selectionEnd: tb.selectionEnd };
}
const create_document = async (path) => {
sse_unlisten();
doc_state.path = path;
doc_state.body = {};
doc_state.mode = {};
const new_mode = { editor: doc_state.userid, running: 0 };
await sse_patch(doc_state.path, {
body: make_document_body(),
mode: new_mode,
});
on_sse_mode_changed(new_mode);
}
const on_textarea_changed = (e) => {
if (is_user_editor()) {
const body = make_document_body();
sse_patch(doc_state.path + "/body", body);
} else {
// ignore...
}
}
Shows the passed document text in the textArea. Starts watching for document changes (body and mode) and updates the textArea to match.
const observe_document = (path) => {
sse_unlisten();
doc_state.path = path;
doc_state.body = {};
doc_state.mode = {};
on_sse_mode_changed({ editor: "", running: 0 });
}
Changes the open document mode (editor user). If the current user is the document editor, changes the editor to the document author (encoded in the the document path). If not, makes the current user the document editor.
const toggle_editor = () => {
const doc_author = doc_state.path.split('/')[1];
const new_editor = is_user_editor() ? doc_author : doc_state.userid;
sse_patch(doc_state.path + "/mode", { editor: new_editor, running: 0 });
}
Called by SSE and internally by read/write document. Setups textArea and SSE listeners as need according to the current document mode.
const on_sse_mode_changed = (e) => {
if (doc_state.mode.editor !== e.editor) {
doc_state.mode.editor = e.editor;
const editable = is_user_editor();
doc_state.textarea.disabled = !editable;
on_mode_changed(doc_state.path, editable);
doc_state.textarea.removeEventListener('input', on_textarea_changed);
doc_state.textarea.removeEventListener('select', on_textarea_changed);
if (editable) {
doc_state.textarea.addEventListener('input', on_textarea_changed);
doc_state.textarea.addEventListener('select', on_textarea_changed);
}
if (editable) {
sse_listen(doc_state.path + "/mode", on_sse_mode_changed);
} else {
sse_listen(doc_state.path, on_sse_document_changed);
}
}
}
Called in response to SSE. Update the text area to match the document. If mode changed as well calls the on_sse_mode_changed
method.
const on_sse_document_changed = (data, path) => {
let body = undefined;
let mode = undefined;
if (path === '/mode')
mode = data;
else if (path === '/body')
body = data;
else if (path === '/') {
mode = data.mode;
body = data.body;
}
if (body && !is_user_editor()) {
const tb = doc_state.textarea;
if (body.hasOwnProperty("text")) {
doc_state.body.text = body.text;
tb.value = body.text;
}
let selectionChanged = false;
if (body.hasOwnProperty("selectionStart")) {
doc_state.body.selectionStart = body.selectionStart;
selectionChanged = true;
}
if (body.hasOwnProperty("selectionEnd")) {
doc_state.body.selectionEnd = body.selectionEnd;
selectionChanged = true;
}
if (selectionChanged) {
tb.setSelectionRange(doc_state.body.selectionStart, doc_state.body.selectionEnd);
}
}
if (mode) {
on_sse_mode_changed(mode);
}
}
Updating the firebase store is a simple JSON fetch request.
const SERVER_URL = "https:/YOUR_DATABASE.firebasedatabase.app/"
const getFirebaseToken = () => {
return null;
}
const sse_prepare_url = async (path) => {
const token = await getFirebaseToken();
if (path && path.at(-1) !== '/')
path += "/"
let url = SERVER_URL + path + ".json";
if (token)
url += "?auth=" + token;
return url;
}
const sse_fetch = async (path, init) => {
const url = await sse_prepare_url(path);
const resp = await fetch(url, init);
if (!resp.ok) {
let errorMessage = "Server error";
try {
errorMessage = await resp.text();
}
catch { }
throw new Error(errorMessage);
}
return resp;
}
const sse_patch = async (path, data) => {
try {
const init = {
"body": JSON.stringify(data),
"method": "PATCH",
"headers": {
"Content-Type": "application/json",
}
}
await sse_fetch(path, init);
}
catch (err) {
console.log(err);
}
}
Listening for Firebase server-side events uses the EventSource class.
let sse_instance;
const sse_unlisten = () => {
if (sse_instance) {
sse_instance.close();
sse_instance = null;
}
}
const sse_listen = async (path, handler) => {
const handleSSEvent = (d) => {
const ev = JSON.parse(d);
handler(ev.data, ev.path);
}
const url = await sse_prepare_url(path);
const evtSource = new EventSource(url);
//magic goes here!
evtSource.addEventListener("patch", handleSSEvent, false);
evtSource.addEventListener("put", handleSSEvent, false);
sse_unlisten();
sse_instance = evtSource;
}
That's it. Just a couple of methods and a realtime editor is ready. Well, almost, we leave the HTML and CSS as an excercise to the reader.
Just kidding, you can look at the rest of code on our github.
Happy hacking!