import { LoroDoc, type Frontiers, type LoroText } from "loro-crdt"; /** The Loro text container that holds a file's text. */ export const TEXT_KEY = "content"; declare const versionBrand: unique symbol; /** The version a reader saw. Pass it back to `write` so the write merges with edits made since. */ export type Version = Frontiers & { readonly [versionBrand]: true }; export interface Snapshot { readonly text: string; readonly version: Version; } export interface WriteOptions { /** * The version the new text was computed from. Edits made after it are kept. * Omit it only when the writer never read the document: the text then replaces the current text. */ readonly base?: Version; } export interface WriteResult { /** The version whose text is exactly the text written, before any merge with later edits. */ readonly version: Version; /** Where the doc's text changed, in the text after the write; null when nothing changed. */ readonly changed: { readonly from: number; readonly to: number } | null; } export type Unsubscribe = () => void; /** The span of `after` that differs from `before`: the shared prefix and suffix trimmed. */ function changedRange(before: string, after: string): WriteResult["changed"] { if (before === after) return null; const max = Math.min(before.length, after.length); let start = 0; while (start < max && before[start] === after[start]) start++; let end = 0; while (end < max - start && before[before.length - 1 - end] === after[after.length - 1 - end]) end++; return { from: start, to: after.length - end }; } /** * A text file backed by one Loro doc (D16). * This is the headless core shared by the Docs and code editors (D12); each editor element is one client of it. */ export class Doc { readonly loro: LoroDoc; readonly #updateListeners = new Set<(bytes: Uint8Array) => void>(); private constructor(loro: LoroDoc) { this.loro = loro; loro.subscribeLocalUpdates((bytes) => this.#emitUpdate(bytes)); } static create(text = ""): Doc { const doc = new Doc(new LoroDoc()); if (text) doc.write(text); return doc; } /** Loads a snapshot or update produced by `export`. */ static load(bytes: Uint8Array): Doc { return new Doc(LoroDoc.fromSnapshot(bytes)); } get text(): string { return this.content().toString(); } read(): Snapshot { return { text: this.text, version: this.loro.frontiers() as Version }; } write(text: string, { base }: WriteOptions = {}): WriteResult { const before = this.text; if (!base) { this.content().update(text); this.loro.commit(); return { version: this.loro.frontiers() as Version, changed: changedRange(before, this.text) }; } const fork = this.loro.forkAt(base); fork.getText(TEXT_KEY).update(text); fork.commit(); // An import is not a local update to Loro, so the fork's changes are emitted here. const bytes = fork.export({ mode: "update", from: this.loro.frontiersToVV(base) }); this.loro.import(bytes); this.#emitUpdate(bytes); return { version: fork.frontiers() as Version, changed: changedRange(before, this.text) }; } export(mode: "snapshot" | "shallow" = "snapshot"): Uint8Array { if (mode === "shallow") { return this.loro.export({ mode: "shallow-snapshot", frontiers: this.loro.oplogFrontiers() }); } return this.loro.export({ mode: "snapshot" }); } /** Calls `listener` after any local or remote change. */ onChange(listener: (doc: Doc) => void): Unsubscribe { return this.loro.subscribe(() => listener(this)); } /** Calls `listener` with the bytes of every change made on this replica, for sending to peers. */ onUpdate(listener: (bytes: Uint8Array) => void): Unsubscribe { this.#updateListeners.add(listener); return () => this.#updateListeners.delete(listener); } #emitUpdate(bytes: Uint8Array): void { for (const listener of this.#updateListeners) listener(bytes); } private content(): LoroText { return this.loro.getText(TEXT_KEY); } }