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