diff --git a/frontend/src/components/MarkdownToolbar.jsx b/frontend/src/components/MarkdownToolbar.jsx index 25ddacd..5d7c9dc 100644 --- a/frontend/src/components/MarkdownToolbar.jsx +++ b/frontend/src/components/MarkdownToolbar.jsx @@ -18,6 +18,11 @@ import './MarkdownToolbar.css' const WRAP = 'wrap' const LINE = 'line' const BLOCK = 'block' +const TIP = 'tip' + +//: What a tip reads as before anything has been typed into it. +const TIP_PHRASE = 'phrase' +const TIP_TEXT = 'the teaching point' const ACTIONS = [ { key: 'bold', label: 'B', title: 'Bold', kind: WRAP, before: '**', after: '**', style: { fontWeight: 800 } }, @@ -31,6 +36,8 @@ const ACTIONS = [ text: '\n| Test | Value | Reference |\n| --- | --- | --- |\n| | | |\n', }, { key: 'math', label: '∑', title: 'Inline maths', kind: WRAP, before: '$', after: '$' }, + // Select the words first; the tip is what you type next. + { key: 'tip', label: '⚕ Tip', title: 'Turn the selected words into a tip', kind: TIP }, ] export default function MarkdownToolbar({ textareaRef, value, onChange, label = 'Formatting' }) { @@ -42,6 +49,8 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label = const text = value || '' let next = text let caret = end + // Where to leave the selection, when landing the caret is not enough. + let select = null if (action.kind === WRAP) { const selected = text.slice(start, end) @@ -63,6 +72,17 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label = const replaced = lines.join('\n') next = text.slice(0, lineStart) + replaced + text.slice(stop) caret = lineStart + replaced.length + } else if (action.kind === TIP) { + // With words selected, they become the underlined phrase and the caret + // waits where the tip goes. With nothing selected there is a placeholder + // to type over, selected so the first keystroke replaces it. + const selected = text.slice(start, end) + const phrase = selected || TIP_PHRASE + const tip = selected ? '' : TIP_TEXT + next = `${text.slice(0, start)}{{${phrase}|${tip}}}${text.slice(end)}` + const tipAt = start + 2 + phrase.length + 1 + select = selected ? [tipAt, tipAt] : [start + 2, start + 2 + phrase.length] + caret = tipAt } else { next = text.slice(0, start) + action.text + text.slice(end) caret = start + action.text.length @@ -72,7 +92,7 @@ export default function MarkdownToolbar({ textareaRef, value, onChange, label = // The caret has to be restored after React has written the new value. requestAnimationFrame(() => { field.focus() - field.setSelectionRange(caret, caret) + field.setSelectionRange(...(select || [caret, caret])) }) } diff --git a/frontend/src/components/MarkdownToolbar.test.jsx b/frontend/src/components/MarkdownToolbar.test.jsx index 6524348..6f4711f 100644 --- a/frontend/src/components/MarkdownToolbar.test.jsx +++ b/frontend/src/components/MarkdownToolbar.test.jsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useRef, useState } from 'react' import MarkdownToolbar from './MarkdownToolbar' @@ -65,6 +65,27 @@ describe('markdown formatting buttons', () => { expect(box().value.startsWith('Labs:')).toBe(true) }) + it('turns the selected words into a tip, waiting where the tip goes', async () => { + render() + select(12, 19) + await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' })) + expect(box()).toHaveValue('A child has {{stridor|}} at rest.') + // The caret sits after the bar, so the next thing typed is the tip itself. + await waitFor(() => expect(box().selectionStart).toBe('A child has {{stridor|'.length)) + }) + + it('with nothing selected, offers a placeholder to type over', async () => { + render() + select(0, 0) + await userEvent.click(screen.getByRole('button', { name: 'Turn the selected words into a tip' })) + expect(box()).toHaveValue('{{phrase|the teaching point}}') + // "phrase" is selected, so the first keystroke replaces it. + await waitFor(() => { + expect(box().selectionStart).toBe(2) + expect(box().selectionEnd).toBe(8) + }) + }) + 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. diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx index f3c35f4..126ea46 100644 --- a/frontend/src/pages/HandbookPage.jsx +++ b/frontend/src/pages/HandbookPage.jsx @@ -122,9 +122,18 @@ export default function HandbookPage() {

Tips in the prose

+

1. A tip on one phrase

+

+ In the question editor, select the words in the stem + (or an option, or the explanation) and press ⚕ Tip on + that field's toolbar. The words become the phrase and the caret waits + where the tip goes — type the sentence and you are done. With nothing + selected the button drops in a marker with phrase + highlighted to type over. +

A phrase in a stem, an option, an explanation or an article can carry - one sentence of teaching behind it. Written as{' '} + one sentence of teaching behind it. What the button writes is{' '} {'{{stridor|Inspiratory stridor is extrathoracic until proven otherwise}}'}{' '} — the phrase first, then a bar, then the tip.

@@ -145,6 +154,24 @@ export default function HandbookPage() { braces. Anything that does not match the shape exactly is left in the text as written — a stray {'{'} in a formula is safe.

+ +

2. A tip for the whole question

+

+ The Attending tip field at the foot of the editor. + One nudge for the question as a whole, offered in the player behind a + ⚕ button above the options. Leave it empty and no button + appears — a question with nothing to say says nothing. +

+ +

What it costs the learner

+

+ Either kind, opened before the answer goes in, is recorded + with that answer. It still counts as correct and the percentage is not + docked; it is shown on Analysis as its own slice —{' '} + correct after a tip — so a learner can see how much of a + score leaned on one. A tip opened while reading the explanation is + revision and costs nothing. +

diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css index f9387da..9cbae9f 100644 --- a/frontend/src/pages/QuestionEditPage.css +++ b/frontend/src/pages/QuestionEditPage.css @@ -46,6 +46,8 @@ .qe-field textarea { min-height: 200px; resize: vertical; line-height: 1.65; } .qe-field textarea.qe-stem { min-height: 340px; } .qe-field textarea.qe-explanation { min-height: 280px; } +/* Shorter than an explanation: the tip is meant to be one sentence. */ +.qe-field textarea.qe-tip { min-height: 84px; } /* ── Options ──────────────────────────────────────────────────────── */ .qe-option { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; } diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx index 0f6f5b1..d2dedd8 100644 --- a/frontend/src/pages/QuestionEditPage.jsx +++ b/frontend/src/pages/QuestionEditPage.jsx @@ -22,7 +22,7 @@ const apiError = (err, fallback) => { const blank = () => ({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', - explanation: '', question_category_id: '', extraCategoryIds: [], option_explanations: {}, + explanation: '', attending_tip: '', question_category_id: '', extraCategoryIds: [], option_explanations: {}, difficulty: '', image_path: '', explanation_image_path: '', }) @@ -46,6 +46,7 @@ export default function QuestionEditPage({ mode = 'edit' }) { const [picking, setPicking] = useState(null) const stemRef = useRef(null) const explanationRef = useRef(null) + const tipRef = useRef(null) // One ref per option, kept across renders so the toolbar can find the // field it belongs to when options are added or reordered. const optionRefs = useRef([]) @@ -74,6 +75,7 @@ export default function QuestionEditPage({ mode = 'edit' }) { options: found.options ? [...found.options] : [], correct_answer: found.correct_answer || '', explanation: found.explanation || '', + attending_tip: found.attending_tip || '', question_category_id: found.question_category_id || '', extraCategoryIds: (found.category_ids || []).filter(c => c !== found.question_category_id), option_explanations: { ...(found.option_explanations || {}) }, @@ -134,6 +136,7 @@ export default function QuestionEditPage({ mode = 'edit' }) { options: form.question_type === 'mcq' ? form.options.filter(o => o.trim()) : null, correct_answer: form.correct_answer.trim(), explanation: form.explanation || null, + attending_tip: form.attending_tip.trim() || null, question_category_id: form.question_category_id === '' ? null : Number(form.question_category_id), additional_category_ids: form.extraCategoryIds.filter(c => c !== Number(form.question_category_id)), option_explanations: form.option_explanations, @@ -302,6 +305,23 @@ export default function QuestionEditPage({ mode = 'edit' }) {