Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336// src/editor/footnote-insertion.ts/** * Pure transform for the footnote-insertion command + toolbar action. * * Pieces: * - `pickNextIntegerFootnoteId(text)`: scans existing refs AND defs and * returns the lowest unused positive integer id. Named footnotes * (`[^foo]`) are ignored — they form a separate namespace. Refs/defs * inside fenced or inline code blocks are skipped (so a tutorial * example in a code block doesn't reserve real ids). * - `findFootnotesSection(text)`: returns `{ sectionStart, sectionEnd }` * marking the body of the LAST `## Footnotes` heading that is NOT * inside a code block, or null. Tolerates CRLF line endings. * - `isInsideFootnoteDef(text, offset)`: detects whether `offset` falls * within any `[^id]: …` def block (used to guard the toolbar/command * against nesting refs inside def bodies). * - `insertFootnote(text, selStart, selEnd)`: the headline transform. * Returns a `TextTransformResult` whose `changes` is a single * `ChangeSpec` or a `ChangeSpec[]` so all edits land in one * transaction. */import type { ChangeSpec } from '@codemirror/state'import { FOOTNOTE_REF_RE, FOOTNOTE_DEF_LINE_RE, FOOTNOTE_CONTINUATION_RE, buildCodeRanges, isInRanges,} from '../services/metadata/extract-metadata'import type { TextTransformResult } from './source-formatting'
const POSITIVE_INTEGER_RE = /^[1-9]\d*$/
/** * Scan refs AND defs for integer-only ids and return the lowest unused * positive integer. Named ids (`[^foo]`) and leading-zero ids (`[^01]`) * are filtered out. Refs/defs whose offsets fall inside fenced or inline * code blocks are SKIPPED — a tutorial example inside `` ``` `` no longer * reserves an integer id (review #3). */export function pickNextIntegerFootnoteId(text: string): number { const used = new Set<number>() const codeRanges = buildCodeRanges(text)
// Re-instantiate from .source: the exported regexes carry /g, which // is stateful between matchAll calls. const refRe = new RegExp(FOOTNOTE_REF_RE.source, 'g') for (const m of text.matchAll(refRe)) { if (!isInRanges(m.index, codeRanges)) { const id = m[1] if (POSITIVE_INTEGER_RE.test(id)) used.add(Number(id)) } }
const defRe = new RegExp(FOOTNOTE_DEF_LINE_RE.source, 'gm') for (const m of text.matchAll(defRe)) { if (!isInRanges(m.index, codeRanges)) { const id = m[1] if (POSITIVE_INTEGER_RE.test(id)) used.add(Number(id)) } }
let candidate = 1 while (used.has(candidate)) candidate++ return candidate}
/** * Locate the LAST `## Footnotes` heading line in the document and return * the byte range of the section's BODY (everything between the heading * line and the next heading of equal-or-lower level, or EOF). * * Returns null if no `## Footnotes` heading exists OUTSIDE a fenced or * inline code block (review #3 — silently splicing defs into a code * block on a tutorial doc is a real corruption bug; we now skip * code-block-internal headings entirely). * * Tolerates CRLF line endings (review #2): the heading regex permits * an optional `\r` before line end, and the section-start advance skips * a trailing `\r\n` rather than just `\n`. * * `sectionStart`: offset of the first character of the line AFTER the * heading line. (If the heading is on the last line with no trailing * newline, this equals `text.length`.) * `sectionEnd`: offset where the section's body ENDS — defined as the * start of the next h1 or h2 line (`/^#{1,2}[ \t]/m`), or `text.length` * if no such terminator exists. h3+ headings DO NOT terminate the * section (they're sub-sections within Footnotes). * * Why the LAST occurrence: if a doc somehow has two `## Footnotes` * sections, newer defs cluster nearer the end of the doc — pick the * later section so appends land where the user expects. */export interface FootnotesSection { sectionStart: number sectionEnd: number}
// `\r?$` rather than `$` so the heading regex matches `## Footnotes\r\n`// under CRLF. The `m` flag makes `$` match before `\n` only; without the// optional `\r`, a trailing `\r` would prevent the match.const FOOTNOTES_HEADING_RE = /^##[ \t]+footnotes[ \t]*\r?$/gimconst NEXT_TERMINATOR_RE = /^#{1,2}[ \t]/gm
export function findFootnotesSection(text: string): FootnotesSection | null { const codeRanges = buildCodeRanges(text) const re = new RegExp(FOOTNOTES_HEADING_RE.source, 'gim') let lastMatch: RegExpExecArray | null = null let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { // Skip headings inside code blocks (review #3). if (isInRanges(m.index, codeRanges)) continue lastMatch = m } if (!lastMatch) return null
// sectionStart: skip past the heading line's terminating newline (if // any). The match's `\r?` captured the `\r` if present, so the heading // text spans up to but not including `\n`. Advance past the `\n` too. let sectionStart = lastMatch.index + lastMatch[0].length if (text.charCodeAt(sectionStart) === 0x0a /* \n */) sectionStart += 1
// sectionEnd: walk forward looking for the next h1/h2 heading line. // Use a fresh stateful regex starting at sectionStart. const term = new RegExp(NEXT_TERMINATOR_RE.source, 'gm') term.lastIndex = sectionStart // Skip terminators that fall inside code blocks too. let next: RegExpExecArray | null = null let nextCandidate: RegExpExecArray | null while ((nextCandidate = term.exec(text)) !== null) { if (isInRanges(nextCandidate.index, codeRanges)) continue next = nextCandidate break } const sectionEnd = next ? next.index : text.length
return { sectionStart, sectionEnd }}
/** * Detect whether `offset` falls within any `[^id]: …` def block. Used * by the toolbar/command surface to no-op (with a Notice) when the user * tries to insert a footnote with the cursor inside another footnote's * body — see D8. * * Implementation mirrors `extract-metadata.ts`'s footnote pass: line-grammar * walk that absorbs continuation lines indented with 4+ spaces or one tab. * For our purpose we only need the start/end offsets, not the parsed body. * * Imports `FOOTNOTE_CONTINUATION_RE` from `extract-metadata.ts` (review * #9 — was previously a local duplicate). */export function isInsideFootnoteDef(text: string, offset: number): boolean { const defRe = new RegExp(FOOTNOTE_DEF_LINE_RE.source, 'gm') let m: RegExpExecArray | null while ((m = defRe.exec(text)) !== null) { const startOffset = m.index // Walk forward to find this def's end, absorbing continuation lines. // Find the end of the first line. let lineEnd = text.indexOf('\n', startOffset) if (lineEnd === -1) lineEnd = text.length let endOffset = lineEnd let cursor = lineEnd + 1 while (cursor < text.length) { let nextLineEnd = text.indexOf('\n', cursor) if (nextLineEnd === -1) nextLineEnd = text.length const lineContent = text.slice(cursor, nextLineEnd) if (lineContent === '') break if (!FOOTNOTE_CONTINUATION_RE.test(lineContent)) break endOffset = nextLineEnd cursor = nextLineEnd + 1 } // Round-3 M1: predicate uses INCLUSIVE bounds on both sides — // `offset >= startOffset && offset <= endOffset`. The cursor sitting // at the offset of `[` (column 0 of the def line) IS on the def // line; reporting false there would let `insertFootnote` splice a // fresh `[^N]` ref at that offset, producing `[^N][^id]: body`, // corrupting the def line. To insert ABOVE the def block, users // place the cursor on the preceding blank line — where this // predicate correctly returns false (offset < startOffset). if (offset >= startOffset && offset <= endOffset) return true } return false}
/** * Headline transform: insert a footnote ref at (or wrapping) the * selection, plus a def-stub appended at the END of the existing * `## Footnotes` section (or a freshly-created one at end-of-doc). * * Returns `null` if the cursor is inside an existing footnote def body * (callers should show a Notice and not dispatch). See D8. * * Single CM6 dispatch — `result.changes` is a `ChangeSpec | ChangeSpec[]`. * * Cursor lands at the END of the inserted def body: `[^N]: <body>|`. * * Selection handling: * - Reverse selections (anchor > head) are normalized at entry. * - Multi-line selections collapse `\n` (or `\r\n`) to a single space * to keep the def body well-formed (D10). */export function insertFootnote( text: string, selectionStart: number, selectionEnd: number,): TextTransformResult | null { // D9: normalize reverse selection. if (selectionStart > selectionEnd) { [selectionStart, selectionEnd] = [selectionEnd, selectionStart] }
// D8: guardrail against nesting refs inside def bodies. if (isInsideFootnoteDef(text, selectionStart) || isInsideFootnoteDef(text, selectionEnd)) { return null }
const id = pickNextIntegerFootnoteId(text) const ref = `[^${id}]` const rawBody = text.slice(selectionStart, selectionEnd) // D10: collapse newlines to single spaces so def-body is well-formed. const body = rawBody.replace(/\r?\n/g, ' ') // Def-stub: `[^N]: <body>` with NO trailing newline. const defStub = `[^${id}]: ${body}`
const section = findFootnotesSection(text)
// Net change in doc length contributed by the inline ref insert. const refDelta = ref.length - (selectionEnd - selectionStart)
if (section !== null) { // CASE A: Footnotes section exists. Insert at section END (NOT // text.length) so content after the section is preserved. let insertPos = section.sectionEnd // Walk back over trailing newlines that belong to the section. while (insertPos > section.sectionStart && text.charCodeAt(insertPos - 1) === 0x0a) { insertPos -= 1 }
let prefix: string if (insertPos === section.sectionStart) { // (i) Empty section. Compute prefix from the chars preceding the // section body (i.e. the heading line). `findFootnotesSection` // consumes at most ONE '\n' after the heading, so `preceding` // either ends with '\n' (heading followed by newline) or has no // trailing newline (heading at EOF). Two cases only. const preceding = text.slice(0, section.sectionStart) if (preceding.endsWith('\n')) { prefix = '\n' } else { // Heading at EOF without trailing newline (N6 case). prefix = '\n\n' } } else { // (ii) Non-empty section. Append on the next line. prefix = '\n' }
const changes: ChangeSpec = [ { from: selectionStart, to: selectionEnd, insert: ref }, { from: insertPos, to: insertPos, insert: `${prefix}${defStub}` }, ] // Final cursor: end of the inserted def body. Compute carefully. // Three cases for the relationship between the ref insert range // [selectionStart, selectionEnd] and insertPos: // (a) selectionEnd <= insertPos: ref insert is BEFORE insertPos; // insertPos shifts by refDelta in the new doc. // (b) selectionStart >= insertPos: ref insert is AFTER insertPos // (cursor in Appendix, post-Footnotes — review #1); insertPos // does NOT shift; the def-stub insert lands first in new-doc // coords, so the cursor at end-of-def-stub is `insertPos + // prefix.length + defStub.length` regardless of refDelta. // (c) Otherwise the ref range straddles insertPos — impossible // here because `findFootnotesSection` returns a section that // cannot overlap the selection's intent (the guardrail // `isInsideFootnoteDef` already rejects selections that touch // def bodies; selection that straddles the section heading // isn't a meaningful user gesture and is treated by case (b) // semantics — the ref insert at selectionStart sits BEFORE // insertPos). const adjustedInsertPos = selectionEnd <= insertPos ? insertPos + refDelta : insertPos const finalCursor = adjustedInsertPos + prefix.length + defStub.length return { changes, selectionStart: finalCursor, selectionEnd: finalCursor, } }
// CASE B: No Footnotes section. Create one at end-of-doc. if (selectionEnd === text.length) { // FUSE: collapse ref + section into one insert. The text immediately // preceding the new heading in the final doc is // `text.slice(0, selectionStart) + ref`, which is guaranteed to end // with `]` (the closing bracket of the ref) — i.e. NOT a newline. // So the lead-in between the ref and the heading is unconditionally // `'\n\n'` (review #6 — was previously a function call that always // returned `'\n\n'` in this branch; inline the constant). const leadIn = '\n\n' const sectionInsert = `${leadIn}## Footnotes\n\n${defStub}` const fused: ChangeSpec = { from: selectionStart, to: selectionEnd, insert: `${ref}${sectionInsert}`, } const finalCursor = selectionStart + ref.length + sectionInsert.length return { changes: fused, selectionStart: finalCursor, selectionEnd: finalCursor, } }
// SPLIT: ref is mid-doc; section append is at end-of-doc. The lead-in // depends on what already terminates the doc. // SPLIT requires selectionEnd < text.length, so the document is non-empty. const computeLeadIn = (precedingText: string): string => { if (precedingText.endsWith('\n\n')) return '' if (precedingText.endsWith('\n')) return '\n' return '\n\n' } const leadIn = computeLeadIn(text) const sectionInsert = `${leadIn}## Footnotes\n\n${defStub}` const changes: ChangeSpec = [ { from: selectionStart, to: selectionEnd, insert: ref }, { from: text.length, to: text.length, insert: sectionInsert }, ] const finalCursor = text.length + refDelta + sectionInsert.length return { changes, selectionStart: finalCursor, selectionEnd: finalCursor, }}