Rebasely Beta: data may be purged

examples/live-notes Public

TypeScript · 96 lines · 3.4 KiB · at 359d966

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