Files
ds-chat/frontend/src/lib/markdownFormat.ts
T
ksmithandClaude Sonnet 5.5 6975ed4dc0 Add a collapsible markdown formatting toolbar to the composer (#75)
Markdown support was already complete and documented, but nothing in the
composer pointed at it -- unlike @, # and : (which advertise themselves
when typed), formatting syntax was only discoverable via Help, buried in
the account menu.

The composer stays a plain markdown textarea; a new "Aa" button opens a
toolbar that inserts the syntax for you: bold, italic, strikethrough,
inline code, code block, link, quote, bulleted/numbered lists, H1-H3,
sub/superscript. Collapsed by default, remembered across reloads and
room switches. Clicking a button again removes the formatting (native
undo doesn't track programmatic edits), and bold/italic/sub/strike
toggles are run-length aware so "***x***" is handled correctly. The
selection math is pure functions in lib/markdownFormat.ts, unit-tested.

The "?" opens /help#formatting in a new tab so the draft isn't lost;
HelpPage now scrolls to the hash once the guide has loaded, since the
browser's own hash scroll fired before the async-fetched headings
existed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:06:56 -06:00

205 lines
7.9 KiB
TypeScript

// #75: pure text transforms behind the composer's formatting toolbar. Each
// takes the textarea's value plus its selection and returns the same shape,
// so Composer just applies the result -- keeping this free of React/DOM is
// what makes the fiddly selection math directly unit-testable.
export interface TextState {
value: string
start: number
end: number
}
export type InlineMarker = '**' | '*' | '~~' | '`' | '~' | '^'
export type FormatAction =
| { kind: 'inline'; marker: InlineMarker }
| { kind: 'prefix'; prefix: 'quote' | 'bullet' | 'ordered' }
| { kind: 'heading'; level: 1 | 2 | 3 }
| { kind: 'codeBlock' }
| { kind: 'link' }
// Markers whose doubled form is a *different* format (`**` bold vs `*`
// italic, `~~` strikethrough vs `~` subscript), so "is this already
// wrapped?" has to look at how long the run of marker characters is rather
// than just whether one is present.
const DOUBLED_IS_DIFFERENT = new Set(['*', '~'])
// A run of 3 `*` is bold+italic: bold (2) and italic (1) are both "present".
function hasMarker(marker: string, run: number): boolean {
if (marker.length === 2) return run >= 2
return DOUBLED_IS_DIFFERENT.has(marker[0]) ? run % 2 === 1 : run >= 1
}
function applyInline(state: TextState, marker: InlineMarker): TextState {
const { value, start, end } = state
const ch = marker[0]
const m = marker.length
const sel = value.slice(start, end)
// Selection includes the markers themselves (e.g. the user selected the
// whole `**word**`) -- unwrap inside the selection.
if (sel.length > 0) {
let lead = 0
while (lead < sel.length && sel[lead] === ch) lead++
let trail = 0
while (trail < sel.length - lead && sel[sel.length - 1 - trail] === ch) trail++
const run = Math.min(lead, trail)
if (run > 0 && hasMarker(marker, run) && sel.length > 2 * m) {
const inner = sel.slice(m, sel.length - m)
return { value: value.slice(0, start) + inner + value.slice(end), start, end: start + inner.length }
}
}
// Markers sit just outside the selection. Skipped when the selection has
// edge whitespace: markers adjacent to it belong to neighbouring spans
// (`*a* and *b*` with " and " selected), not to this selection.
const edgeWhitespace = sel.length > 0 && (/^\s/.test(sel) || /\s$/.test(sel))
if (!edgeWhitespace) {
let before = 0
while (start - before - 1 >= 0 && value[start - before - 1] === ch) before++
let after = 0
while (end + after < value.length && value[end + after] === ch) after++
const run = Math.min(before, after)
if (run > 0 && hasMarker(marker, run)) {
return {
value: value.slice(0, start - m) + sel + value.slice(end + m),
start: start - m,
end: end - m,
}
}
}
// Nothing selected (or only whitespace): drop in an empty pair, cursor between.
if (sel.trim() === '') {
return {
value: value.slice(0, start) + marker + marker + value.slice(start),
start: start + m,
end: start + m,
}
}
// Markdown doesn't treat `** word**` as bold -- keep the markers hugging
// the text, outside any whitespace the selection happens to include.
const innerStart = start + (sel.length - sel.trimStart().length)
const innerEnd = end - (sel.length - sel.trimEnd().length)
return {
value:
value.slice(0, innerStart) + marker + value.slice(innerStart, innerEnd) + marker + value.slice(innerEnd),
start: innerStart + m,
end: innerEnd + m,
}
}
// The whole lines a selection touches. A selection that ends right after a
// newline (i.e. at the very start of the next line) doesn't count that line.
function lineBounds(value: string, start: number, end: number) {
const lineStart = start === 0 ? 0 : value.lastIndexOf('\n', start - 1) + 1
const effectiveEnd = end > start && value[end - 1] === '\n' ? end - 1 : end
const nextNewline = value.indexOf('\n', effectiveEnd)
return { lineStart, lineEnd: nextNewline === -1 ? value.length : nextNewline }
}
function transformLines(state: TextState, transform: (lines: string[]) => string[]): TextState {
const { value, start, end } = state
const { lineStart, lineEnd } = lineBounds(value, start, end)
const oldBlock = value.slice(lineStart, lineEnd)
const newBlock = transform(oldBlock.split('\n')).join('\n')
const next = value.slice(0, lineStart) + newBlock + value.slice(lineEnd)
if (start === end) {
const cursor = Math.max(lineStart, start + (newBlock.length - oldBlock.length))
return { value: next, start: cursor, end: cursor }
}
// Keep the affected lines selected so the same button toggles it back off.
return { value: next, start: lineStart, end: lineStart + newBlock.length }
}
const ORDERED_RE = /^\d+\. /
function applyPrefix(state: TextState, kind: 'quote' | 'bullet' | 'ordered'): TextState {
const marker = kind === 'quote' ? '> ' : '- '
const has = (line: string) => (kind === 'ordered' ? ORDERED_RE.test(line) : line.startsWith(marker))
const strip = (line: string) => (kind === 'ordered' ? line.replace(ORDERED_RE, '') : line.slice(marker.length))
return transformLines(state, (lines) => {
// Blank lines inside a multi-line selection are paragraph breaks, not
// list items -- only a lone blank line (cursor on an empty line) gets one.
const multi = lines.length > 1
const skip = (line: string) => multi && line === ''
const targets = lines.filter((line) => !skip(line))
if (targets.length > 0 && targets.every(has)) {
return lines.map((line) => (has(line) ? strip(line) : line))
}
let n = 0
return lines.map((line) => {
if (skip(line)) return line
n++
if (kind === 'ordered') return `${n}. ${strip(line)}`
return has(line) ? line : marker + line
})
})
}
const HEADING_RE = /^#{1,6} /
function applyHeading(state: TextState, level: 1 | 2 | 3): TextState {
const prefix = '#'.repeat(level) + ' '
return transformLines(state, (lines) => {
const multi = lines.length > 1
const skip = (line: string) => multi && line === ''
const targets = lines.filter((line) => !skip(line))
if (targets.length > 0 && targets.every((line) => line.startsWith(prefix))) {
return lines.map((line) => (line.startsWith(prefix) ? line.slice(prefix.length) : line))
}
return lines.map((line) => (skip(line) ? line : prefix + line.replace(HEADING_RE, '')))
})
}
// A fence only counts at the start of a line, so mid-line cursors get a
// newline inserted first.
function applyCodeBlock(state: TextState): TextState {
const { value, start, end } = state
const before = value.slice(0, start)
const after = value.slice(end)
const sel = value.slice(start, end).replace(/\n$/, '')
const lead = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
const trail = after.length > 0 && !after.startsWith('\n') ? '\n' : ''
const next = `${before}${lead}\`\`\`\n${sel}\n\`\`\`${trail}${after}`
const innerStart = before.length + lead.length + 4
return { value: next, start: innerStart, end: innerStart + sel.length }
}
function applyLink(state: TextState): TextState {
const { value, start, end } = state
const sel = value.slice(start, end)
let text: string
let selStart: number
let selEnd: number
if (/^https?:\/\/\S+$/.test(sel)) {
text = `[](${sel})`
selStart = selEnd = start + 1
} else if (sel) {
text = `[${sel}](url)`
selStart = start + sel.length + 3
selEnd = selStart + 3
} else {
text = '[link text](url)'
selStart = start + 1
selEnd = selStart + 9
}
return { value: value.slice(0, start) + text + value.slice(end), start: selStart, end: selEnd }
}
export function applyFormat(state: TextState, action: FormatAction): TextState {
switch (action.kind) {
case 'inline':
return applyInline(state, action.marker)
case 'prefix':
return applyPrefix(state, action.prefix)
case 'heading':
return applyHeading(state, action.level)
case 'codeBlock':
return applyCodeBlock(state)
case 'link':
return applyLink(state)
}
}