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:
ksmithandClaude Sonnet 5.5 committed 2026-10-02 16:06:56 -06:00
1 parent c54c1bf97d
commit 6975ed4dc0
11 files changed
+682 -2

No files matched your search

@@ -0,0 +1,104 @@
import type { ReactNode } from 'react'
import type { FormatAction, InlineMarker } from '../lib/markdownFormat'
import './FormattingToolbar.css'
interface FormattingToolbarProps {
id: string
disabled?: boolean
onAction: (action: FormatAction) => void
}
interface ToolbarButton {
label: string
// Shows the syntax the button inserts -- the toolbar doubles as the
// "quick guide" for people who'd rather learn to type it themselves.
title: string
content: ReactNode
action: FormatAction
}
const inline = (marker: InlineMarker): FormatAction => ({ kind: 'inline', marker })
const GROUPS: ToolbarButton[][] = [
[
{ label: 'Bold', title: 'Bold: **text**', content: <b>B</b>, action: inline('**') },
{ label: 'Italic', title: 'Italic: *text*', content: <i>I</i>, action: inline('*') },
{ label: 'Strikethrough', title: 'Strikethrough: ~~text~~', content: <s>S</s>, action: inline('~~') },
],
[
{ label: 'Inline code', title: 'Inline code: `code`', content: '</>', action: inline('`') },
{ label: 'Code block', title: 'Code block: ``` on its own lines', content: '{ }', action: { kind: 'codeBlock' } },
],
[
{
label: 'Link',
title: 'Link: [text](url)',
content: (
<svg width="15" height="15" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M8.5 11.5a3 3 0 0 0 4.2 0l2.6-2.6a3 3 0 0 0-4.2-4.2l-.9.9M11.5 8.5a3 3 0 0 0-4.2 0l-2.6 2.6a3 3 0 0 0 4.2 4.2l.9-.9"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
action: { kind: 'link' },
},
{ label: 'Quote', title: 'Quote: > text', content: '“', action: { kind: 'prefix', prefix: 'quote' } },
],
[
{ label: 'Bulleted list', title: 'Bulleted list: - item', content: '•', action: { kind: 'prefix', prefix: 'bullet' } },
{ label: 'Numbered list', title: 'Numbered list: 1. item', content: '1.', action: { kind: 'prefix', prefix: 'ordered' } },
],
[
{ label: 'Heading 1', title: 'Heading 1: # text', content: 'H1', action: { kind: 'heading', level: 1 } },
{ label: 'Heading 2', title: 'Heading 2: ## text', content: 'H2', action: { kind: 'heading', level: 2 } },
{ label: 'Heading 3', title: 'Heading 3: ### text', content: 'H3', action: { kind: 'heading', level: 3 } },
],
[
{ label: 'Subscript', title: 'Subscript: ~text~ (no spaces)', content: 'x₂', action: inline('~') },
{ label: 'Superscript', title: 'Superscript: ^text^ (no spaces)', content: 'x²', action: inline('^') },
],
]
export function FormattingToolbar({ id, disabled, onAction }: FormattingToolbarProps) {
return (
<div id={id} className="formatting-toolbar" role="toolbar" aria-label="Formatting">
{GROUPS.map((group) => (
<div key={group[0].label} className="formatting-toolbar-group">
{group.map((button) => (
<button
key={button.label}
type="button"
className="formatting-toolbar-btn"
title={button.title}
aria-label={button.label}
disabled={disabled}
// Keeps focus (and the selection) in the textarea -- a click
// on a button would otherwise blur it, and on mobile dismiss
// the keyboard.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onAction(button.action)}
>
{button.content}
</button>
))}
</div>
))}
{/* A new tab on purpose: the draft lives in Composer's own state, so
navigating this one away would lose it. */}
<a
className="formatting-toolbar-btn formatting-toolbar-help"
href="/help#formatting"
target="_blank"
rel="noopener noreferrer"
title="Formatting help (opens in a new tab)"
aria-label="Formatting help"
>
?
</a>
</div>
)
}