feat: formatting buttons on the question editor — a toolbar, not a WYSIWYG
"How do I bold? add a list etc?" — answered, and the interesting part is what it is not. Milkdown was the obvious choice; it is already in the project. Round- tripping a question stem through it first showed why not: bullets come back as `*` with blank lines inserted between them, tables are repadded, and anything it does not recognise is escaped. The first two reflow text nobody edited — and learners' highlights are stored as character offsets into that exact string, so a reflow on any save moves every one of them. So the text stays byte-for-byte as typed and the buttons only insert syntax at the cursor: bold, italic, code, bullet and numbered lists (every selected line, and already-bulleted lines left alone), heading, a table skeleton, inline maths. The Markdown preview that was already there shows the result. Frontend 308/308. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
101b03348a
commit
3b45eaf3a6
5 changed files with 210 additions and 7 deletions
11
docs/TODO.md
11
docs/TODO.md
|
|
@ -95,9 +95,14 @@ Captured so nothing is lost while the article writing runs.
|
|||
### Editor and figures
|
||||
- [x] **Rich editing on the question page** — no new platform needed: Milkdown
|
||||
is already installed and used for articles, courses and the quick modal.
|
||||
- [ ] **Milkdown on the stem and the options**, replacing the plain textareas.
|
||||
Note the stem carries manual-highlight offsets, so check what a WYSIWYG
|
||||
rewrite does to them before switching.
|
||||
- [x] **Formatting on the stem, options and explanation** — done 2026-09-11,
|
||||
but NOT with Milkdown, and the check is why. Round-tripping a stem
|
||||
through it showed: bullets come back as `*` with blank lines inserted,
|
||||
tables are repadded, and `[[id|Title]]` is escaped to `\[\[…]]`. The
|
||||
first two reflow text nobody edited, which moves every saved highlight
|
||||
offset; the third breaks cross-references outright (fixed separately,
|
||||
since ArticleEditor already used Milkdown). A toolbar over the plain
|
||||
textarea gives the same buttons and changes nothing it was not asked to.
|
||||
- [x] **Many figures per question** — `question_media` links a question to any
|
||||
number of images in the bank, each with a role (stem or explanation), a
|
||||
label the prose can refer to ("Figure 1") and an order. The 346 existing
|
||||
|
|
|
|||
25
frontend/src/components/MarkdownToolbar.css
Normal file
25
frontend/src/components/MarkdownToolbar.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* Formatting buttons over a plain textarea. */
|
||||
|
||||
.mdbar {
|
||||
display: flex; gap: 4px; flex-wrap: wrap; align-items: center;
|
||||
padding: 6px; margin-bottom: -1px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.mdbar button {
|
||||
min-width: 32px; min-height: 32px; padding: 4px 9px;
|
||||
font: inherit; font-size: 0.8rem; line-height: 1;
|
||||
background: var(--card-bg); color: var(--text-muted);
|
||||
border: 1px solid var(--border); border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.mdbar button:hover { color: var(--primary); border-color: var(--primary); }
|
||||
|
||||
/* The field below joins on to the bar. */
|
||||
.mdbar + textarea { border-top-left-radius: 0; border-top-right-radius: 0; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* Touch targets, and the bar scrolls rather than wrapping to three rows. */
|
||||
.mdbar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
|
||||
.mdbar::-webkit-scrollbar { display: none; }
|
||||
.mdbar button { min-height: 40px; flex-shrink: 0; }
|
||||
}
|
||||
89
frontend/src/components/MarkdownToolbar.jsx
Normal file
89
frontend/src/components/MarkdownToolbar.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import './MarkdownToolbar.css'
|
||||
|
||||
/**
|
||||
* Markdown formatting buttons over a plain textarea.
|
||||
*
|
||||
* Not a WYSIWYG. Milkdown is the WYSIWYG and it is used elsewhere, but it
|
||||
* rewrites what it is given: bullets come back as `*` with blank lines between
|
||||
* them, tables are repadded, and anything it does not recognise gets escaped.
|
||||
* For a question stem that is a problem twice over — learners' highlights are
|
||||
* stored as character offsets into this exact text, so a reflow moves every one
|
||||
* of them, and the reflow would happen on any save, including one where nothing
|
||||
* was really edited.
|
||||
*
|
||||
* So the text stays exactly as typed and the buttons only insert syntax at the
|
||||
* cursor. Bold is still one click; nothing else moves.
|
||||
*/
|
||||
|
||||
const WRAP = 'wrap'
|
||||
const LINE = 'line'
|
||||
const BLOCK = 'block'
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: 'bold', label: 'B', title: 'Bold', kind: WRAP, before: '**', after: '**', style: { fontWeight: 800 } },
|
||||
{ key: 'italic', label: 'I', title: 'Italic', kind: WRAP, before: '*', after: '*', style: { fontStyle: 'italic' } },
|
||||
{ key: 'code', label: '<>', title: 'Code', kind: WRAP, before: '`', after: '`' },
|
||||
{ key: 'bullets', label: '• List', title: 'Bullet list', kind: LINE, prefix: '- ' },
|
||||
{ key: 'numbers', label: '1. List', title: 'Numbered list', kind: LINE, prefix: '1. ' },
|
||||
{ key: 'heading', label: 'H', title: 'Heading', kind: LINE, prefix: '### ' },
|
||||
{
|
||||
key: 'table', label: 'Table', title: 'Insert a table', kind: BLOCK,
|
||||
text: '\n| Test | Value | Reference |\n| --- | --- | --- |\n| | | |\n',
|
||||
},
|
||||
{ key: 'math', label: '∑', title: 'Inline maths', kind: WRAP, before: '$', after: '$' },
|
||||
]
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef, value, onChange, label = 'Formatting' }) {
|
||||
const apply = (action) => {
|
||||
const field = textareaRef.current
|
||||
if (!field) return
|
||||
const start = field.selectionStart ?? 0
|
||||
const end = field.selectionEnd ?? 0
|
||||
const text = value || ''
|
||||
let next = text
|
||||
let caret = end
|
||||
|
||||
if (action.kind === WRAP) {
|
||||
const selected = text.slice(start, end)
|
||||
next = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
|
||||
// With nothing selected the cursor lands between the markers, ready to
|
||||
// type; with a selection it lands after it.
|
||||
caret = selected ? end + action.before.length + action.after.length : start + action.before.length
|
||||
} else if (action.kind === LINE) {
|
||||
// Whole lines, so selecting three lines makes three bullets.
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
||||
const lineEnd = text.indexOf('\n', end)
|
||||
const stop = lineEnd === -1 ? text.length : lineEnd
|
||||
const block = text.slice(lineStart, stop)
|
||||
const numbered = action.prefix === '1. '
|
||||
const lines = block.split('\n').map((line, index) => (
|
||||
line.startsWith(action.prefix) || (numbered && /^\d+\. /.test(line))
|
||||
? line
|
||||
: (numbered ? `${index + 1}. ` : action.prefix) + line))
|
||||
const replaced = lines.join('\n')
|
||||
next = text.slice(0, lineStart) + replaced + text.slice(stop)
|
||||
caret = lineStart + replaced.length
|
||||
} else {
|
||||
next = text.slice(0, start) + action.text + text.slice(end)
|
||||
caret = start + action.text.length
|
||||
}
|
||||
|
||||
onChange(next)
|
||||
// The caret has to be restored after React has written the new value.
|
||||
requestAnimationFrame(() => {
|
||||
field.focus()
|
||||
field.setSelectionRange(caret, caret)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mdbar" role="toolbar" aria-label={label}>
|
||||
{ACTIONS.map(action => (
|
||||
<button type="button" key={action.key} title={action.title} aria-label={action.title}
|
||||
style={action.style} onClick={() => apply(action)}>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/MarkdownToolbar.test.jsx
Normal file
77
frontend/src/components/MarkdownToolbar.test.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useRef, useState } from 'react'
|
||||
import MarkdownToolbar from './MarkdownToolbar'
|
||||
|
||||
function Harness({ initial = '' }) {
|
||||
const ref = useRef(null)
|
||||
const [value, setValue] = useState(initial)
|
||||
return (
|
||||
<>
|
||||
<MarkdownToolbar textareaRef={ref} value={value} onChange={setValue} />
|
||||
<textarea ref={ref} value={value} aria-label="Body" onChange={e => setValue(e.target.value)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const box = () => screen.getByLabelText('Body')
|
||||
const select = (from, to) => {
|
||||
box().setSelectionRange(from, to)
|
||||
box().focus()
|
||||
}
|
||||
|
||||
describe('markdown formatting buttons', () => {
|
||||
it('wraps a selection and leaves the rest alone', async () => {
|
||||
render(<Harness initial="fever and cough" />)
|
||||
select(0, 5)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Bold' }))
|
||||
expect(box()).toHaveValue('**fever** and cough')
|
||||
})
|
||||
|
||||
it('with nothing selected, opens the markers ready to type between', async () => {
|
||||
render(<Harness initial="abc" />)
|
||||
select(3, 3)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Italic' }))
|
||||
expect(box()).toHaveValue('abc**')
|
||||
})
|
||||
|
||||
it('bullets every line of a selection, not just the first', async () => {
|
||||
render(<Harness initial={'Temp 39.1\nHR 148\nRR 40'} />)
|
||||
select(0, 22)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Bullet list' }))
|
||||
expect(box()).toHaveValue('- Temp 39.1\n- HR 148\n- RR 40')
|
||||
})
|
||||
|
||||
it('numbers a list in sequence', async () => {
|
||||
render(<Harness initial={'Croup\nEpiglottitis'} />)
|
||||
select(0, 17)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Numbered list' }))
|
||||
expect(box()).toHaveValue('1. Croup\n2. Epiglottitis')
|
||||
})
|
||||
|
||||
it('does not bullet a line that is already a bullet', async () => {
|
||||
render(<Harness initial={'- Temp 39.1\nHR 148'} />)
|
||||
select(0, 18)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Bullet list' }))
|
||||
expect(box()).toHaveValue('- Temp 39.1\n- HR 148')
|
||||
})
|
||||
|
||||
it('inserts a table skeleton at the cursor', async () => {
|
||||
render(<Harness initial="Labs:" />)
|
||||
select(5, 5)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Insert a table' }))
|
||||
expect(box().value).toContain('| Test | Value | Reference |')
|
||||
expect(box().value.startsWith('Labs:')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves everything else exactly as typed — no reflow', async () => {
|
||||
// The whole reason this is not a WYSIWYG: a stem carries learners'
|
||||
// highlight offsets, so untouched text must come back byte for byte.
|
||||
const original = 'Labs:\n\n| Na | 128 |\n| --- | --- |\n\n- one\n- two\n'
|
||||
render(<Harness initial={original} />)
|
||||
select(0, 0)
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Bold' }))
|
||||
expect(box().value).toBe(`****${original}`)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import CategoryDrilldown from '../components/CategoryDrilldown'
|
||||
import RichText from '../components/RichText'
|
||||
import ImagePicker from '../components/ImagePicker'
|
||||
import FigureManager from '../components/FigureManager'
|
||||
import MarkdownToolbar from '../components/MarkdownToolbar'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
import './QuestionEditPage.css'
|
||||
|
||||
|
|
@ -42,6 +43,8 @@ export default function QuestionEditPage({ mode = 'edit' }) {
|
|||
const [showVersions, setShowVersions] = useState(false)
|
||||
// Which image field the picker is filling, or null when it is closed.
|
||||
const [picking, setPicking] = useState(null)
|
||||
const stemRef = useRef(null)
|
||||
const explanationRef = useRef(null)
|
||||
// Back to wherever you opened this from — the bank with its filters, an
|
||||
// article, the manager — rather than always to the bank you may not have used.
|
||||
const { state } = useLocation()
|
||||
|
|
@ -205,7 +208,9 @@ export default function QuestionEditPage({ mode = 'edit' }) {
|
|||
<div className="qe-card-body">
|
||||
<label className="qe-field">
|
||||
<span>Stem</span>
|
||||
<textarea className="qe-stem" value={form.question_text} aria-label="Question text"
|
||||
<MarkdownToolbar textareaRef={stemRef} value={form.question_text}
|
||||
label="Stem formatting" onChange={v => setField('question_text', v)} />
|
||||
<textarea ref={stemRef} className="qe-stem" value={form.question_text} aria-label="Question text"
|
||||
onChange={e => setField('question_text', e.target.value)} />
|
||||
</label>
|
||||
{form.question_text && (
|
||||
|
|
@ -276,8 +281,10 @@ export default function QuestionEditPage({ mode = 'edit' }) {
|
|||
<div className="qe-card-body">
|
||||
<label className="qe-field">
|
||||
<span>Overall explanation</span>
|
||||
<textarea className="qe-explanation" value={form.explanation} aria-label="Explanation"
|
||||
onChange={e => setField('explanation', e.target.value)} />
|
||||
<MarkdownToolbar textareaRef={explanationRef} value={form.explanation}
|
||||
label="Explanation formatting" onChange={v => setField('explanation', v)} />
|
||||
<textarea ref={explanationRef} className="qe-explanation" value={form.explanation}
|
||||
aria-label="Explanation" onChange={e => setField('explanation', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in a new issue