From 3b45eaf3a6c91537d65409b11b558faeeacbadfc Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 13:20:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20formatting=20buttons=20on=20the=20quest?= =?UTF-8?q?ion=20editor=20=E2=80=94=20a=20toolbar,=20not=20a=20WYSIWYG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- docs/TODO.md | 11 ++- frontend/src/components/MarkdownToolbar.css | 25 ++++++ frontend/src/components/MarkdownToolbar.jsx | 89 +++++++++++++++++++ .../src/components/MarkdownToolbar.test.jsx | 77 ++++++++++++++++ frontend/src/pages/QuestionEditPage.jsx | 15 +++- 5 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/MarkdownToolbar.css create mode 100644 frontend/src/components/MarkdownToolbar.jsx create mode 100644 frontend/src/components/MarkdownToolbar.test.jsx 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 ( +
+ {ACTIONS.map(action => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/MarkdownToolbar.test.jsx b/frontend/src/components/MarkdownToolbar.test.jsx new file mode 100644 index 0000000..6524348 --- /dev/null +++ b/frontend/src/components/MarkdownToolbar.test.jsx @@ -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 ( + <> + +