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,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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user