Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101import * as Automerge from '@automerge/automerge';import * as diffLib from 'diff';
/** * Character-level CRDT text utilities, ported from pushwork * (`pushwork/src/utils/text-diff.ts`) to keep Automerge document behaviour — * and therefore the on-the-wire change ops — matching pushwork as closely as * possible. */
/** * Read content from an Automerge document field, normalizing legacy * ImmutableString values to plain strings for backwards compatibility. * * Returns `string | Uint8Array | null` (binary content is passed through). */export function readDocContent(content: unknown): string | Uint8Array | null { if (content == null) return null; if (typeof content === 'string') return content; if (content instanceof Uint8Array) return content; // Legacy ImmutableString — convert to plain string. if (Automerge.isImmutableString(content)) return content.toString(); return null;}
/** * Update text content on an Automerge document property inside a change * callback. * * If the existing value is already a collaborative text string, we diff and * splice for minimal CRDT operations. If the existing value is a legacy * ImmutableString (or undefined) we can't splice into it, so we assign the * whole string, which converts the field to a collaborative text CRDT. */export function updateTextContent( // eslint-disable-next-line @typescript-eslint/no-explicit-any doc: any, path: Automerge.Prop[], newContent: string,): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const target = path.reduce((obj: any, key) => obj?.[key], doc);
if (typeof target === 'string') { // Already a collaborative text string — diff and splice. spliceText(doc, path, target, newContent); } else { // Legacy ImmutableString, undefined, or other — assign directly. // This converts the field to a collaborative text CRDT. // eslint-disable-next-line @typescript-eslint/no-explicit-any let obj: any = doc; for (let i = 0; i < path.length - 1; i++) { obj = obj[path[i]!]; } obj[path[path.length - 1]!] = newContent; }}
/** * Apply a character-level diff between `oldContent` and `newContent` as * Automerge splice operations on the given property path, preserving the * collaborative text CRDT structure. */export function spliceText( // eslint-disable-next-line @typescript-eslint/no-explicit-any doc: any, path: Automerge.Prop[], oldContent: string, newContent: string,): void { if (oldContent === newContent) return;
// Fast path: old empty — insert everything. if (oldContent === '') { Automerge.splice(doc, path, 0, 0, newContent); return; }
// Fast path: new empty — delete everything. if (newContent === '') { Automerge.splice(doc, path, 0, oldContent.length); return; }
const changes = diffLib.diffChars(oldContent, newContent);
let pos = 0; for (const part of changes) { if (part.removed) { Automerge.splice(doc, path, pos, part.value.length); // Don't advance pos — text shifted left after deletion. } else if (part.added) { Automerge.splice(doc, path, pos, 0, part.value); pos += part.value.length; } else { // Unchanged — advance the cursor. pos += part.value.length; } }}