/** * 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, pos: number, level: 0 | 1 | 2 | 3 | 4 | 5 | 6, ): A.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, ): 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 } }) }