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,55 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FormatAction } from '../lib/markdownFormat'
|
||||
import { FormattingToolbar } from './FormattingToolbar'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const EXPECTED: [string, FormatAction][] = [
|
||||
['Bold', { kind: 'inline', marker: '**' }],
|
||||
['Italic', { kind: 'inline', marker: '*' }],
|
||||
['Strikethrough', { kind: 'inline', marker: '~~' }],
|
||||
['Inline code', { kind: 'inline', marker: '`' }],
|
||||
['Code block', { kind: 'codeBlock' }],
|
||||
['Link', { kind: 'link' }],
|
||||
['Quote', { kind: 'prefix', prefix: 'quote' }],
|
||||
['Bulleted list', { kind: 'prefix', prefix: 'bullet' }],
|
||||
['Numbered list', { kind: 'prefix', prefix: 'ordered' }],
|
||||
['Heading 1', { kind: 'heading', level: 1 }],
|
||||
['Heading 2', { kind: 'heading', level: 2 }],
|
||||
['Heading 3', { kind: 'heading', level: 3 }],
|
||||
['Subscript', { kind: 'inline', marker: '~' }],
|
||||
['Superscript', { kind: 'inline', marker: '^' }],
|
||||
]
|
||||
|
||||
describe('FormattingToolbar', () => {
|
||||
it.each(EXPECTED)('%s button calls onAction with its action', (label, action) => {
|
||||
const onAction = vi.fn()
|
||||
render(<FormattingToolbar id="t" onAction={onAction} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: label }))
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
expect(onAction).toHaveBeenCalledWith(action)
|
||||
})
|
||||
|
||||
it('disables every button when disabled, but leaves the help link usable', () => {
|
||||
render(<FormattingToolbar id="t" disabled onAction={vi.fn()} />)
|
||||
const buttons = screen.getAllByRole('button') as HTMLButtonElement[]
|
||||
expect(buttons).toHaveLength(EXPECTED.length)
|
||||
expect(buttons.every((b) => b.disabled)).toBe(true)
|
||||
expect(screen.getByRole('link', { name: 'Formatting help' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens help in a new tab so the draft in the composer is not lost', () => {
|
||||
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
|
||||
const help = screen.getByRole('link', { name: 'Formatting help' })
|
||||
expect(help.getAttribute('href')).toBe('/help#formatting')
|
||||
expect(help.getAttribute('target')).toBe('_blank')
|
||||
expect(help.getAttribute('rel')).toContain('noopener')
|
||||
})
|
||||
|
||||
it('keeps focus in the textarea by cancelling mousedown on buttons', () => {
|
||||
render(<FormattingToolbar id="t" onAction={vi.fn()} />)
|
||||
// fireEvent returns false when the event's default was prevented.
|
||||
expect(fireEvent.mouseDown(screen.getByRole('button', { name: 'Bold' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user