QuestionEditor
question-editorStructured editor for one math question: seven types, figures, options, blanks, rubric, and a live QuestionCard preview.
Usage
Basic usage
Single choice: add, remove, and reorder options, and the correct answer follows the content; the preview on the right is QuestionCard.
Switching the type clears options and answer
Preview (same typesetting as the question display)
In , and . The value of is ( )
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
2 to 8 options, lettered in order
Pick one
Preview (same typesetting as the question display)
From we get .
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor value={question} onChange={setQuestion} />Fill in the blanks
The number of blanks follows ____ in the stem, with a one-click align when they differ; a blank may accept several equivalent forms.
Switching the type clears options and answer
Preview (same typesetting as the question display)
Write as a decimal: , and as a percentage: .
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
Wrap formulas in $…$, e.g. $x^{2}$
Wrap formulas in $…$, e.g. $x^{2}$
Wrap formulas in $…$, e.g. $x^{2}$
One row per blank, in stem order; a blank may accept several equivalent forms
Wrap formulas in $…$, e.g. $x^{2}$
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor value={question} onChange={setQuestion} />Rubric
Calculation and extended-response questions can switch to a rubric; the rubric total sits next to the question score.
Switching the type clears options and answer
Preview (same typesetting as the question display)
Evaluate
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
Preview (same typesetting as the question display)
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
Optional; show the key steps
Wrap formulas in $…$, e.g. $x^{2}$
Wrap formulas in $…$, e.g. $x^{2}$
Rubric total 8, question score 8
Score point by point; the total should equal the question score
Wrap formulas in $…$, e.g. $x^{2}$
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor value={question} onChange={setQuestion} />Figures
 in the stem is resolved by resolveFigure; the Insert image button appears only with onUploadFigure.
Switching the type clears options and answer
Preview (same typesetting as the question display)
As shown, in with , the relation between and is ( )
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
Wrap formulas in $…$, e.g. $x^{2}$
Wrap formulas in $…$, e.g. $x^{2}$
2 to 8 options, lettered in order
Pick one
Wrap formulas in $…$, e.g. $x^{2}$
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor
value={question}
onChange={setQuestion}
resolveFigure={(key) => fileUrl(key)}
onUploadFigure={async (file) => (await upload(file)).key}
/>Review bar and private fields
issues are listed at the top with a Resolved button each; extra holds the consumer's own fields.
- An option may be missing
- Answer in doubt
Switching the type clears options and answer
Preview (same typesetting as the question display)
In , and . The value of is ( )
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
2 to 8 options, lettered in order
Pick one
Preview (same typesetting as the question display)
From we get .
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor
value={question}
onChange={setQuestion}
issues={[{ label: "An option may be missing" }]}
onResolveIssue={(label) => resolve(label)}
extra={<PrivateFields />}
/>Hiding score and estimated time
When a score belongs to the paper-question pair rather than the question itself, hide the input with hiddenFields: the editor writes nothing to a hidden field, switching type no longer recomputes the score, and the metrics row shrinks to the columns it still has.
Switching the type clears options and answer
Preview (same typesetting as the question display)
In , and . The value of is ( )
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
2 to 8 options, lettered in order
Pick one
Preview (same typesetting as the question display)
From we get .
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor
value={question}
onChange={setQuestion}
hiddenFields={["score", "estimatedMinutes"]}
/>Validate everything on submit
By default only edited fields turn red; showAllIssues attaches every validateQuestion issue at once.
Switching the type clears options and answer
Wrap formulas in $…$, e.g. $x^{2}$
For fill-in-the-blank, mark each blank with ____ in the stem
Wrap formulas in $…$, e.g. $x^{2}$
Wrap formulas in $…$, e.g. $x^{2}$
2 to 8 options, lettered in order
Pick at least two
Wrap formulas in $…$, e.g. $x^{2}$
1 star easiest, 5 stars hardest
Preview (the same card used for display)
Preview appears once the stem has text
<QuestionEditor value={question} onChange={setQuestion} showAllIssues />Read only
disabled: the read-only state after review.
Switching the type clears options and answer
Preview (same typesetting as the question display)
In , and . The value of is ( )
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
2 to 8 options, lettered in order
Pick one
Preview (same typesetting as the question display)
From we get .
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor value={question} onChange={setQuestion} disabled />When to use
Question-bank entry pages and the calibration page after AI or Word import: anywhere "edit one question" happens, instead of a 1200-line form per page. It owns the question itself (the Question canonical shape). Subject, textbook section, knowledge points, general-scope notes, and upload endpoints are consumer-private: put them in the extra slot and upload through onUploadFigure.
Display only: QuestionCard. Student answering: QuestionAnswer. A single formula-capable input: MathTextarea (what this component is built from).
Import
import { QuestionEditor, emptyQuestion, validateQuestion, toWireAnswer, fromWire } from "@hulianui/ui/math"It lives in @hulianui/ui/math rather than the root package: the stem, options, and preview are all Formula, so KaTeX comes along; consumers that never typeset math should not pay for it.
Props
| Name | Type | Default | Description |
|---|---|---|---|
| value | Question | - | Controlled value in the canonical shape (Question from @hulianui/ui/math). Blank answers are always arrays inside the editor (one entry even for a single blank); flatten on the way out with toWireAnswer |
| onChange | (next: Question) => void | - | Called with the whole canonical shape on every edit |
| disabled | boolean | false | Read-only |
| resolveFigure | (key: string) => string | - | Maps  in the stem to a displayable URL. The thumbnail strip, the lightbox, and the stem preview all use it; when the stem has figures and it is missing, thumbnails show only the key and a dev warning fires |
| onUploadFigure | (file: File) => Promise<string> | - | Uploads one figure and resolves to its storage key. The Insert image button appears only when provided; on success  is appended to the stem, on failure the reason shows in the strip |
| figureFilter | (key: string) => boolean | - | Which keys count as figures (they enter the thumbnail strip, can be added or removed, and are written back at the end of the stem). Omitted = all of them. References it rejects stay exactly where they are in the stem body; the editor never moves them and never touches their alt text |
| extra | ReactNode | - | Consumer-private fields, rendered after the type and before the stem |
| issues | { label, tone? }[] | - | Review bar listed at the top, one Resolved button per item |
| onResolveIssue | (label: string) => void | - | Called from the Resolved button; the button is omitted when this is missing |
| defaultScoreByType | Partial<Record<QuestionType, number>> | - | Overrides the per-type default score. On a type switch the score changes only if it still equals the old type's default |
| templates | readonly FormulaTemplateGroup[] | - | Passed to every MathTextarea |
| visualEditor | ComponentType<MathFieldLikeProps> | - | Passed to every MathTextarea (MathField satisfies the contract) |
| macros | Record<string, string> | - | KaTeX macros passed to every MathTextarea and to the stem preview (the QuestionCard preview on the right does not take macros) |
| hiddenFields | readonly ("difficulty" | "score" | "estimatedMinutes")[] | - | Which fields of the metrics row are not rendered. Products where a score belongs to the paper-question pair rather than the question use it to drop the score input; the editor never writes a hidden field (switching type no longer recomputes score) and its value passes through Question untouched. With all three hidden the row disappears |
| preview | boolean | true | Live QuestionCard preview on the right (below on narrow screens), with answer and explanation |
| showAllIssues | boolean | false | Show every validateQuestion issue at once. By default only fields the user has edited show issues; set it after the user presses submit |
| className | string | - | Root node (a two-column grid) |
Events
| Name | Arguments | Description |
|---|---|---|
| onChange | (next: Question) | Any field changed. A type switch resets options and answer together and remaps score through the default table |
| onUploadFigure | (file: File) | The user picked an image. The resolved key is written into the latest stem (text typed during the upload is kept) |
| onResolveIssue | (label: string) | Resolved pressed on the review bar |
Slots
| Name | Description |
|---|---|
| extra | Private-field area after the type and before the stem, for subject / section / knowledge-point fields that are not part of "one question" |
Localization
All copy comes from the components.questionEditor locale (QuestionEditorLocale, source of truth in question-editor.locale.ts; zhCN and enUS are wired). Type names and the True / False labels come from components.question (shared with QuestionCard). The validateQuestion code table is validation, keyed by QuestionValidationCode.
Companion pure functions
All exported from @hulianui/ui/math:
questionFormulaIssues(q)returns{ field, key?, issue }[]: runsvalidateFormulaSyntaxfield by field (stem, each option, each form of each blank, reference answer and rubric points, explanation). Every input already shows the same issue inline; this one is for the submit button. Compose the message from themathTextarealocale:position(line, column) + syntax[code].shapeIsDirty(q)returnsboolean: whether switching type would lose content (an option has text, or the answer differs from the type's empty shape). Reuse it for a "clear form" confirmation.switchType(q, type, defaults?)returnsQuestion: resets options and answer together and remaps the score.optionCaption(key, text)returnsstring: the label on the correct-answer control (Aplus the first 20 characters of the option as plain text).stemBody(stem, accept?)/joinStemFigures(body, keys): split and join the stem body and the figure block with the editor's own rule.acceptis the same predicate asfigureFilter; omitted, every reference counts as a figure.
Pitfalls
- `value` must be canonical. Legacy variants such as
answer: "A,C",options: ["A. x"], oranswer: "true"go throughfromWirefirst; fed directly they render as "no answer selected" andvalidateQuestionreportsanswer_out_of_range. - Flatten blank answers on the way out. Inside the editor a single blank is
["90"]; if the backend wants a plain string for single blanks, calltoWireAnswer(question)before submitting. - Switching type clears options and answer (with a confirmation when there is content). Keeping the old shape would produce values such as a true-false question with options, which the backend rejects.
- Figures live in the stem, not in another field. The input hides
butvalue.stemcontains it; if figures lived elsewhere, paper preview, the student view, and export would get none of them. WithoutresolveFigurethe preview drops figures and thumbnails show only the key. - Not every `` should count as a figure; carve inline formula images out with `figureFilter`. By default every image reference in the stem is treated as a figure and the whole set is moved to the end of the stem. That is wrong for the inline formula images a Word import pipeline produces:
x has the solution set ______.turns into "x has the solution set ______." plus a trailing image after a single edit, and the sentence no longer reads. Split them by key prefix:
```tsx
<QuestionEditor figureFilter={(key) => !key.startsWith("import/formula/")} ... />
```
Rejected references stay verbatim in the stem body (the  text is visible in the input), the strip does not list them, they cannot be deleted there, and neither their position nor their alt text changes.
- The stem preview needs `resolveFigure` to show figures. With it, the preview under the input renders through the same path as the display end (figures drawn, alt taken from the reference itself); without it the preview falls back to plain
<Formula>, which does not understand markdown image syntax and printsverbatim. When the body has neither formulas nor figures the preview box is hidden entirely - it would be character-for-character the input. - Upload appears only with `onUploadFigure`. Enforce type and size limits inside the callback; reject with
throw new Error("Max 5MB per image")and the message shows verbatim in the strip. - Validation shows only edited fields by default. Set
showAllIssuesto flag everything at submit time; do not draw errors again outside. - When score or timing is not part of your question model, hide it with `hiddenFields` instead of zeroing the default score (#358). A
defaultScoreByTypefull of zeros only stops the editor from inventing a score; the input is still there and teachers still fill it in - and what they type either contradicts the product ("this question is worth 3 points" inside a question bank) or gets dropped silently on submit. Once hidden, the editor writes nothing to that field: switching type no longer recomputesscoreeither, because writing a field the user cannot see is a silent data change. The value itself still passes through untouched, andvalidateQuestionis unchanged - an issue on a hidden field simply has nowhere to show. Only the three fields of the metrics row can be hidden; stem, options, answer and explanation cannot. - No submit button. Submitting, required private fields (such as subject), and an upper bound for
estimatedMinutesbelong to the page. - JSX attribute strings do not process `\\`. Put formulas with backslashes in TS strings (
stem: "$\\frac{1}{2}$"), not in JSX attributes.
Related
- QuestionCard: the preview is this component (
showAnswer+resolveFigure) - MathTextarea: every formula-capable input
- Formula: the question-domain pure functions in
@hulianui/ui/math(validateQuestion/defaultShape/toWireAnswer/fromWire/splitStemFigures) - Field: how errors are attached
Playground
Switching the type clears options and answer
Preview (same typesetting as the question display)
In , and . The value of is ( )
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
For fill-in-the-blank, mark each blank with ____ in the stem
2 to 8 options, lettered in order
Pick one
Preview (same typesetting as the question display)
From we get .
Red source in the preview means KaTeX cannot parse that formula. Check the command spelling.
1 star easiest, 5 stars hardest
Preview (the same card used for display)
<QuestionEditor value={question} onChange={setQuestion} />