examples/live-notes Public
// Edit operations on a plain-text note, and the transform that lets two editors' concurrent// edits land in the same order everywhere. Offsets count UTF-16 code units, the same units a// textarea's selectionStart uses, so the browser and the room agree on every position.//// There are only two kinds of operation. A replacement (typing over a selection) is a delete// followed by an insert.export type Insert = { type: "insert"; pos: number; text: string };export type Delete = { type: "delete"; pos: number; count: number };export type Op = Insert | Delete;export class OpError extends Error {}export function insert(pos: number, text: string): Insert { return { type: "insert", pos, text };}export function del(pos: number, count: number): Delete { return { type: "delete", pos, count };}// An op that changes nothing: an empty insert, or a delete of zero characters. Transform// produces these when another edit already removed the text an op was about.export function isNoop(op: Op): boolean { return op.type === "insert" ? op.text.length === 0 : op.count === 0;}// Applies an op to a document. Throws OpError if the op does not fit the document, which means// the sender and the room disagree about the text; the caller should resync that sender.export function apply(doc: string, op: Op): string { if (!Number.isInteger(op.pos) || op.pos < 0 || op.pos > doc.length) { throw new OpError(`${op.type} at ${op.pos} is outside a document of length ${doc.length}`); } if (op.type === "insert") return doc.slice(0, op.pos) + op.text + doc.slice(op.pos); if (!Number.isInteger(op.count) || op.count < 0 || op.pos + op.count > doc.length) { throw new OpError(`delete of ${op.count} at ${op.pos} runs past a document of length ${doc.length}`); } return doc.slice(0, op.pos) + doc.slice(op.pos + op.count);}// Rewrites `op` so it can be applied after `other`, where both were written against the same// document. `otherFirst` says which of the two the room sequenced first. It only matters when// both insert at the same offset: the one sequenced first ends up first in the text, so the room// passes true (the logged op came first) and an editor passes false when it moves a remote op// past its own unacknowledged edits.//// Two rules keep this small:// - An insert that lands strictly inside a range someone else deleted is dropped (it becomes a// no-op), and the delete grows to swallow it. Both orders then agree on the result.// - Deletes that overlap only remove what is still there.export function transform(op: Op, other: Op, otherFirst: boolean): Op { if (op.type === "insert") { if (other.type === "insert") { const shift = other.pos < op.pos || (other.pos === op.pos && otherFirst); return shift ? insert(op.pos + other.text.length, op.text) : op; } const end = other.pos + other.count; if (op.pos <= other.pos) return op; if (op.pos >= end) return insert(op.pos - other.count, op.text); return insert(other.pos, ""); } const end = op.pos + op.count; if (other.type === "insert") { if (other.pos <= op.pos) return del(op.pos + other.text.length, op.count); if (other.pos >= end) return op; return del(op.pos, op.count + other.text.length); } const otherEnd = other.pos + other.count; if (end <= other.pos) return op; if (op.pos >= otherEnd) return del(op.pos - other.count, op.count); const overlap = Math.min(end, otherEnd) - Math.max(op.pos, other.pos); return del(Math.min(op.pos, other.pos), op.count - overlap);}// Moves `op` past a run of ops the room sequenced before it, in order.export function transformPast(op: Op, ops: readonly Op[]): Op { let out = op; for (const other of ops) out = transform(out, other, true); return out;}// The ops that turn `before` into `after`, found by trimming the common prefix and suffix. A// textarea's input event changes one contiguous range, so this is exact for typing, pasting// and deleting a selection.export function diff(before: string, after: string): Op[] { let start = 0; const max = Math.min(before.length, after.length); while (start < max && before[start] === after[start]) start++; let endBefore = before.length; let endAfter = after.length; while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) { endBefore--; endAfter--; } const ops: Op[] = []; if (endBefore > start) ops.push(del(start, endBefore - start)); if (endAfter > start) ops.push(insert(start, after.slice(start, endAfter))); return ops;}