examples/live-notes Public
// One editor's side of a note, without the DOM: what it shows, which version of the room it has// seen, and its own edits the room has not acknowledged yet.//// The editor keeps at most one op in flight. Further edits wait in a queue until the room acks// the one in flight, so every op it sends is written against a version the room has. Queued// keystrokes that touch the same spot are folded into one op, so a fast typist sends a word// per round trip rather than a letter. When// someone else's op arrives, the session moves it past the edits still pending here, and moves// those edits past it, the same way the room will.import { apply, compose, diff, isNoop, transform, type Op } from "../src/ops.ts";import type { ClientMessage, ServerMessage } from "../src/protocol.ts";export class Session { // The room's text with this editor's pending ops applied: what the textarea shows. text = ""; // The last room version this editor has seen. version = 0; #inflight: Op | null = null; #queue: Op[] = []; #send: (msg: ClientMessage) => void; constructor(send: (msg: ClientMessage) => void) { this.#send = send; } // Edits made here that the room has not acknowledged. get pending(): number { return (this.#inflight ? 1 : 0) + this.#queue.length; } // The user changed the text to `next`. edit(next: string): void { for (const op of diff(this.text, next)) { this.text = apply(this.text, op); const last = this.#queue.at(-1); const merged = last && compose(last, op); if (merged) this.#queue[this.#queue.length - 1] = merged; else this.#queue.push(op); } this.#flush(); } // Handles a frame from the room. Returns the op it applied to `text` for a remote edit (so // the caller can keep the caret in place), "reset" when the text was replaced, else null. receive(msg: ServerMessage): Op | "reset" | null { switch (msg.type) { case "snapshot": // Pending edits were written against a text the room no longer has; they are dropped. this.text = msg.text; this.version = msg.version; this.#inflight = null; this.#queue = []; return "reset"; case "ack": this.version = msg.version; this.#inflight = null; this.#flush(); return null; case "op": { this.version = msg.version; let remote = msg.op; if (this.#inflight) { const mine = transform(this.#inflight, remote, true); remote = transform(remote, this.#inflight, false); this.#inflight = mine; } this.#queue = this.#queue.map((op) => { const mine = transform(op, remote, true); remote = transform(remote, op, false); return mine; }); this.text = apply(this.text, remote); return remote; } case "presence": case "error": return null; } } #flush(): void { if (this.#inflight) return; this.#queue = this.#queue.filter((op) => !isNoop(op)); const next = this.#queue.shift(); if (!next) return; this.#inflight = next; this.#send({ type: "op", base: this.version, op: next }); }}// Where a caret at `pos` ends up after `op`. Text inserted right at the caret goes after it.export function moveCaret(pos: number, op: Op): number { if (op.type === "insert") return op.pos < pos ? pos + op.text.length : pos; if (pos <= op.pos) return pos; return Math.max(op.pos, pos - op.count);}