Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191/** * Source mode formatting utilities. * These functions operate on raw text strings and return the modified text * along with updated cursor positions. */import type { ChangeSpec } from '@codemirror/state'import { QUOTE_LINE_RE } from './quote-syntax'import { coreInlineSpecNameForDelimiter, type InlineSemanticChange,} from './inline-semantic-ops'
export interface TextTransformResult { /** * The change(s) to apply. Most transforms return a single * `{from, to, insert}`. The footnote-insertion transform may return * an ARRAY of two change specs (inline ref + def-stub append) so * `view.dispatch({ changes })` lands them in one transaction — * matching the single-undo invariant. CM6's `ChangeSpec` accepts * both shapes natively. */ changes: ChangeSpec selectionStart: number selectionEnd: number semantic?: InlineSemanticChange}
function createAddMarkSemantic( delimiter: string, selectionStart: number, selectionEnd: number,): InlineSemanticChange | undefined { const specName = coreInlineSpecNameForDelimiter(delimiter) if (!specName) return undefined const offset = delimiter.length return { ops: [ { kind: 'add-mark', specName, range: { start: selectionStart + offset, end: selectionEnd + offset }, attrs: null, }, ], }}
function createUnmarkSemantic( delimiter: string, rangeStart: number, rangeEnd: number,): InlineSemanticChange | undefined { if (rangeStart >= rangeEnd) return undefined const specName = coreInlineSpecNameForDelimiter(delimiter) if (!specName) return undefined return { ops: [ { kind: 'unmark', specName, range: { start: rangeStart, end: rangeEnd }, }, ], }}
function createPairDeletionSemantic( delimiter: string, rangeStart: number, rangeEnd: number, originalContent: string,): InlineSemanticChange | undefined { const unmark = createUnmarkSemantic(delimiter, rangeStart, rangeEnd) if (!unmark) return undefined if (delimiter.startsWith('`') && delimiter.length > 1) { const survivingWidth = delimiter.length - 1 return hasExactDelimiterRun(originalContent, '`', survivingWidth) ? unmark : undefined } if (delimiter !== '**' && delimiter !== '__') return unmark return { ops: [ ...unmark.ops, { kind: 'add-mark', specName: 'italic', range: { start: rangeStart, end: rangeEnd }, attrs: null, }, ], }}
function hasExactDelimiterRun(text: string, delimiter: string, width: number): boolean { let pos = 0 while (pos < text.length) { if (text[pos] !== delimiter) { pos += 1 continue } const start = pos while (pos < text.length && text[pos] === delimiter) pos += 1 if (pos - start === width) return true } return false}
function contentBetweenDelimiterRuns( text: string, firstPos: number, secondPos: number, delimiter: string,): string { if ([...text.slice(firstPos, secondPos + 1)].every(ch => ch === delimiter)) return '' let openingEnd = firstPos while (openingEnd + 1 < secondPos && text[openingEnd + 1] === delimiter) openingEnd += 1 let closingStart = secondPos while (closingStart - 1 > openingEnd && text[closingStart - 1] === delimiter) closingStart -= 1 return text.slice(openingEnd + 1, closingStart)}
function symmetricRunLengthOnLine(text: string, pos: number, ch: string): number { let start = pos while (start > 0 && text[start - 1] === ch && text[start - 1] !== '\n') { start -= 1 } let end = pos + 1 while (end < text.length && text[end] === ch && text[end] !== '\n') { end += 1 } return end - start}
function inferSymmetricDelimiter(fullText: string, deletedPos: number, pairPos: number): string { const ch = fullText[deletedPos] if (ch === '~') return '~~' if (ch === '`') { const deletedRunLength = symmetricRunLengthOnLine(fullText, deletedPos, ch) const pairRunLength = symmetricRunLengthOnLine(fullText, pairPos, ch) return deletedRunLength === pairRunLength ? ch.repeat(deletedRunLength) : '' } if (ch !== '*' && ch !== '_') return ch
const deletedRunLength = symmetricRunLengthOnLine(fullText, deletedPos, ch) const pairRunLength = symmetricRunLengthOnLine(fullText, pairPos, ch) if (deletedRunLength === 1 && pairRunLength === 1) return ch if (deletedRunLength === 2 && pairRunLength === 2) return ch.repeat(2) if (ch === '_' && deletedRunLength !== pairRunLength) { return ch.repeat(Math.min(deletedRunLength, pairRunLength, 2)) } return ''}
function getSurvivingContentRange( delimiter: string, replacementStart: number, replacementText: string,): { start: number; end: number } { if (delimiter.length <= 1) { return { start: replacementStart, end: replacementStart + replacementText.length } } const ch = delimiter[0] let leading = 0 while (leading < replacementText.length && replacementText[leading] === ch) leading += 1 let trailing = 0 let pos = replacementText.length - 1 while (pos >= leading && replacementText[pos] === ch) { trailing += 1 pos -= 1 } return { start: replacementStart + leading, end: replacementStart + replacementText.length - trailing, }}
/** * Wrap selected text with a delimiter (e.g., ** for bold, * for italic). * If no text is selected, inserts the delimiter pair and positions cursor between. */export function wrapSelection( text: string, selectionStart: number, selectionEnd: number, delimiter: string): TextTransformResult { const dLen = delimiter.length
// --- Unwrap detection --- // Check if the text immediately surrounding the selection (or cursor) matches // the delimiter, indicating the selection is already formatted.
if (selectionStart === selectionEnd) { // Collapsed cursor: check if cursor sits between delimiter pair (e.g., **|**) const beforeCursor = text.slice(selectionStart - dLen, selectionStart) const afterCursor = text.slice(selectionStart, selectionStart + dLen) if ( selectionStart >= dLen && selectionStart + dLen <= text.length && beforeCursor === delimiter && afterCursor === delimiter ) { // Remove both delimiters const cursorPos = selectionStart - dLen return { changes: { from: selectionStart - dLen, to: selectionStart + dLen, insert: '' }, selectionStart: cursorPos, selectionEnd: cursorPos, } }
// No unwrap: insert delimiter pair and position cursor between const cursorPos = selectionStart + dLen return { changes: { from: selectionStart, to: selectionEnd, insert: `${delimiter}${delimiter}` }, selectionStart: cursorPos, selectionEnd: cursorPos, } }
// Range selection: check if selection is already wrapped const beforeSelection = text.slice(selectionStart - dLen, selectionStart) const afterSelection = text.slice(selectionEnd, selectionEnd + dLen) if ( selectionStart >= dLen && selectionEnd + dLen <= text.length && beforeSelection === delimiter && afterSelection === delimiter ) { // Unwrap: remove delimiters around selection const selectedText = text.slice(selectionStart, selectionEnd) return { changes: { from: selectionStart - dLen, to: selectionEnd + dLen, insert: selectedText }, selectionStart: selectionStart - dLen, selectionEnd: selectionEnd - dLen, semantic: createUnmarkSemantic(delimiter, selectionStart - dLen, selectionEnd - dLen), } }
// Wrap selection with delimiter const selected = text.slice(selectionStart, selectionEnd) return { changes: { from: selectionStart, to: selectionEnd, insert: `${delimiter}${selected}${delimiter}` }, selectionStart: selectionStart + dLen, selectionEnd: selectionEnd + dLen, semantic: createAddMarkSemantic(delimiter, selectionStart, selectionEnd), }}
/** * Get the line containing the cursor position. * Returns the line's start index, end index, and content. */function getLineAtPosition(text: string, position: number): { lineStart: number lineEnd: number lineContent: string} { // Find line start let lineStart = position while (lineStart > 0 && text[lineStart - 1] !== '\n') { lineStart-- }
// Find line end let lineEnd = position while (lineEnd < text.length && text[lineEnd] !== '\n') { lineEnd++ }
return { lineStart, lineEnd, lineContent: text.slice(lineStart, lineEnd), }}
/** * Toggle a line prefix (e.g., "> " for blockquote, "- " for bullet list). * If the line already starts with the prefix, remove it; otherwise add it. */export function toggleLinePrefix( text: string, selectionStart: number, selectionEnd: number, prefix: string): TextTransformResult { const { lineStart, lineContent } = getLineAtPosition(text, selectionStart)
if (lineContent.startsWith(prefix)) { // Remove prefix return { changes: { from: lineStart, to: lineStart + prefix.length, insert: '' }, selectionStart: Math.max(lineStart, selectionStart - prefix.length), selectionEnd: Math.max(lineStart, selectionEnd - prefix.length), } }
// Add prefix return { changes: { from: lineStart, to: lineStart, insert: prefix }, selectionStart: selectionStart + prefix.length, selectionEnd: selectionEnd + prefix.length, }}
/** * Insert link template [text]() around selected text. * Cursor is positioned inside the parentheses for manual URL entry. */export function insertLinkTemplate( text: string, selectionStart: number, selectionEnd: number): TextTransformResult { const selected = text.slice(selectionStart, selectionEnd) const insert = `[${selected}]()` // Position cursor inside the parentheses const cursorPos = selectionStart + 1 + selected.length + 2 return { changes: { from: selectionStart, to: selectionEnd, insert }, selectionStart: cursorPos, selectionEnd: cursorPos, }}
/** * Insert a wikilink trigger `[[` at the cursor (or wrapping the * selection if any) so the EditorSuggest dispatcher fires its * autocomplete dropdown. * * Behavior: * - No selection: inserts `[[` at the cursor; cursor lands directly * after the `[[`. The user can then type to filter the dropdown * and press Enter to insert `[[target]]`. The matching `]]` is * supplied by the suggester's `selectSuggestion` handler — we * intentionally do NOT pre-insert `]]` here, because that would * end the wikilink trigger range (the suggester's `onTrigger` * regex requires no intervening `]]`). * - Selection: wraps the selection as `[[selection`. Same trailing * `]]` rationale — selectSuggestion appends the closing brackets * and any optional `|alias`. If the user dismisses with Escape, * they have a `[[partial` literal which they can finish manually. */export function insertWikilinkTrigger( text: string, selectionStart: number, selectionEnd: number): TextTransformResult { const selected = text.slice(selectionStart, selectionEnd) const insert = `[[${selected}` // Cursor lands at the end of the inserted text (after [[ + selection), // which is where the EditorSuggest dispatcher's onTrigger expects to // see the cursor for `[[…<cursor>` pattern matching. const cursorPos = selectionStart + insert.length return { changes: { from: selectionStart, to: selectionEnd, insert }, selectionStart: cursorPos, selectionEnd: cursorPos, }}
/** * Result from findFormattingPair for symmetric delimiters (*, **, ~~, `). * pairPos is the index of the matching character to remove. */interface SymmetricPairResult { pairPos: number type: 'symmetric'}
/** * Result from findFormattingPair for link open bracket [. * Removing [ means we also remove ](url). * removeStart..removeEnd (inclusive) is the range to remove. */interface LinkOpenPairResult { removeStart: number // position of ] removeEnd: number // position of ) (inclusive) type: 'link-open'}
/** * Result from findFormattingPair for link close paren ). * Removing ) means we also remove [text](. * removeStart..removeEnd (inclusive) is the range to remove. */interface LinkClosePairResult { removeStart: number // position of [ removeEnd: number // position of ( (inclusive) type: 'link-close'}
/** * Result from findFormattingPair for link close bracket ]. * Removing ] means we also remove [ and (url). * Same visual result as deleting [ — keep the link text. */interface LinkCloseBracketPairResult { openBracket: number // position of [ closeParen: number // position of ) type: 'link-close-bracket'}
/** * Result from findFormattingPair for link open paren (. * Removing ( means we also remove [text] and ). * Same visual result as deleting ) — keep the URL. */interface LinkOpenParenPairResult { openBracket: number // position of [ closeParen: number // position of ) type: 'link-open-paren'}
interface StructuralPairResult { pairPos: number type: 'structural'}
export type PairResult = | SymmetricPairResult | LinkOpenPairResult | LinkClosePairResult | LinkCloseBracketPairResult | LinkOpenParenPairResult | StructuralPairResult
/** * Find the matching formatting pair for a character at the given position. * Returns null if the character is not a formatting delimiter or has no match. * * For symmetric delimiters (*, _, ~, `): finds the matching delimiter using * left-flanking/right-flanking heuristics (CommonMark-inspired). * - Left-flanking run (non-whitespace after): acts as opening → search FORWARD for closing * - Right-flanking run (non-whitespace before): acts as closing → search BACKWARD for opening * - Both flanking (content on both sides): ambiguous → try backward first, then forward * For ** and ~~, matches the OUTERMOST of a run to the OUTERMOST of the opposite run, * and INNERMOST to INNERMOST. * * For link syntax: * - [ → finds ](url) pattern and returns range to remove; keeps link text * - ] → finds [text and (url) pattern; keeps link text (same result as [) * - ( → finds [text] and ) pattern; keeps URL (same result as )) * - ) → finds [text]( pattern and returns range to remove; keeps URL */export function findFormattingPair(text: string, pos: number): PairResult | null { const ch = text[pos] if (ch === undefined) return null
if (ch === '*' || ch === '_' || ch === '~' || ch === '`') { return findSymmetricPair(text, pos, ch) }
if (ch === '[') { return findLinkFromOpen(text, pos) ?? findStructuralPair(text, pos) }
if (ch === ']') { return findLinkFromCloseBracket(text, pos) ?? findStructuralPair(text, pos) }
if (ch === '(') { return findLinkFromOpenParen(text, pos) ?? findStructuralPair(text, pos) }
if (ch === ')') { return findLinkFromClose(text, pos) ?? findStructuralPair(text, pos) }
if (ch === '{' || ch === '}') return findStructuralPair(text, pos)
return null}
const STRUCTURAL_OPEN_TO_CLOSE: Readonly<Record<string, string>> = { '(': ')', '[': ']', '{': '}',}
const STRUCTURAL_CLOSE_TO_OPEN: Readonly<Record<string, string>> = { ')': '(', ']': '[', '}': '{',}
function findStructuralPair(text: string, pos: number): StructuralPairResult | null { const ch = text[pos] if (isEscaped(text, pos)) return null const closing = STRUCTURAL_OPEN_TO_CLOSE[ch] if (closing) { const stack = [ch] for (let index = pos + 1; index < text.length; index++) { const current = text[index] if (!STRUCTURAL_OPEN_TO_CLOSE[current] && !STRUCTURAL_CLOSE_TO_OPEN[current]) continue if (isEscaped(text, index)) continue if (STRUCTURAL_OPEN_TO_CLOSE[current]) { stack.push(current) } else if (STRUCTURAL_CLOSE_TO_OPEN[current]) { if (stack[stack.length - 1] !== STRUCTURAL_CLOSE_TO_OPEN[current]) return null stack.pop() if (stack.length === 0) return { pairPos: index, type: 'structural' } } } return null }
const opening = STRUCTURAL_CLOSE_TO_OPEN[ch] if (!opening) return null const stack = [ch] for (let index = pos - 1; index >= 0; index--) { const current = text[index] if (!STRUCTURAL_OPEN_TO_CLOSE[current] && !STRUCTURAL_CLOSE_TO_OPEN[current]) continue if (isEscaped(text, index)) continue if (STRUCTURAL_CLOSE_TO_OPEN[current]) { stack.push(current) } else if (STRUCTURAL_OPEN_TO_CLOSE[current]) { if (stack[stack.length - 1] !== STRUCTURAL_OPEN_TO_CLOSE[current]) return null stack.pop() if (stack.length === 0) return { pairPos: index, type: 'structural' } } } return null}
function isEscaped(text: string, pos: number): boolean { let backslashes = 0 for (let index = pos - 1; index >= 0 && text[index] === '\\'; index--) backslashes++ return backslashes % 2 === 1}
/** * Find the matching symmetric delimiter for *, ~, or `. * * Uses left-flanking/right-flanking heuristics to determine search direction: * - Left-flanking (non-whitespace/BOF after run end): run acts as OPENING → search forward * - Right-flanking (non-whitespace/EOF before run start): run acts as CLOSING → search backward * - Both (content on both sides, like `abc*word*def`): ambiguous → try backward, then forward * * For multi-char delimiters (**, ~~): outermost-to-outermost matching. * The leftmost char of the opening run pairs with the rightmost char of the closing run, * and the rightmost of opening pairs with leftmost of closing. * * This correctly handles adjacent spans like `*a* *b*`: * - `*` at pos 2 is right-flanking → searches backward → finds opening `*` at 0 ✓ * - `*` at pos 4 is left-flanking → searches forward → finds closing `*` at 6 ✓ */function findSymmetricPair(text: string, pos: number, ch: string): SymmetricPairResult | null { // Find the full run containing pos let runStart = pos while (runStart > 0 && text[runStart - 1] === ch) runStart-- let runEnd = pos while (runEnd < text.length - 1 && text[runEnd + 1] === ch) runEnd++
if (ch === '`' || ch === '_') { return findRestrictedSymmetricPair(text, pos, ch, runStart, runEnd) }
// Determine flanking: is there non-whitespace content adjacent to this run? const charBefore = runStart > 0 ? text[runStart - 1] : null const charAfter = runEnd < text.length - 1 ? text[runEnd + 1] : null const isLeftFlanking = charAfter !== null && charAfter !== ' ' && charAfter !== '\t' && charAfter !== '\n' const isRightFlanking = charBefore !== null && charBefore !== ' ' && charBefore !== '\t' && charBefore !== '\n'
// Right-flanking (closing) → search backward for opening run // If ambiguous (both), also try backward first if (isRightFlanking) { const backwardResult = findMatchingRunBackward(text, runStart, ch) if (backwardResult !== null) { const { matchRunStart, matchRunEnd } = backwardResult const matchRunLen = matchRunEnd - matchRunStart + 1 const offsetFromRight = runEnd - pos if (offsetFromRight < matchRunLen) { return { pairPos: matchRunStart + offsetFromRight, type: 'symmetric' } } } }
// Left-flanking (opening) → search forward for closing run // Also reached if backward search failed for ambiguous runs if (isLeftFlanking) { const forwardResult = findMatchingRunForward(text, runEnd, ch) if (forwardResult !== null) { const { matchRunStart, matchRunEnd } = forwardResult const matchRunLen = matchRunEnd - matchRunStart + 1 const offsetFromLeft = pos - runStart if (offsetFromLeft < matchRunLen) { return { pairPos: matchRunEnd - offsetFromLeft, type: 'symmetric' } } } }
// Empty pair detection: a run like ** or **** with no content between opening // and closing halves. The run must have even length so it splits evenly into // opening + closing (e.g., ** = * + *, **** = ** + **). // Position in first half pairs with mirror position in second half. const runLen = runEnd - runStart + 1 if (runLen >= 2 && runLen % 2 === 0) { const halfLen = runLen / 2 const offsetInRun = pos - runStart if (offsetInRun < halfLen) { // First half (opening) → pair with mirror in second half return { pairPos: runEnd - offsetInRun, type: 'symmetric' } } else { // Second half (closing) → pair with mirror in first half const offsetFromEnd = runEnd - pos return { pairPos: runStart + offsetFromEnd, type: 'symmetric' } } }
return null}
function findRestrictedSymmetricPair( text: string, pos: number, ch: '`' | '_', runStart: number, runEnd: number,): SymmetricPairResult | null { const width = runEnd - runStart + 1 const mayClose = ch === '`' || canCloseUnderscore(text, runStart, runEnd) const mayOpen = ch === '`' || canOpenUnderscore(text, runStart, runEnd)
if (mayClose) { const match = findMatchingRunBackwardByWidth(text, runStart, ch, ch === '`' ? width : null, (start, end) => ch === '`' || canOpenUnderscore(text, start, end)) if (match) { const offset = pos - runStart if (offset <= match.end - match.start) { return { pairPos: match.end - offset, type: 'symmetric' } } } }
if (mayOpen) { const match = findMatchingRunForwardByWidth(text, runEnd, ch, ch === '`' ? width : null, (start, end) => ch === '`' || canCloseUnderscore(text, start, end)) if (match) { const offset = runEnd - pos if (offset <= match.end - match.start) { return { pairPos: match.start + offset, type: 'symmetric' } } } }
const before = runStart > 0 ? text[runStart - 1] : null const after = runEnd < text.length - 1 ? text[runEnd + 1] : null const underscoreMayBeEmptyPair = ch !== '_' || ((before === null || /\s/u.test(before)) && (after === null || /\s/u.test(after))) if (width >= 2 && width % 2 === 0 && underscoreMayBeEmptyPair) { const halfWidth = width / 2 const offset = pos - runStart return { pairPos: offset < halfWidth ? runEnd - offset : runStart + (runEnd - pos), type: 'symmetric', } }
return null}
function findMatchingRunBackwardByWidth( text: string, from: number, ch: string, width: number | null, accepts: (start: number, end: number) => boolean,): { start: number; end: number } | null { let pos = from - 1 while (pos >= 0 && text[pos] !== '\n') { if (text[pos] !== ch) { pos -= 1 continue } const end = pos while (pos > 0 && text[pos - 1] === ch) pos -= 1 const start = pos if ((width === null || end - start + 1 === width) && accepts(start, end)) return { start, end } pos = start - 1 } return null}
function findMatchingRunForwardByWidth( text: string, from: number, ch: string, width: number | null, accepts: (start: number, end: number) => boolean,): { start: number; end: number } | null { let pos = from + 1 while (pos < text.length && text[pos] !== '\n') { if (text[pos] !== ch) { pos += 1 continue } const start = pos while (pos < text.length - 1 && text[pos + 1] === ch) pos += 1 const end = pos if ((width === null || end - start + 1 === width) && accepts(start, end)) return { start, end } pos = end + 1 } return null}
function underscoreFlanking( text: string, runStart: number, runEnd: number,): { left: boolean; right: boolean; beforePunctuation: boolean; afterPunctuation: boolean } { const before = runStart > 0 ? text[runStart - 1]! : null const after = runEnd < text.length - 1 ? text[runEnd + 1]! : null const beforeWhitespace = before === null || /\s/u.test(before) const afterWhitespace = after === null || /\s/u.test(after) const beforePunctuation = before !== null && /[\p{P}\p{S}]/u.test(before) const afterPunctuation = after !== null && /[\p{P}\p{S}]/u.test(after) return { left: !afterWhitespace && (!afterPunctuation || beforeWhitespace || beforePunctuation), right: !beforeWhitespace && (!beforePunctuation || afterWhitespace || afterPunctuation), beforePunctuation, afterPunctuation, }}
function canOpenUnderscore(text: string, runStart: number, runEnd: number): boolean { const flanking = underscoreFlanking(text, runStart, runEnd) return flanking.left && (!flanking.right || flanking.beforePunctuation)}
function canCloseUnderscore(text: string, runStart: number, runEnd: number): boolean { const flanking = underscoreFlanking(text, runStart, runEnd) return flanking.right && (!flanking.left || flanking.afterPunctuation)}
/** * Search backward from runStart for a run of the same character. * The run must have content between it and the current run (i.e., they form a pair). * Returns the start and end positions of the matching run, or null. */function findMatchingRunBackward( text: string, runStart: number, ch: string): { matchRunStart: number; matchRunEnd: number } | null { // Search backward from before the current run let searchPos = runStart - 1 // Skip content between pairs (everything that's not our delimiter). // Stop at newlines — inline formatting doesn't span lines. while (searchPos >= 0 && text[searchPos] !== ch && text[searchPos] !== '\n') { searchPos-- }
if (searchPos < 0 || text[searchPos] !== ch) return null
// Find the full matching run (still bounded by line start) const matchRunEnd = searchPos let matchRunStart = searchPos while (matchRunStart > 0 && text[matchRunStart - 1] === ch) { matchRunStart-- }
return { matchRunStart, matchRunEnd }}
/** * Search forward from runEnd for a run of the same character. */function findMatchingRunForward( text: string, runEnd: number, ch: string): { matchRunStart: number; matchRunEnd: number } | null { let searchPos = runEnd + 1 // Stop at newlines — inline formatting doesn't span lines. while (searchPos < text.length && text[searchPos] !== ch && text[searchPos] !== '\n') { searchPos++ }
if (searchPos >= text.length || text[searchPos] !== ch) return null
const matchRunStart = searchPos let matchRunEnd = searchPos while (matchRunEnd < text.length - 1 && text[matchRunEnd + 1] === ch) { matchRunEnd++ }
return { matchRunStart, matchRunEnd }}
/** * Find link syntax when [ is deleted. * Looks for [...](...) pattern starting from the [ position. */function findLinkFromOpen(text: string, bracketPos: number): LinkOpenPairResult | null { if (isEscaped(text, bracketPos)) return null // Find matching ] — stop at newlines (inline links don't span lines) let depth = 0 let closeBracket = -1 for (let i = bracketPos; i < text.length; i++) { if (text[i] === '\n') break if ((text[i] === '[' || text[i] === ']') && isEscaped(text, i)) continue if (text[i] === '[') depth++ else if (text[i] === ']') { depth-- if (depth === 0) { closeBracket = i break } } } if (closeBracket === -1) return null
// Check for ( immediately after ] if (closeBracket + 1 >= text.length || text[closeBracket + 1] !== '(') return null
// Find matching ) — stop at newlines let parenDepth = 0 let closeParen = -1 for (let i = closeBracket + 1; i < text.length; i++) { if (text[i] === '\n') break if ((text[i] === '(' || text[i] === ')') && isEscaped(text, i)) continue if (text[i] === '(') parenDepth++ else if (text[i] === ')') { parenDepth-- if (parenDepth === 0) { closeParen = i break } } } if (closeParen === -1) return null
return { removeStart: closeBracket, removeEnd: closeParen, type: 'link-open', }}
/** * Find link syntax when ) is deleted. * Looks for [...](...) pattern ending at the ) position. */function findLinkFromClose(text: string, parenPos: number): LinkClosePairResult | null { if (isEscaped(text, parenPos)) return null // Find matching ( going backward — stop at newlines let depth = 0 let openParen = -1 for (let i = parenPos; i >= 0; i--) { if (text[i] === '\n') break if ((text[i] === '(' || text[i] === ')') && isEscaped(text, i)) continue if (text[i] === ')') depth++ else if (text[i] === '(') { depth-- if (depth === 0) { openParen = i break } } } if (openParen === -1) return null
// Check for ] immediately before ( if ( openParen - 1 < 0 || text[openParen - 1] !== ']' || isEscaped(text, openParen - 1) ) return null
// Find matching [ going backward from ] — stop at newlines let bracketDepth = 0 let openBracket = -1 for (let i = openParen - 1; i >= 0; i--) { if (text[i] === '\n') break if ((text[i] === '[' || text[i] === ']') && isEscaped(text, i)) continue if (text[i] === ']') bracketDepth++ else if (text[i] === '[') { bracketDepth-- if (bracketDepth === 0) { openBracket = i break } } } if (openBracket === -1) return null
return { removeStart: openBracket, removeEnd: openParen, type: 'link-close', }}
/** * Find link syntax when ] is deleted. * Looks backward for [ and forward for (url) to identify [text](url) pattern. * Result: keep the link text (same visual result as deleting [). */function findLinkFromCloseBracket(text: string, bracketPos: number): LinkCloseBracketPairResult | null { if (isEscaped(text, bracketPos)) return null // Check for ( immediately after ] if (bracketPos + 1 >= text.length || text[bracketPos + 1] !== '(') return null
// Find matching [ going backward — stop at newlines let depth = 0 let openBracket = -1 for (let i = bracketPos; i >= 0; i--) { if (text[i] === '\n') break if ((text[i] === '[' || text[i] === ']') && isEscaped(text, i)) continue if (text[i] === ']') depth++ else if (text[i] === '[') { depth-- if (depth === 0) { openBracket = i break } } } if (openBracket === -1) return null
// Find matching ) going forward from ( — stop at newlines let parenDepth = 0 let closeParen = -1 for (let i = bracketPos + 1; i < text.length; i++) { if (text[i] === '\n') break if ((text[i] === '(' || text[i] === ')') && isEscaped(text, i)) continue if (text[i] === '(') parenDepth++ else if (text[i] === ')') { parenDepth-- if (parenDepth === 0) { closeParen = i break } } } if (closeParen === -1) return null
return { openBracket, closeParen, type: 'link-close-bracket', }}
/** * Find link syntax when ( is deleted. * Checks for ] immediately before (, then finds [ and ). * Result: keep the URL (same visual result as deleting )). */function findLinkFromOpenParen(text: string, parenPos: number): LinkOpenParenPairResult | null { if (isEscaped(text, parenPos)) return null // Check for ] immediately before ( if ( parenPos - 1 < 0 || text[parenPos - 1] !== ']' || isEscaped(text, parenPos - 1) ) return null
// Find matching [ going backward from ] — stop at newlines let bracketDepth = 0 let openBracket = -1 for (let i = parenPos - 1; i >= 0; i--) { if (text[i] === '\n') break if ((text[i] === '[' || text[i] === ']') && isEscaped(text, i)) continue if (text[i] === ']') bracketDepth++ else if (text[i] === '[') { bracketDepth-- if (bracketDepth === 0) { openBracket = i break } } } if (openBracket === -1) return null
// Find matching ) going forward — stop at newlines let parenDepth = 0 let closeParen = -1 for (let i = parenPos; i < text.length; i++) { if (text[i] === '\n') break if ((text[i] === '(' || text[i] === ')') && isEscaped(text, i)) continue if (text[i] === '(') parenDepth++ else if (text[i] === ')') { parenDepth-- if (parenDepth === 0) { closeParen = i break } } } if (closeParen === -1) return null
return { openBracket, closeParen, type: 'link-open-paren', }}
/** * Result of attempting to delete a formatting pair. * Returns the full new text and the cursor position. */
/** * Given the full text and the position of a character being deleted, * compute the resulting text with the matching pair also removed. * Returns null if the character has no matching pair. * * @param fullText - The complete text BEFORE the deletion * @param deletedPos - The position of the character being deleted in fullText * @returns The new text with both characters removed, and cursor position */export function deleteFormattingPair(fullText: string, deletedPos: number): TextTransformResult | null { const pair = findFormattingPair(fullText, deletedPos) if (pair === null) return null
switch (pair.type) { case 'symmetric': { // Remove both the deleted char and its pair const posA = Math.min(deletedPos, pair.pairPos) const posB = Math.max(deletedPos, pair.pairPos) // Keep the content between the delimiters const insert = fullText.slice(posA + 1, posB) const cursorOffset = deletedPos < pair.pairPos ? deletedPos : deletedPos - 1 const delimiter = inferSymmetricDelimiter(fullText, deletedPos, pair.pairPos) const survivingRange = getSurvivingContentRange(delimiter, posA, insert) const originalContent = delimiter.startsWith('`') ? contentBetweenDelimiterRuns(fullText, posA, posB, '`') : insert return { changes: { from: posA, to: posB + 1, insert }, selectionStart: cursorOffset, selectionEnd: cursorOffset, semantic: createPairDeletionSemantic( delimiter, survivingRange.start, survivingRange.end, originalContent, ), } }
case 'structural': { const posA = Math.min(deletedPos, pair.pairPos) const posB = Math.max(deletedPos, pair.pairPos) const insert = fullText.slice(posA + 1, posB) const cursorOffset = deletedPos < pair.pairPos ? deletedPos : deletedPos - 1 return { changes: { from: posA, to: posB + 1, insert }, selectionStart: cursorOffset, selectionEnd: cursorOffset, } }
case 'link-open': { // Deleting [: remove [ and ](url) // Keep the link text (between [ and ]) const linkText = fullText.slice(deletedPos + 1, pair.removeStart) return { changes: { from: deletedPos, to: pair.removeEnd + 1, insert: linkText }, selectionStart: deletedPos, selectionEnd: deletedPos, } }
case 'link-close': { // Deleting ): remove [text]( and ) // Keep the URL (between ( and )) const url = fullText.slice(pair.removeEnd + 1, deletedPos) return { changes: { from: pair.removeStart, to: deletedPos + 1, insert: url }, selectionStart: pair.removeStart + url.length, selectionEnd: pair.removeStart + url.length, } }
case 'link-close-bracket': { // Deleting ]: remove [ and ](url) // Keep the link text (between [ and ]) const linkText = fullText.slice(pair.openBracket + 1, deletedPos) return { changes: { from: pair.openBracket, to: pair.closeParen + 1, insert: linkText }, selectionStart: pair.openBracket + linkText.length, selectionEnd: pair.openBracket + linkText.length, } }
case 'link-open-paren': { // Deleting (: remove [text]( and ) // Keep the URL (between ( and )) const url = fullText.slice(deletedPos + 1, pair.closeParen) return { changes: { from: pair.openBracket, to: pair.closeParen + 1, insert: url }, selectionStart: pair.openBracket, selectionEnd: pair.openBracket, } } }}
export function cycleHeadingPrefix( text: string, selectionStart: number, selectionEnd: number): TextTransformResult { const { lineStart, lineContent } = getLineAtPosition(text, selectionStart)
// Detect current heading level const h3Match = lineContent.match(/^### /) const h2Match = lineContent.match(/^## /) const h1Match = lineContent.match(/^# /)
let insert: string let removeLen: number let prefixDelta: number
if (h3Match) { // Remove heading (### -> plain): remove "### " (4 chars) insert = '' removeLen = 4 prefixDelta = -4 } else if (h2Match) { // Upgrade to H3 (## -> ###): insert one # at line start insert = '#' removeLen = 0 prefixDelta = 1 } else if (h1Match) { // Upgrade to H2 (# -> ##): insert one # at line start insert = '#' removeLen = 0 prefixDelta = 1 } else { // Add H1: insert "# " at line start insert = '# ' removeLen = 0 prefixDelta = 2 }
// Adjust cursor positions const isCollapsedAtStart = selectionStart === lineStart && selectionEnd === lineStart const newStart = isCollapsedAtStart ? lineStart : Math.max(lineStart, selectionStart + prefixDelta) const newEnd = isCollapsedAtStart ? lineStart : Math.max(lineStart, selectionEnd + prefixDelta)
return { changes: { from: lineStart, to: lineStart + removeLen, insert }, selectionStart: newStart, selectionEnd: newEnd, }}
export function toggleBlockquotePrefix( text: string, selectionStart: number, selectionEnd: number,): TextTransformResult { const { lineStart, lineContent } = getLineAtPosition(text, selectionStart) const quote = QUOTE_LINE_RE.exec(lineContent) if (!quote) return toggleLinePrefix(text, selectionStart, selectionEnd, '> ')
// The quote toolbar is a one-marker toggle. It removes exactly the deepest // marker whether the source uses spaced (`> > text`) or compact (`>> text`) // nesting, leaving no quote only when the line was at depth one. const prefix = quote[1] const lastMarker = prefix.lastIndexOf('>') const remainingPrefix = prefix.slice(0, lastMarker) // Only one separator belongs to the removed quote marker. Additional // whitespace can be indentation of the quoted list/content and must survive. const contentIndent = prefix.slice(lastMarker + 1).replace(/^[ \t]/, '') const normalizedPrefix = (remainingPrefix.endsWith('>') ? `${remainingPrefix} ` : remainingPrefix) + contentIndent const delta = normalizedPrefix.length - prefix.length return { changes: { from: lineStart, to: lineStart + prefix.length, insert: normalizedPrefix }, selectionStart: Math.max(lineStart, selectionStart + delta), selectionEnd: Math.max(lineStart, selectionEnd + delta), }}
export function toggleTodoPrefix( text: string, selectionStart: number, selectionEnd: number,): TextTransformResult { return toggleLinePrefix(text, selectionStart, selectionEnd, '- [ ] ')}