Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
5.9 kB · 174 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175/** * Block-level semantic operations on Automerge text + marks. * * Block marks differ from inline marks in three important ways: * * 1. **Line-anchored.** A heading is "this line is H1," not "characters * [4..15) are bold." Block marks cover a *line range* (start of first * line of block to start of next block / end of doc). * * 2. **Attribute-carrying.** Headings have a level (1-6). Ordered list * items have a number. Code fences have a language. Callouts have a * type. These attributes are encoded in the mark's *value* — serialized * as JSON string per Automerge constraint (marks values are primitives). * * 3. **No paired delimiters.** A heading has `# ` at line start; no closing * delimiter. The "end" of the block is implicit (end of line, or for * multi-line blocks, start of next block). * * For this exploration we start with headings — simplest block, clearest * analog to bold. * * Heading mark shape: * { name: 'heading', value: '{"level":1}', start: lineStart, end: lineEnd } * * lineStart = position of first non-prefix char of heading text * lineEnd = position immediately before `\n` (or doc end) * * Why anchor on content positions (not the `#`s): * Same reason as inline: the content is the stable thing across edits; the * delimiters can be rewritten by the reconciler. */import * as A from '@automerge/automerge'import type { Doc } from './ops.js'
export interface HeadingAttrs { level: 1 | 2 | 3 | 4 | 5 | 6}
/** Find the line containing position `pos`. Returns {lineStart, lineEnd}. */function lineBounds(text: string, pos: number): { lineStart: number; lineEnd: number } { let lineStart = pos while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart-- let lineEnd = pos while (lineEnd < text.length && text[lineEnd] !== '\n') lineEnd++ return { lineStart, lineEnd }}
/** * Detect an existing heading prefix on a line. * Returns the number of `#` chars and the position of the first content * char (after the `#`s and one optional space). */function detectHeadingPrefix( text: string, lineStart: number,): { level: number; contentStart: number } | null { let i = lineStart let hashCount = 0 while (i < text.length && text[i] === '#') { hashCount++ i++ } if (hashCount < 1 || hashCount > 6) return null // Must be followed by a space to count as ATX heading if (text[i] !== ' ') return null return { level: hashCount, contentStart: i + 1 }}
/** * Set the heading level on the line containing `pos`. Models Reams' * heading-cycle action (sort of) — but with an explicit target level. * * - If line has no heading: insert `# ` (level 1) or `## `, etc., and add mark. * - If line has heading of a different level: replace the prefix `##` etc. * and update the mark's attrs. * - If line has heading of the same level: remove the prefix and the mark. */export function setHeadingLevel( doc: A.Doc<Doc>, pos: number, level: 0 | 1 | 2 | 3 | 4 | 5 | 6,): A.Doc<Doc> { return A.change(doc, (d) => { const text = d.content const { lineStart, lineEnd } = lineBounds(text, pos) const existing = detectHeadingPrefix(text, lineStart)
if (existing) { if (level === existing.level) { // Remove the heading entirely. Splice out the `# ` prefix and unmark. // Pre-splice mark range: [existing.contentStart .. lineEnd) const prefixLen = existing.contentStart - lineStart A.unmark( d, ['content'], { start: existing.contentStart, end: lineEnd, expand: 'both', }, 'heading', ) A.splice(d, ['content'], lineStart, prefixLen, '') return } if (level === 0) { // Caller asked to remove heading explicitly. const prefixLen = existing.contentStart - lineStart A.unmark( d, ['content'], { start: existing.contentStart, end: lineEnd, expand: 'both', }, 'heading', ) A.splice(d, ['content'], lineStart, prefixLen, '') return } // Different level: replace the `#`-run and update mark. const oldHashRun = existing.level A.splice(d, ['content'], lineStart, oldHashRun, '#'.repeat(level)) // Content positions shift by (level - oldHashRun). const shift = level - oldHashRun const newContentStart = existing.contentStart + shift const newLineEnd = lineEnd + shift // Remove old mark and add new with new level. A.unmark( d, ['content'], { start: newContentStart, end: newLineEnd, expand: 'both' }, 'heading', ) A.mark( d, ['content'], { start: newContentStart, end: newLineEnd, expand: 'both' }, 'heading', JSON.stringify({ level }), ) return }
// No existing heading. If level is 0 (remove), no-op. if (level === 0) return
// Add a heading: insert `# ` at line start, mark the content. const prefix = '#'.repeat(level) + ' ' A.splice(d, ['content'], lineStart, 0, prefix) const contentStart = lineStart + prefix.length const newLineEnd = lineEnd + prefix.length A.mark( d, ['content'], { start: contentStart, end: newLineEnd, expand: 'both' }, 'heading', JSON.stringify({ level }), ) })}
/** Read heading marks (parsed). */export function readHeadings( doc: A.Doc<Doc>,): Array<{ start: number; end: number; level: number }> { return A.marks(doc, ['content']) .filter((m) => m.name === 'heading' && typeof m.value === 'string') .map((m) => { const v = JSON.parse(m.value as string) as HeadingAttrs return { start: m.start, end: m.end, level: v.level } })}