@mathscan/math-exercise-engine 1.0.12 → 1.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -21,6 +21,8 @@ const AST_TYPES = {
21
21
  BOLD_TEXT: "BoldText",
22
22
  /** Inline input widget (string or numeric) */
23
23
  INLINE_INPUT: "InlineInput",
24
+ /** Inline fraction widget (numerator/denominator with optional inputs) */
25
+ FRACTION: "Fraction",
24
26
  // ═══════════════════════════════════════════════════════════════════════════
25
27
  // Blocks
26
28
  // ═══════════════════════════════════════════════════════════════════════════
@@ -112,7 +114,9 @@ const JSON_TYPES = {
112
114
  /** Radio button options widget */
113
115
  RADIO_OPTIONS: "radio-options",
114
116
  /** Numeric keypad widget */
115
- NUMERIC_KEYPAD: "numeric-keypad"
117
+ NUMERIC_KEYPAD: "numeric-keypad",
118
+ /** Inline fraction widget */
119
+ FRACTION: "fraction"
116
120
  };
117
121
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
118
122
  const VALIDATION_MODES = {
@@ -134,6 +138,17 @@ const SIDE_PAD = 7;
134
138
  const CSS_VAR_SIDE_PAD = "--nl-side-pad";
135
139
  const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
136
140
  const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
141
+ function getFractionKey(widgetId, part) {
142
+ return `${widgetId}__${part}`;
143
+ }
144
+ function isFractionKey(key2) {
145
+ return key2.endsWith("__num") || key2.endsWith("__den");
146
+ }
147
+ function parseFractionKey(key2) {
148
+ const match = key2.match(/^(.+)__(num|den)$/);
149
+ if (!match) return null;
150
+ return { widgetId: match[1], part: match[2] };
151
+ }
137
152
  const Newline = createToken({
138
153
  name: "Newline",
139
154
  pattern: /\r?\n/,
@@ -235,6 +250,10 @@ const HIGHLIGHT = createToken({
235
250
  name: "HIGHLIGHT",
236
251
  pattern: /HIGHLIGHT(?!_TEXT)/
237
252
  });
253
+ const FRACTION = createToken({
254
+ name: "FRACTION",
255
+ pattern: /FRACTION/
256
+ });
238
257
  const NumberLiteral = createToken({
239
258
  name: "NumberLiteral",
240
259
  pattern: /-?\d+(\.\d+)?/
@@ -344,6 +363,7 @@ const allTokens = [
344
363
  CELL,
345
364
  COUNTER,
346
365
  HIGHLIGHT,
366
+ FRACTION,
347
367
  // Literals
348
368
  NumberLiteral,
349
369
  StringLiteral,
@@ -678,6 +698,8 @@ class MathExerciseParser extends CstParser {
678
698
  { ALT: () => this.SUBRULE(this.colorText) },
679
699
  // BOLD_TEXT (for bold text)
680
700
  { ALT: () => this.SUBRULE(this.boldText) },
701
+ // FRACTION (inline fraction widget)
702
+ { ALT: () => this.SUBRULE(this.fraction) },
681
703
  // W_S_I (inline input widget)
682
704
  { ALT: () => this.SUBRULE(this.inlineInput) }
683
705
  ]);
@@ -703,6 +725,12 @@ class MathExerciseParser extends CstParser {
703
725
  this.SUBRULE(this.parameterList);
704
726
  this.CONSUME(RBracket);
705
727
  });
728
+ this.fraction = this.RULE("fraction", () => {
729
+ this.CONSUME(FRACTION);
730
+ this.CONSUME(LBracket);
731
+ this.SUBRULE(this.parameterList);
732
+ this.CONSUME(RBracket);
733
+ });
706
734
  this.inlineInput = this.RULE("inlineInput", () => {
707
735
  this.OR([
708
736
  { ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
@@ -885,13 +913,22 @@ const InlineInputWidgetSchema = z.discriminatedUnion("inputType", [
885
913
  answer: z.union([z.string(), z.number()]).optional()
886
914
  })
887
915
  ]);
916
+ const FractionInlineSchema = z.object({
917
+ type: z.literal(AST_TYPES.FRACTION),
918
+ name: z.string(),
919
+ numerator: z.number().optional(),
920
+ denominator: z.number().optional(),
921
+ answer: z.string().optional(),
922
+ simplified: z.boolean().default(true)
923
+ });
888
924
  const InlineNodeSchema = z.union([
889
925
  FreeTextInlineSchema,
890
926
  SpaceInlineSchema,
891
927
  HighlightedTextInlineSchema,
892
928
  ColorTextInlineSchema,
893
929
  BoldTextInlineSchema,
894
- InlineInputWidgetSchema
930
+ InlineInputWidgetSchema,
931
+ FractionInlineSchema
895
932
  ]);
896
933
  function createFreeText(value) {
897
934
  return FreeTextInlineSchema.parse({
@@ -944,6 +981,13 @@ function createNumericInput(name, digits, answer) {
944
981
  answer
945
982
  });
946
983
  }
984
+ function createFraction(name, options = {}) {
985
+ return FractionInlineSchema.parse({
986
+ type: AST_TYPES.FRACTION,
987
+ name,
988
+ ...options
989
+ });
990
+ }
947
991
  function isFreeText(value) {
948
992
  return FreeTextInlineSchema.safeParse(value).success;
949
993
  }
@@ -953,6 +997,9 @@ function isSpace(value) {
953
997
  function isInlineInput(value) {
954
998
  return InlineInputWidgetSchema.safeParse(value).success;
955
999
  }
1000
+ function isFraction(value) {
1001
+ return FractionInlineSchema.safeParse(value).success;
1002
+ }
956
1003
  function isInlineNode(value) {
957
1004
  return InlineNodeSchema.safeParse(value).success;
958
1005
  }
@@ -1445,6 +1492,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1445
1492
  if (ctx.boldText) {
1446
1493
  return this.visit(ctx.boldText);
1447
1494
  }
1495
+ if (ctx.fraction) {
1496
+ return this.visit(ctx.fraction);
1497
+ }
1448
1498
  if (ctx.inlineInput) {
1449
1499
  return this.visit(ctx.inlineInput);
1450
1500
  }
@@ -1478,6 +1528,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1478
1528
  }
1479
1529
  return createBoldText(value, highlight);
1480
1530
  }
1531
+ fraction(ctx) {
1532
+ const params = this.visit(ctx.parameterList);
1533
+ const name = params.name;
1534
+ const numerator = params.numerator;
1535
+ const denominator = params.denominator;
1536
+ const answer = params.answer;
1537
+ const simplified = params.simplified !== void 0 ? params.simplified : void 0;
1538
+ return createFraction(name, { numerator, denominator, answer, simplified });
1539
+ }
1481
1540
  inlineInput(ctx) {
1482
1541
  const params = this.visit(ctx.parameterList);
1483
1542
  const name = params.name;
@@ -1750,11 +1809,18 @@ const NumericKeypadJSONSchema = z.object({
1750
1809
  includeOperators: z.boolean().optional(),
1751
1810
  includeDecimal: z.boolean().optional()
1752
1811
  });
1812
+ const FractionJSONSchema = z.object({
1813
+ type: z.literal(JSON_TYPES.FRACTION),
1814
+ numerator: z.number().nullable(),
1815
+ denominator: z.number().nullable(),
1816
+ simplified: z.boolean()
1817
+ });
1753
1818
  const WidgetJSONSchema = z.union([
1754
1819
  InlineInputJSONSchema,
1755
1820
  DragOptionsJSONSchema,
1756
1821
  RadioOptionsJSONSchema,
1757
- NumericKeypadJSONSchema
1822
+ NumericKeypadJSONSchema,
1823
+ FractionJSONSchema
1758
1824
  ]);
1759
1825
  const ValidationJSONSchema = z.object({
1760
1826
  mode: z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
@@ -1839,6 +1905,9 @@ function isRadioOptionsJSON(widget) {
1839
1905
  function isNumericKeypadJSON(widget) {
1840
1906
  return widget.type === JSON_TYPES.NUMERIC_KEYPAD;
1841
1907
  }
1908
+ function isFractionJSON(widget) {
1909
+ return widget.type === JSON_TYPES.FRACTION;
1910
+ }
1842
1911
  class LayoutIdGenerator {
1843
1912
  constructor() {
1844
1913
  this.counter = 0;
@@ -1903,6 +1972,17 @@ function toJSON(ast) {
1903
1972
  answers[inline.name] = inline.answer;
1904
1973
  }
1905
1974
  }
1975
+ if (inline.type === AST_TYPES.FRACTION) {
1976
+ widgets[inline.name] = {
1977
+ type: JSON_TYPES.FRACTION,
1978
+ numerator: inline.numerator ?? null,
1979
+ denominator: inline.denominator ?? null,
1980
+ simplified: inline.simplified ?? true
1981
+ };
1982
+ if (inline.answer !== void 0) {
1983
+ answers[inline.name] = inline.answer;
1984
+ }
1985
+ }
1906
1986
  }
1907
1987
  for (const block of ast.lines) {
1908
1988
  if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -2093,6 +2173,12 @@ function transformInline(inline, idGenerator) {
2093
2173
  type: JSON_TYPES.WIDGET_REF,
2094
2174
  widgetId: inline.name
2095
2175
  };
2176
+ case AST_TYPES.FRACTION:
2177
+ return {
2178
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
2179
+ type: JSON_TYPES.WIDGET_REF,
2180
+ widgetId: inline.name
2181
+ };
2096
2182
  default:
2097
2183
  throw new Error(`Unknown inline type: ${inline.type}`);
2098
2184
  }
@@ -2146,6 +2232,7 @@ function isNLDragCorrect(encoded, expectedLabel, expectedPosition) {
2146
2232
  const position = normalize(payload.slice(atIdx + 1));
2147
2233
  return label === normalize(expectedLabel) && position === normalize(expectedPosition);
2148
2234
  }
2235
+ const math$1 = create(all);
2149
2236
  function collectNumberLines(items) {
2150
2237
  const result = [];
2151
2238
  for (const item of items) {
@@ -2185,7 +2272,9 @@ function validateSimple(exercise, userInputs) {
2185
2272
  for (const [widgetId, expected] of Object.entries(answers)) {
2186
2273
  totalCount++;
2187
2274
  const userValue = userInputs[widgetId];
2188
- const isCorrect = areValuesEqual(userValue, expected, positionMap[widgetId]);
2275
+ const widget = exercise.widgets[widgetId];
2276
+ const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
2277
+ const isCorrect = isFractionLenient ? areFractionsEquivalent(userValue, expected) : areValuesEqual(userValue, expected, positionMap[widgetId]);
2189
2278
  details[widgetId] = {
2190
2279
  widgetId,
2191
2280
  userValue,
@@ -2221,6 +2310,16 @@ function areValuesEqual(userValue, expected, expectedPosition) {
2221
2310
  }
2222
2311
  return normalize(userValue) === normalize(expected);
2223
2312
  }
2313
+ function areFractionsEquivalent(user, expected) {
2314
+ if (user === void 0 || user === null) return false;
2315
+ try {
2316
+ const userFrac = math$1.fraction(String(user));
2317
+ const expectedFrac = math$1.fraction(String(expected));
2318
+ return math$1.equal(userFrac, expectedFrac);
2319
+ } catch {
2320
+ return areValuesEqual(user, expected);
2321
+ }
2322
+ }
2224
2323
  const math = create(all);
2225
2324
  const BLOCKED_FUNCTIONS = [
2226
2325
  "import",
@@ -2374,7 +2473,7 @@ function validateExpression(exercise, userInputs) {
2374
2473
  hasInvalidInput = true;
2375
2474
  continue;
2376
2475
  }
2377
- const numValue = typeof rawValue === "number" ? rawValue : parseFloat(String(rawValue).trim());
2476
+ const numValue = typeof rawValue === "number" ? rawValue : parseFractionOrNumber(String(rawValue).trim());
2378
2477
  if (!Number.isFinite(numValue)) {
2379
2478
  details[varName] = {
2380
2479
  widgetId: varName,
@@ -2469,6 +2568,39 @@ function findValidCombination(expression, ranges) {
2469
2568
  }
2470
2569
  return null;
2471
2570
  }
2571
+ function parseFractionOrNumber(s) {
2572
+ const parts = s.split("/");
2573
+ if (parts.length === 2) {
2574
+ const num = parseFloat(parts[0]);
2575
+ const den = parseFloat(parts[1]);
2576
+ if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
2577
+ return num / den;
2578
+ }
2579
+ return NaN;
2580
+ }
2581
+ return parseFloat(s);
2582
+ }
2583
+ function preprocessFractionValues(exercise, userInputs) {
2584
+ const result = { ...userInputs };
2585
+ for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
2586
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
2587
+ const numKey = getFractionKey(widgetId, "num");
2588
+ const denKey = getFractionKey(widgetId, "den");
2589
+ const numEffective = widget.numerator !== null ? String(widget.numerator) : result[numKey] !== void 0 ? String(result[numKey]) : "";
2590
+ const denEffective = widget.denominator !== null ? String(widget.denominator) : result[denKey] !== void 0 ? String(result[denKey]) : "";
2591
+ if (numEffective !== "" && denEffective !== "") {
2592
+ result[widgetId] = `${numEffective}/${denEffective}`;
2593
+ }
2594
+ delete result[numKey];
2595
+ delete result[denKey];
2596
+ }
2597
+ for (const key2 of Object.keys(result)) {
2598
+ if (isFractionKey(key2)) {
2599
+ delete result[key2];
2600
+ }
2601
+ }
2602
+ return result;
2603
+ }
2472
2604
  function validate(exercise, userInputs) {
2473
2605
  if (!exercise.validation) {
2474
2606
  return {
@@ -2476,12 +2608,13 @@ function validate(exercise, userInputs) {
2476
2608
  details: {}
2477
2609
  };
2478
2610
  }
2611
+ const processedInputs = preprocessFractionValues(exercise, userInputs);
2479
2612
  const validatedExercise = exercise;
2480
2613
  switch (validatedExercise.validation.mode) {
2481
2614
  case VALIDATION_MODES.SIMPLE:
2482
- return validateSimple(validatedExercise, userInputs);
2615
+ return validateSimple(validatedExercise, processedInputs);
2483
2616
  case VALIDATION_MODES.EXPRESSION:
2484
- return validateExpression(validatedExercise, userInputs);
2617
+ return validateExpression(validatedExercise, processedInputs);
2485
2618
  default:
2486
2619
  return {
2487
2620
  isCorrect: false,
@@ -6551,6 +6684,7 @@ const InlineInput = ({
6551
6684
  widgetId,
6552
6685
  value,
6553
6686
  isDropped = false,
6687
+ validationStatus = null,
6554
6688
  inputType,
6555
6689
  length,
6556
6690
  digits,
@@ -6568,6 +6702,7 @@ const InlineInput = ({
6568
6702
  }, [handleChange]);
6569
6703
  const expectedLength = inputType === "numeric" ? digits : length;
6570
6704
  const dynamicWidth = `${(expectedLength || 5) * 0.8 + 1}em`;
6705
+ const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
6571
6706
  return /* @__PURE__ */ jsx(
6572
6707
  Droppable,
6573
6708
  {
@@ -6580,7 +6715,7 @@ const InlineInput = ({
6580
6715
  /* @__PURE__ */ jsx(
6581
6716
  "button",
6582
6717
  {
6583
- className: "inline-input-chip",
6718
+ className: `inline-input-chip${statusClass}`,
6584
6719
  onClick: () => handleChange("", false),
6585
6720
  title: "Click to remove",
6586
6721
  children: value
@@ -6593,7 +6728,7 @@ const InlineInput = ({
6593
6728
  {
6594
6729
  id: widgetId + "-input",
6595
6730
  type: "text",
6596
- className: "inline-input",
6731
+ className: `inline-input${statusClass}`,
6597
6732
  value,
6598
6733
  onChange: (e) => handleChange(e.target.value, false),
6599
6734
  onFocus: () => onFocus?.(widgetId),
@@ -6670,18 +6805,83 @@ const NumericKeypad = ({
6670
6805
  includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
6671
6806
  ] });
6672
6807
  };
6808
+ const FractionInput = ({
6809
+ widgetId,
6810
+ numerator,
6811
+ denominator,
6812
+ values,
6813
+ droppedInputs,
6814
+ validationStatus = null,
6815
+ onChange,
6816
+ onFocus
6817
+ }) => {
6818
+ const numKey = getFractionKey(widgetId, "num");
6819
+ const denKey = getFractionKey(widgetId, "den");
6820
+ const numValue = values[numKey] ?? "";
6821
+ const denValue = values[denKey] ?? "";
6822
+ const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
6823
+ const handleDrop = useCallback((subKey) => (item) => {
6824
+ if (item.type === "drag-option") {
6825
+ const data = item.data;
6826
+ onChange(subKey, data.value, true, String(item.id));
6827
+ }
6828
+ }, [onChange]);
6829
+ const renderSlot = (subKey, fixedValue, currentValue, label) => {
6830
+ if (fixedValue !== null) {
6831
+ return /* @__PURE__ */ jsx("span", { className: "fraction-fixed", children: fixedValue });
6832
+ }
6833
+ const isDroppedHere = droppedInputs.has(subKey);
6834
+ return /* @__PURE__ */ jsx(
6835
+ Droppable,
6836
+ {
6837
+ id: subKey,
6838
+ accepts: ["drag-option"],
6839
+ onDrop: handleDrop(subKey),
6840
+ className: "fraction-slot-droppable",
6841
+ children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsx(
6842
+ "button",
6843
+ {
6844
+ className: `inline-input-chip fraction-chip${statusClass}`,
6845
+ onClick: () => onChange(subKey, "", false),
6846
+ title: "Click to remove",
6847
+ children: currentValue
6848
+ }
6849
+ ) : /* @__PURE__ */ jsx(
6850
+ "input",
6851
+ {
6852
+ id: `${subKey}-input`,
6853
+ type: "text",
6854
+ className: `inline-input fraction-input${statusClass}`,
6855
+ value: currentValue,
6856
+ onChange: (e) => onChange(subKey, e.target.value, false),
6857
+ onFocus: () => onFocus?.(subKey),
6858
+ maxLength: 4,
6859
+ "aria-label": label
6860
+ }
6861
+ )
6862
+ }
6863
+ );
6864
+ };
6865
+ return /* @__PURE__ */ jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
6866
+ /* @__PURE__ */ jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator") }),
6867
+ /* @__PURE__ */ jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
6868
+ /* @__PURE__ */ jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
6869
+ ] });
6870
+ };
6673
6871
  const RadioOptions = ({
6674
6872
  name,
6675
6873
  options,
6676
6874
  value,
6875
+ validationStatus = null,
6677
6876
  onChange
6678
6877
  }) => {
6679
6878
  return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
6680
6879
  const isSelected = value === option;
6880
+ const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
6681
6881
  return /* @__PURE__ */ jsx(
6682
6882
  "button",
6683
6883
  {
6684
- className: `radio-option${isSelected ? " selected" : ""}`,
6884
+ className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
6685
6885
  onClick: () => onChange(name, option),
6686
6886
  type: "button",
6687
6887
  children: option
@@ -6702,11 +6902,17 @@ function isRadioOptionsWidget(widget) {
6702
6902
  function isNumericKeypadWidget(widget) {
6703
6903
  return widget.type === "numeric-keypad";
6704
6904
  }
6905
+ function isFractionWidget(widget) {
6906
+ return widget.type === "fraction";
6907
+ }
6705
6908
  const WidgetRenderer = ({
6706
6909
  widget,
6707
6910
  widgetId,
6708
6911
  value,
6912
+ values = {},
6709
6913
  isDropped = false,
6914
+ validationStatus = null,
6915
+ droppedInputs = /* @__PURE__ */ new Set(),
6710
6916
  onChange,
6711
6917
  onInputFocus,
6712
6918
  onKeypadChange
@@ -6718,6 +6924,7 @@ const WidgetRenderer = ({
6718
6924
  widgetId,
6719
6925
  value,
6720
6926
  isDropped,
6927
+ validationStatus,
6721
6928
  inputType: widget.inputType,
6722
6929
  length: widget.inputType === "string" ? widget.length : void 0,
6723
6930
  digits: widget.inputType === "numeric" ? widget.digits : void 0,
@@ -6743,6 +6950,7 @@ const WidgetRenderer = ({
6743
6950
  name: widgetId,
6744
6951
  options: widget.options,
6745
6952
  value,
6953
+ validationStatus,
6746
6954
  onChange
6747
6955
  }
6748
6956
  );
@@ -6759,6 +6967,21 @@ const WidgetRenderer = ({
6759
6967
  }
6760
6968
  );
6761
6969
  }
6970
+ if (isFractionWidget(widget)) {
6971
+ return /* @__PURE__ */ jsx(
6972
+ FractionInput,
6973
+ {
6974
+ widgetId,
6975
+ numerator: widget.numerator,
6976
+ denominator: widget.denominator,
6977
+ values,
6978
+ droppedInputs,
6979
+ validationStatus,
6980
+ onChange,
6981
+ onFocus: onInputFocus
6982
+ }
6983
+ );
6984
+ }
6762
6985
  return null;
6763
6986
  };
6764
6987
  const EPSILON = 10 ** -INTERNAL_PRECISION;
@@ -7266,6 +7489,7 @@ const NumberLineRenderer = ({
7266
7489
  widgets,
7267
7490
  values,
7268
7491
  droppedInputs,
7492
+ validationDetails,
7269
7493
  onChange,
7270
7494
  onInputFocus
7271
7495
  }) => {
@@ -7333,6 +7557,7 @@ const NumberLineRenderer = ({
7333
7557
  widgets,
7334
7558
  values,
7335
7559
  droppedInputs,
7560
+ validationDetails,
7336
7561
  onChange,
7337
7562
  onInputFocus
7338
7563
  },
@@ -7457,6 +7682,7 @@ const LayoutRenderer = ({
7457
7682
  widgets,
7458
7683
  values,
7459
7684
  droppedInputs,
7685
+ validationDetails,
7460
7686
  onChange,
7461
7687
  onInputFocus
7462
7688
  }) => {
@@ -7509,13 +7735,18 @@ const LayoutRenderer = ({
7509
7735
  case JSON_TYPES.WIDGET_REF: {
7510
7736
  const widget = widgets[item.widgetId];
7511
7737
  if (!widget) return null;
7738
+ const isCorrect = validationDetails?.[item.widgetId];
7739
+ const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
7512
7740
  return /* @__PURE__ */ jsx(
7513
7741
  WidgetRenderer,
7514
7742
  {
7515
7743
  widget,
7516
7744
  widgetId: item.widgetId,
7517
7745
  value: values[item.widgetId] || "",
7746
+ values,
7518
7747
  isDropped: droppedInputs.has(item.widgetId),
7748
+ validationStatus,
7749
+ droppedInputs,
7519
7750
  onChange,
7520
7751
  onInputFocus
7521
7752
  }
@@ -7535,6 +7766,7 @@ const LayoutRenderer = ({
7535
7766
  widgets,
7536
7767
  values,
7537
7768
  droppedInputs,
7769
+ validationDetails,
7538
7770
  onChange,
7539
7771
  onInputFocus
7540
7772
  },
@@ -7553,6 +7785,7 @@ const LayoutRenderer = ({
7553
7785
  widgets,
7554
7786
  values,
7555
7787
  droppedInputs,
7788
+ validationDetails,
7556
7789
  onChange,
7557
7790
  onInputFocus
7558
7791
  },
@@ -7569,6 +7802,7 @@ const LayoutRenderer = ({
7569
7802
  widgets,
7570
7803
  values,
7571
7804
  droppedInputs,
7805
+ validationDetails,
7572
7806
  onChange,
7573
7807
  onInputFocus
7574
7808
  }
@@ -7596,9 +7830,15 @@ const ExerciseRenderer = forwardRef(({
7596
7830
  const [values, setValues] = useState({});
7597
7831
  const [droppedSources, setDroppedSources] = useState(/* @__PURE__ */ new Map());
7598
7832
  const [focusedInputId, setFocusedInputId] = useState(null);
7833
+ const [validationDetails, setValidationDetails] = useState(null);
7599
7834
  useImperativeHandle(ref, () => ({
7600
7835
  submit: () => {
7601
7836
  const result = validate(json, values);
7837
+ const details = {};
7838
+ for (const [id, v] of Object.entries(result.details)) {
7839
+ details[id] = v.isCorrect;
7840
+ }
7841
+ setValidationDetails(details);
7602
7842
  onSubmit?.(values);
7603
7843
  return result;
7604
7844
  },
@@ -7607,6 +7847,7 @@ const ExerciseRenderer = forwardRef(({
7607
7847
  setValues({});
7608
7848
  setDroppedSources(/* @__PURE__ */ new Map());
7609
7849
  setFocusedInputId(null);
7850
+ setValidationDetails(null);
7610
7851
  },
7611
7852
  focusFirstInput: () => {
7612
7853
  const container = document.querySelector(".math-exercise-renderer");
@@ -7720,6 +7961,7 @@ const ExerciseRenderer = forwardRef(({
7720
7961
  widgets: json.widgets,
7721
7962
  values,
7722
7963
  droppedInputs,
7964
+ validationDetails,
7723
7965
  onChange: handleChange,
7724
7966
  onInputFocus: setFocusedInputId
7725
7967
  },
@@ -7734,6 +7976,7 @@ const ExerciseRenderer = forwardRef(({
7734
7976
  widgets: json.widgets,
7735
7977
  values,
7736
7978
  droppedInputs,
7979
+ validationDetails,
7737
7980
  onChange: handleChange,
7738
7981
  onInputFocus: setFocusedInputId
7739
7982
  },
@@ -7749,6 +7992,7 @@ const ExerciseRenderer = forwardRef(({
7749
7992
  widgets: json.widgets,
7750
7993
  values,
7751
7994
  droppedInputs,
7995
+ validationDetails,
7752
7996
  onChange: handleChange,
7753
7997
  onInputFocus: setFocusedInputId
7754
7998
  },
@@ -7838,6 +8082,8 @@ export {
7838
8082
  ExerciseMetaSchema,
7839
8083
  ExerciseRenderer,
7840
8084
  ExpressionValidationSchema,
8085
+ FractionInlineSchema,
8086
+ FractionJSONSchema,
7841
8087
  FreeTextInlineSchema,
7842
8088
  GroupItemSchema,
7843
8089
  HighlightedTextInlineSchema,
@@ -7885,6 +8131,7 @@ export {
7885
8131
  createDragOptions,
7886
8132
  createExerciseDocument,
7887
8133
  createExpressionValidation,
8134
+ createFraction,
7888
8135
  createFreeText,
7889
8136
  createHighlightedText,
7890
8137
  createLineBlock,
@@ -7897,6 +8144,7 @@ export {
7897
8144
  evaluate,
7898
8145
  extractVariables as extractExpressionVariables,
7899
8146
  findValidCombination,
8147
+ getFractionKey,
7900
8148
  getMissingInputs,
7901
8149
  isBoldText,
7902
8150
  isColorText,
@@ -7904,6 +8152,9 @@ export {
7904
8152
  isDragOptionsJSON,
7905
8153
  isExerciseDocument,
7906
8154
  isExpressionValidation,
8155
+ isFraction,
8156
+ isFractionJSON,
8157
+ isFractionKey,
7907
8158
  isFreeText,
7908
8159
  isGroupItem,
7909
8160
  isHighlightedText,
@@ -7930,6 +8181,7 @@ export {
7930
8181
  isValidationNode,
7931
8182
  isWidgetRef,
7932
8183
  parse,
8184
+ parseFractionKey,
7933
8185
  parseSafe,
7934
8186
  renderExercise,
7935
8187
  toAST,