import * 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; } } }