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