@mathscan/math-exercise-engine 1.1.0 → 1.1.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.js CHANGED
@@ -54,6 +54,8 @@ const AST_TYPES = {
54
54
  MATCHER_ROW: "MatcherRow",
55
55
  /** Item within a matcher row */
56
56
  MATCHER_ITEM: "MatcherItem",
57
+ /** Pair-matcher layout block (top-row ↔ bottom-row pairing) */
58
+ PAIR_MATCHER_BLOCK: "PairMatcherBlock",
57
59
  /** Visual fraction inline widget */
58
60
  VISUAL_FRACTION: "VisualFraction",
59
61
  // ═══════════════════════════════════════════════════════════════════════════
@@ -72,6 +74,8 @@ const AST_TYPES = {
72
74
  SIMPLE_VALIDATION: "SimpleValidation",
73
75
  /** Expression-based validation */
74
76
  EXPRESSION_VALIDATION: "ExpressionValidation",
77
+ /** Mixed validation: per-widget simple answers + expression over free variables */
78
+ MIXED_VALIDATION: "MixedValidation",
75
79
  // ═══════════════════════════════════════════════════════════════════════════
76
80
  // Document
77
81
  // ═══════════════════════════════════════════════════════════════════════════
@@ -124,6 +128,8 @@ const JSON_TYPES = {
124
128
  MATCHER_ROW: "matcher-row",
125
129
  /** Item within a matcher row */
126
130
  MATCHER_ITEM: "matcher-item",
131
+ /** Pair-matcher widget (top-row ↔ bottom-row pairing) */
132
+ PAIR_MATCHER: "pair-matcher",
127
133
  // ═══════════════════════════════════════════════════════════════════════════
128
134
  // Widget Types
129
135
  // ═══════════════════════════════════════════════════════════════════════════
@@ -141,11 +147,18 @@ const JSON_TYPES = {
141
147
  VISUAL_FRACTION: "visual-fraction"
142
148
  };
143
149
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
150
+ const STATEFUL_WIDGET_TYPES = /* @__PURE__ */ new Set([
151
+ JSON_TYPES.MATCHER,
152
+ JSON_TYPES.PAIR_MATCHER,
153
+ JSON_TYPES.VISUAL_FRACTION
154
+ ]);
144
155
  const VALIDATION_MODES = {
145
156
  /** Simple answer matching validation */
146
157
  SIMPLE: "simple",
147
158
  /** Expression-based validation using JavaScript expressions */
148
- EXPRESSION: "expression"
159
+ EXPRESSION: "expression",
160
+ /** Mixed: simple per-widget answers AND a boolean expression over free variables */
161
+ MIXED: "mixed"
149
162
  };
150
163
  const INPUT_TYPES = {
151
164
  /** String/text input */
@@ -281,10 +294,22 @@ const FRACTION = chevrotain.createToken({
281
294
  name: "FRACTION",
282
295
  pattern: /FRACTION/
283
296
  });
297
+ const PAIR_MATCHER = chevrotain.createToken({
298
+ name: "PAIR_MATCHER",
299
+ pattern: /PAIR_MATCHER/
300
+ });
284
301
  const MATCHER = chevrotain.createToken({
285
302
  name: "MATCHER",
286
303
  pattern: /MATCHER/
287
304
  });
305
+ const TOP_ROW = chevrotain.createToken({
306
+ name: "TOP_ROW",
307
+ pattern: /TOP_ROW/
308
+ });
309
+ const BOTTOM_ROW = chevrotain.createToken({
310
+ name: "BOTTOM_ROW",
311
+ pattern: /BOTTOM_ROW/
312
+ });
288
313
  const ITEM = chevrotain.createToken({
289
314
  name: "ITEM",
290
315
  pattern: /ITEM/
@@ -403,11 +428,14 @@ const allTokens = [
403
428
  POSITION,
404
429
  TABLE,
405
430
  ROW,
431
+ TOP_ROW,
432
+ BOTTOM_ROW,
406
433
  CELL,
407
434
  COUNTER,
408
435
  HIGHLIGHT,
409
436
  VISUAL_FRACTION,
410
437
  FRACTION,
438
+ PAIR_MATCHER,
411
439
  MATCHER,
412
440
  ITEM,
413
441
  ANSWERS,
@@ -561,6 +589,10 @@ class MathExerciseParser extends chevrotain.CstParser {
561
589
  GATE: this.BACKTRACK(this.numberLine),
562
590
  ALT: () => this.SUBRULE(this.numberLine)
563
591
  },
592
+ {
593
+ GATE: this.BACKTRACK(this.pairMatcher),
594
+ ALT: () => this.SUBRULE(this.pairMatcher)
595
+ },
564
596
  {
565
597
  GATE: this.BACKTRACK(this.matcher),
566
598
  ALT: () => this.SUBRULE(this.matcher)
@@ -822,6 +854,69 @@ class MathExerciseParser extends chevrotain.CstParser {
822
854
  });
823
855
  this.CONSUME(RBracket);
824
856
  });
857
+ this.pairMatcher = this.RULE("pairMatcher", () => {
858
+ this.MANY(() => {
859
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
860
+ });
861
+ this.CONSUME(PAIR_MATCHER);
862
+ this.CONSUME(LBracket);
863
+ this.SUBRULE(this.parameterList);
864
+ this.CONSUME(RBracket);
865
+ this.MANY2(() => {
866
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
867
+ });
868
+ this.CONSUME(LBrace);
869
+ this.MANY3(() => {
870
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
871
+ });
872
+ this.SUBRULE(this.pairMatcherTopSection);
873
+ this.SUBRULE(this.pairMatcherBottomSection);
874
+ this.SUBRULE(this.matcherAnswers);
875
+ this.CONSUME(RBrace);
876
+ this.MANY4(() => {
877
+ this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
878
+ });
879
+ });
880
+ this.pairMatcherTopSection = this.RULE("pairMatcherTopSection", () => {
881
+ this.MANY(() => {
882
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
883
+ });
884
+ this.CONSUME(TOP_ROW);
885
+ this.MANY2(() => {
886
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
887
+ });
888
+ this.CONSUME(LBrace);
889
+ this.MANY3(() => {
890
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
891
+ });
892
+ this.AT_LEAST_ONE(() => {
893
+ this.SUBRULE(this.matcherItem);
894
+ });
895
+ this.CONSUME(RBrace);
896
+ this.MANY4(() => {
897
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
898
+ });
899
+ });
900
+ this.pairMatcherBottomSection = this.RULE("pairMatcherBottomSection", () => {
901
+ this.MANY(() => {
902
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
903
+ });
904
+ this.CONSUME(BOTTOM_ROW);
905
+ this.MANY2(() => {
906
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
907
+ });
908
+ this.CONSUME(LBrace);
909
+ this.MANY3(() => {
910
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
911
+ });
912
+ this.AT_LEAST_ONE(() => {
913
+ this.SUBRULE(this.matcherItem);
914
+ });
915
+ this.CONSUME(RBrace);
916
+ this.MANY4(() => {
917
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
918
+ });
919
+ });
825
920
  this.line = this.RULE("line", () => {
826
921
  this.MANY(() => {
827
922
  this.CONSUME(Space);
@@ -1636,6 +1731,91 @@ function createMatcherBlock(name, rows, answers, colors) {
1636
1731
  function isMatcherBlock(value) {
1637
1732
  return MatcherBlockSchema.safeParse(value).success;
1638
1733
  }
1734
+ const PairMatcherBlockSchema = zod.z.object({
1735
+ type: zod.z.literal(AST_TYPES.PAIR_MATCHER_BLOCK),
1736
+ name: zod.z.string().min(1),
1737
+ /** Items in the top row — at least one required */
1738
+ topItems: zod.z.array(MatcherItemSchema).min(1),
1739
+ /** Items in the bottom row — at least one required */
1740
+ bottomItems: zod.z.array(MatcherItemSchema).min(1),
1741
+ /**
1742
+ * Each answer pair is [topId, bottomId].
1743
+ * At least one pair is required.
1744
+ */
1745
+ answers: zod.z.array(zod.z.tuple([zod.z.string().min(1), zod.z.string().min(1)])).min(1)
1746
+ }).superRefine((data, ctx) => {
1747
+ const idRegistry = /* @__PURE__ */ new Map();
1748
+ const sections = [
1749
+ { key: "topItems", items: data.topItems },
1750
+ { key: "bottomItems", items: data.bottomItems }
1751
+ ];
1752
+ for (const { key: key2, items } of sections) {
1753
+ for (let i = 0; i < items.length; i++) {
1754
+ const { itemId } = items[i];
1755
+ const existing = idRegistry.get(itemId);
1756
+ if (existing) {
1757
+ const isCrossSection = existing.section !== key2;
1758
+ ctx.addIssue({
1759
+ code: zod.z.ZodIssueCode.custom,
1760
+ message: isCrossSection ? `itemId "${itemId}" appears in both TOP and BOTTOM — ids must be globally unique` : `Duplicate itemId "${itemId}" in ${key2 === "topItems" ? "TOP" : "BOTTOM"} section`,
1761
+ path: [key2, i, "itemId"]
1762
+ });
1763
+ } else {
1764
+ idRegistry.set(itemId, { section: key2, index: i });
1765
+ }
1766
+ }
1767
+ }
1768
+ const topIds = new Set(data.topItems.map((it) => it.itemId));
1769
+ const bottomIds = new Set(data.bottomItems.map((it) => it.itemId));
1770
+ const usedTopIds = /* @__PURE__ */ new Set();
1771
+ const usedBottomIds = /* @__PURE__ */ new Set();
1772
+ data.answers.forEach(([topId, bottomId], i) => {
1773
+ if (!topIds.has(topId)) {
1774
+ ctx.addIssue({
1775
+ code: zod.z.ZodIssueCode.custom,
1776
+ message: `Answer pair [${topId}, ${bottomId}]: "${topId}" is not declared in TOP section`,
1777
+ path: ["answers", i, 0]
1778
+ });
1779
+ }
1780
+ if (!bottomIds.has(bottomId)) {
1781
+ ctx.addIssue({
1782
+ code: zod.z.ZodIssueCode.custom,
1783
+ message: `Answer pair [${topId}, ${bottomId}]: "${bottomId}" is not declared in BOTTOM section`,
1784
+ path: ["answers", i, 1]
1785
+ });
1786
+ }
1787
+ if (usedTopIds.has(topId)) {
1788
+ ctx.addIssue({
1789
+ code: zod.z.ZodIssueCode.custom,
1790
+ message: `topId "${topId}" appears in more than one answer pair`,
1791
+ path: ["answers", i, 0]
1792
+ });
1793
+ } else {
1794
+ usedTopIds.add(topId);
1795
+ }
1796
+ if (usedBottomIds.has(bottomId)) {
1797
+ ctx.addIssue({
1798
+ code: zod.z.ZodIssueCode.custom,
1799
+ message: `bottomId "${bottomId}" appears in more than one answer pair`,
1800
+ path: ["answers", i, 1]
1801
+ });
1802
+ } else {
1803
+ usedBottomIds.add(bottomId);
1804
+ }
1805
+ });
1806
+ });
1807
+ function createPairMatcherBlock(name, topItems, bottomItems, answers) {
1808
+ return PairMatcherBlockSchema.parse({
1809
+ type: AST_TYPES.PAIR_MATCHER_BLOCK,
1810
+ name,
1811
+ topItems,
1812
+ bottomItems,
1813
+ answers
1814
+ });
1815
+ }
1816
+ function isPairMatcherBlock(value) {
1817
+ return PairMatcherBlockSchema.safeParse(value).success;
1818
+ }
1639
1819
  const DragOptionsToolSchema = zod.z.object({
1640
1820
  type: zod.z.literal(AST_TYPES.DRAG_OPTIONS),
1641
1821
  options: zod.z.array(zod.z.string()).min(1)
@@ -1694,9 +1874,14 @@ const ExpressionValidationSchema = zod.z.object({
1694
1874
  type: zod.z.literal(AST_TYPES.EXPRESSION_VALIDATION),
1695
1875
  expression: zod.z.string()
1696
1876
  });
1877
+ const MixedValidationSchema = zod.z.object({
1878
+ type: zod.z.literal(AST_TYPES.MIXED_VALIDATION),
1879
+ expression: zod.z.string().min(1)
1880
+ });
1697
1881
  const ValidationNodeSchema = zod.z.discriminatedUnion("type", [
1698
1882
  SimpleValidationSchema,
1699
- ExpressionValidationSchema
1883
+ ExpressionValidationSchema,
1884
+ MixedValidationSchema
1700
1885
  ]);
1701
1886
  function createSimpleValidation() {
1702
1887
  return SimpleValidationSchema.parse({
@@ -1709,16 +1894,25 @@ function createExpressionValidation(expression) {
1709
1894
  expression
1710
1895
  });
1711
1896
  }
1897
+ function createMixedValidation(expression) {
1898
+ return MixedValidationSchema.parse({
1899
+ type: AST_TYPES.MIXED_VALIDATION,
1900
+ expression
1901
+ });
1902
+ }
1712
1903
  function isSimpleValidation(value) {
1713
1904
  return SimpleValidationSchema.safeParse(value).success;
1714
1905
  }
1715
1906
  function isExpressionValidation(value) {
1716
1907
  return ExpressionValidationSchema.safeParse(value).success;
1717
1908
  }
1909
+ function isMixedValidation(value) {
1910
+ return MixedValidationSchema.safeParse(value).success;
1911
+ }
1718
1912
  function isValidationNode(value) {
1719
1913
  return ValidationNodeSchema.safeParse(value).success;
1720
1914
  }
1721
- const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema]);
1915
+ const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema]);
1722
1916
  const ExerciseDocumentSchema = zod.z.object({
1723
1917
  type: zod.z.literal(AST_TYPES.EXERCISE_DOCUMENT),
1724
1918
  question: TextBlockSchema.optional(),
@@ -1766,6 +1960,12 @@ const ExerciseDocumentSchema = zod.z.object({
1766
1960
  visitInlines(item.inlines, ["lines", bi, "rows", ri, "items", ii, "inlines"]);
1767
1961
  });
1768
1962
  });
1963
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
1964
+ const pm = block;
1965
+ record(pm.name, ["lines", bi, "name"]);
1966
+ [...pm.topItems, ...pm.bottomItems].forEach((item, ii) => {
1967
+ visitInlines(item.inlines, ["lines", bi, "items", ii, "inlines"]);
1968
+ });
1769
1969
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
1770
1970
  record(block.name, ["lines", bi, "name"]);
1771
1971
  }
@@ -1814,6 +2014,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1814
2014
  if (ctx.table) return this.visit(ctx.table);
1815
2015
  if (ctx.radioButtons) return this.visit(ctx.radioButtons);
1816
2016
  if (ctx.numberLine) return this.visit(ctx.numberLine);
2017
+ if (ctx.pairMatcher) return this.visit(ctx.pairMatcher);
1817
2018
  if (ctx.matcher) return this.visit(ctx.matcher);
1818
2019
  if (ctx.line) return this.visit(ctx.line);
1819
2020
  throw new Error("Unknown content block");
@@ -1927,6 +2128,32 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1927
2128
  return ctx.ids ? ctx.ids.map((t) => t.image) : [];
1928
2129
  }
1929
2130
  // ═══════════════════════════════════════════════════════════════════════════
2131
+ // Pair Matcher Block
2132
+ // ═══════════════════════════════════════════════════════════════════════════
2133
+ pairMatcher(ctx) {
2134
+ const params = this.visit(ctx.parameterList);
2135
+ const name = params.name;
2136
+ const topItems = this.visit(ctx.pairMatcherTopSection);
2137
+ const bottomItems = this.visit(ctx.pairMatcherBottomSection);
2138
+ const rawAnswers = this.visit(ctx.matcherAnswers);
2139
+ return createPairMatcherBlock(name, topItems, bottomItems, rawAnswers);
2140
+ }
2141
+ pairMatcherTopSection(ctx) {
2142
+ return this.visitMatcherItemList(ctx);
2143
+ }
2144
+ pairMatcherBottomSection(ctx) {
2145
+ return this.visitMatcherItemList(ctx);
2146
+ }
2147
+ /**
2148
+ * Maps `ctx.matcherItem` CST nodes to MatcherItem AST nodes.
2149
+ * Shared by pairMatcherTopSection and pairMatcherBottomSection because their
2150
+ * grammar rules are structurally identical — both are `matcherItem+` lists
2151
+ * wrapped in a labelled section keyword.
2152
+ */
2153
+ visitMatcherItemList(ctx) {
2154
+ return ctx.matcherItem ? ctx.matcherItem.map((i) => this.visit(i)) : [];
2155
+ }
2156
+ // ═══════════════════════════════════════════════════════════════════════════
1930
2157
  // Line Block
1931
2158
  // ═══════════════════════════════════════════════════════════════════════════
1932
2159
  line(ctx) {
@@ -2132,6 +2359,12 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2132
2359
  } else if (type === "expression") {
2133
2360
  const expression = params.expression;
2134
2361
  return createExpressionValidation(expression);
2362
+ } else if (type === "mixed") {
2363
+ const expression = params.expression;
2364
+ if (!expression || expression.trim() === "") {
2365
+ throw new Error("Mixed validation requires a non-empty expression");
2366
+ }
2367
+ return createMixedValidation(expression);
2135
2368
  }
2136
2369
  throw new Error(`Unknown validation type: ${type}`);
2137
2370
  }
@@ -2405,6 +2638,11 @@ const MatcherWidgetJSONSchema = zod.z.object({
2405
2638
  /** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
2406
2639
  groupCount: zod.z.number().int().min(1)
2407
2640
  });
2641
+ const PairMatcherWidgetJSONSchema = zod.z.object({
2642
+ type: zod.z.literal(JSON_TYPES.PAIR_MATCHER),
2643
+ topItems: zod.z.array(MatcherItemContentSchema).min(1),
2644
+ bottomItems: zod.z.array(MatcherItemContentSchema).min(1)
2645
+ });
2408
2646
  const VisualFractionJSONSchema = zod.z.object({
2409
2647
  type: zod.z.literal(JSON_TYPES.VISUAL_FRACTION),
2410
2648
  shape: zod.z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
@@ -2429,10 +2667,11 @@ const WidgetJSONSchema = zod.z.union([
2429
2667
  NumericKeypadJSONSchema,
2430
2668
  FractionJSONSchema,
2431
2669
  MatcherWidgetJSONSchema,
2670
+ PairMatcherWidgetJSONSchema,
2432
2671
  VisualFractionJSONSchema
2433
2672
  ]);
2434
2673
  const ValidationJSONSchema = zod.z.object({
2435
- mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
2674
+ mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION, VALIDATION_MODES.MIXED]),
2436
2675
  // Simple answers: string|number per widget id.
2437
2676
  // Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
2438
2677
  // mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
@@ -2568,6 +2807,17 @@ function toJSON(ast) {
2568
2807
  id: idGenerator.generate(JSON_TYPES.LINEBREAK),
2569
2808
  type: JSON_TYPES.LINEBREAK
2570
2809
  });
2810
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
2811
+ const pmBlock = block;
2812
+ layout.push({
2813
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
2814
+ type: JSON_TYPES.WIDGET_REF,
2815
+ widgetId: pmBlock.name
2816
+ });
2817
+ layout.push({
2818
+ id: idGenerator.generate(JSON_TYPES.LINEBREAK),
2819
+ type: JSON_TYPES.LINEBREAK
2820
+ });
2571
2821
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
2572
2822
  const radioBlock = block;
2573
2823
  layout.push({
@@ -2649,6 +2899,10 @@ function toJSON(ast) {
2649
2899
  const matcherBlock = block;
2650
2900
  widgets[matcherBlock.name] = buildMatcherWidget(matcherBlock, idGenerator);
2651
2901
  answers[matcherBlock.name] = matcherBlock.answers;
2902
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
2903
+ const pmBlock = block;
2904
+ widgets[pmBlock.name] = buildPairMatcherWidget(pmBlock, idGenerator);
2905
+ answers[pmBlock.name] = pmBlock.answers;
2652
2906
  }
2653
2907
  }
2654
2908
  if (ast.tools) {
@@ -2697,6 +2951,11 @@ function collectAllInlines(blocks) {
2697
2951
  result.push(...item.inlines);
2698
2952
  }
2699
2953
  }
2954
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
2955
+ const pm = block;
2956
+ for (const item of [...pm.topItems, ...pm.bottomItems]) {
2957
+ result.push(...item.inlines);
2958
+ }
2700
2959
  }
2701
2960
  }
2702
2961
  return result;
@@ -2750,20 +3009,20 @@ function transformNumberLine(numberLine, idGenerator) {
2750
3009
  };
2751
3010
  return item;
2752
3011
  }
3012
+ function mapMatcherItemList(items, idGenerator) {
3013
+ return items.map((item) => ({
3014
+ id: idGenerator.generate(JSON_TYPES.MATCHER_ITEM),
3015
+ type: JSON_TYPES.MATCHER_ITEM,
3016
+ itemId: item.itemId,
3017
+ content: item.inlines.map((inline) => transformInline(inline, idGenerator))
3018
+ }));
3019
+ }
2753
3020
  function buildMatcherWidget(matcher, idGenerator) {
2754
- const rows = matcher.rows.map((row) => {
2755
- const items = row.items.map((item) => ({
2756
- id: idGenerator.generate(JSON_TYPES.MATCHER_ITEM),
2757
- type: JSON_TYPES.MATCHER_ITEM,
2758
- itemId: item.itemId,
2759
- content: item.inlines.map((inline) => transformInline(inline, idGenerator))
2760
- }));
2761
- return {
2762
- id: idGenerator.generate(JSON_TYPES.MATCHER_ROW),
2763
- type: JSON_TYPES.MATCHER_ROW,
2764
- items
2765
- };
2766
- });
3021
+ const rows = matcher.rows.map((row) => ({
3022
+ id: idGenerator.generate(JSON_TYPES.MATCHER_ROW),
3023
+ type: JSON_TYPES.MATCHER_ROW,
3024
+ items: mapMatcherItemList(row.items, idGenerator)
3025
+ }));
2767
3026
  const result = {
2768
3027
  type: JSON_TYPES.MATCHER,
2769
3028
  rows,
@@ -2774,6 +3033,13 @@ function buildMatcherWidget(matcher, idGenerator) {
2774
3033
  }
2775
3034
  return result;
2776
3035
  }
3036
+ function buildPairMatcherWidget(pm, idGenerator) {
3037
+ return {
3038
+ type: JSON_TYPES.PAIR_MATCHER,
3039
+ topItems: mapMatcherItemList(pm.topItems, idGenerator),
3040
+ bottomItems: mapMatcherItemList(pm.bottomItems, idGenerator)
3041
+ };
3042
+ }
2777
3043
  function transformTable(table, idGenerator) {
2778
3044
  const rows = table.rows.map((row) => {
2779
3045
  const cells = row.cells.map((cell) => {
@@ -2902,6 +3168,32 @@ function transformValidation(validation, answers, widgets) {
2902
3168
  expression: validation.expression,
2903
3169
  variables
2904
3170
  };
3171
+ } else if (validation.type === AST_TYPES.MIXED_VALIDATION) {
3172
+ const validVariableNames = Object.keys(widgets);
3173
+ const variables = extractVariables$1(validation.expression, validVariableNames);
3174
+ const inExpression = new Set(variables);
3175
+ for (const [name, widget] of Object.entries(widgets)) {
3176
+ if (name.startsWith("_")) continue;
3177
+ if (name in answers) continue;
3178
+ if (inExpression.has(name)) continue;
3179
+ if (widget.type === JSON_TYPES.FRACTION) {
3180
+ const w = widget;
3181
+ if (w.numerator !== null && w.denominator !== null) continue;
3182
+ }
3183
+ if (widget.type === JSON_TYPES.VISUAL_FRACTION) {
3184
+ const w = widget;
3185
+ if (Array.isArray(w.filled) && w.filled.length > 0) continue;
3186
+ }
3187
+ throw new Error(
3188
+ `Mixed validation: widget "${name}" has no \`answer\` and is not referenced in expression "${validation.expression}"`
3189
+ );
3190
+ }
3191
+ return {
3192
+ mode: VALIDATION_MODES.MIXED,
3193
+ expression: validation.expression,
3194
+ answers,
3195
+ variables
3196
+ };
2905
3197
  }
2906
3198
  throw new Error(`Unknown validation type: ${validation.type}`);
2907
3199
  }
@@ -8426,6 +8718,19 @@ function areMatcherEqual(expected, result) {
8426
8718
  const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
8427
8719
  return lodashExports.isEqual(normalize2(expected), normalize2(result));
8428
8720
  }
8721
+ function getPairMatcherGroupResults(expected, result) {
8722
+ if (!Array.isArray(result)) return [];
8723
+ const normalizeGroup = (g) => lodashExports.sortBy(g).join("|");
8724
+ const expectedKeys = new Set(expected.map(normalizeGroup));
8725
+ return result.map((group) => {
8726
+ const key2 = normalizeGroup(group);
8727
+ if (expectedKeys.has(key2)) {
8728
+ expectedKeys.delete(key2);
8729
+ return { group, isCorrect: true };
8730
+ }
8731
+ return { group, isCorrect: false };
8732
+ });
8733
+ }
8429
8734
  function areVisualFractionCountsEqual(expected, result) {
8430
8735
  if (!Array.isArray(result)) return false;
8431
8736
  if (expected.length !== result.length) return false;
@@ -8464,7 +8769,8 @@ function validateSimple(exercise, userInputs) {
8464
8769
  return {
8465
8770
  isCorrect: false,
8466
8771
  details: {},
8467
- score: 0
8772
+ score: 0,
8773
+ error: "No answers defined for simple validation"
8468
8774
  };
8469
8775
  }
8470
8776
  const positionMap = buildPositionMap(exercise);
@@ -8479,6 +8785,21 @@ function validateSimple(exercise, userInputs) {
8479
8785
  else allCorrect = false;
8480
8786
  continue;
8481
8787
  }
8788
+ if (widget?.type === JSON_TYPES.PAIR_MATCHER) {
8789
+ const userValue2 = userInputs[widgetId];
8790
+ const groupResults = getPairMatcherGroupResults(expected, userValue2);
8791
+ const isCorrect2 = groupResults.length === expected.length && groupResults.every((r) => r.isCorrect);
8792
+ details[widgetId] = {
8793
+ widgetId,
8794
+ userValue: userValue2,
8795
+ isCorrect: isCorrect2,
8796
+ groupResults
8797
+ };
8798
+ totalCount++;
8799
+ if (isCorrect2) correctCount++;
8800
+ else allCorrect = false;
8801
+ continue;
8802
+ }
8482
8803
  if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
8483
8804
  const userValue2 = userInputs[widgetId];
8484
8805
  const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
@@ -8797,6 +9118,55 @@ function parseFractionOrNumber(s) {
8797
9118
  }
8798
9119
  return parseFloat(s);
8799
9120
  }
9121
+ function validateMixed(exercise, userInputs) {
9122
+ const answers = exercise.validation.answers ?? {};
9123
+ const simpleResult = validateSimple(exercise, userInputs);
9124
+ const scalarInputs = {};
9125
+ for (const [k, v] of Object.entries(userInputs)) {
9126
+ if (typeof v === "string" || typeof v === "number") scalarInputs[k] = v;
9127
+ }
9128
+ const exprResult = validateExpression(exercise, scalarInputs);
9129
+ const details = {};
9130
+ const answerKeys = new Set(Object.keys(answers));
9131
+ for (const k of answerKeys) {
9132
+ const simpleDetail = simpleResult.details[k];
9133
+ if (!simpleDetail) continue;
9134
+ const inExpr = k in exprResult.details;
9135
+ details[k] = {
9136
+ ...simpleDetail,
9137
+ simpleCorrect: simpleDetail.isCorrect,
9138
+ ...inExpr && { expressionCorrect: exprResult.isCorrect },
9139
+ isCorrect: inExpr ? simpleDetail.isCorrect && exprResult.isCorrect : simpleDetail.isCorrect
9140
+ };
9141
+ }
9142
+ for (const [k, detail] of Object.entries(exprResult.details)) {
9143
+ if (!(k in details)) {
9144
+ details[k] = {
9145
+ ...detail,
9146
+ expressionCorrect: exprResult.isCorrect
9147
+ };
9148
+ }
9149
+ }
9150
+ const simpleCount = Object.keys(answers).length;
9151
+ let simpleCorrect = 0;
9152
+ for (const k of Object.keys(answers)) {
9153
+ if (simpleResult.details[k]?.isCorrect) simpleCorrect++;
9154
+ }
9155
+ const score = (simpleCorrect + (exprResult.isCorrect ? 1 : 0)) / (simpleCount + 1);
9156
+ const isCorrect = simpleResult.isCorrect && exprResult.isCorrect;
9157
+ const result = {
9158
+ isCorrect,
9159
+ details,
9160
+ score,
9161
+ expression: exercise.validation.expression,
9162
+ scope: exprResult.scope
9163
+ };
9164
+ const propagatedError = exprResult.error ?? simpleResult.error;
9165
+ if (!isCorrect && propagatedError) {
9166
+ result.error = propagatedError;
9167
+ }
9168
+ return result;
9169
+ }
8800
9170
  function preprocessFractionValues(exercise, userInputs) {
8801
9171
  const result = { ...userInputs };
8802
9172
  for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
@@ -8839,6 +9209,8 @@ function validate(exercise, userInputs) {
8839
9209
  }
8840
9210
  return validateExpression(validatedExercise, scalar);
8841
9211
  }
9212
+ case VALIDATION_MODES.MIXED:
9213
+ return validateMixed(validatedExercise, processedInputs);
8842
9214
  default:
8843
9215
  return {
8844
9216
  isCorrect: false,
@@ -8852,18 +9224,27 @@ function getMissingInputs(exercise, userInputs) {
8852
9224
  if (!exercise.validation) {
8853
9225
  return missing;
8854
9226
  }
9227
+ const isEmpty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
8855
9228
  if (exercise.validation.mode === VALIDATION_MODES.SIMPLE && exercise.validation.answers) {
8856
9229
  for (const widgetId of Object.keys(exercise.validation.answers)) {
8857
- if (!(widgetId in userInputs) || userInputs[widgetId] === "") {
9230
+ if (!(widgetId in userInputs) || isEmpty(userInputs[widgetId])) {
8858
9231
  missing.push(widgetId);
8859
9232
  }
8860
9233
  }
8861
9234
  } else if (exercise.validation.mode === VALIDATION_MODES.EXPRESSION && exercise.validation.variables) {
8862
9235
  for (const varName of exercise.validation.variables) {
8863
- if (!(varName in userInputs) || userInputs[varName] === "") {
9236
+ if (!(varName in userInputs) || isEmpty(userInputs[varName])) {
8864
9237
  missing.push(varName);
8865
9238
  }
8866
9239
  }
9240
+ } else if (exercise.validation.mode === VALIDATION_MODES.MIXED) {
9241
+ const keys = /* @__PURE__ */ new Set([
9242
+ ...Object.keys(exercise.validation.answers ?? {}),
9243
+ ...exercise.validation.variables ?? []
9244
+ ]);
9245
+ for (const k of keys) {
9246
+ if (!(k in userInputs) || isEmpty(userInputs[k])) missing.push(k);
9247
+ }
8867
9248
  }
8868
9249
  return missing;
8869
9250
  }
@@ -12686,7 +13067,6 @@ const Draggable = ({
12686
13067
  });
12687
13068
  const style = {
12688
13069
  transform: CSS.Translate.toString(transform),
12689
- opacity: isDragging ? 0.5 : 1,
12690
13070
  cursor: disabled ? "default" : "grab",
12691
13071
  touchAction: "none"
12692
13072
  };
@@ -13698,10 +14078,10 @@ function buildGroups(assignments) {
13698
14078
  }
13699
14079
  return Array.from(map.values());
13700
14080
  }
13701
- const EMPTY_WIDGETS = {};
13702
- const EMPTY_VALUES = {};
13703
- const EMPTY_DROPPED = /* @__PURE__ */ new Set();
13704
- const NOOP = () => {
14081
+ const EMPTY_WIDGETS$1 = {};
14082
+ const EMPTY_VALUES$1 = {};
14083
+ const EMPTY_DROPPED$1 = /* @__PURE__ */ new Set();
14084
+ const NOOP$1 = () => {
13705
14085
  };
13706
14086
  const MatcherWidget = ({
13707
14087
  widgetId,
@@ -13712,7 +14092,7 @@ const MatcherWidget = ({
13712
14092
  widgets
13713
14093
  }) => {
13714
14094
  const { rows, colors: userColors, groupCount } = widget;
13715
- const widgetsMap = widgets ?? EMPTY_WIDGETS;
14095
+ const widgetsMap = widgets ?? EMPTY_WIDGETS$1;
13716
14096
  const palette = React.useMemo(
13717
14097
  () => resolveGroupPalette(userColors, groupCount),
13718
14098
  [userColors, groupCount]
@@ -13777,9 +14157,9 @@ const MatcherWidget = ({
13777
14157
  {
13778
14158
  item: layoutItem,
13779
14159
  widgets: widgetsMap,
13780
- values: EMPTY_VALUES,
13781
- droppedInputs: EMPTY_DROPPED,
13782
- onChange: NOOP
14160
+ values: EMPTY_VALUES$1,
14161
+ droppedInputs: EMPTY_DROPPED$1,
14162
+ onChange: NOOP$1
13783
14163
  },
13784
14164
  layoutItem.id
13785
14165
  ))
@@ -13791,6 +14171,424 @@ const MatcherWidget = ({
13791
14171
  );
13792
14172
  };
13793
14173
  MatcherWidget.displayName = "MatcherWidget";
14174
+ function isArabic(text) {
14175
+ return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
14176
+ }
14177
+ function hasLatinChars(text) {
14178
+ return /[A-Za-z]/.test(text);
14179
+ }
14180
+ function itemContainsArabic(item) {
14181
+ if (item.type === JSON_TYPES.TEXT && "value" in item) {
14182
+ return isArabic(item.value);
14183
+ }
14184
+ if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14185
+ return isArabic(item.value);
14186
+ }
14187
+ if (item.type === JSON_TYPES.GROUP && "items" in item) {
14188
+ return item.items.some((subItem) => itemContainsArabic(subItem));
14189
+ }
14190
+ return false;
14191
+ }
14192
+ function itemContainsLatin(item) {
14193
+ if (item.type === JSON_TYPES.TEXT && "value" in item) {
14194
+ return hasLatinChars(item.value);
14195
+ }
14196
+ if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14197
+ return hasLatinChars(item.value);
14198
+ }
14199
+ if (item.type === JSON_TYPES.GROUP && "items" in item) {
14200
+ return item.items.some((subItem) => itemContainsLatin(subItem));
14201
+ }
14202
+ return false;
14203
+ }
14204
+ function shouldItemsUseRTL(items) {
14205
+ const hasArabic = items.some(itemContainsArabic);
14206
+ const hasLatin = items.some(itemContainsLatin);
14207
+ return hasArabic && !hasLatin;
14208
+ }
14209
+ function usePairLines({
14210
+ containerRef,
14211
+ pairs,
14212
+ lineDirections,
14213
+ validationStatus,
14214
+ pairStatusByTopId
14215
+ }) {
14216
+ const dotRefs = React.useRef(/* @__PURE__ */ new Map());
14217
+ const [lines, setLines] = React.useState([]);
14218
+ const recalcLines = React.useCallback(() => {
14219
+ const container = containerRef.current;
14220
+ if (!container) return;
14221
+ const cr = container.getBoundingClientRect();
14222
+ const newLines = [];
14223
+ for (const [topId, bottomId] of Object.entries(pairs)) {
14224
+ const topDot = dotRefs.current.get(`top:${topId}`);
14225
+ const bottomDot = dotRefs.current.get(`bottom:${bottomId}`);
14226
+ if (!topDot || !bottomDot) continue;
14227
+ const tr = topDot.getBoundingClientRect();
14228
+ const br = bottomDot.getBoundingClientRect();
14229
+ const status = validationStatus ? pairStatusByTopId[topId] ?? (validationStatus === "correct" ? "correct" : "incorrect") : "idle";
14230
+ const direction = lineDirections[topId] ?? "top-to-bottom";
14231
+ const startRect = direction === "top-to-bottom" ? tr : br;
14232
+ const endRect = direction === "top-to-bottom" ? br : tr;
14233
+ newLines.push({
14234
+ topId,
14235
+ x1: startRect.left - cr.left + startRect.width / 2,
14236
+ y1: startRect.top - cr.top + startRect.height / 2,
14237
+ x2: endRect.left - cr.left + endRect.width / 2,
14238
+ y2: endRect.top - cr.top + endRect.height / 2,
14239
+ status
14240
+ });
14241
+ }
14242
+ setLines(newLines);
14243
+ }, [pairs, lineDirections, validationStatus, pairStatusByTopId]);
14244
+ React.useLayoutEffect(() => {
14245
+ recalcLines();
14246
+ }, [recalcLines]);
14247
+ React.useEffect(() => {
14248
+ const el = containerRef.current;
14249
+ if (!el) return;
14250
+ const ro = new ResizeObserver(recalcLines);
14251
+ ro.observe(el);
14252
+ return () => ro.disconnect();
14253
+ }, [containerRef, recalcLines]);
14254
+ return { dotRefs, lines };
14255
+ }
14256
+ const EMPTY_WIDGETS = {};
14257
+ const EMPTY_VALUES = {};
14258
+ const EMPTY_DROPPED = /* @__PURE__ */ new Set();
14259
+ const NOOP = () => {
14260
+ };
14261
+ function buildInitialState(defaultValue2, topIds, bottomIds) {
14262
+ if (!defaultValue2?.length) return { pairs: {}, lineDirections: {} };
14263
+ const pairs = {};
14264
+ const lineDirections = {};
14265
+ for (const pair of defaultValue2) {
14266
+ if (!Array.isArray(pair) || pair.length !== 2 || pair[0] == null || pair[1] == null) continue;
14267
+ const [first, second] = pair;
14268
+ if (topIds.has(first) && bottomIds.has(second)) {
14269
+ pairs[first] = second;
14270
+ lineDirections[first] = "top-to-bottom";
14271
+ } else if (bottomIds.has(first) && topIds.has(second)) {
14272
+ pairs[second] = first;
14273
+ lineDirections[second] = "bottom-to-top";
14274
+ }
14275
+ }
14276
+ return { pairs, lineDirections };
14277
+ }
14278
+ function findTopForBottom(pairs, bottomId) {
14279
+ return Object.keys(pairs).find((topId) => pairs[topId] === bottomId);
14280
+ }
14281
+ function withLineDirection(lineDirections, pairs, topId, bottomId, direction) {
14282
+ const next = { ...lineDirections };
14283
+ delete next[topId];
14284
+ const existingTop = findTopForBottom(pairs, bottomId);
14285
+ if (existingTop !== void 0) delete next[existingTop];
14286
+ next[topId] = direction;
14287
+ return next;
14288
+ }
14289
+ function getPairIds(pair, topIds, bottomIds) {
14290
+ if (pair.length !== 2) return null;
14291
+ const first = pair[0];
14292
+ const second = pair[1];
14293
+ if (first === void 0 || second === void 0) return null;
14294
+ if (topIds.has(first) && bottomIds.has(second)) return { topId: first, bottomId: second };
14295
+ if (bottomIds.has(first) && topIds.has(second)) return { topId: second, bottomId: first };
14296
+ return null;
14297
+ }
14298
+ function withPair(pairs, topId, bottomId) {
14299
+ const next = { ...pairs };
14300
+ delete next[topId];
14301
+ const existingTop = findTopForBottom(next, bottomId);
14302
+ if (existingTop !== void 0) delete next[existingTop];
14303
+ next[topId] = bottomId;
14304
+ return next;
14305
+ }
14306
+ function resolveCardStatus(ownId, isTop, pairs, pending, validationStatus, pairStatus) {
14307
+ if (validationStatus) {
14308
+ const topId2 = isTop ? ownId : findTopForBottom(pairs, ownId);
14309
+ if (topId2 === void 0 || pairs[topId2] === void 0) return "idle";
14310
+ return pairStatus ?? (validationStatus === "correct" ? "correct" : "incorrect");
14311
+ }
14312
+ if (pending?.side === (isTop ? "top" : "bottom") && pending.id === ownId) return "pending";
14313
+ const topId = isTop ? ownId : findTopForBottom(pairs, ownId);
14314
+ if (topId !== void 0 && pairs[topId] !== void 0) return "paired";
14315
+ return "idle";
14316
+ }
14317
+ function buildDotClass(status, position) {
14318
+ const cls = ["pair-matcher-dot", `pair-matcher-dot--${position}`];
14319
+ if (status !== "idle") {
14320
+ cls.push("pair-matcher-dot--active");
14321
+ if (status !== "paired") cls.push(`pair-matcher-dot--${status}`);
14322
+ }
14323
+ return cls.join(" ");
14324
+ }
14325
+ function buildCardClass(status, isFlashing) {
14326
+ const cls = ["pair-matcher-card"];
14327
+ if (status !== "idle") cls.push(`pair-matcher-card--${status}`);
14328
+ if (isFlashing) cls.push("pair-matcher-card--flash");
14329
+ return cls.join(" ");
14330
+ }
14331
+ function lineStrokeVar(status) {
14332
+ if (status === "correct") return "var(--pm-line-color-correct)";
14333
+ if (status === "incorrect") return "var(--pm-line-color-incorrect)";
14334
+ return "var(--pm-line-color)";
14335
+ }
14336
+ function renderPairMatcherItemContent(item, widgets) {
14337
+ if (item.type === JSON_TYPES.SPACE) {
14338
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "space", children: " " }, item.id);
14339
+ }
14340
+ if (item.type === JSON_TYPES.GROUP) {
14341
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "group", children: item.items.map((subItem) => renderPairMatcherItemContent(subItem, widgets)) }, item.id);
14342
+ }
14343
+ return /* @__PURE__ */ jsxRuntime.jsx(
14344
+ LayoutRenderer,
14345
+ {
14346
+ item,
14347
+ widgets,
14348
+ values: EMPTY_VALUES,
14349
+ droppedInputs: EMPTY_DROPPED,
14350
+ onChange: NOOP
14351
+ },
14352
+ item.id
14353
+ );
14354
+ }
14355
+ const PairMatcherWidget = ({
14356
+ widgetId,
14357
+ widget,
14358
+ onChange,
14359
+ validationStatus = null,
14360
+ validationDetail,
14361
+ defaultValue: defaultValue2,
14362
+ widgets,
14363
+ shuffleItems = true
14364
+ }) => {
14365
+ const { topItems, bottomItems } = widget;
14366
+ const [shuffledTop] = React.useState(
14367
+ () => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
14368
+ );
14369
+ const [shuffledBottom] = React.useState(
14370
+ () => shuffleItems ? lodashExports.shuffle([...bottomItems]) : [...bottomItems]
14371
+ );
14372
+ const widgetsMap = widgets ?? EMPTY_WIDGETS;
14373
+ const topIds = React.useMemo(() => new Set(topItems.map((item) => item.itemId)), [topItems]);
14374
+ const bottomIds = React.useMemo(() => new Set(bottomItems.map((item) => item.itemId)), [bottomItems]);
14375
+ const pairStatusByTopId = React.useMemo(() => {
14376
+ const statuses = {};
14377
+ for (const result of validationDetail?.groupResults ?? []) {
14378
+ const ids2 = getPairIds(result.group, topIds, bottomIds);
14379
+ if (ids2) statuses[ids2.topId] = result.isCorrect ? "correct" : "incorrect";
14380
+ }
14381
+ return statuses;
14382
+ }, [validationDetail?.groupResults, topIds, bottomIds]);
14383
+ const [state, setState] = React.useState(() => ({
14384
+ ...buildInitialState(defaultValue2, topIds, bottomIds),
14385
+ pending: null,
14386
+ flashTopId: null,
14387
+ flashBottomId: null
14388
+ }));
14389
+ const flashTimerRef = React.useRef(null);
14390
+ const handleTopClick = React.useCallback((topId) => {
14391
+ if (validationStatus) return;
14392
+ if (flashTimerRef.current) {
14393
+ clearTimeout(flashTimerRef.current);
14394
+ flashTimerRef.current = null;
14395
+ }
14396
+ setState((prev) => {
14397
+ if (prev.pairs[topId] !== void 0) {
14398
+ const { [topId]: _, ...rest } = prev.pairs;
14399
+ const { [topId]: __, ...restDirections } = prev.lineDirections;
14400
+ if (prev.pending?.side === "bottom") {
14401
+ return {
14402
+ pairs: withPair(rest, topId, prev.pending.id),
14403
+ lineDirections: withLineDirection(restDirections, rest, topId, prev.pending.id, "bottom-to-top"),
14404
+ pending: null,
14405
+ flashTopId: topId,
14406
+ flashBottomId: prev.pending.id
14407
+ };
14408
+ }
14409
+ return {
14410
+ pairs: rest,
14411
+ lineDirections: restDirections,
14412
+ pending: { side: "top", id: topId },
14413
+ flashTopId: null,
14414
+ flashBottomId: null
14415
+ };
14416
+ }
14417
+ if (prev.pending?.side === "bottom") {
14418
+ return {
14419
+ pairs: withPair(prev.pairs, topId, prev.pending.id),
14420
+ lineDirections: withLineDirection(prev.lineDirections, prev.pairs, topId, prev.pending.id, "bottom-to-top"),
14421
+ pending: null,
14422
+ flashTopId: topId,
14423
+ flashBottomId: prev.pending.id
14424
+ };
14425
+ }
14426
+ return {
14427
+ ...prev,
14428
+ pending: prev.pending?.side === "top" && prev.pending.id === topId ? null : { side: "top", id: topId },
14429
+ flashTopId: null,
14430
+ flashBottomId: null
14431
+ };
14432
+ });
14433
+ }, [validationStatus]);
14434
+ const handleBottomClick = React.useCallback((bottomId) => {
14435
+ if (validationStatus) return;
14436
+ if (flashTimerRef.current) {
14437
+ clearTimeout(flashTimerRef.current);
14438
+ flashTimerRef.current = null;
14439
+ }
14440
+ setState((prev) => {
14441
+ const existingTop = findTopForBottom(prev.pairs, bottomId);
14442
+ if (existingTop !== void 0) {
14443
+ const { [existingTop]: _, ...rest } = prev.pairs;
14444
+ const { [existingTop]: __, ...restDirections } = prev.lineDirections;
14445
+ if (prev.pending?.side === "top" && prev.pending.id !== existingTop) {
14446
+ return {
14447
+ pairs: withPair(rest, prev.pending.id, bottomId),
14448
+ lineDirections: withLineDirection(restDirections, rest, prev.pending.id, bottomId, "top-to-bottom"),
14449
+ pending: null,
14450
+ flashTopId: prev.pending.id,
14451
+ flashBottomId: bottomId
14452
+ };
14453
+ }
14454
+ return {
14455
+ pairs: rest,
14456
+ lineDirections: restDirections,
14457
+ pending: { side: "bottom", id: bottomId },
14458
+ flashTopId: null,
14459
+ flashBottomId: null
14460
+ };
14461
+ }
14462
+ if (prev.pending?.side === "top") {
14463
+ return {
14464
+ pairs: withPair(prev.pairs, prev.pending.id, bottomId),
14465
+ lineDirections: withLineDirection(prev.lineDirections, prev.pairs, prev.pending.id, bottomId, "top-to-bottom"),
14466
+ pending: null,
14467
+ flashTopId: prev.pending.id,
14468
+ flashBottomId: bottomId
14469
+ };
14470
+ }
14471
+ return {
14472
+ ...prev,
14473
+ pending: prev.pending?.side === "bottom" && prev.pending.id === bottomId ? null : { side: "bottom", id: bottomId },
14474
+ flashTopId: null,
14475
+ flashBottomId: null
14476
+ };
14477
+ });
14478
+ }, [validationStatus]);
14479
+ React.useEffect(() => {
14480
+ if (validationStatus || state.flashTopId === null || state.flashBottomId === null) return;
14481
+ flashTimerRef.current = setTimeout(() => {
14482
+ flashTimerRef.current = null;
14483
+ setState((current) => {
14484
+ if (current.flashTopId === null && current.flashBottomId === null) return current;
14485
+ return { ...current, flashTopId: null, flashBottomId: null };
14486
+ });
14487
+ }, 650);
14488
+ return () => {
14489
+ if (flashTimerRef.current) {
14490
+ clearTimeout(flashTimerRef.current);
14491
+ flashTimerRef.current = null;
14492
+ }
14493
+ };
14494
+ }, [state.flashTopId, state.flashBottomId, validationStatus]);
14495
+ const onChangeRef = React.useRef(onChange);
14496
+ onChangeRef.current = onChange;
14497
+ const isMounted = React.useRef(false);
14498
+ React.useEffect(() => {
14499
+ if (!isMounted.current) {
14500
+ isMounted.current = true;
14501
+ return;
14502
+ }
14503
+ const value = Object.entries(state.pairs).map(
14504
+ ([topId, bottomId]) => state.lineDirections[topId] === "bottom-to-top" ? [bottomId, topId] : [topId, bottomId]
14505
+ );
14506
+ onChangeRef.current(widgetId, value);
14507
+ }, [state.pairs, state.lineDirections, widgetId]);
14508
+ const containerRef = React.useRef(null);
14509
+ const { dotRefs, lines } = usePairLines({
14510
+ containerRef,
14511
+ pairs: state.pairs,
14512
+ lineDirections: state.lineDirections,
14513
+ validationStatus,
14514
+ pairStatusByTopId
14515
+ });
14516
+ const cols = React.useMemo(
14517
+ () => Math.max(topItems.length, bottomItems.length, 1),
14518
+ [topItems.length, bottomItems.length]
14519
+ );
14520
+ const renderCard = (item, isTop) => {
14521
+ const isRtl = shouldItemsUseRTL(item.content);
14522
+ const pairedTopId = isTop ? item.itemId : findTopForBottom(state.pairs, item.itemId);
14523
+ const pairStatus = pairedTopId ? pairStatusByTopId[pairedTopId] ?? null : null;
14524
+ const status = resolveCardStatus(
14525
+ item.itemId,
14526
+ isTop,
14527
+ state.pairs,
14528
+ state.pending,
14529
+ validationStatus,
14530
+ pairStatus
14531
+ );
14532
+ const isFlashing = isTop ? state.flashTopId === item.itemId : state.flashBottomId === item.itemId;
14533
+ const dotKey = isTop ? `top:${item.itemId}` : `bottom:${item.itemId}`;
14534
+ const dot = /* @__PURE__ */ jsxRuntime.jsx(
14535
+ "span",
14536
+ {
14537
+ className: buildDotClass(status, isTop ? "south" : "north"),
14538
+ ref: (el) => {
14539
+ if (el) dotRefs.current.set(dotKey, el);
14540
+ else dotRefs.current.delete(dotKey);
14541
+ }
14542
+ }
14543
+ );
14544
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "pair-matcher-cell", children: [
14545
+ !isTop && dot,
14546
+ /* @__PURE__ */ jsxRuntime.jsx(
14547
+ "button",
14548
+ {
14549
+ type: "button",
14550
+ className: `${buildCardClass(status, isFlashing)}${isRtl ? " pair-matcher-card--rtl" : ""}`,
14551
+ dir: isRtl ? "rtl" : void 0,
14552
+ onClick: () => isTop ? handleTopClick(item.itemId) : handleBottomClick(item.itemId),
14553
+ "aria-pressed": status !== "idle",
14554
+ "aria-disabled": validationStatus ? "true" : void 0,
14555
+ children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "pair-matcher-card-content", children: item.content.map((li) => renderPairMatcherItemContent(li, widgetsMap)) })
14556
+ }
14557
+ ),
14558
+ isTop && dot
14559
+ ] }, item.id);
14560
+ };
14561
+ return /* @__PURE__ */ jsxRuntime.jsxs(
14562
+ "div",
14563
+ {
14564
+ className: `pair-matcher-widget${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
14565
+ style: { "--pm-cols": cols },
14566
+ ref: containerRef,
14567
+ role: "group",
14568
+ "aria-label": "Pair matcher exercise",
14569
+ children: [
14570
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "pair-matcher-row pair-matcher-row--top", children: shuffledTop.map((item) => renderCard(item, true)) }),
14571
+ /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "pair-matcher-lines", "aria-hidden": "true", children: lines.map((line) => /* @__PURE__ */ jsxRuntime.jsx(
14572
+ "line",
14573
+ {
14574
+ x1: line.x1,
14575
+ y1: line.y1,
14576
+ x2: line.x2,
14577
+ y2: line.y2,
14578
+ stroke: lineStrokeVar(line.status),
14579
+ strokeWidth: "var(--pm-line-width, 4)",
14580
+ strokeLinecap: "round",
14581
+ pathLength: 1,
14582
+ className: "pair-matcher-line"
14583
+ },
14584
+ line.topId
14585
+ )) }),
14586
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "pair-matcher-row pair-matcher-row--bottom", children: shuffledBottom.map((item) => renderCard(item, false)) })
14587
+ ]
14588
+ }
14589
+ );
14590
+ };
14591
+ PairMatcherWidget.displayName = "PairMatcherWidget";
13794
14592
  function isInlineInputWidget(widget) {
13795
14593
  return widget.type === "inline-input";
13796
14594
  }
@@ -13809,6 +14607,9 @@ function isFractionWidget(widget) {
13809
14607
  function isMatcherWidget(widget) {
13810
14608
  return widget.type === "matcher";
13811
14609
  }
14610
+ function isPairMatcherWidget(widget) {
14611
+ return widget.type === "pair-matcher";
14612
+ }
13812
14613
  function isVisualFractionWidget(widget) {
13813
14614
  return widget.type === "visual-fraction";
13814
14615
  }
@@ -13821,6 +14622,7 @@ const WidgetRenderer = ({
13821
14622
  validationStatus = null,
13822
14623
  numValidationStatus = null,
13823
14624
  denValidationStatus = null,
14625
+ validationDetail,
13824
14626
  droppedInputs = /* @__PURE__ */ new Set(),
13825
14627
  widgets,
13826
14628
  onChange,
@@ -13920,6 +14722,20 @@ const WidgetRenderer = ({
13920
14722
  }
13921
14723
  );
13922
14724
  }
14725
+ if (isPairMatcherWidget(widget)) {
14726
+ return /* @__PURE__ */ jsxRuntime.jsx(
14727
+ PairMatcherWidget,
14728
+ {
14729
+ widgetId,
14730
+ widget,
14731
+ onChange,
14732
+ validationStatus,
14733
+ validationDetail,
14734
+ defaultValue: Array.isArray(value) ? value : void 0,
14735
+ widgets
14736
+ }
14737
+ );
14738
+ }
13923
14739
  if (isVisualFractionWidget(widget)) {
13924
14740
  return /* @__PURE__ */ jsxRuntime.jsx(
13925
14741
  VisualFraction,
@@ -14676,36 +15492,6 @@ const NumberLineRenderer = ({
14676
15492
  }
14677
15493
  );
14678
15494
  };
14679
- function isArabic(text) {
14680
- return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
14681
- }
14682
- function hasLatinChars(text) {
14683
- return /[A-Za-z]/.test(text);
14684
- }
14685
- function itemContainsArabic(item) {
14686
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
14687
- return isArabic(item.value);
14688
- }
14689
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14690
- return isArabic(item.value);
14691
- }
14692
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
14693
- return item.items.some((subItem) => itemContainsArabic(subItem));
14694
- }
14695
- return false;
14696
- }
14697
- function itemContainsLatin(item) {
14698
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
14699
- return hasLatinChars(item.value);
14700
- }
14701
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14702
- return hasLatinChars(item.value);
14703
- }
14704
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
14705
- return item.items.some((subItem) => itemContainsLatin(subItem));
14706
- }
14707
- return false;
14708
- }
14709
15495
  function isTextItem(item) {
14710
15496
  return item.type === JSON_TYPES.TEXT || item.type === JSON_TYPES.HIGHLIGHTED_TEXT || item.type === JSON_TYPES.BOLD_TEXT || item.type === JSON_TYPES.COLOR_TEXT || item.type === JSON_TYPES.OPERATOR || item.type === JSON_TYPES.SPACE || item.type === JSON_TYPES.GROUP;
14711
15497
  }
@@ -14808,9 +15594,10 @@ const LayoutRenderer = ({
14808
15594
  case JSON_TYPES.WIDGET_REF: {
14809
15595
  const widget = widgets[item.widgetId];
14810
15596
  if (!widget) return null;
14811
- const isCorrect = validationDetails?.[item.widgetId];
15597
+ const validationDetail = validationDetails?.[item.widgetId];
15598
+ const isCorrect = validationDetail?.isCorrect;
14812
15599
  const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
14813
- const widgetKey = widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.VISUAL_FRACTION ? `${item.widgetId}-${resetKey}` : item.widgetId;
15600
+ const widgetKey = STATEFUL_WIDGET_TYPES.has(widget.type) ? `${item.widgetId}-${resetKey}` : item.widgetId;
14814
15601
  return /* @__PURE__ */ jsxRuntime.jsx(
14815
15602
  WidgetRenderer,
14816
15603
  {
@@ -14820,8 +15607,9 @@ const LayoutRenderer = ({
14820
15607
  values,
14821
15608
  isDropped: droppedInputs.has(item.widgetId),
14822
15609
  validationStatus,
14823
- numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]),
14824
- denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]),
15610
+ numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]?.isCorrect),
15611
+ denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]?.isCorrect),
15612
+ validationDetail,
14825
15613
  droppedInputs,
14826
15614
  widgets,
14827
15615
  onChange,
@@ -14904,7 +15692,7 @@ const DEFAULT_RULER_TOOL = {
14904
15692
  function buildInitialValues(json) {
14905
15693
  const initial = {};
14906
15694
  for (const [id, widget] of Object.entries(json.widgets)) {
14907
- if (widget.type === JSON_TYPES.MATCHER) initial[id] = [];
15695
+ if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
14908
15696
  }
14909
15697
  return initial;
14910
15698
  }
@@ -14923,10 +15711,12 @@ const ExerciseRenderer = React.forwardRef(({
14923
15711
  React.useImperativeHandle(ref, () => ({
14924
15712
  submit: () => {
14925
15713
  const result = validate(json, values);
14926
- const details = {};
14927
- for (const [id, v] of Object.entries(result.details)) {
14928
- details[id] = v.isCorrect;
14929
- }
15714
+ const details = { ...result.details };
15715
+ const slotValidation = (id, raw, expected) => ({
15716
+ widgetId: id,
15717
+ userValue: typeof raw === "string" ? raw : void 0,
15718
+ isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
15719
+ });
14930
15720
  for (const [widgetId, widget] of Object.entries(json.widgets)) {
14931
15721
  if (widget.type !== JSON_TYPES.FRACTION) continue;
14932
15722
  if (!(widgetId in details)) continue;
@@ -14934,18 +15724,12 @@ const ExerciseRenderer = React.forwardRef(({
14934
15724
  if (!answer) continue;
14935
15725
  const [expectedNum, expectedDen] = answer.split("/", 2);
14936
15726
  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;
15727
+ if (details[widgetId]?.isCorrect) {
15728
+ if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
15729
+ if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
14940
15730
  } 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
- }
15731
+ if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
15732
+ if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
14949
15733
  }
14950
15734
  }
14951
15735
  setValidationDetails(details);
@@ -15216,6 +16000,8 @@ exports.LinebreakSchema = LinebreakSchema;
15216
16000
  exports.MatcherBlockSchema = MatcherBlockSchema;
15217
16001
  exports.MatcherItemSchema = MatcherItemSchema;
15218
16002
  exports.MatcherRowSchema = MatcherRowSchema;
16003
+ exports.MatcherWidgetJSONSchema = MatcherWidgetJSONSchema;
16004
+ exports.MixedValidationSchema = MixedValidationSchema;
15219
16005
  exports.NumberLineCounterItemSchema = NumberLineCounterItemSchema;
15220
16006
  exports.NumberLineHighlightItemSchema = NumberLineHighlightItemSchema;
15221
16007
  exports.NumberLineItemSchema = NumberLineItemSchema;
@@ -15224,6 +16010,8 @@ exports.NumericKeypadJSONSchema = NumericKeypadJSONSchema;
15224
16010
  exports.NumericKeypadToolSchema = NumericKeypadToolSchema;
15225
16011
  exports.OperatorItemSchema = OperatorItemSchema;
15226
16012
  exports.OperatorSchema = OperatorSchema;
16013
+ exports.PairMatcherBlockSchema = PairMatcherBlockSchema;
16014
+ exports.PairMatcherWidgetJSONSchema = PairMatcherWidgetJSONSchema;
15227
16015
  exports.ParseError = ParseError;
15228
16016
  exports.QuestionTextSchema = QuestionTextSchema;
15229
16017
  exports.RadioOptionsJSONSchema = RadioOptionsJSONSchema;
@@ -15263,8 +16051,10 @@ exports.createLineBlock = createLineBlock;
15263
16051
  exports.createMatcherBlock = createMatcherBlock;
15264
16052
  exports.createMatcherItem = createMatcherItem;
15265
16053
  exports.createMatcherRow = createMatcherRow;
16054
+ exports.createMixedValidation = createMixedValidation;
15266
16055
  exports.createNumericInput = createNumericInput;
15267
16056
  exports.createNumericKeypad = createNumericKeypad;
16057
+ exports.createPairMatcherBlock = createPairMatcherBlock;
15268
16058
  exports.createSimpleValidation = createSimpleValidation;
15269
16059
  exports.createSpace = createSpace;
15270
16060
  exports.createStringInput = createStringInput;
@@ -15297,9 +16087,13 @@ exports.isLinebreak = isLinebreak;
15297
16087
  exports.isMatcherBlock = isMatcherBlock;
15298
16088
  exports.isMatcherItem = isMatcherItem;
15299
16089
  exports.isMatcherRow = isMatcherRow;
16090
+ exports.isMatcherWidget = isMatcherWidget;
16091
+ exports.isMixedValidation = isMixedValidation;
15300
16092
  exports.isNumericKeypad = isNumericKeypad;
15301
16093
  exports.isNumericKeypadJSON = isNumericKeypadJSON;
15302
16094
  exports.isOperatorItem = isOperatorItem;
16095
+ exports.isPairMatcherBlock = isPairMatcherBlock;
16096
+ exports.isPairMatcherWidget = isPairMatcherWidget;
15303
16097
  exports.isQuestionText = isQuestionText;
15304
16098
  exports.isRadioOptionsJSON = isRadioOptionsJSON;
15305
16099
  exports.isSimpleValidation = isSimpleValidation;
@@ -15329,6 +16123,7 @@ exports.validateExerciseJSON = validateExerciseJSON;
15329
16123
  exports.validateExerciseJSONSafe = validateExerciseJSONSafe;
15330
16124
  exports.validateExpression = validateExpression;
15331
16125
  exports.validateInputFormats = validateInputFormats;
16126
+ exports.validateMixed = validateMixed;
15332
16127
  exports.validateSimple = validateSimple;
15333
16128
  exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
15334
16129
  exports.validateWidgetJSON = validateWidgetJSON;