March 02, 2025

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.

Live learning

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.

Goals

  1. Teacher can observer any number of student documents. Seeing the code written in realtime.
  2. Teacher can take control of the document, change the code and pass control back to the student.
  3. Editing by both student and teacher at the same time must not be possible. Eg a “full duplex” editor is not what we want.
  4. Student can observe fixed by the teacher in realtime.

Tools

  1. Html + javascript will be used for the student and teacher environment. These will be static files. No server, no build step.
  2. Google firebase realtime database will be used for document and state storage
  3. Standard javascript fetch and EventSource (serverSentEvents) will be used to implement data flow.

The absolute minimum you need to know about Firebase realtime

  1. It is a json store
  2. Objects within the store have a unique URL, which is equivalent to their object access path: given an object {jane:{car:{maker:”audi”}}}, we can request the root object (with a naked “/.json”), the maker with /jane/car/maker.json or any object in between. To read the data just fetch the url.
  3. You can add/replace an object at any url with a post fetch
  4. You can add/modify some properties of the object with patch fetch.
  5. What makes it realtime, are the ServerSentEvents. In a nutshell you ask the browser to monitor a url -> the root or nested object. When the object or its child changes your callback is called with the relative url path of change and the actual new data.

Conceptual model

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.

Data model

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.

Operations

Let's take a look at the code, shall we.

Create a new shared document editor instance

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 { ... };
}

List all documents.

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;
}

Create a new document

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...
	}
}

Read document (and get updates as it changes)

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 });
}

Toggle writer user.

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 });
}

Listen to document mode (writer) changes

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);
		}
	}
}

Listen to whole document (text and mode) changes

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);
	}
}

Firebase methods and events listener

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;
}

Wrap

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!

Continue reading

CSV file - the data mover

March 09, 2025
CSV file - the data mover

Are you looking for a good way to move data from and to your app? You know, export and import? Do you wish for maximum compatibility? Nothing beats a good ol’ CSV.

Processing bank transfer in Salesforce.com

Fundraising tools for non-profits

September 27, 2024
Fundraising tools for non-profits

Are you running or working for a non-profit?
Do you struggle with managing donors, donations and communication?
Let's look at how digital tools can simplify your life.

©2022-2025 Inuko s.r.o
Privacy PolicyCloud
ENSKCZ
ICO 54507006
VAT SK2121695400
Vajanskeho 5
Senec 90301
Slovakia EU

contact@inuko.net