Private
Public Access
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>
205 lines
7.9 KiB
TypeScript
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)
|
|
}
|
|
}
|