/** * Reams semantic operations on top of Automerge text + marks. * * Every semantic op writes BOTH the markdown text AND a corresponding mark * in the same Automerge.change callback. Raw text ops only write text. * * Marks use Automerge's Peritext-based marks API. * * Conventions: * - text lives at doc.content (a plain string, mutated via Automerge.splice) * - bold mark name: "bold", value: true * - bold expand: "after" — typing immediately after a bold range extends bold * (matches Peritext Example 8 / common rich-text editor behavior) * ...actually we use "both" per Automerge docs example for bold; revisit if needed. */ import * as A from '@automerge/automerge' export type Doc = { content: string } /** * Toggle-bold a range, mimicking Reams' Cmd+B behavior. * * If the range is already wrapped in **…** literal text, unwrap (remove delimiters) * AND unmark. * * If the range is not wrapped, insert ** before and after AND add a bold mark * on the content range (between the delimiters). * * NB: range positions are in the doc's current content string, NOT counting marks. */ export function toggleBold( doc: A.Doc, start: number, end: number, ): A.Doc { return A.change(doc, (d) => { const text = d.content // Detect already-wrapped (text-bold). If so, unwrap. const before = text.slice(Math.max(0, start - 2), start) const after = text.slice(end, end + 2) if (before === '**' && after === '**') { // Unwrap with "rebuild" strategy: splice out the entire **content** range // (opening delim + content + closing delim) and reinsert "content" as // brand-new characters. New characters get fresh CRDT IDs, so they are // NOT part of the bold mark's anchored range. The mark stays attached // to the now-tombstoned characters, where it cannot affect rendering. // // This makes unwrap robust against concurrent "extend" edits that would // otherwise keep the mark alive (Peritext rule). const content = text.slice(start, end) // Single splice replacing [start-2 .. end+2) with `content`. A.splice(d, ['content'], start - 2, end + 2 - (start - 2), content) // Also call unmark, defensively — for the case where there are no // concurrent extensions, the mark is on the old range and unmark // clears it. With rebuild, even if unmark is partial, the new chars // are outside the mark. A.unmark( d, ['content'], { start: start - 2, end: end - 2, expand: 'both' }, 'bold', ) return } // Wrap: insert opening ** at start, closing at end+2 (positions in the post-insert text). A.splice(d, ['content'], start, 0, '**') A.splice(d, ['content'], end + 2, 0, '**') // The wrapped content is now at [start+2 .. end+2). Add the mark there. A.mark( d, ['content'], { start: start + 2, end: end + 2, expand: 'both' }, 'bold', true, ) }) } /** * Raw text insertion (no semantic op, no mark). * Models typed characters, plain paste, etc. */ export function insertText( doc: A.Doc, pos: number, text: string, ): A.Doc { return A.change(doc, (d) => { A.splice(d, ['content'], pos, 0, text) }) } /** * Raw text deletion (no semantic op, no mark). * Models raw Backspace, selection delete, etc. */ export function deleteText( doc: A.Doc, from: number, length: number, ): A.Doc { return A.change(doc, (d) => { A.splice(d, ['content'], from, length, '') }) } /** * Initialize a doc with starting text and no marks. */ export function makeDoc(initial: string): A.Doc { return A.from({ content: initial }) } /** * Helper: get current text. */ export function text(doc: A.Doc): string { return doc.content } /** * Helper: get all active marks at the current state. */ export function marks(doc: A.Doc) { return A.marks(doc, ['content']) }