/** * 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 `[[…` 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> = { '(': ')', '[': ']', '{': '}', } const STRUCTURAL_CLOSE_TO_OPEN: Readonly> = { ')': '(', ']': '[', '}': '{', } 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, '- [ ] ') }