@mathscan/math-exercise-engine 1.0.16 → 1.0.17

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.js CHANGED
@@ -160,6 +160,7 @@ const SIDE_PAD = 7;
160
160
  const CSS_VAR_SIDE_PAD = "--nl-side-pad";
161
161
  const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
162
162
  const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
163
+ const NUMBER_LINE_DRAG_MODES = ["axis", "inputs"];
163
164
  function getFractionKey(widgetId, part) {
164
165
  return `${widgetId}__${part}`;
165
166
  }
@@ -1156,7 +1157,8 @@ const FractionInlineSchema = zod.z.object({
1156
1157
  numerator: zod.z.number().optional(),
1157
1158
  denominator: zod.z.number().optional(),
1158
1159
  answer: zod.z.string().optional(),
1159
- simplified: zod.z.boolean().default(true)
1160
+ simplified: zod.z.boolean().default(true),
1161
+ placeholder: zod.z.string().max(32).optional()
1160
1162
  });
1161
1163
  const IMAGE_SRC_SCHEME_RE$1 = /^(https?:\/\/|data:image\/)/i;
1162
1164
  const ImageInlineSchema = zod.z.object({
@@ -1438,6 +1440,11 @@ const NumberLineBlockSchema = zod.z.object({
1438
1440
  }).optional(),
1439
1441
  counter: NumberLineCounterSchema.optional(),
1440
1442
  highlights: zod.z.array(NumberLineHighlightSchema).optional(),
1443
+ dragMode: zod.z.enum(NUMBER_LINE_DRAG_MODES, {
1444
+ errorMap: () => ({
1445
+ message: `NUMBER_LINE: dragMode must be one of ${NUMBER_LINE_DRAG_MODES.join(", ")}`
1446
+ })
1447
+ }).optional(),
1441
1448
  positions: zod.z.array(NumberLinePositionSchema)
1442
1449
  }).refine((data) => data.min < data.max, {
1443
1450
  message: "NUMBER_LINE: min must be less than max"
@@ -1475,7 +1482,7 @@ const NumberLineBlockSchema = zod.z.object({
1475
1482
  }
1476
1483
  }
1477
1484
  });
1478
- function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights) {
1485
+ function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights, dragMode) {
1479
1486
  return NumberLineBlockSchema.parse({
1480
1487
  type: AST_TYPES.NUMBER_LINE_BLOCK,
1481
1488
  min,
@@ -1486,6 +1493,7 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style, lab
1486
1493
  labelVisibility,
1487
1494
  counter,
1488
1495
  highlights,
1496
+ dragMode,
1489
1497
  positions
1490
1498
  });
1491
1499
  }
@@ -1850,6 +1858,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1850
1858
  const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
1851
1859
  const style = params.style;
1852
1860
  const labelVisibility = params.labelVisibility;
1861
+ const dragMode = params.dragMode;
1853
1862
  const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
1854
1863
  const rawCounters = ctx.counter;
1855
1864
  if (rawCounters && rawCounters.length > 1) {
@@ -1857,7 +1866,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1857
1866
  }
1858
1867
  const counter = rawCounters ? this.visit(rawCounters) : void 0;
1859
1868
  const highlights = ctx.highlight ? ctx.highlight.map((h) => this.visit(h)) : [];
1860
- return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights);
1869
+ return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights, dragMode);
1861
1870
  }
1862
1871
  position(ctx) {
1863
1872
  const params = this.visit(ctx.parameterList);
@@ -2050,7 +2059,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2050
2059
  const denominator = params.denominator;
2051
2060
  const answer = params.answer;
2052
2061
  const simplified = params.simplified !== void 0 ? params.simplified : void 0;
2053
- return createFraction(name, { numerator, denominator, answer, simplified });
2062
+ const placeholder = params.placeholder;
2063
+ return createFraction(name, { numerator, denominator, answer, simplified, placeholder });
2054
2064
  }
2055
2065
  image(ctx) {
2056
2066
  const params = this.visit(ctx.parameterList);
@@ -2334,6 +2344,7 @@ const NumberLineItemSchema = zod.z.object({
2334
2344
  labelVisibility: zod.z.enum(NUMBER_LINE_LABELS).optional(),
2335
2345
  counter: NumberLineCounterItemSchema.optional(),
2336
2346
  highlights: zod.z.array(NumberLineHighlightItemSchema).optional(),
2347
+ dragMode: zod.z.enum(NUMBER_LINE_DRAG_MODES).optional(),
2337
2348
  positions: zod.z.array(NumberLinePositionItemSchema)
2338
2349
  });
2339
2350
  const MatcherItemContentSchema = zod.z.object({
@@ -2383,7 +2394,8 @@ const FractionJSONSchema = zod.z.object({
2383
2394
  type: zod.z.literal(JSON_TYPES.FRACTION),
2384
2395
  numerator: zod.z.number().nullable(),
2385
2396
  denominator: zod.z.number().nullable(),
2386
- simplified: zod.z.boolean()
2397
+ simplified: zod.z.boolean(),
2398
+ placeholder: zod.z.string().max(32).optional()
2387
2399
  });
2388
2400
  const MatcherWidgetJSONSchema = zod.z.object({
2389
2401
  type: zod.z.literal(JSON_TYPES.MATCHER),
@@ -2601,7 +2613,8 @@ function toJSON(ast) {
2601
2613
  type: JSON_TYPES.FRACTION,
2602
2614
  numerator: inline.numerator ?? null,
2603
2615
  denominator: inline.denominator ?? null,
2604
- simplified: inline.simplified ?? true
2616
+ simplified: inline.simplified ?? true,
2617
+ placeholder: inline.placeholder
2605
2618
  };
2606
2619
  if (inline.answer !== void 0) {
2607
2620
  answers[inline.name] = inline.answer;
@@ -2732,6 +2745,7 @@ function transformNumberLine(numberLine, idGenerator) {
2732
2745
  ...numberLine.labelVisibility !== void 0 && { labelVisibility: numberLine.labelVisibility },
2733
2746
  ...counterItem !== void 0 && { counter: counterItem },
2734
2747
  ...highlights !== void 0 && { highlights },
2748
+ ...numberLine.dragMode !== void 0 && { dragMode: numberLine.dragMode },
2735
2749
  positions
2736
2750
  };
2737
2751
  return item;
@@ -13092,8 +13106,11 @@ const FractionInput = ({
13092
13106
  values,
13093
13107
  droppedInputs,
13094
13108
  validationStatus = null,
13109
+ numValidationStatus = null,
13110
+ denValidationStatus = null,
13095
13111
  onChange,
13096
- onFocus
13112
+ onFocus,
13113
+ placeholder
13097
13114
  }) => {
13098
13115
  const numKey = getFractionKey(widgetId, "num");
13099
13116
  const denKey = getFractionKey(widgetId, "den");
@@ -13106,11 +13123,12 @@ const FractionInput = ({
13106
13123
  onChange(subKey, data.value, true, String(item.id));
13107
13124
  }
13108
13125
  }, [onChange]);
13109
- const renderSlot = (subKey, fixedValue, currentValue, label) => {
13126
+ const renderSlot = (subKey, fixedValue, currentValue, label, slotStatus) => {
13110
13127
  if (fixedValue !== null) {
13111
13128
  return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-fixed", children: fixedValue });
13112
13129
  }
13113
13130
  const isDroppedHere = droppedInputs.has(subKey);
13131
+ const slotClass = slotStatus ? ` widget--${slotStatus}` : statusClass;
13114
13132
  return /* @__PURE__ */ jsxRuntime.jsx(
13115
13133
  Droppable,
13116
13134
  {
@@ -13121,7 +13139,7 @@ const FractionInput = ({
13121
13139
  children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsxRuntime.jsx(
13122
13140
  "button",
13123
13141
  {
13124
- className: `inline-input-chip fraction-chip${statusClass}`,
13142
+ className: `inline-input-chip fraction-chip${slotClass}`,
13125
13143
  onClick: () => onChange(subKey, "", false),
13126
13144
  title: "Click to remove",
13127
13145
  children: currentValue
@@ -13131,21 +13149,23 @@ const FractionInput = ({
13131
13149
  {
13132
13150
  id: `${subKey}-input`,
13133
13151
  type: "text",
13134
- className: `inline-input fraction-input${statusClass}`,
13152
+ className: `inline-input fraction-input${slotClass}`,
13135
13153
  value: currentValue,
13136
13154
  onChange: (e) => onChange(subKey, e.target.value, false),
13137
13155
  onFocus: () => onFocus?.(subKey),
13138
- maxLength: 4,
13139
- "aria-label": label
13156
+ maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
13157
+ size: placeholder ? placeholder.length : void 0,
13158
+ "aria-label": label,
13159
+ placeholder
13140
13160
  }
13141
13161
  )
13142
13162
  }
13143
13163
  );
13144
13164
  };
13145
13165
  return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
13146
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator") }),
13166
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator", numValidationStatus) }),
13147
13167
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
13148
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
13168
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator", denValidationStatus) })
13149
13169
  ] });
13150
13170
  };
13151
13171
  function isLightColor(hex) {
@@ -13417,7 +13437,9 @@ const GridShape = ({
13417
13437
  className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
13418
13438
  style: {
13419
13439
  "--visual-fraction-divisions": divisions,
13420
- "--visual-fraction-grid-cols": getGridColumnCount(divisions)
13440
+ "--visual-fraction-grid-cols": getGridColumnCount(divisions),
13441
+ // Safe integer division: zod schema enforces divisions = perfect square.
13442
+ "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
13421
13443
  },
13422
13444
  children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
13423
13445
  "div",
@@ -13567,7 +13589,7 @@ const VisualFraction = ({
13567
13589
  [divisions, filledCount, filledPartGroups, partAssignments, answer, filledPalette, answerPalette]
13568
13590
  );
13569
13591
  const statusClass = validationStatus ? ` visual-fraction-widget--${validationStatus} widget--${validationStatus}` : "";
13570
- const rootStyle = React.useMemo(() => getSizeStyle(widget.size, shape), [widget.size, shape]);
13592
+ const rootStyle = React.useMemo(() => getSizeStyle(widget.size), [widget.size, shape]);
13571
13593
  const ShapeComponent = SHAPE_COMPONENTS[shape];
13572
13594
  return /* @__PURE__ */ jsxRuntime.jsxs(
13573
13595
  "div",
@@ -13592,15 +13614,10 @@ const VisualFraction = ({
13592
13614
  );
13593
13615
  };
13594
13616
  VisualFraction.displayName = "VisualFraction";
13595
- function getSizeStyle(size, shape) {
13617
+ function getSizeStyle(size, _shape) {
13596
13618
  if (size === void 0) return void 0;
13597
- if (shape === "circle" || shape === "triangle") {
13598
- return {
13599
- "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
13600
- };
13601
- }
13602
13619
  return {
13603
- "--visual-fraction-part-size": `${size}rem`
13620
+ "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
13604
13621
  };
13605
13622
  }
13606
13623
  const RadioOptions = ({
@@ -13802,6 +13819,8 @@ const WidgetRenderer = ({
13802
13819
  values = {},
13803
13820
  isDropped = false,
13804
13821
  validationStatus = null,
13822
+ numValidationStatus = null,
13823
+ denValidationStatus = null,
13805
13824
  droppedInputs = /* @__PURE__ */ new Set(),
13806
13825
  widgets,
13807
13826
  onChange,
@@ -13880,8 +13899,11 @@ const WidgetRenderer = ({
13880
13899
  values,
13881
13900
  droppedInputs,
13882
13901
  validationStatus,
13902
+ numValidationStatus,
13903
+ denValidationStatus,
13883
13904
  onChange,
13884
- onFocus: onInputFocus
13905
+ onFocus: onInputFocus,
13906
+ placeholder: widget.placeholder
13885
13907
  }
13886
13908
  );
13887
13909
  }
@@ -13931,7 +13953,7 @@ const getMajorTicks = (min, max, step) => {
13931
13953
  const count = Math.floor((max - min) / step);
13932
13954
  const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
13933
13955
  const lastTick = ticks[ticks.length - 1];
13934
- if (!almostEqual(lastTick, max)) {
13956
+ if (roundTick(lastTick) !== roundTick(max)) {
13935
13957
  ticks.push(max);
13936
13958
  } else {
13937
13959
  ticks[ticks.length - 1] = max;
@@ -14542,7 +14564,7 @@ const NumberLineRenderer = ({
14542
14564
  return step;
14543
14565
  }, [counter, counterCount, min, max, step]);
14544
14566
  const majorTicks = React.useMemo(
14545
- () => getMajorTicks(min, max, effectiveStep).map(roundTick),
14567
+ () => [...new Set(getMajorTicks(min, max, effectiveStep).map(roundTick))],
14546
14568
  [min, max, effectiveStep]
14547
14569
  );
14548
14570
  const minorValues = React.useMemo(
@@ -14551,16 +14573,17 @@ const NumberLineRenderer = ({
14551
14573
  );
14552
14574
  const showArrowRight = lineStyle === "arrow-right" || lineStyle === "arrow-both";
14553
14575
  const showArrowLeft = lineStyle === "arrow-left" || lineStyle === "arrow-both";
14554
- const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
14576
+ const hasDragOptions = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
14577
+ const isAxisDragMode = hasDragOptions && item.dragMode === "axis";
14555
14578
  const inputTickSet = React.useMemo(() => {
14556
- if (isDragMode) return /* @__PURE__ */ new Set();
14579
+ if (isAxisDragMode) return /* @__PURE__ */ new Set();
14557
14580
  return new Set(
14558
14581
  positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => roundTick(pos.value))
14559
14582
  );
14560
- }, [isDragMode, positions]);
14583
+ }, [isAxisDragMode, positions]);
14561
14584
  const visiblePositions = React.useMemo(
14562
- () => positions.filter((pos) => !isDragMode || getPositionWidgetId(pos.content) === null),
14563
- [isDragMode, positions]
14585
+ () => positions.filter((pos) => !isAxisDragMode || getPositionWidgetId(pos.content) === null),
14586
+ [isAxisDragMode, positions]
14564
14587
  );
14565
14588
  const visiblePositionIds = React.useMemo(
14566
14589
  () => visiblePositions.map((pos) => pos.id),
@@ -14570,7 +14593,7 @@ const NumberLineRenderer = ({
14570
14593
  return /* @__PURE__ */ jsxRuntime.jsxs(
14571
14594
  "div",
14572
14595
  {
14573
- className: `number-line${isDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
14596
+ className: `number-line${isAxisDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
14574
14597
  style: {
14575
14598
  [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
14576
14599
  ...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
@@ -14600,7 +14623,7 @@ const NumberLineRenderer = ({
14600
14623
  },
14601
14624
  pos.id
14602
14625
  )) }),
14603
- isDragMode ? /* @__PURE__ */ jsxRuntime.jsx(
14626
+ isAxisDragMode ? /* @__PURE__ */ jsxRuntime.jsx(
14604
14627
  FreeDragAxis,
14605
14628
  {
14606
14629
  axisDropId: `${item.id}--nl-axis`,
@@ -14711,6 +14734,7 @@ function groupLineItems(items) {
14711
14734
  }
14712
14735
  return groups;
14713
14736
  }
14737
+ const toValidationStatus = (v) => v === true ? "correct" : v === false ? "incorrect" : null;
14714
14738
  const LayoutRenderer = ({
14715
14739
  item,
14716
14740
  widgets,
@@ -14726,8 +14750,10 @@ const LayoutRenderer = ({
14726
14750
  const hasArabic = isArabic(item.value);
14727
14751
  return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `question-text ${hasArabic ? "question-text-arabic" : ""}`, children: item.value });
14728
14752
  }
14729
- case JSON_TYPES.TEXT:
14730
- return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `text-item ${isArabic(item.value) ? "text-item-arabic" : "text-item-not-arabic"}`, children: item.value });
14753
+ case JSON_TYPES.TEXT: {
14754
+ const isNum = /^[\d.,\s]+$/.test(item.value);
14755
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `text-item ${isNum ? "text-item-number" : ""} ${isArabic(item.value) ? "text-item-arabic" : "text-item-not-arabic"}`, children: item.value });
14756
+ }
14731
14757
  case JSON_TYPES.HIGHLIGHTED_TEXT: {
14732
14758
  const hasArabic = isArabic(item.value);
14733
14759
  const dirAttr = !hasArabic ? "ltr" : void 0;
@@ -14794,6 +14820,8 @@ const LayoutRenderer = ({
14794
14820
  values,
14795
14821
  isDropped: droppedInputs.has(item.widgetId),
14796
14822
  validationStatus,
14823
+ numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]),
14824
+ denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]),
14797
14825
  droppedInputs,
14798
14826
  widgets,
14799
14827
  onChange,
@@ -14899,6 +14927,27 @@ const ExerciseRenderer = React.forwardRef(({
14899
14927
  for (const [id, v] of Object.entries(result.details)) {
14900
14928
  details[id] = v.isCorrect;
14901
14929
  }
14930
+ for (const [widgetId, widget] of Object.entries(json.widgets)) {
14931
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
14932
+ if (!(widgetId in details)) continue;
14933
+ const answer = json.validation?.answers?.[widgetId];
14934
+ if (!answer) continue;
14935
+ const [expectedNum, expectedDen] = answer.split("/", 2);
14936
+ if (!expectedNum || !expectedDen) continue;
14937
+ if (details[widgetId]) {
14938
+ if (widget.numerator === null) details[`${widgetId}__num`] = true;
14939
+ if (widget.denominator === null) details[`${widgetId}__den`] = true;
14940
+ } else {
14941
+ if (widget.numerator === null) {
14942
+ const u = values[`${widgetId}__num`];
14943
+ details[`${widgetId}__num`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedNum);
14944
+ }
14945
+ if (widget.denominator === null) {
14946
+ const u = values[`${widgetId}__den`];
14947
+ details[`${widgetId}__den`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedDen);
14948
+ }
14949
+ }
14950
+ }
14902
14951
  setValidationDetails(details);
14903
14952
  onSubmit?.(values);
14904
14953
  return result;