diff --git a/docs/TODO.md b/docs/TODO.md index 133e061..ab32fa3 100644 --- a/docs/TODO.md +++ b/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 diff --git a/frontend/src/components/MarkdownToolbar.css b/frontend/src/components/MarkdownToolbar.css new file mode 100644 index 0000000..52a2da7 --- /dev/null +++ b/frontend/src/components/MarkdownToolbar.css @@ -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; } +} diff --git a/frontend/src/components/MarkdownToolbar.jsx b/frontend/src/components/MarkdownToolbar.jsx new file mode 100644 index 0000000..25ddacd --- /dev/null +++ b/frontend/src/components/MarkdownToolbar.jsx @@ -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 ( +