Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
4.0 kB · 126 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127/** * 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<Doc>, start: number, end: number,): A.Doc<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<Doc>, pos: number, text: string,): A.Doc<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<Doc>, from: number, length: number,): A.Doc<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<Doc> { return A.from({ content: initial })}
/** * Helper: get current text. */export function text(doc: A.Doc<Doc>): string { return doc.content}
/** * Helper: get all active marks at the current state. */export function marks(doc: A.Doc<Doc>) { return A.marks(doc, ['content'])}