examples/live-notes Public
// The editor page: a textarea bound to one note over a WebSocket. The note id comes from the// URL fragment, so /#groceries edits the note "groceries".import { Session, moveCaret } from "./session.ts";import type { Editor, ServerMessage } from "../src/protocol.ts";const NOTE_ID = /^[A-Za-z0-9_-]{1,64}$/;const area = document.querySelector<HTMLTextAreaElement>("#note")!;const status = document.querySelector<HTMLElement>("#status")!;const title = document.querySelector<HTMLElement>("#title")!;const people = document.querySelector<HTMLUListElement>("#here")!;function noteId(): string { const id = decodeURIComponent(location.hash.slice(1)); return NOTE_ID.test(id) ? id : "scratch";}let socket: WebSocket | null = null;let retry = 0;// Who is here, from the room's latest presence message, and when each was last seen typing.let here: Editor[] = [];let me: string | null = null;const typing = new Map<string, number>();const TYPING_MS = 1500;function renderPeople(): void { const now = Date.now(); people.replaceChildren( ...here.map((editor) => { const li = document.createElement("li"); li.style.setProperty("--color", editor.color); li.textContent = editor.id === me ? `${editor.name} (you)` : editor.name; if ((typing.get(editor.id) ?? 0) > now) li.classList.add("typing"); return li; }), );}function sawTyping(id: string): void { typing.set(id, Date.now() + TYPING_MS); renderPeople(); setTimeout(renderPeople, TYPING_MS + 50);}function connect(): void { const id = noteId(); title.textContent = id; document.title = `${id} · live-notes`; area.disabled = true; status.textContent = "Connecting"; const scheme = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${scheme}://${location.host}/notes/${encodeURIComponent(id)}/ws`); const session = new Session((msg) => ws.send(JSON.stringify(msg))); socket = ws; ws.addEventListener("message", (event) => { const msg = JSON.parse(String(event.data)) as ServerMessage; if (msg.type === "presence") { here = msg.here; if (msg.you) me = msg.you.id; renderPeople(); return; } const { selectionStart, selectionEnd } = area; const applied = session.receive(msg); if (applied === "reset") { area.value = session.text; area.disabled = false; area.focus(); retry = 0; status.textContent = msg.type === "snapshot" && msg.reason ? `Edit undone: ${msg.reason}` : `Version ${session.version}`; } else if (applied) { area.value = session.text; area.setSelectionRange(moveCaret(selectionStart, applied), moveCaret(selectionEnd, applied)); if (msg.type === "op") sawTyping(msg.by); } if (msg.type !== "snapshot") status.textContent = session.pending ? "Saving" : `Version ${session.version}`; }); area.oninput = () => { session.edit(area.value); status.textContent = "Saving"; }; ws.addEventListener("close", () => { if (socket !== ws) return; area.disabled = true; here = []; renderPeople(); // Back off to at most 10 seconds between attempts. const wait = Math.min(10_000, 500 * 2 ** retry++); status.textContent = "Offline, reconnecting"; setTimeout(connect, wait); });}window.addEventListener("hashchange", () => { const old = socket; socket = null; old?.close(); connect();});connect();