From 6975ed4dc002ecf83c73db69d20938fe14b7cec2 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Fri, 2 Oct 2026 16:06:56 -0600 Subject: [PATCH] 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 --- USER_GUIDE.md | 8 +- frontend/src/components/Composer.css | 28 +++ frontend/src/components/Composer.tsx | 42 ++++ frontend/src/components/FormattingToolbar.css | 41 ++++ .../src/components/FormattingToolbar.test.tsx | 55 +++++ frontend/src/components/FormattingToolbar.tsx | 104 +++++++++ .../src/lib/formattingToolbarPref.test.ts | 31 +++ frontend/src/lib/formattingToolbarPref.ts | 21 ++ frontend/src/lib/markdownFormat.test.ts | 139 ++++++++++++ frontend/src/lib/markdownFormat.ts | 204 ++++++++++++++++++ frontend/src/pages/HelpPage.tsx | 11 +- 11 files changed, 682 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/FormattingToolbar.css create mode 100644 frontend/src/components/FormattingToolbar.test.tsx create mode 100644 frontend/src/components/FormattingToolbar.tsx create mode 100644 frontend/src/lib/formattingToolbarPref.test.ts create mode 100644 frontend/src/lib/formattingToolbarPref.ts create mode 100644 frontend/src/lib/markdownFormat.test.ts create mode 100644 frontend/src/lib/markdownFormat.ts diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 3fb66aa..ee4bf8a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -70,7 +70,13 @@ send. Use **Shift+Enter** to add a line break without sending. ## Formatting -Messages support Markdown: +Messages support Markdown. Prefer buttons to typing the syntax? Click the +**Aa** button next to the emoji button in the composer to open a formatting +toolbar — it inserts the markdown below into your message for you (select some +text first to wrap it, or click a button again to take the formatting back +off). It stays collapsed until you open it, and remembers your choice. + +Or type it yourself: - `**bold**`, `*italic*`, `~~strikethrough~~` - `` `inline code` `` and fenced code blocks (three backticks) diff --git a/frontend/src/components/Composer.css b/frontend/src/components/Composer.css index 1683f75..4736a26 100644 --- a/frontend/src/components/Composer.css +++ b/frontend/src/components/Composer.css @@ -105,6 +105,34 @@ cursor: not-allowed; } +.composer-format-toggle { + width: 36px; + height: 36px; + flex: none; + border-radius: var(--radius); + background: transparent; + color: var(--ds-muted); + border: 1px solid var(--ds-border); + font-size: 0.82rem; + font-weight: 700; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.composer-format-toggle:hover:not(:disabled), +.composer-format-toggle-open { + color: var(--ds-text); + border-color: var(--ds-accent); +} + +.composer-format-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .composer-attach-wrap { position: relative; flex: none; diff --git a/frontend/src/components/Composer.tsx b/frontend/src/components/Composer.tsx index bbfbdf4..65e1cf0 100644 --- a/frontend/src/components/Composer.tsx +++ b/frontend/src/components/Composer.tsx @@ -15,10 +15,13 @@ import { getUploadLimit } from '../api/uploads' import { useCustomEmoji } from '../context/CustomEmojiContext' import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes' import { formatFileSize } from '../lib/fileSize' +import { getFormattingToolbarOpen, setFormattingToolbarOpen } from '../lib/formattingToolbarPref' +import { applyFormat, type FormatAction } from '../lib/markdownFormat' import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji' import type { MyRoomItem, RoomMember } from '../types' import { EmojiPicker } from './EmojiPicker' import { EmojiShortcodeAutocomplete, type EmojiShortcodeMatch } from './EmojiShortcodeAutocomplete' +import { FormattingToolbar } from './FormattingToolbar' import { MentionAutocomplete } from './MentionAutocomplete' import { RoomReferenceAutocomplete } from './RoomReferenceAutocomplete' import './Composer.css' @@ -136,6 +139,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc const [emojiActiveIndex, setEmojiActiveIndex] = useState(0) const [dragActive, setDragActive] = useState(false) const [attachMenuOpen, setAttachMenuOpen] = useState(false) + const [toolbarOpen, setToolbarOpen] = useState(getFormattingToolbarOpen) const { byShortcode: customEmojiByShortcode } = useCustomEmoji() const textareaRef = useRef(null) const fileInputRef = useRef(null) @@ -449,6 +453,29 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc }) } + function toggleToolbar() { + const next = !toolbarOpen + setToolbarOpen(next) + setFormattingToolbarOpen(next) + } + + // #75: the toolbar only ever edits the textarea's plain markdown text -- + // nothing about what gets sent changes. + function applyFormatAction(action: FormatAction) { + const el = textareaRef.current + if (!el) return + const result = applyFormat( + { value, start: el.selectionStart ?? value.length, end: el.selectionEnd ?? value.length }, + action, + ) + setValue(result.value) + requestAnimationFrame(() => { + el.focus() + el.setSelectionRange(result.start, result.end) + autoGrow() + }) + } + function insertEmoji(emoji: string) { const el = textareaRef.current setEmojiPickerOpen(false) @@ -517,6 +544,9 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc )} {uploadError &&
{uploadError}
} + {toolbarOpen && ( + + )}
)}
+