Private
Public Access
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>
This commit is contained in:
1 parent
c54c1bf97d
commit
6975ed4dc0
11 files changed
+682
-2
No files matched your search
@@ -0,0 +1,204 @@
|
||||
// #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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user