Rebasely Beta: data may be purged

examples/live-notes Public

TypeScript · 118 lines · 5.3 KiB · at 70b1506

// 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;}// One op with the effect of `a` followed by `b`, or null when they touch separate places.// Editors use it to fold a burst of keystrokes into a single op while one is in flight.export function compose(a: Op, b: Op): Op | null {  if (a.type === "insert") {    const at = b.pos - a.pos;    if (at < 0 || at > a.text.length) return null;    if (b.type === "insert") return insert(a.pos, a.text.slice(0, at) + b.text + a.text.slice(at));    // Deleting part of what was just typed.    if (at + b.count > a.text.length) return null;    return insert(a.pos, a.text.slice(0, at) + a.text.slice(at + b.count));  }  if (b.type !== "delete") return null;  if (b.pos === a.pos) return del(a.pos, a.count + b.count); // forward delete, repeated  if (b.pos + b.count === a.pos) return del(b.pos, a.count + b.count); // backspace, repeated  return null;}