@mathscan/math-exercise-engine 1.2.0 → 1.2.2

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
@@ -392,13 +392,9 @@ const MATCHER = createToken({
392
392
  name: "MATCHER",
393
393
  pattern: /MATCHER/
394
394
  });
395
- const TOP_ROW = createToken({
396
- name: "TOP_ROW",
397
- pattern: /TOP_ROW/
398
- });
399
- const BOTTOM_ROW = createToken({
400
- name: "BOTTOM_ROW",
401
- pattern: /BOTTOM_ROW/
395
+ const GROUP = createToken({
396
+ name: "GROUP",
397
+ pattern: /GROUP/
402
398
  });
403
399
  const ITEM = createToken({
404
400
  name: "ITEM",
@@ -554,8 +550,7 @@ const allTokens = [
554
550
  ARROW,
555
551
  TABLE,
556
552
  ROW,
557
- TOP_ROW,
558
- BOTTOM_ROW,
553
+ GROUP,
559
554
  CELL,
560
555
  COUNTER,
561
556
  CLOCK,
@@ -1063,39 +1058,19 @@ class MathExerciseParser extends CstParser {
1063
1058
  this.MANY3(() => {
1064
1059
  this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1065
1060
  });
1066
- this.SUBRULE(this.pairMatcherTopSection);
1067
- this.SUBRULE(this.pairMatcherBottomSection);
1061
+ this.SUBRULE(this.pairMatcherGroupSection);
1062
+ this.SUBRULE2(this.pairMatcherGroupSection);
1068
1063
  this.SUBRULE(this.matcherAnswers);
1069
1064
  this.CONSUME(RBrace);
1070
1065
  this.MANY4(() => {
1071
1066
  this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
1072
1067
  });
1073
1068
  });
1074
- this.pairMatcherTopSection = this.RULE("pairMatcherTopSection", () => {
1075
- this.MANY(() => {
1076
- this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1077
- });
1078
- this.CONSUME(TOP_ROW);
1079
- this.MANY2(() => {
1080
- this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1081
- });
1082
- this.CONSUME(LBrace);
1083
- this.MANY3(() => {
1084
- this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1085
- });
1086
- this.AT_LEAST_ONE(() => {
1087
- this.SUBRULE(this.matcherItem);
1088
- });
1089
- this.CONSUME(RBrace);
1090
- this.MANY4(() => {
1091
- this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1092
- });
1093
- });
1094
- this.pairMatcherBottomSection = this.RULE("pairMatcherBottomSection", () => {
1069
+ this.pairMatcherGroupSection = this.RULE("pairMatcherGroupSection", () => {
1095
1070
  this.MANY(() => {
1096
1071
  this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1097
1072
  });
1098
- this.CONSUME(BOTTOM_ROW);
1073
+ this.CONSUME(GROUP);
1099
1074
  this.MANY2(() => {
1100
1075
  this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1101
1076
  });
@@ -2588,20 +2563,24 @@ function isMatcherBlock(value) {
2588
2563
  const PairMatcherBlockSchema = z.object({
2589
2564
  type: z.literal(AST_TYPES.PAIR_MATCHER_BLOCK),
2590
2565
  name: z.string().min(1),
2591
- /** Items in the top row — at least one required */
2592
- topItems: z.array(MatcherItemSchema).min(1),
2593
- /** Items in the bottom row — at least one required */
2594
- bottomItems: z.array(MatcherItemSchema).min(1),
2566
+ /** Row layout (top/bottom, default) or column layout (left/right). */
2567
+ layout: z.enum(["rows", "columns"]).optional(),
2568
+ /** Exactly two groups, in declaration order — neither more nor fewer. */
2569
+ groups: z.tuple([
2570
+ z.array(MatcherItemSchema).min(1),
2571
+ z.array(MatcherItemSchema).min(1)
2572
+ ]),
2595
2573
  /**
2596
- * Each answer pair is [topId, bottomId].
2574
+ * Each answer pair is [groups[0] id, groups[1] id].
2597
2575
  * At least one pair is required.
2598
2576
  */
2599
2577
  answers: z.array(z.tuple([z.string().min(1), z.string().min(1)])).min(1)
2600
2578
  }).superRefine((data, ctx) => {
2579
+ const [groupAItems, groupBItems] = data.groups;
2601
2580
  const idRegistry = /* @__PURE__ */ new Map();
2602
2581
  const sections = [
2603
- { key: "topItems", items: data.topItems },
2604
- { key: "bottomItems", items: data.bottomItems }
2582
+ { key: 0, items: groupAItems },
2583
+ { key: 1, items: groupBItems }
2605
2584
  ];
2606
2585
  for (const { key: key2, items } of sections) {
2607
2586
  for (let i = 0; i < items.length; i++) {
@@ -2611,59 +2590,59 @@ const PairMatcherBlockSchema = z.object({
2611
2590
  const isCrossSection = existing.section !== key2;
2612
2591
  ctx.addIssue({
2613
2592
  code: z.ZodIssueCode.custom,
2614
- message: isCrossSection ? `itemId "${itemId}" appears in both TOP and BOTTOM — ids must be globally unique` : `Duplicate itemId "${itemId}" in ${key2 === "topItems" ? "TOP" : "BOTTOM"} section`,
2615
- path: [key2, i, "itemId"]
2593
+ message: isCrossSection ? `itemId "${itemId}" appears in both groups — ids must be globally unique` : `Duplicate itemId "${itemId}" in group ${key2}`,
2594
+ path: ["groups", key2, i, "itemId"]
2616
2595
  });
2617
2596
  } else {
2618
2597
  idRegistry.set(itemId, { section: key2, index: i });
2619
2598
  }
2620
2599
  }
2621
2600
  }
2622
- const topIds = new Set(data.topItems.map((it) => it.itemId));
2623
- const bottomIds = new Set(data.bottomItems.map((it) => it.itemId));
2624
- const usedTopIds = /* @__PURE__ */ new Set();
2625
- const usedBottomIds = /* @__PURE__ */ new Set();
2626
- data.answers.forEach(([topId, bottomId], i) => {
2627
- if (!topIds.has(topId)) {
2601
+ const groupAIds = new Set(groupAItems.map((it) => it.itemId));
2602
+ const groupBIds = new Set(groupBItems.map((it) => it.itemId));
2603
+ const usedGroupAIds = /* @__PURE__ */ new Set();
2604
+ const usedGroupBIds = /* @__PURE__ */ new Set();
2605
+ data.answers.forEach(([aId, bId], i) => {
2606
+ if (!groupAIds.has(aId)) {
2628
2607
  ctx.addIssue({
2629
2608
  code: z.ZodIssueCode.custom,
2630
- message: `Answer pair [${topId}, ${bottomId}]: "${topId}" is not declared in TOP section`,
2609
+ message: `Answer pair [${aId}, ${bId}]: "${aId}" is not declared in groups[0]`,
2631
2610
  path: ["answers", i, 0]
2632
2611
  });
2633
2612
  }
2634
- if (!bottomIds.has(bottomId)) {
2613
+ if (!groupBIds.has(bId)) {
2635
2614
  ctx.addIssue({
2636
2615
  code: z.ZodIssueCode.custom,
2637
- message: `Answer pair [${topId}, ${bottomId}]: "${bottomId}" is not declared in BOTTOM section`,
2616
+ message: `Answer pair [${aId}, ${bId}]: "${bId}" is not declared in groups[1]`,
2638
2617
  path: ["answers", i, 1]
2639
2618
  });
2640
2619
  }
2641
- if (usedTopIds.has(topId)) {
2620
+ if (usedGroupAIds.has(aId)) {
2642
2621
  ctx.addIssue({
2643
2622
  code: z.ZodIssueCode.custom,
2644
- message: `topId "${topId}" appears in more than one answer pair`,
2623
+ message: `id "${aId}" appears in more than one answer pair`,
2645
2624
  path: ["answers", i, 0]
2646
2625
  });
2647
2626
  } else {
2648
- usedTopIds.add(topId);
2627
+ usedGroupAIds.add(aId);
2649
2628
  }
2650
- if (usedBottomIds.has(bottomId)) {
2629
+ if (usedGroupBIds.has(bId)) {
2651
2630
  ctx.addIssue({
2652
2631
  code: z.ZodIssueCode.custom,
2653
- message: `bottomId "${bottomId}" appears in more than one answer pair`,
2632
+ message: `id "${bId}" appears in more than one answer pair`,
2654
2633
  path: ["answers", i, 1]
2655
2634
  });
2656
2635
  } else {
2657
- usedBottomIds.add(bottomId);
2636
+ usedGroupBIds.add(bId);
2658
2637
  }
2659
2638
  });
2660
2639
  });
2661
- function createPairMatcherBlock(name, topItems, bottomItems, answers) {
2640
+ function createPairMatcherBlock(name, groups, answers, layout) {
2662
2641
  return PairMatcherBlockSchema.parse({
2663
2642
  type: AST_TYPES.PAIR_MATCHER_BLOCK,
2664
2643
  name,
2665
- topItems,
2666
- bottomItems,
2644
+ layout,
2645
+ groups,
2667
2646
  answers
2668
2647
  });
2669
2648
  }
@@ -2850,7 +2829,7 @@ const ExerciseDocumentSchema = z.object({
2850
2829
  } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
2851
2830
  const pm = block;
2852
2831
  record(pm.name, ["lines", bi, "name"]);
2853
- [...pm.topItems, ...pm.bottomItems].forEach((item, ii) => {
2832
+ pm.groups.flat().forEach((item, ii) => {
2854
2833
  visitInlines(item.inlines, ["lines", bi, "items", ii, "inlines"]);
2855
2834
  });
2856
2835
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
@@ -3063,24 +3042,14 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3063
3042
  pairMatcher(ctx) {
3064
3043
  const params = this.visit(ctx.parameterList);
3065
3044
  const name = params.name;
3066
- const topItems = this.visit(ctx.pairMatcherTopSection);
3067
- const bottomItems = this.visit(ctx.pairMatcherBottomSection);
3045
+ const layout = params.layout;
3046
+ const [firstGroup, secondGroup] = ctx.pairMatcherGroupSection;
3047
+ const groupAItems = this.visit(firstGroup);
3048
+ const groupBItems = this.visit(secondGroup);
3068
3049
  const rawAnswers = this.visit(ctx.matcherAnswers);
3069
- return createPairMatcherBlock(name, topItems, bottomItems, rawAnswers);
3070
- }
3071
- pairMatcherTopSection(ctx) {
3072
- return this.visitMatcherItemList(ctx);
3050
+ return createPairMatcherBlock(name, [groupAItems, groupBItems], rawAnswers, layout);
3073
3051
  }
3074
- pairMatcherBottomSection(ctx) {
3075
- return this.visitMatcherItemList(ctx);
3076
- }
3077
- /**
3078
- * Maps `ctx.matcherItem` CST nodes to MatcherItem AST nodes.
3079
- * Shared by pairMatcherTopSection and pairMatcherBottomSection because their
3080
- * grammar rules are structurally identical — both are `matcherItem+` lists
3081
- * wrapped in a labelled section keyword.
3082
- */
3083
- visitMatcherItemList(ctx) {
3052
+ pairMatcherGroupSection(ctx) {
3084
3053
  return ctx.matcherItem ? ctx.matcherItem.map((i) => this.visit(i)) : [];
3085
3054
  }
3086
3055
  // ═══════════════════════════════════════════════════════════════════════════
@@ -3872,8 +3841,12 @@ const MatcherWidgetJSONSchema = z.object({
3872
3841
  });
3873
3842
  const PairMatcherWidgetJSONSchema = z.object({
3874
3843
  type: z.literal(JSON_TYPES.PAIR_MATCHER),
3875
- topItems: z.array(MatcherItemContentSchema).min(1),
3876
- bottomItems: z.array(MatcherItemContentSchema).min(1)
3844
+ /** Row layout (top/bottom, default) or column layout (left/right). */
3845
+ layout: z.enum(["rows", "columns"]).optional(),
3846
+ groups: z.tuple([
3847
+ z.array(MatcherItemContentSchema).min(1),
3848
+ z.array(MatcherItemContentSchema).min(1)
3849
+ ])
3877
3850
  });
3878
3851
  const VisualFractionJSONSchema = z.object({
3879
3852
  type: z.literal(JSON_TYPES.VISUAL_FRACTION),
@@ -9833,7 +9806,7 @@ function collectAllInlines(blocks) {
9833
9806
  }
9834
9807
  } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
9835
9808
  const pm = block;
9836
- for (const item of [...pm.topItems, ...pm.bottomItems]) {
9809
+ for (const item of pm.groups.flat()) {
9837
9810
  result.push(...item.inlines);
9838
9811
  }
9839
9812
  }
@@ -9940,10 +9913,14 @@ function buildMatcherWidget(matcher, idGenerator, inlineDragIds) {
9940
9913
  return result;
9941
9914
  }
9942
9915
  function buildPairMatcherWidget(pm, idGenerator, inlineDragIds) {
9916
+ const [groupAItems, groupBItems] = pm.groups;
9943
9917
  return {
9944
9918
  type: JSON_TYPES.PAIR_MATCHER,
9945
- topItems: mapMatcherItemList(pm.topItems, idGenerator, inlineDragIds),
9946
- bottomItems: mapMatcherItemList(pm.bottomItems, idGenerator, inlineDragIds)
9919
+ ...pm.layout !== void 0 && { layout: pm.layout },
9920
+ groups: [
9921
+ mapMatcherItemList(groupAItems, idGenerator, inlineDragIds),
9922
+ mapMatcherItemList(groupBItems, idGenerator, inlineDragIds)
9923
+ ]
9947
9924
  };
9948
9925
  }
9949
9926
  function transformVerticalOperation(vop, idGenerator) {
@@ -16738,7 +16715,9 @@ const PairMatcherWidget = ({
16738
16715
  widgets,
16739
16716
  shuffleItems = true
16740
16717
  }) => {
16741
- const { topItems, bottomItems } = widget;
16718
+ const { groups, layout } = widget;
16719
+ const [topItems, bottomItems] = groups;
16720
+ const isColumns = layout === "columns";
16742
16721
  const exerciseLocked = useExerciseLocked();
16743
16722
  const [shuffledTop] = useState(
16744
16723
  () => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
@@ -16915,8 +16894,8 @@ const PairMatcherWidget = ({
16915
16894
  pairStatusByTopId
16916
16895
  });
16917
16896
  const cols = useMemo(
16918
- () => Math.max(topItems.length, bottomItems.length, 1),
16919
- [topItems.length, bottomItems.length]
16897
+ () => isColumns ? null : Math.max(topItems.length, bottomItems.length, 1),
16898
+ [isColumns, topItems.length, bottomItems.length]
16920
16899
  );
16921
16900
  const renderCard = (item, isTop) => {
16922
16901
  const isRtl = shouldItemsUseRTL(item.content);
@@ -16962,8 +16941,8 @@ const PairMatcherWidget = ({
16962
16941
  return /* @__PURE__ */ jsxs(
16963
16942
  "div",
16964
16943
  {
16965
- className: `pair-matcher-widget${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
16966
- style: { "--pm-cols": cols },
16944
+ className: `pair-matcher-widget${isColumns ? " pair-matcher-widget--columns" : ""}${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
16945
+ style: cols === null ? {} : { "--pm-cols": cols },
16967
16946
  ref: containerRef,
16968
16947
  role: "group",
16969
16948
  "aria-label": "Pair matcher exercise",
@@ -19397,16 +19376,14 @@ const ExerciseRenderer = forwardRef(({
19397
19376
  setResetKey((k) => k + 1);
19398
19377
  },
19399
19378
  focusFirstInput: () => {
19400
- const container = rootRef.current;
19379
+ const container = rootRef.current ?? document.querySelector(".math-exercise-renderer");
19401
19380
  if (!container) return;
19402
- const candidates = container.querySelectorAll(
19403
- 'input[type="text"], input[type="number"], textarea, select'
19381
+ const candidates = Array.from(
19382
+ container.querySelectorAll('input[type="text"], input[type="number"], textarea, select')
19404
19383
  );
19405
- for (const el of Array.from(candidates)) {
19406
- if (el.readOnly || el.disabled) continue;
19407
- el.focus();
19408
- return;
19409
- }
19384
+ const canTakeInput = (el) => !el.readOnly && !el.disabled;
19385
+ const target = candidates.find(canTakeInput) ?? (locked ? void 0 : candidates[0]);
19386
+ target?.focus();
19410
19387
  }
19411
19388
  }), [json, values, onSubmit, playSound, locked]);
19412
19389
  const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {