@mathscan/math-exercise-engine 1.1.1 → 1.1.3

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
@@ -23,6 +23,8 @@ const AST_TYPES = {
23
23
  BOLD_TEXT: "BoldText",
24
24
  /** Inline input widget (string or numeric) */
25
25
  INLINE_INPUT: "InlineInput",
26
+ /** Single inline draggable option (appears in-flow with text) */
27
+ INLINE_DRAG_OPTION: "InlineDragOption",
26
28
  /** Inline fraction widget (numerator/denominator with optional inputs) */
27
29
  FRACTION: "Fraction",
28
30
  /** Inline image element */
@@ -40,6 +42,16 @@ const AST_TYPES = {
40
42
  TABLE_ROW: "TableRow",
41
43
  /** Cell within a table row */
42
44
  TABLE_CELL: "TableCell",
45
+ /** Vertical operation (column arithmetic) block */
46
+ VERTICAL_OPERATION_BLOCK: "VerticalOperationBlock",
47
+ /** Row within a vertical operation */
48
+ VOP_ROW: "VopRow",
49
+ /** Cell within a vertical-operation row */
50
+ VOP_CELL: "VopCell",
51
+ /** Separator line within a vertical operation */
52
+ VOP_SEPARATOR: "VopSeparator",
53
+ /** Carry row within a vertical operation */
54
+ VOP_CARRY_ROW: "VopCarryRow",
43
55
  /** Number line layout block */
44
56
  NUMBER_LINE_BLOCK: "NumberLineBlock",
45
57
  /** A position marker on the number line */
@@ -54,6 +66,8 @@ const AST_TYPES = {
54
66
  MATCHER_ROW: "MatcherRow",
55
67
  /** Item within a matcher row */
56
68
  MATCHER_ITEM: "MatcherItem",
69
+ /** Pair-matcher layout block (top-row ↔ bottom-row pairing) */
70
+ PAIR_MATCHER_BLOCK: "PairMatcherBlock",
57
71
  /** Visual fraction inline widget */
58
72
  VISUAL_FRACTION: "VisualFraction",
59
73
  // ═══════════════════════════════════════════════════════════════════════════
@@ -72,6 +86,8 @@ const AST_TYPES = {
72
86
  SIMPLE_VALIDATION: "SimpleValidation",
73
87
  /** Expression-based validation */
74
88
  EXPRESSION_VALIDATION: "ExpressionValidation",
89
+ /** Mixed validation: per-widget simple answers + expression over free variables */
90
+ MIXED_VALIDATION: "MixedValidation",
75
91
  // ═══════════════════════════════════════════════════════════════════════════
76
92
  // Document
77
93
  // ═══════════════════════════════════════════════════════════════════════════
@@ -110,6 +126,16 @@ const JSON_TYPES = {
110
126
  TABLE_ROW: "table-row",
111
127
  /** Cell within a table */
112
128
  TABLE_CELL: "table-cell",
129
+ /** Vertical operation (column arithmetic) block */
130
+ VERTICAL_OPERATION: "vertical-operation",
131
+ /** Row within a vertical operation */
132
+ VOP_ROW: "vop-row",
133
+ /** Cell within a vertical-operation row */
134
+ VOP_CELL: "vop-cell",
135
+ /** Separator within a vertical operation */
136
+ VOP_SEPARATOR: "vop-separator",
137
+ /** Carry row within a vertical operation */
138
+ VOP_CARRY_ROW: "vop-carry-row",
113
139
  /** Number line layout block */
114
140
  NUMBER_LINE: "number-line",
115
141
  /** Position marker within a number line */
@@ -124,6 +150,8 @@ const JSON_TYPES = {
124
150
  MATCHER_ROW: "matcher-row",
125
151
  /** Item within a matcher row */
126
152
  MATCHER_ITEM: "matcher-item",
153
+ /** Pair-matcher widget (top-row ↔ bottom-row pairing) */
154
+ PAIR_MATCHER: "pair-matcher",
127
155
  // ═══════════════════════════════════════════════════════════════════════════
128
156
  // Widget Types
129
157
  // ═══════════════════════════════════════════════════════════════════════════
@@ -131,6 +159,8 @@ const JSON_TYPES = {
131
159
  INLINE_INPUT: "inline-input",
132
160
  /** Drag-and-drop options widget */
133
161
  DRAG_OPTIONS: "drag-options",
162
+ /** Single inline draggable option (appears in-flow with text) */
163
+ INLINE_DRAG_OPTION: "inline-drag-option",
134
164
  /** Radio button options widget */
135
165
  RADIO_OPTIONS: "radio-options",
136
166
  /** Numeric keypad widget */
@@ -141,11 +171,25 @@ const JSON_TYPES = {
141
171
  VISUAL_FRACTION: "visual-fraction"
142
172
  };
143
173
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
174
+ const STATEFUL_WIDGET_TYPES = /* @__PURE__ */ new Set([
175
+ JSON_TYPES.MATCHER,
176
+ JSON_TYPES.PAIR_MATCHER,
177
+ JSON_TYPES.VISUAL_FRACTION
178
+ ]);
179
+ const INLINE_DRAG_OPT_PREFIX = "_inline_drag_opt_";
180
+ const RTL_INLINE_WIDGET_TYPES = /* @__PURE__ */ new Set([
181
+ JSON_TYPES.INLINE_INPUT,
182
+ JSON_TYPES.INLINE_DRAG_OPTION,
183
+ JSON_TYPES.FRACTION,
184
+ JSON_TYPES.VISUAL_FRACTION
185
+ ]);
144
186
  const VALIDATION_MODES = {
145
187
  /** Simple answer matching validation */
146
188
  SIMPLE: "simple",
147
189
  /** Expression-based validation using JavaScript expressions */
148
- EXPRESSION: "expression"
190
+ EXPRESSION: "expression",
191
+ /** Mixed: simple per-widget answers AND a boolean expression over free variables */
192
+ MIXED: "mixed"
149
193
  };
150
194
  const INPUT_TYPES = {
151
195
  /** String/text input */
@@ -221,6 +265,10 @@ const STRING_INPUT = chevrotain.createToken({
221
265
  name: "STRING_INPUT",
222
266
  pattern: /STRING_INPUT/
223
267
  });
268
+ const INLINE_DRAG_OPTION = chevrotain.createToken({
269
+ name: "INLINE_DRAG_OPTION",
270
+ pattern: /INLINE_DRAG_OPTION/
271
+ });
224
272
  const DRAGABLE_BUTTONS_OPTIONS = chevrotain.createToken({
225
273
  name: "DRAGABLE_BUTTONS_OPTIONS",
226
274
  pattern: /DRAGABLE_BUTTONS_OPTIONS/
@@ -281,10 +329,22 @@ const FRACTION = chevrotain.createToken({
281
329
  name: "FRACTION",
282
330
  pattern: /FRACTION/
283
331
  });
332
+ const PAIR_MATCHER = chevrotain.createToken({
333
+ name: "PAIR_MATCHER",
334
+ pattern: /PAIR_MATCHER/
335
+ });
284
336
  const MATCHER = chevrotain.createToken({
285
337
  name: "MATCHER",
286
338
  pattern: /MATCHER/
287
339
  });
340
+ const TOP_ROW = chevrotain.createToken({
341
+ name: "TOP_ROW",
342
+ pattern: /TOP_ROW/
343
+ });
344
+ const BOTTOM_ROW = chevrotain.createToken({
345
+ name: "BOTTOM_ROW",
346
+ pattern: /BOTTOM_ROW/
347
+ });
288
348
  const ITEM = chevrotain.createToken({
289
349
  name: "ITEM",
290
350
  pattern: /ITEM/
@@ -297,6 +357,26 @@ const IMAGE = chevrotain.createToken({
297
357
  name: "IMAGE",
298
358
  pattern: /IMAGE/
299
359
  });
360
+ const VERTICAL_OPERATION = chevrotain.createToken({
361
+ name: "VERTICAL_OPERATION",
362
+ pattern: /VERTICAL_OPERATION/
363
+ });
364
+ const SEP = chevrotain.createToken({
365
+ name: "SEP",
366
+ pattern: /SEP(?![a-zA-Z0-9_])/
367
+ });
368
+ const CARRY = chevrotain.createToken({
369
+ name: "CARRY",
370
+ pattern: /CARRY(?![a-zA-Z0-9_])/
371
+ });
372
+ const INPUT = chevrotain.createToken({
373
+ name: "INPUT",
374
+ pattern: /INPUT(?=\s*\[)/
375
+ });
376
+ const Underscore = chevrotain.createToken({
377
+ name: "Underscore",
378
+ pattern: /_(?![a-zA-Z0-9_])/
379
+ });
300
380
  const NumberLiteral = chevrotain.createToken({
301
381
  name: "NumberLiteral",
302
382
  pattern: /-?\d+(\.\d+)?/
@@ -393,6 +473,7 @@ const allTokens = [
393
473
  HIGHLIGHT_TEXT,
394
474
  W_S_I,
395
475
  STRING_INPUT,
476
+ INLINE_DRAG_OPTION,
396
477
  DRAGABLE_BUTTONS_OPTIONS,
397
478
  RADIO_BUTTONS_OPTIONS,
398
479
  NUMERIC_KEYPAD,
@@ -403,15 +484,23 @@ const allTokens = [
403
484
  POSITION,
404
485
  TABLE,
405
486
  ROW,
487
+ TOP_ROW,
488
+ BOTTOM_ROW,
406
489
  CELL,
407
490
  COUNTER,
408
491
  HIGHLIGHT,
409
492
  VISUAL_FRACTION,
410
493
  FRACTION,
494
+ PAIR_MATCHER,
411
495
  MATCHER,
412
496
  ITEM,
413
497
  ANSWERS,
414
498
  IMAGE,
499
+ VERTICAL_OPERATION,
500
+ SEP,
501
+ CARRY,
502
+ INPUT,
503
+ Underscore,
415
504
  // Literals
416
505
  NumberLiteral,
417
506
  StringLiteral,
@@ -561,10 +650,18 @@ class MathExerciseParser extends chevrotain.CstParser {
561
650
  GATE: this.BACKTRACK(this.numberLine),
562
651
  ALT: () => this.SUBRULE(this.numberLine)
563
652
  },
653
+ {
654
+ GATE: this.BACKTRACK(this.pairMatcher),
655
+ ALT: () => this.SUBRULE(this.pairMatcher)
656
+ },
564
657
  {
565
658
  GATE: this.BACKTRACK(this.matcher),
566
659
  ALT: () => this.SUBRULE(this.matcher)
567
660
  },
661
+ {
662
+ GATE: this.BACKTRACK(this.verticalOperation),
663
+ ALT: () => this.SUBRULE(this.verticalOperation)
664
+ },
568
665
  { ALT: () => this.SUBRULE(this.line) }
569
666
  ]);
570
667
  });
@@ -822,6 +919,193 @@ class MathExerciseParser extends chevrotain.CstParser {
822
919
  });
823
920
  this.CONSUME(RBracket);
824
921
  });
922
+ this.pairMatcher = this.RULE("pairMatcher", () => {
923
+ this.MANY(() => {
924
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
925
+ });
926
+ this.CONSUME(PAIR_MATCHER);
927
+ this.CONSUME(LBracket);
928
+ this.SUBRULE(this.parameterList);
929
+ this.CONSUME(RBracket);
930
+ this.MANY2(() => {
931
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
932
+ });
933
+ this.CONSUME(LBrace);
934
+ this.MANY3(() => {
935
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
936
+ });
937
+ this.SUBRULE(this.pairMatcherTopSection);
938
+ this.SUBRULE(this.pairMatcherBottomSection);
939
+ this.SUBRULE(this.matcherAnswers);
940
+ this.CONSUME(RBrace);
941
+ this.MANY4(() => {
942
+ this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
943
+ });
944
+ });
945
+ this.pairMatcherTopSection = this.RULE("pairMatcherTopSection", () => {
946
+ this.MANY(() => {
947
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
948
+ });
949
+ this.CONSUME(TOP_ROW);
950
+ this.MANY2(() => {
951
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
952
+ });
953
+ this.CONSUME(LBrace);
954
+ this.MANY3(() => {
955
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
956
+ });
957
+ this.AT_LEAST_ONE(() => {
958
+ this.SUBRULE(this.matcherItem);
959
+ });
960
+ this.CONSUME(RBrace);
961
+ this.MANY4(() => {
962
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
963
+ });
964
+ });
965
+ this.pairMatcherBottomSection = this.RULE("pairMatcherBottomSection", () => {
966
+ this.MANY(() => {
967
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
968
+ });
969
+ this.CONSUME(BOTTOM_ROW);
970
+ this.MANY2(() => {
971
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
972
+ });
973
+ this.CONSUME(LBrace);
974
+ this.MANY3(() => {
975
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
976
+ });
977
+ this.AT_LEAST_ONE(() => {
978
+ this.SUBRULE(this.matcherItem);
979
+ });
980
+ this.CONSUME(RBrace);
981
+ this.MANY4(() => {
982
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
983
+ });
984
+ });
985
+ this.verticalOperation = this.RULE("verticalOperation", () => {
986
+ this.MANY(() => {
987
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
988
+ });
989
+ this.CONSUME(VERTICAL_OPERATION);
990
+ this.OPTION(() => {
991
+ this.CONSUME(LBracket);
992
+ this.SUBRULE(this.parameterList);
993
+ this.CONSUME(RBracket);
994
+ });
995
+ this.MANY2(() => {
996
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
997
+ });
998
+ this.CONSUME(LBrace);
999
+ this.MANY3(() => {
1000
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1001
+ });
1002
+ this.AT_LEAST_ONE(() => {
1003
+ this.OR4([
1004
+ { ALT: () => this.SUBRULE(this.vopRow) },
1005
+ { ALT: () => this.SUBRULE(this.vopCarry) },
1006
+ { ALT: () => this.SUBRULE(this.vopSep) }
1007
+ ]);
1008
+ });
1009
+ this.CONSUME(RBrace);
1010
+ this.MANY4(() => {
1011
+ this.OR5([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
1012
+ });
1013
+ });
1014
+ this.vopRow = this.RULE("vopRow", () => {
1015
+ this.CONSUME(ROW);
1016
+ this.OPTION(() => {
1017
+ this.CONSUME(LBracket);
1018
+ this.SUBRULE(this.parameterList);
1019
+ this.CONSUME(RBracket);
1020
+ });
1021
+ this.MANY(() => {
1022
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1023
+ });
1024
+ this.CONSUME(LBrace);
1025
+ this.MANY2(() => {
1026
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1027
+ });
1028
+ this.SUBRULE(this.vopCell);
1029
+ this.MANY3(() => {
1030
+ this.MANY4(() => {
1031
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1032
+ });
1033
+ this.CONSUME(Comma);
1034
+ this.MANY5(() => {
1035
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1036
+ });
1037
+ this.SUBRULE2(this.vopCell);
1038
+ });
1039
+ this.MANY6(() => {
1040
+ this.OR5([{ ALT: () => this.CONSUME5(Space) }, { ALT: () => this.CONSUME5(Newline) }]);
1041
+ });
1042
+ this.CONSUME(RBrace);
1043
+ this.MANY7(() => {
1044
+ this.OR6([{ ALT: () => this.CONSUME6(Space) }, { ALT: () => this.CONSUME6(Newline) }]);
1045
+ });
1046
+ });
1047
+ this.vopCell = this.RULE("vopCell", () => {
1048
+ this.OR([
1049
+ { ALT: () => this.CONSUME(NumberLiteral) },
1050
+ { ALT: () => this.CONSUME(StringLiteral) },
1051
+ { ALT: () => this.CONSUME(Underscore) },
1052
+ {
1053
+ ALT: () => {
1054
+ this.CONSUME(INPUT);
1055
+ this.CONSUME(LBracket);
1056
+ this.SUBRULE(this.parameterList);
1057
+ this.CONSUME(RBracket);
1058
+ }
1059
+ },
1060
+ { ALT: () => this.SUBRULE(this.colorText) },
1061
+ { ALT: () => this.SUBRULE(this.boldText) },
1062
+ { ALT: () => this.SUBRULE(this.textHighlighter) }
1063
+ ]);
1064
+ });
1065
+ this.vopCarry = this.RULE("vopCarry", () => {
1066
+ this.CONSUME(CARRY);
1067
+ this.MANY(() => {
1068
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1069
+ });
1070
+ this.CONSUME(LBrace);
1071
+ this.MANY2(() => {
1072
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1073
+ });
1074
+ this.SUBRULE(this.vopCell);
1075
+ this.MANY3(() => {
1076
+ this.MANY4(() => {
1077
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1078
+ });
1079
+ this.CONSUME(Comma);
1080
+ this.MANY5(() => {
1081
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1082
+ });
1083
+ this.SUBRULE2(this.vopCell);
1084
+ });
1085
+ this.MANY6(() => {
1086
+ this.OR5([{ ALT: () => this.CONSUME5(Space) }, { ALT: () => this.CONSUME5(Newline) }]);
1087
+ });
1088
+ this.CONSUME(RBrace);
1089
+ this.MANY7(() => {
1090
+ this.OR6([{ ALT: () => this.CONSUME6(Newline) }, { ALT: () => this.CONSUME6(Space) }]);
1091
+ });
1092
+ });
1093
+ this.vopSep = this.RULE("vopSep", () => {
1094
+ this.CONSUME(SEP);
1095
+ this.OPTION(() => {
1096
+ this.MANY(() => {
1097
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1098
+ });
1099
+ this.CONSUME(LBrace);
1100
+ this.MANY2(() => {
1101
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1102
+ });
1103
+ this.CONSUME(RBrace);
1104
+ });
1105
+ this.MANY3(() => {
1106
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1107
+ });
1108
+ });
825
1109
  this.line = this.RULE("line", () => {
826
1110
  this.MANY(() => {
827
1111
  this.CONSUME(Space);
@@ -875,7 +1159,9 @@ class MathExerciseParser extends chevrotain.CstParser {
875
1159
  // VISUAL_FRACTION — always allowed; display-only when answer=[] or absence of answer parameter, interactive when answer=[...] with values.
876
1160
  { ALT: () => this.SUBRULE(this.visualFraction) },
877
1161
  // W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
878
- { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) }
1162
+ { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) },
1163
+ // INLINE_DRAG_OPTION (single draggable option inline with text) — excluded in display-only
1164
+ { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineDragOption) }
879
1165
  ]);
880
1166
  });
881
1167
  this.textHighlighter = this.RULE("textHighlighter", () => {
@@ -957,6 +1243,12 @@ class MathExerciseParser extends chevrotain.CstParser {
957
1243
  this.SUBRULE(this.parameterList);
958
1244
  this.CONSUME(RBracket);
959
1245
  });
1246
+ this.inlineDragOption = this.RULE("inlineDragOption", () => {
1247
+ this.CONSUME(INLINE_DRAG_OPTION);
1248
+ this.CONSUME(LBracket);
1249
+ this.CONSUME(StringLiteral);
1250
+ this.CONSUME(RBracket);
1251
+ });
960
1252
  this.tool = this.RULE("tool", () => {
961
1253
  this.OR([
962
1254
  { ALT: () => this.SUBRULE(this.dragableButtons) },
@@ -1225,6 +1517,10 @@ function isPerfectSquare(value) {
1225
1517
  const root = Math.sqrt(value);
1226
1518
  return Number.isInteger(root);
1227
1519
  }
1520
+ const InlineDragOptionSchema = zod.z.object({
1521
+ type: zod.z.literal(AST_TYPES.INLINE_DRAG_OPTION),
1522
+ value: zod.z.string().min(1)
1523
+ });
1228
1524
  const InlineNodeSchema = zod.z.union([
1229
1525
  FreeTextInlineSchema,
1230
1526
  SpaceInlineSchema,
@@ -1234,7 +1530,8 @@ const InlineNodeSchema = zod.z.union([
1234
1530
  InlineInputWidgetSchema,
1235
1531
  FractionInlineSchema,
1236
1532
  ImageInlineSchema,
1237
- VisualFractionInlineSchema
1533
+ VisualFractionInlineSchema,
1534
+ InlineDragOptionSchema
1238
1535
  ]);
1239
1536
  function createFreeText(value) {
1240
1537
  return FreeTextInlineSchema.parse({
@@ -1270,24 +1567,32 @@ function createBoldText(value, highlight) {
1270
1567
  });
1271
1568
  }
1272
1569
  function createStringInput(name, length, answer, placeholder) {
1273
- return parseOrThrow(InlineInputWidgetSchema, {
1274
- type: AST_TYPES.INLINE_INPUT,
1275
- name,
1276
- inputType: INPUT_TYPES.STRING,
1277
- length,
1278
- placeholder,
1279
- answer
1280
- }, "STRING_INPUT");
1570
+ return parseOrThrow(
1571
+ InlineInputWidgetSchema,
1572
+ {
1573
+ type: AST_TYPES.INLINE_INPUT,
1574
+ name,
1575
+ inputType: INPUT_TYPES.STRING,
1576
+ length,
1577
+ placeholder,
1578
+ answer
1579
+ },
1580
+ "STRING_INPUT"
1581
+ );
1281
1582
  }
1282
1583
  function createNumericInput(name, digits, answer, placeholder) {
1283
- return parseOrThrow(InlineInputWidgetSchema, {
1284
- type: AST_TYPES.INLINE_INPUT,
1285
- name,
1286
- inputType: INPUT_TYPES.NUMERIC,
1287
- digits,
1288
- placeholder,
1289
- answer
1290
- }, "NUMERIC_INPUT");
1584
+ return parseOrThrow(
1585
+ InlineInputWidgetSchema,
1586
+ {
1587
+ type: AST_TYPES.INLINE_INPUT,
1588
+ name,
1589
+ inputType: INPUT_TYPES.NUMERIC,
1590
+ digits,
1591
+ placeholder,
1592
+ answer
1593
+ },
1594
+ "NUMERIC_INPUT"
1595
+ );
1291
1596
  }
1292
1597
  function createImage(name, src, alt, options = {}) {
1293
1598
  return ImageInlineSchema.parse({
@@ -1313,6 +1618,12 @@ function createVisualFraction(name, divisions, options = {}) {
1313
1618
  ...options
1314
1619
  });
1315
1620
  }
1621
+ function createInlineDragOption(value) {
1622
+ return InlineDragOptionSchema.parse({
1623
+ type: AST_TYPES.INLINE_DRAG_OPTION,
1624
+ value
1625
+ });
1626
+ }
1316
1627
  function isFreeText(value) {
1317
1628
  return FreeTextInlineSchema.safeParse(value).success;
1318
1629
  }
@@ -1334,6 +1645,9 @@ function isInlineNode(value) {
1334
1645
  function isVisualFractionInline(value) {
1335
1646
  return VisualFractionInlineSchema.safeParse(value).success;
1336
1647
  }
1648
+ function isInlineDragOption(value) {
1649
+ return InlineDragOptionSchema.safeParse(value).success;
1650
+ }
1337
1651
  const TextBlockSchema = zod.z.object({
1338
1652
  type: zod.z.literal(AST_TYPES.TEXT_BLOCK),
1339
1653
  value: zod.z.string()
@@ -1387,17 +1701,19 @@ const NumberLineCounterSchema = zod.z.object({
1387
1701
  min: zod.z.number().int().min(1, { message: "COUNTER: min must be >= 1" }),
1388
1702
  max: zod.z.number().int().positive({ message: "COUNTER: max must be > 0" }),
1389
1703
  default: zod.z.number().int().nonnegative().optional(),
1390
- name: zod.z.string().optional()
1704
+ name: zod.z.string().optional(),
1705
+ answer: zod.z.number().int().positive({ message: "COUNTER: answer must be a positive integer" }).optional()
1391
1706
  }).refine((d) => d.min <= d.max, {
1392
1707
  message: "COUNTER: min must be <= max"
1393
1708
  });
1394
- function createNumberLineCounter(max, min, defaultValue2, name) {
1709
+ function createNumberLineCounter(max, min, defaultValue2, name, answer) {
1395
1710
  return NumberLineCounterSchema.parse({
1396
1711
  type: AST_TYPES.NUMBER_LINE_COUNTER,
1397
1712
  min: min ?? 1,
1398
1713
  max,
1399
1714
  ...defaultValue2 !== void 0 && { default: defaultValue2 },
1400
- ...name !== void 0 && { name }
1715
+ ...name !== void 0 && { name },
1716
+ ...answer !== void 0 && { answer }
1401
1717
  });
1402
1718
  }
1403
1719
  const NumberLineHighlightSchema = zod.z.object({
@@ -1531,6 +1847,146 @@ function createTableBlock(cols, rows) {
1531
1847
  rows
1532
1848
  });
1533
1849
  }
1850
+ const VOP_OPERATORS = ["+", "-", "×", "÷", "="];
1851
+ function normalizeVopOperator(raw) {
1852
+ const map = {
1853
+ "+": "+",
1854
+ "-": "-",
1855
+ "−": "-",
1856
+ "*": "×",
1857
+ "x": "×",
1858
+ "X": "×",
1859
+ "×": "×",
1860
+ "/": "÷",
1861
+ "÷": "÷",
1862
+ "=": "="
1863
+ };
1864
+ const op = map[raw];
1865
+ if (!op) throw new Error(`VERTICAL_OPERATION: unsupported op "${raw}"`);
1866
+ return op;
1867
+ }
1868
+ const VopDigitCellSchema = zod.z.object({
1869
+ type: zod.z.literal(AST_TYPES.VOP_CELL),
1870
+ kind: zod.z.literal("digit"),
1871
+ value: zod.z.string()
1872
+ });
1873
+ const VopLabelCellSchema = zod.z.object({
1874
+ type: zod.z.literal(AST_TYPES.VOP_CELL),
1875
+ kind: zod.z.literal("label"),
1876
+ value: zod.z.string()
1877
+ });
1878
+ const VopInputCellSchema = zod.z.object({
1879
+ type: zod.z.literal(AST_TYPES.VOP_CELL),
1880
+ kind: zod.z.literal("input"),
1881
+ name: zod.z.string().min(1),
1882
+ digits: zod.z.number().int().positive().default(1),
1883
+ answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
1884
+ });
1885
+ const VopEmptyCellSchema = zod.z.object({
1886
+ type: zod.z.literal(AST_TYPES.VOP_CELL),
1887
+ kind: zod.z.literal("empty")
1888
+ });
1889
+ const VopColorCellSchema = zod.z.object({
1890
+ type: zod.z.literal(AST_TYPES.VOP_CELL),
1891
+ kind: zod.z.literal("color"),
1892
+ value: zod.z.string(),
1893
+ color: zod.z.string()
1894
+ });
1895
+ const VopBoldCellSchema = zod.z.object({
1896
+ type: zod.z.literal(AST_TYPES.VOP_CELL),
1897
+ kind: zod.z.literal("bold"),
1898
+ value: zod.z.string()
1899
+ });
1900
+ const VopHighlightCellSchema = zod.z.object({
1901
+ type: zod.z.literal(AST_TYPES.VOP_CELL),
1902
+ kind: zod.z.literal("highlight"),
1903
+ value: zod.z.string()
1904
+ });
1905
+ const VopCellSchema = zod.z.discriminatedUnion("kind", [
1906
+ VopDigitCellSchema,
1907
+ VopLabelCellSchema,
1908
+ VopColorCellSchema,
1909
+ VopBoldCellSchema,
1910
+ VopHighlightCellSchema,
1911
+ VopInputCellSchema,
1912
+ VopEmptyCellSchema
1913
+ ]);
1914
+ function createVopDigitCell(value) {
1915
+ return VopDigitCellSchema.parse({ type: AST_TYPES.VOP_CELL, kind: "digit", value });
1916
+ }
1917
+ function createVopInputCell(name, digits = 1, answer) {
1918
+ return VopInputCellSchema.parse({
1919
+ type: AST_TYPES.VOP_CELL,
1920
+ kind: "input",
1921
+ name,
1922
+ digits,
1923
+ answer
1924
+ });
1925
+ }
1926
+ function createVopLabelCell(value) {
1927
+ return VopLabelCellSchema.parse({ type: AST_TYPES.VOP_CELL, kind: "label", value });
1928
+ }
1929
+ function createVopEmptyCell() {
1930
+ return VopEmptyCellSchema.parse({ type: AST_TYPES.VOP_CELL, kind: "empty" });
1931
+ }
1932
+ function createVopColorCell(value, color) {
1933
+ return VopColorCellSchema.parse({ type: AST_TYPES.VOP_CELL, kind: "color", value, color });
1934
+ }
1935
+ function createVopBoldCell(value) {
1936
+ return VopBoldCellSchema.parse({ type: AST_TYPES.VOP_CELL, kind: "bold", value });
1937
+ }
1938
+ function createVopHighlightCell(value) {
1939
+ return VopHighlightCellSchema.parse({ type: AST_TYPES.VOP_CELL, kind: "highlight", value });
1940
+ }
1941
+ const VopRowSchema = zod.z.object({
1942
+ type: zod.z.literal(AST_TYPES.VOP_ROW),
1943
+ cells: zod.z.array(VopCellSchema).min(1),
1944
+ op: zod.z.enum(VOP_OPERATORS).optional()
1945
+ });
1946
+ function createVopRow(cells, op) {
1947
+ return VopRowSchema.parse({ type: AST_TYPES.VOP_ROW, cells, op });
1948
+ }
1949
+ const VopCarryRowSchema = zod.z.object({
1950
+ type: zod.z.literal(AST_TYPES.VOP_CARRY_ROW),
1951
+ cells: zod.z.array(VopCellSchema).min(1)
1952
+ });
1953
+ function createVopCarryRow(cells) {
1954
+ return VopCarryRowSchema.parse({ type: AST_TYPES.VOP_CARRY_ROW, cells });
1955
+ }
1956
+ const VopSeparatorSchema = zod.z.object({
1957
+ type: zod.z.literal(AST_TYPES.VOP_SEPARATOR)
1958
+ });
1959
+ function createVopSeparator() {
1960
+ return VopSeparatorSchema.parse({ type: AST_TYPES.VOP_SEPARATOR });
1961
+ }
1962
+ const VopBlockItemSchema = zod.z.union([VopRowSchema, VopCarryRowSchema, VopSeparatorSchema]);
1963
+ const VerticalOperationBlockSchema = zod.z.object({
1964
+ type: zod.z.literal(AST_TYPES.VERTICAL_OPERATION_BLOCK),
1965
+ op: zod.z.enum(VOP_OPERATORS).optional(),
1966
+ items: zod.z.array(VopBlockItemSchema).min(1)
1967
+ });
1968
+ function createVerticalOperationBlock(op, items) {
1969
+ return VerticalOperationBlockSchema.parse({
1970
+ type: AST_TYPES.VERTICAL_OPERATION_BLOCK,
1971
+ op,
1972
+ items
1973
+ });
1974
+ }
1975
+ function isVopCell(value) {
1976
+ return VopCellSchema.safeParse(value).success;
1977
+ }
1978
+ function isVopRow(value) {
1979
+ return VopRowSchema.safeParse(value).success;
1980
+ }
1981
+ function isVopCarryRow(value) {
1982
+ return VopCarryRowSchema.safeParse(value).success;
1983
+ }
1984
+ function isVopSeparator(value) {
1985
+ return VopSeparatorSchema.safeParse(value).success;
1986
+ }
1987
+ function isVerticalOperationBlock(value) {
1988
+ return VerticalOperationBlockSchema.safeParse(value).success;
1989
+ }
1534
1990
  const DisplayFractionInlineSchema = FractionInlineSchema.refine(
1535
1991
  (f) => f.numerator !== void 0 && f.denominator !== void 0,
1536
1992
  { message: "FRACTION inside MATCHER ITEM must be in display mode (both numerator and denominator required)" }
@@ -1636,6 +2092,91 @@ function createMatcherBlock(name, rows, answers, colors) {
1636
2092
  function isMatcherBlock(value) {
1637
2093
  return MatcherBlockSchema.safeParse(value).success;
1638
2094
  }
2095
+ const PairMatcherBlockSchema = zod.z.object({
2096
+ type: zod.z.literal(AST_TYPES.PAIR_MATCHER_BLOCK),
2097
+ name: zod.z.string().min(1),
2098
+ /** Items in the top row — at least one required */
2099
+ topItems: zod.z.array(MatcherItemSchema).min(1),
2100
+ /** Items in the bottom row — at least one required */
2101
+ bottomItems: zod.z.array(MatcherItemSchema).min(1),
2102
+ /**
2103
+ * Each answer pair is [topId, bottomId].
2104
+ * At least one pair is required.
2105
+ */
2106
+ answers: zod.z.array(zod.z.tuple([zod.z.string().min(1), zod.z.string().min(1)])).min(1)
2107
+ }).superRefine((data, ctx) => {
2108
+ const idRegistry = /* @__PURE__ */ new Map();
2109
+ const sections = [
2110
+ { key: "topItems", items: data.topItems },
2111
+ { key: "bottomItems", items: data.bottomItems }
2112
+ ];
2113
+ for (const { key: key2, items } of sections) {
2114
+ for (let i = 0; i < items.length; i++) {
2115
+ const { itemId } = items[i];
2116
+ const existing = idRegistry.get(itemId);
2117
+ if (existing) {
2118
+ const isCrossSection = existing.section !== key2;
2119
+ ctx.addIssue({
2120
+ code: zod.z.ZodIssueCode.custom,
2121
+ message: isCrossSection ? `itemId "${itemId}" appears in both TOP and BOTTOM — ids must be globally unique` : `Duplicate itemId "${itemId}" in ${key2 === "topItems" ? "TOP" : "BOTTOM"} section`,
2122
+ path: [key2, i, "itemId"]
2123
+ });
2124
+ } else {
2125
+ idRegistry.set(itemId, { section: key2, index: i });
2126
+ }
2127
+ }
2128
+ }
2129
+ const topIds = new Set(data.topItems.map((it) => it.itemId));
2130
+ const bottomIds = new Set(data.bottomItems.map((it) => it.itemId));
2131
+ const usedTopIds = /* @__PURE__ */ new Set();
2132
+ const usedBottomIds = /* @__PURE__ */ new Set();
2133
+ data.answers.forEach(([topId, bottomId], i) => {
2134
+ if (!topIds.has(topId)) {
2135
+ ctx.addIssue({
2136
+ code: zod.z.ZodIssueCode.custom,
2137
+ message: `Answer pair [${topId}, ${bottomId}]: "${topId}" is not declared in TOP section`,
2138
+ path: ["answers", i, 0]
2139
+ });
2140
+ }
2141
+ if (!bottomIds.has(bottomId)) {
2142
+ ctx.addIssue({
2143
+ code: zod.z.ZodIssueCode.custom,
2144
+ message: `Answer pair [${topId}, ${bottomId}]: "${bottomId}" is not declared in BOTTOM section`,
2145
+ path: ["answers", i, 1]
2146
+ });
2147
+ }
2148
+ if (usedTopIds.has(topId)) {
2149
+ ctx.addIssue({
2150
+ code: zod.z.ZodIssueCode.custom,
2151
+ message: `topId "${topId}" appears in more than one answer pair`,
2152
+ path: ["answers", i, 0]
2153
+ });
2154
+ } else {
2155
+ usedTopIds.add(topId);
2156
+ }
2157
+ if (usedBottomIds.has(bottomId)) {
2158
+ ctx.addIssue({
2159
+ code: zod.z.ZodIssueCode.custom,
2160
+ message: `bottomId "${bottomId}" appears in more than one answer pair`,
2161
+ path: ["answers", i, 1]
2162
+ });
2163
+ } else {
2164
+ usedBottomIds.add(bottomId);
2165
+ }
2166
+ });
2167
+ });
2168
+ function createPairMatcherBlock(name, topItems, bottomItems, answers) {
2169
+ return PairMatcherBlockSchema.parse({
2170
+ type: AST_TYPES.PAIR_MATCHER_BLOCK,
2171
+ name,
2172
+ topItems,
2173
+ bottomItems,
2174
+ answers
2175
+ });
2176
+ }
2177
+ function isPairMatcherBlock(value) {
2178
+ return PairMatcherBlockSchema.safeParse(value).success;
2179
+ }
1639
2180
  const DragOptionsToolSchema = zod.z.object({
1640
2181
  type: zod.z.literal(AST_TYPES.DRAG_OPTIONS),
1641
2182
  options: zod.z.array(zod.z.string()).min(1)
@@ -1694,9 +2235,14 @@ const ExpressionValidationSchema = zod.z.object({
1694
2235
  type: zod.z.literal(AST_TYPES.EXPRESSION_VALIDATION),
1695
2236
  expression: zod.z.string()
1696
2237
  });
2238
+ const MixedValidationSchema = zod.z.object({
2239
+ type: zod.z.literal(AST_TYPES.MIXED_VALIDATION),
2240
+ expression: zod.z.string().min(1)
2241
+ });
1697
2242
  const ValidationNodeSchema = zod.z.discriminatedUnion("type", [
1698
2243
  SimpleValidationSchema,
1699
- ExpressionValidationSchema
2244
+ ExpressionValidationSchema,
2245
+ MixedValidationSchema
1700
2246
  ]);
1701
2247
  function createSimpleValidation() {
1702
2248
  return SimpleValidationSchema.parse({
@@ -1709,16 +2255,25 @@ function createExpressionValidation(expression) {
1709
2255
  expression
1710
2256
  });
1711
2257
  }
2258
+ function createMixedValidation(expression) {
2259
+ return MixedValidationSchema.parse({
2260
+ type: AST_TYPES.MIXED_VALIDATION,
2261
+ expression
2262
+ });
2263
+ }
1712
2264
  function isSimpleValidation(value) {
1713
2265
  return SimpleValidationSchema.safeParse(value).success;
1714
2266
  }
1715
2267
  function isExpressionValidation(value) {
1716
2268
  return ExpressionValidationSchema.safeParse(value).success;
1717
2269
  }
2270
+ function isMixedValidation(value) {
2271
+ return MixedValidationSchema.safeParse(value).success;
2272
+ }
1718
2273
  function isValidationNode(value) {
1719
2274
  return ValidationNodeSchema.safeParse(value).success;
1720
2275
  }
1721
- const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema]);
2276
+ const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema]);
1722
2277
  const ExerciseDocumentSchema = zod.z.object({
1723
2278
  type: zod.z.literal(AST_TYPES.EXERCISE_DOCUMENT),
1724
2279
  question: TextBlockSchema.optional(),
@@ -1766,6 +2321,12 @@ const ExerciseDocumentSchema = zod.z.object({
1766
2321
  visitInlines(item.inlines, ["lines", bi, "rows", ri, "items", ii, "inlines"]);
1767
2322
  });
1768
2323
  });
2324
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
2325
+ const pm = block;
2326
+ record(pm.name, ["lines", bi, "name"]);
2327
+ [...pm.topItems, ...pm.bottomItems].forEach((item, ii) => {
2328
+ visitInlines(item.inlines, ["lines", bi, "items", ii, "inlines"]);
2329
+ });
1769
2330
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
1770
2331
  record(block.name, ["lines", bi, "name"]);
1771
2332
  }
@@ -1814,7 +2375,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1814
2375
  if (ctx.table) return this.visit(ctx.table);
1815
2376
  if (ctx.radioButtons) return this.visit(ctx.radioButtons);
1816
2377
  if (ctx.numberLine) return this.visit(ctx.numberLine);
2378
+ if (ctx.pairMatcher) return this.visit(ctx.pairMatcher);
1817
2379
  if (ctx.matcher) return this.visit(ctx.matcher);
2380
+ if (ctx.verticalOperation) return this.visit(ctx.verticalOperation);
1818
2381
  if (ctx.line) return this.visit(ctx.line);
1819
2382
  throw new Error("Unknown content block");
1820
2383
  }
@@ -1886,7 +2449,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1886
2449
  const min = params.min !== void 0 ? Number(params.min) : void 0;
1887
2450
  const defaultValue2 = params.default !== void 0 ? Number(params.default) : void 0;
1888
2451
  const name = params.name;
1889
- return createNumberLineCounter(max, min, defaultValue2, name);
2452
+ const answer = params.answer !== void 0 ? Number(params.answer) : void 0;
2453
+ return createNumberLineCounter(max, min, defaultValue2, name, answer);
1890
2454
  }
1891
2455
  highlight(ctx) {
1892
2456
  const params = this.visit(ctx.parameterList);
@@ -1927,6 +2491,102 @@ class NaturalDSLVisitor extends BaseCstVisitor {
1927
2491
  return ctx.ids ? ctx.ids.map((t) => t.image) : [];
1928
2492
  }
1929
2493
  // ═══════════════════════════════════════════════════════════════════════════
2494
+ // Pair Matcher Block
2495
+ // ═══════════════════════════════════════════════════════════════════════════
2496
+ pairMatcher(ctx) {
2497
+ const params = this.visit(ctx.parameterList);
2498
+ const name = params.name;
2499
+ const topItems = this.visit(ctx.pairMatcherTopSection);
2500
+ const bottomItems = this.visit(ctx.pairMatcherBottomSection);
2501
+ const rawAnswers = this.visit(ctx.matcherAnswers);
2502
+ return createPairMatcherBlock(name, topItems, bottomItems, rawAnswers);
2503
+ }
2504
+ pairMatcherTopSection(ctx) {
2505
+ return this.visitMatcherItemList(ctx);
2506
+ }
2507
+ pairMatcherBottomSection(ctx) {
2508
+ return this.visitMatcherItemList(ctx);
2509
+ }
2510
+ /**
2511
+ * Maps `ctx.matcherItem` CST nodes to MatcherItem AST nodes.
2512
+ * Shared by pairMatcherTopSection and pairMatcherBottomSection because their
2513
+ * grammar rules are structurally identical — both are `matcherItem+` lists
2514
+ * wrapped in a labelled section keyword.
2515
+ */
2516
+ visitMatcherItemList(ctx) {
2517
+ return ctx.matcherItem ? ctx.matcherItem.map((i) => this.visit(i)) : [];
2518
+ }
2519
+ // ═══════════════════════════════════════════════════════════════════════════
2520
+ // Vertical Operation Block
2521
+ // ═══════════════════════════════════════════════════════════════════════════
2522
+ verticalOperation(ctx) {
2523
+ const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
2524
+ const op = params.op !== void 0 ? normalizeVopOperator(String(params.op)) : void 0;
2525
+ const tagged = [];
2526
+ ctx.vopRow?.forEach((cstNode, i) => {
2527
+ tagged.push({ offset: cstNode.location?.startOffset ?? i, node: this.visit(cstNode) });
2528
+ });
2529
+ ctx.vopCarry?.forEach((cstNode, i) => {
2530
+ tagged.push({ offset: cstNode.location?.startOffset ?? i, node: this.visit(cstNode) });
2531
+ });
2532
+ ctx.vopSep?.forEach((cstNode, i) => {
2533
+ tagged.push({ offset: cstNode.location?.startOffset ?? i, node: this.visit(cstNode) });
2534
+ });
2535
+ tagged.sort((a, b) => a.offset - b.offset);
2536
+ const items = tagged.map((t) => t.node);
2537
+ return createVerticalOperationBlock(op, items);
2538
+ }
2539
+ vopRow(ctx) {
2540
+ const cells = ctx.vopCell ? ctx.vopCell.map((c) => this.visit(c)) : [];
2541
+ const rowParams = ctx.parameterList ? this.visit(ctx.parameterList) : {};
2542
+ const op = rowParams.op !== void 0 ? normalizeVopOperator(String(rowParams.op)) : void 0;
2543
+ return createVopRow(cells, op);
2544
+ }
2545
+ vopCarry(ctx) {
2546
+ const cells = ctx.vopCell ? ctx.vopCell.map((c) => this.visit(c)) : [];
2547
+ return createVopCarryRow(cells);
2548
+ }
2549
+ vopSep(_ctx) {
2550
+ return createVopSeparator();
2551
+ }
2552
+ vopCell(ctx) {
2553
+ if (ctx.NumberLiteral) {
2554
+ const token = ctx.NumberLiteral[0];
2555
+ return createVopDigitCell(token.image);
2556
+ }
2557
+ if (ctx.StringLiteral) {
2558
+ const token = ctx.StringLiteral[0];
2559
+ const value = token.image.slice(1, -1);
2560
+ return createVopLabelCell(value);
2561
+ }
2562
+ if (ctx.Underscore) {
2563
+ return createVopEmptyCell();
2564
+ }
2565
+ if (ctx.INPUT) {
2566
+ const params = this.visit(ctx.parameterList);
2567
+ const name = params.name;
2568
+ if (!name) {
2569
+ throw new Error("INPUT inside VERTICAL_OPERATION requires parameter: name");
2570
+ }
2571
+ const digits = params.digits !== void 0 ? Number(params.digits) : 1;
2572
+ const answer = params.answer;
2573
+ return createVopInputCell(name, digits, answer);
2574
+ }
2575
+ if (ctx.colorText) {
2576
+ const inline = this.visit(ctx.colorText);
2577
+ return createVopColorCell(inline.value, inline.color);
2578
+ }
2579
+ if (ctx.boldText) {
2580
+ const inline = this.visit(ctx.boldText);
2581
+ return createVopBoldCell(inline.value);
2582
+ }
2583
+ if (ctx.textHighlighter) {
2584
+ const inline = this.visit(ctx.textHighlighter);
2585
+ return createVopHighlightCell(inline.value);
2586
+ }
2587
+ throw new Error("Unknown vopCell");
2588
+ }
2589
+ // ═══════════════════════════════════════════════════════════════════════════
1930
2590
  // Line Block
1931
2591
  // ═══════════════════════════════════════════════════════════════════════════
1932
2592
  line(ctx) {
@@ -2022,6 +2682,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2022
2682
  if (ctx.inlineInput) {
2023
2683
  return this.visit(ctx.inlineInput);
2024
2684
  }
2685
+ if (ctx.inlineDragOption) {
2686
+ return this.visit(ctx.inlineDragOption);
2687
+ }
2025
2688
  throw new Error("Unknown inline element");
2026
2689
  }
2027
2690
  textHighlighter(ctx) {
@@ -2098,6 +2761,11 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2098
2761
  const digits = params.digits;
2099
2762
  return createNumericInput(name, digits, answer, placeholder);
2100
2763
  }
2764
+ inlineDragOption(ctx) {
2765
+ const valueToken = ctx.StringLiteral[0];
2766
+ const value = this.extractString(valueToken.image);
2767
+ return createInlineDragOption(value);
2768
+ }
2101
2769
  // ═══════════════════════════════════════════════════════════════════════════
2102
2770
  // Tools
2103
2771
  // ═══════════════════════════════════════════════════════════════════════════
@@ -2132,6 +2800,12 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2132
2800
  } else if (type === "expression") {
2133
2801
  const expression = params.expression;
2134
2802
  return createExpressionValidation(expression);
2803
+ } else if (type === "mixed") {
2804
+ const expression = params.expression;
2805
+ if (!expression || expression.trim() === "") {
2806
+ throw new Error("Mixed validation requires a non-empty expression");
2807
+ }
2808
+ return createMixedValidation(expression);
2135
2809
  }
2136
2810
  throw new Error(`Unknown validation type: ${type}`);
2137
2811
  }
@@ -2286,7 +2960,8 @@ const LayoutItemSchema = zod.z.lazy(
2286
2960
  GroupItemSchema,
2287
2961
  OperatorItemSchema,
2288
2962
  TableItemSchema,
2289
- NumberLineItemSchema
2963
+ NumberLineItemSchema,
2964
+ VerticalOperationItemSchema
2290
2965
  ])
2291
2966
  );
2292
2967
  const GroupItemSchema = zod.z.object({
@@ -2323,7 +2998,8 @@ const NumberLineCounterItemSchema = zod.z.object({
2323
2998
  min: zod.z.number().int().min(1),
2324
2999
  max: zod.z.number().int().positive(),
2325
3000
  default: zod.z.number().int().nonnegative().optional(),
2326
- name: zod.z.string().optional()
3001
+ name: zod.z.string().optional(),
3002
+ answer: zod.z.number().int().positive().optional()
2327
3003
  });
2328
3004
  const NumberLineHighlightItemSchema = zod.z.object({
2329
3005
  id: zod.z.string(),
@@ -2360,6 +3036,80 @@ const MatcherRowItemSchema = zod.z.object({
2360
3036
  type: zod.z.literal(JSON_TYPES.MATCHER_ROW),
2361
3037
  items: zod.z.array(MatcherItemContentSchema).min(1)
2362
3038
  });
3039
+ const VopOperatorJSONSchema = zod.z.enum(["+", "-", "×", "÷", "="]);
3040
+ const VopDigitCellItemSchema = zod.z.object({
3041
+ id: zod.z.string(),
3042
+ type: zod.z.literal(JSON_TYPES.VOP_CELL),
3043
+ kind: zod.z.literal("digit"),
3044
+ value: zod.z.string()
3045
+ });
3046
+ const VopInputCellItemSchema = zod.z.object({
3047
+ id: zod.z.string(),
3048
+ type: zod.z.literal(JSON_TYPES.VOP_CELL),
3049
+ kind: zod.z.literal("input"),
3050
+ widgetId: zod.z.string()
3051
+ });
3052
+ const VopLabelCellItemSchema = zod.z.object({
3053
+ id: zod.z.string(),
3054
+ type: zod.z.literal(JSON_TYPES.VOP_CELL),
3055
+ kind: zod.z.literal("label"),
3056
+ value: zod.z.string()
3057
+ });
3058
+ const VopEmptyCellItemSchema = zod.z.object({
3059
+ id: zod.z.string(),
3060
+ type: zod.z.literal(JSON_TYPES.VOP_CELL),
3061
+ kind: zod.z.literal("empty")
3062
+ });
3063
+ const VopColorCellItemSchema = zod.z.object({
3064
+ id: zod.z.string(),
3065
+ type: zod.z.literal(JSON_TYPES.VOP_CELL),
3066
+ kind: zod.z.literal("color"),
3067
+ value: zod.z.string(),
3068
+ color: zod.z.string()
3069
+ });
3070
+ const VopBoldCellItemSchema = zod.z.object({
3071
+ id: zod.z.string(),
3072
+ type: zod.z.literal(JSON_TYPES.VOP_CELL),
3073
+ kind: zod.z.literal("bold"),
3074
+ value: zod.z.string()
3075
+ });
3076
+ const VopHighlightCellItemSchema = zod.z.object({
3077
+ id: zod.z.string(),
3078
+ type: zod.z.literal(JSON_TYPES.VOP_CELL),
3079
+ kind: zod.z.literal("highlight"),
3080
+ value: zod.z.string()
3081
+ });
3082
+ const VopCellItemSchema = zod.z.discriminatedUnion("kind", [
3083
+ VopDigitCellItemSchema,
3084
+ VopLabelCellItemSchema,
3085
+ VopColorCellItemSchema,
3086
+ VopBoldCellItemSchema,
3087
+ VopHighlightCellItemSchema,
3088
+ VopInputCellItemSchema,
3089
+ VopEmptyCellItemSchema
3090
+ ]);
3091
+ const VopRowItemSchema = zod.z.object({
3092
+ id: zod.z.string(),
3093
+ type: zod.z.literal(JSON_TYPES.VOP_ROW),
3094
+ cells: zod.z.array(VopCellItemSchema),
3095
+ op: VopOperatorJSONSchema.optional()
3096
+ });
3097
+ const VopCarryRowItemSchema = zod.z.object({
3098
+ id: zod.z.string(),
3099
+ type: zod.z.literal(JSON_TYPES.VOP_CARRY_ROW),
3100
+ cells: zod.z.array(VopCellItemSchema)
3101
+ });
3102
+ const VopSeparatorItemSchema = zod.z.object({
3103
+ id: zod.z.string(),
3104
+ type: zod.z.literal(JSON_TYPES.VOP_SEPARATOR)
3105
+ });
3106
+ const VerticalOperationItemSchema = zod.z.object({
3107
+ id: zod.z.string(),
3108
+ type: zod.z.literal(JSON_TYPES.VERTICAL_OPERATION),
3109
+ op: VopOperatorJSONSchema.optional(),
3110
+ cols: zod.z.number().int().positive(),
3111
+ items: zod.z.array(zod.z.union([VopRowItemSchema, VopCarryRowItemSchema, VopSeparatorItemSchema]))
3112
+ });
2363
3113
  const StringInputJSONSchema = zod.z.object({
2364
3114
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
2365
3115
  inputType: zod.z.literal(INPUT_TYPES.STRING),
@@ -2380,6 +3130,10 @@ const DragOptionsJSONSchema = zod.z.object({
2380
3130
  type: zod.z.literal(JSON_TYPES.DRAG_OPTIONS),
2381
3131
  options: zod.z.array(zod.z.string())
2382
3132
  });
3133
+ const InlineDragOptionJSONSchema = zod.z.object({
3134
+ type: zod.z.literal(JSON_TYPES.INLINE_DRAG_OPTION),
3135
+ value: zod.z.string().min(1)
3136
+ });
2383
3137
  const RadioOptionsJSONSchema = zod.z.object({
2384
3138
  type: zod.z.literal(JSON_TYPES.RADIO_OPTIONS),
2385
3139
  options: zod.z.array(zod.z.string())
@@ -2405,6 +3159,11 @@ const MatcherWidgetJSONSchema = zod.z.object({
2405
3159
  /** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
2406
3160
  groupCount: zod.z.number().int().min(1)
2407
3161
  });
3162
+ const PairMatcherWidgetJSONSchema = zod.z.object({
3163
+ type: zod.z.literal(JSON_TYPES.PAIR_MATCHER),
3164
+ topItems: zod.z.array(MatcherItemContentSchema).min(1),
3165
+ bottomItems: zod.z.array(MatcherItemContentSchema).min(1)
3166
+ });
2408
3167
  const VisualFractionJSONSchema = zod.z.object({
2409
3168
  type: zod.z.literal(JSON_TYPES.VISUAL_FRACTION),
2410
3169
  shape: zod.z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
@@ -2425,14 +3184,16 @@ const VisualFractionJSONSchema = zod.z.object({
2425
3184
  const WidgetJSONSchema = zod.z.union([
2426
3185
  InlineInputJSONSchema,
2427
3186
  DragOptionsJSONSchema,
3187
+ InlineDragOptionJSONSchema,
2428
3188
  RadioOptionsJSONSchema,
2429
3189
  NumericKeypadJSONSchema,
2430
3190
  FractionJSONSchema,
2431
3191
  MatcherWidgetJSONSchema,
3192
+ PairMatcherWidgetJSONSchema,
2432
3193
  VisualFractionJSONSchema
2433
3194
  ]);
2434
3195
  const ValidationJSONSchema = zod.z.object({
2435
- mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
3196
+ mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION, VALIDATION_MODES.MIXED]),
2436
3197
  // Simple answers: string|number per widget id.
2437
3198
  // Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
2438
3199
  // mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
@@ -2516,12 +3277,18 @@ function isTableRowItem(item) {
2516
3277
  function isTableCellItem(item) {
2517
3278
  return item.type === JSON_TYPES.TABLE_CELL;
2518
3279
  }
3280
+ function isVerticalOperationItem(item) {
3281
+ return item.type === JSON_TYPES.VERTICAL_OPERATION;
3282
+ }
2519
3283
  function isInlineInputJSON(widget) {
2520
3284
  return widget.type === JSON_TYPES.INLINE_INPUT;
2521
3285
  }
2522
3286
  function isDragOptionsJSON(widget) {
2523
3287
  return widget.type === JSON_TYPES.DRAG_OPTIONS;
2524
3288
  }
3289
+ function isInlineDragOptionJSON(widget) {
3290
+ return widget.type === JSON_TYPES.INLINE_DRAG_OPTION;
3291
+ }
2525
3292
  function isRadioOptionsJSON(widget) {
2526
3293
  return widget.type === JSON_TYPES.RADIO_OPTIONS;
2527
3294
  }
@@ -2544,6 +3311,18 @@ class LayoutIdGenerator {
2544
3311
  }
2545
3312
  function toJSON(ast) {
2546
3313
  const idGenerator = new LayoutIdGenerator();
3314
+ const widgets = {};
3315
+ const answers = {};
3316
+ const allInlines = collectAllInlines(ast.lines);
3317
+ const inlineDragIds = /* @__PURE__ */ new Map();
3318
+ {
3319
+ let counter = 0;
3320
+ for (const inline of allInlines) {
3321
+ if (inline.type === AST_TYPES.INLINE_DRAG_OPTION) {
3322
+ inlineDragIds.set(inline, `${INLINE_DRAG_OPT_PREFIX}${counter++}`);
3323
+ }
3324
+ }
3325
+ }
2547
3326
  const layout = [];
2548
3327
  if (ast.question) {
2549
3328
  layout.push({
@@ -2554,9 +3333,13 @@ function toJSON(ast) {
2554
3333
  }
2555
3334
  for (const block of ast.lines) {
2556
3335
  if (block.type === AST_TYPES.TABLE_BLOCK) {
2557
- layout.push(transformTable(block, idGenerator));
3336
+ layout.push(transformTable(block, idGenerator, inlineDragIds));
2558
3337
  } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
2559
- layout.push(transformNumberLine(block, idGenerator));
3338
+ const nlItem = transformNumberLine(block, idGenerator, inlineDragIds);
3339
+ layout.push(nlItem);
3340
+ if (nlItem.counter?.answer !== void 0) {
3341
+ answers[nlItem.counter.id] = nlItem.counter.answer;
3342
+ }
2560
3343
  } else if (block.type === AST_TYPES.MATCHER_BLOCK) {
2561
3344
  const matcherBlock = block;
2562
3345
  layout.push({
@@ -2568,6 +3351,19 @@ function toJSON(ast) {
2568
3351
  id: idGenerator.generate(JSON_TYPES.LINEBREAK),
2569
3352
  type: JSON_TYPES.LINEBREAK
2570
3353
  });
3354
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
3355
+ const pmBlock = block;
3356
+ layout.push({
3357
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
3358
+ type: JSON_TYPES.WIDGET_REF,
3359
+ widgetId: pmBlock.name
3360
+ });
3361
+ layout.push({
3362
+ id: idGenerator.generate(JSON_TYPES.LINEBREAK),
3363
+ type: JSON_TYPES.LINEBREAK
3364
+ });
3365
+ } else if (block.type === AST_TYPES.VERTICAL_OPERATION_BLOCK) {
3366
+ layout.push(transformVerticalOperation(block, idGenerator));
2571
3367
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
2572
3368
  const radioBlock = block;
2573
3369
  layout.push({
@@ -2580,13 +3376,10 @@ function toJSON(ast) {
2580
3376
  type: JSON_TYPES.LINEBREAK
2581
3377
  });
2582
3378
  } else {
2583
- const lineItems = transformLine(block, idGenerator);
3379
+ const lineItems = transformLine(block, idGenerator, inlineDragIds);
2584
3380
  layout.push(...lineItems);
2585
3381
  }
2586
3382
  }
2587
- const widgets = {};
2588
- const answers = {};
2589
- const allInlines = collectAllInlines(ast.lines);
2590
3383
  for (const inline of allInlines) {
2591
3384
  if (inline.type === AST_TYPES.INLINE_INPUT) {
2592
3385
  if (inline.inputType === INPUT_TYPES.STRING) {
@@ -2620,6 +3413,13 @@ function toJSON(ast) {
2620
3413
  answers[inline.name] = inline.answer;
2621
3414
  }
2622
3415
  }
3416
+ if (inline.type === AST_TYPES.INLINE_DRAG_OPTION) {
3417
+ const widgetId = inlineDragIds.get(inline);
3418
+ widgets[widgetId] = {
3419
+ type: JSON_TYPES.INLINE_DRAG_OPTION,
3420
+ value: inline.value
3421
+ };
3422
+ }
2623
3423
  if (inline.type === AST_TYPES.VISUAL_FRACTION) {
2624
3424
  const answerArr = inline.answer ?? [];
2625
3425
  widgets[inline.name] = {
@@ -2637,6 +3437,27 @@ function toJSON(ast) {
2637
3437
  }
2638
3438
  }
2639
3439
  }
3440
+ for (const block of ast.lines) {
3441
+ if (block.type !== AST_TYPES.VERTICAL_OPERATION_BLOCK) continue;
3442
+ const vop = block;
3443
+ for (const item of vop.items) {
3444
+ if (item.type !== AST_TYPES.VOP_ROW && item.type !== AST_TYPES.VOP_CARRY_ROW) continue;
3445
+ for (const cell of item.cells) {
3446
+ if (cell.kind !== "input") continue;
3447
+ if (widgets[cell.name]) {
3448
+ throw new Error(`VERTICAL_OPERATION: duplicate INPUT name "${cell.name}"`);
3449
+ }
3450
+ widgets[cell.name] = {
3451
+ type: JSON_TYPES.INLINE_INPUT,
3452
+ inputType: INPUT_TYPES.NUMERIC,
3453
+ digits: cell.digits
3454
+ };
3455
+ if (cell.answer !== void 0) {
3456
+ answers[cell.name] = cell.answer;
3457
+ }
3458
+ }
3459
+ }
3460
+ }
2640
3461
  for (const block of ast.lines) {
2641
3462
  if (block.type === AST_TYPES.RADIO_OPTIONS) {
2642
3463
  const radioBlock = block;
@@ -2647,8 +3468,12 @@ function toJSON(ast) {
2647
3468
  answers[radioBlock.name] = radioBlock.answer;
2648
3469
  } else if (block.type === AST_TYPES.MATCHER_BLOCK) {
2649
3470
  const matcherBlock = block;
2650
- widgets[matcherBlock.name] = buildMatcherWidget(matcherBlock, idGenerator);
3471
+ widgets[matcherBlock.name] = buildMatcherWidget(matcherBlock, idGenerator, inlineDragIds);
2651
3472
  answers[matcherBlock.name] = matcherBlock.answers;
3473
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
3474
+ const pmBlock = block;
3475
+ widgets[pmBlock.name] = buildPairMatcherWidget(pmBlock, idGenerator, inlineDragIds);
3476
+ answers[pmBlock.name] = pmBlock.answers;
2652
3477
  }
2653
3478
  }
2654
3479
  if (ast.tools) {
@@ -2697,13 +3522,18 @@ function collectAllInlines(blocks) {
2697
3522
  result.push(...item.inlines);
2698
3523
  }
2699
3524
  }
3525
+ } else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
3526
+ const pm = block;
3527
+ for (const item of [...pm.topItems, ...pm.bottomItems]) {
3528
+ result.push(...item.inlines);
3529
+ }
2700
3530
  }
2701
3531
  }
2702
3532
  return result;
2703
3533
  }
2704
- function transformNumberLine(numberLine, idGenerator) {
3534
+ function transformNumberLine(numberLine, idGenerator, inlineDragIds) {
2705
3535
  const positions = numberLine.positions.map((pos) => {
2706
- const content = pos.inlines.map((inline) => transformInline(inline, idGenerator));
3536
+ const content = pos.inlines.map((inline) => transformInline(inline, idGenerator, inlineDragIds));
2707
3537
  return {
2708
3538
  id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_POSITION),
2709
3539
  type: JSON_TYPES.NUMBER_LINE_POSITION,
@@ -2720,7 +3550,8 @@ function transformNumberLine(numberLine, idGenerator) {
2720
3550
  min: c.min,
2721
3551
  max: c.max,
2722
3552
  ...c.default !== void 0 && { default: c.default },
2723
- ...c.name !== void 0 && { name: c.name }
3553
+ ...c.name !== void 0 && { name: c.name },
3554
+ ...c.answer !== void 0 && { answer: c.answer }
2724
3555
  };
2725
3556
  }
2726
3557
  let highlights;
@@ -2750,20 +3581,20 @@ function transformNumberLine(numberLine, idGenerator) {
2750
3581
  };
2751
3582
  return item;
2752
3583
  }
2753
- 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
- });
3584
+ function mapMatcherItemList(items, idGenerator, inlineDragIds) {
3585
+ return items.map((item) => ({
3586
+ id: idGenerator.generate(JSON_TYPES.MATCHER_ITEM),
3587
+ type: JSON_TYPES.MATCHER_ITEM,
3588
+ itemId: item.itemId,
3589
+ content: item.inlines.map((inline) => transformInline(inline, idGenerator, inlineDragIds))
3590
+ }));
3591
+ }
3592
+ function buildMatcherWidget(matcher, idGenerator, inlineDragIds) {
3593
+ const rows = matcher.rows.map((row) => ({
3594
+ id: idGenerator.generate(JSON_TYPES.MATCHER_ROW),
3595
+ type: JSON_TYPES.MATCHER_ROW,
3596
+ items: mapMatcherItemList(row.items, idGenerator, inlineDragIds)
3597
+ }));
2767
3598
  const result = {
2768
3599
  type: JSON_TYPES.MATCHER,
2769
3600
  rows,
@@ -2774,10 +3605,72 @@ function buildMatcherWidget(matcher, idGenerator) {
2774
3605
  }
2775
3606
  return result;
2776
3607
  }
2777
- function transformTable(table, idGenerator) {
3608
+ function buildPairMatcherWidget(pm, idGenerator, inlineDragIds) {
3609
+ return {
3610
+ type: JSON_TYPES.PAIR_MATCHER,
3611
+ topItems: mapMatcherItemList(pm.topItems, idGenerator, inlineDragIds),
3612
+ bottomItems: mapMatcherItemList(pm.bottomItems, idGenerator, inlineDragIds)
3613
+ };
3614
+ }
3615
+ function transformVerticalOperation(vop, idGenerator) {
3616
+ const anyRowHasOp = vop.items.some(
3617
+ (item) => item.type === AST_TYPES.VOP_ROW && item.op !== void 0
3618
+ );
3619
+ if (!anyRowHasOp && vop.op === void 0) {
3620
+ throw new Error("VERTICAL_OPERATION: at least one op is required (set op on the block or on individual ROWs)");
3621
+ }
3622
+ let cols = 1;
3623
+ for (const item of vop.items) {
3624
+ if (item.type === AST_TYPES.VOP_ROW || item.type === AST_TYPES.VOP_CARRY_ROW) {
3625
+ cols = Math.max(cols, item.cells.length);
3626
+ }
3627
+ }
3628
+ function buildCells(row) {
3629
+ return row.cells.map((cell) => {
3630
+ const id = idGenerator.generate(JSON_TYPES.VOP_CELL);
3631
+ if (cell.kind === "digit") return { id, type: JSON_TYPES.VOP_CELL, kind: "digit", value: cell.value };
3632
+ if (cell.kind === "label") return { id, type: JSON_TYPES.VOP_CELL, kind: "label", value: cell.value };
3633
+ if (cell.kind === "color") return { id, type: JSON_TYPES.VOP_CELL, kind: "color", value: cell.value, color: cell.color };
3634
+ if (cell.kind === "bold") return { id, type: JSON_TYPES.VOP_CELL, kind: "bold", value: cell.value };
3635
+ if (cell.kind === "highlight") return { id, type: JSON_TYPES.VOP_CELL, kind: "highlight", value: cell.value };
3636
+ if (cell.kind === "input") return { id, type: JSON_TYPES.VOP_CELL, kind: "input", widgetId: cell.name };
3637
+ return { id, type: JSON_TYPES.VOP_CELL, kind: "empty" };
3638
+ });
3639
+ }
3640
+ const items = vop.items.map((item) => {
3641
+ if (item.type === AST_TYPES.VOP_SEPARATOR) {
3642
+ return {
3643
+ id: idGenerator.generate(JSON_TYPES.VOP_SEPARATOR),
3644
+ type: JSON_TYPES.VOP_SEPARATOR
3645
+ };
3646
+ }
3647
+ if (item.type === AST_TYPES.VOP_CARRY_ROW) {
3648
+ return {
3649
+ id: idGenerator.generate(JSON_TYPES.VOP_CARRY_ROW),
3650
+ type: JSON_TYPES.VOP_CARRY_ROW,
3651
+ cells: buildCells(item)
3652
+ };
3653
+ }
3654
+ const row = item;
3655
+ return {
3656
+ id: idGenerator.generate(JSON_TYPES.VOP_ROW),
3657
+ type: JSON_TYPES.VOP_ROW,
3658
+ cells: buildCells(row),
3659
+ ...row.op !== void 0 && { op: row.op }
3660
+ };
3661
+ });
3662
+ return {
3663
+ id: idGenerator.generate(JSON_TYPES.VERTICAL_OPERATION),
3664
+ type: JSON_TYPES.VERTICAL_OPERATION,
3665
+ op: vop.op,
3666
+ cols,
3667
+ items
3668
+ };
3669
+ }
3670
+ function transformTable(table, idGenerator, inlineDragIds) {
2778
3671
  const rows = table.rows.map((row) => {
2779
3672
  const cells = row.cells.map((cell) => {
2780
- const items = cell.inlines.map((inline) => transformInline(inline, idGenerator));
3673
+ const items = cell.inlines.map((inline) => transformInline(inline, idGenerator, inlineDragIds));
2781
3674
  const cellItem = {
2782
3675
  id: idGenerator.generate(JSON_TYPES.TABLE_CELL),
2783
3676
  type: JSON_TYPES.TABLE_CELL,
@@ -2801,10 +3694,10 @@ function transformTable(table, idGenerator) {
2801
3694
  rows
2802
3695
  };
2803
3696
  }
2804
- function transformLine(line, idGenerator) {
3697
+ function transformLine(line, idGenerator, inlineDragIds) {
2805
3698
  const items = [];
2806
3699
  for (const inline of line.inlines) {
2807
- items.push(transformInline(inline, idGenerator));
3700
+ items.push(transformInline(inline, idGenerator, inlineDragIds));
2808
3701
  }
2809
3702
  items.push({
2810
3703
  id: idGenerator.generate(JSON_TYPES.LINEBREAK),
@@ -2812,7 +3705,7 @@ function transformLine(line, idGenerator) {
2812
3705
  });
2813
3706
  return items;
2814
3707
  }
2815
- function transformInline(inline, idGenerator) {
3708
+ function transformInline(inline, idGenerator, inlineDragIds) {
2816
3709
  switch (inline.type) {
2817
3710
  case AST_TYPES.FREE_TEXT:
2818
3711
  const trimmed = inline.value.trim();
@@ -2884,6 +3777,12 @@ function transformInline(inline, idGenerator) {
2884
3777
  type: JSON_TYPES.WIDGET_REF,
2885
3778
  widgetId: inline.name
2886
3779
  };
3780
+ case AST_TYPES.INLINE_DRAG_OPTION:
3781
+ return {
3782
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
3783
+ type: JSON_TYPES.WIDGET_REF,
3784
+ widgetId: inlineDragIds.get(inline)
3785
+ };
2887
3786
  default:
2888
3787
  throw new Error(`Unknown inline type: ${inline.type}`);
2889
3788
  }
@@ -2902,6 +3801,32 @@ function transformValidation(validation, answers, widgets) {
2902
3801
  expression: validation.expression,
2903
3802
  variables
2904
3803
  };
3804
+ } else if (validation.type === AST_TYPES.MIXED_VALIDATION) {
3805
+ const validVariableNames = Object.keys(widgets);
3806
+ const variables = extractVariables$1(validation.expression, validVariableNames);
3807
+ const inExpression = new Set(variables);
3808
+ for (const [name, widget] of Object.entries(widgets)) {
3809
+ if (name.startsWith("_")) continue;
3810
+ if (name in answers) continue;
3811
+ if (inExpression.has(name)) continue;
3812
+ if (widget.type === JSON_TYPES.FRACTION) {
3813
+ const w = widget;
3814
+ if (w.numerator !== null && w.denominator !== null) continue;
3815
+ }
3816
+ if (widget.type === JSON_TYPES.VISUAL_FRACTION) {
3817
+ const w = widget;
3818
+ if (Array.isArray(w.filled) && w.filled.length > 0) continue;
3819
+ }
3820
+ throw new Error(
3821
+ `Mixed validation: widget "${name}" has no \`answer\` and is not referenced in expression "${validation.expression}"`
3822
+ );
3823
+ }
3824
+ return {
3825
+ mode: VALIDATION_MODES.MIXED,
3826
+ expression: validation.expression,
3827
+ answers,
3828
+ variables
3829
+ };
2905
3830
  }
2906
3831
  throw new Error(`Unknown validation type: ${validation.type}`);
2907
3832
  }
@@ -8426,6 +9351,19 @@ function areMatcherEqual(expected, result) {
8426
9351
  const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
8427
9352
  return lodashExports.isEqual(normalize2(expected), normalize2(result));
8428
9353
  }
9354
+ function getPairMatcherGroupResults(expected, result) {
9355
+ if (!Array.isArray(result)) return [];
9356
+ const normalizeGroup = (g) => lodashExports.sortBy(g).join("|");
9357
+ const expectedKeys = new Set(expected.map(normalizeGroup));
9358
+ return result.map((group) => {
9359
+ const key2 = normalizeGroup(group);
9360
+ if (expectedKeys.has(key2)) {
9361
+ expectedKeys.delete(key2);
9362
+ return { group, isCorrect: true };
9363
+ }
9364
+ return { group, isCorrect: false };
9365
+ });
9366
+ }
8429
9367
  function areVisualFractionCountsEqual(expected, result) {
8430
9368
  if (!Array.isArray(result)) return false;
8431
9369
  if (expected.length !== result.length) return false;
@@ -8464,7 +9402,8 @@ function validateSimple(exercise, userInputs) {
8464
9402
  return {
8465
9403
  isCorrect: false,
8466
9404
  details: {},
8467
- score: 0
9405
+ score: 0,
9406
+ error: "No answers defined for simple validation"
8468
9407
  };
8469
9408
  }
8470
9409
  const positionMap = buildPositionMap(exercise);
@@ -8479,6 +9418,21 @@ function validateSimple(exercise, userInputs) {
8479
9418
  else allCorrect = false;
8480
9419
  continue;
8481
9420
  }
9421
+ if (widget?.type === JSON_TYPES.PAIR_MATCHER) {
9422
+ const userValue2 = userInputs[widgetId];
9423
+ const groupResults = getPairMatcherGroupResults(expected, userValue2);
9424
+ const isCorrect2 = groupResults.length === expected.length && groupResults.every((r) => r.isCorrect);
9425
+ details[widgetId] = {
9426
+ widgetId,
9427
+ userValue: userValue2,
9428
+ isCorrect: isCorrect2,
9429
+ groupResults
9430
+ };
9431
+ totalCount++;
9432
+ if (isCorrect2) correctCount++;
9433
+ else allCorrect = false;
9434
+ continue;
9435
+ }
8482
9436
  if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
8483
9437
  const userValue2 = userInputs[widgetId];
8484
9438
  const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
@@ -8793,9 +9747,58 @@ function parseFractionOrNumber(s) {
8793
9747
  if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
8794
9748
  return num / den;
8795
9749
  }
8796
- return NaN;
9750
+ return NaN;
9751
+ }
9752
+ return parseFloat(s);
9753
+ }
9754
+ function validateMixed(exercise, userInputs) {
9755
+ const answers = exercise.validation.answers ?? {};
9756
+ const simpleResult = validateSimple(exercise, userInputs);
9757
+ const scalarInputs = {};
9758
+ for (const [k, v] of Object.entries(userInputs)) {
9759
+ if (typeof v === "string" || typeof v === "number") scalarInputs[k] = v;
9760
+ }
9761
+ const exprResult = validateExpression(exercise, scalarInputs);
9762
+ const details = {};
9763
+ const answerKeys = new Set(Object.keys(answers));
9764
+ for (const k of answerKeys) {
9765
+ const simpleDetail = simpleResult.details[k];
9766
+ if (!simpleDetail) continue;
9767
+ const inExpr = k in exprResult.details;
9768
+ details[k] = {
9769
+ ...simpleDetail,
9770
+ simpleCorrect: simpleDetail.isCorrect,
9771
+ ...inExpr && { expressionCorrect: exprResult.isCorrect },
9772
+ isCorrect: inExpr ? simpleDetail.isCorrect && exprResult.isCorrect : simpleDetail.isCorrect
9773
+ };
9774
+ }
9775
+ for (const [k, detail] of Object.entries(exprResult.details)) {
9776
+ if (!(k in details)) {
9777
+ details[k] = {
9778
+ ...detail,
9779
+ expressionCorrect: exprResult.isCorrect
9780
+ };
9781
+ }
8797
9782
  }
8798
- return parseFloat(s);
9783
+ const simpleCount = Object.keys(answers).length;
9784
+ let simpleCorrect = 0;
9785
+ for (const k of Object.keys(answers)) {
9786
+ if (simpleResult.details[k]?.isCorrect) simpleCorrect++;
9787
+ }
9788
+ const score = (simpleCorrect + (exprResult.isCorrect ? 1 : 0)) / (simpleCount + 1);
9789
+ const isCorrect = simpleResult.isCorrect && exprResult.isCorrect;
9790
+ const result = {
9791
+ isCorrect,
9792
+ details,
9793
+ score,
9794
+ expression: exercise.validation.expression,
9795
+ scope: exprResult.scope
9796
+ };
9797
+ const propagatedError = exprResult.error ?? simpleResult.error;
9798
+ if (!isCorrect && propagatedError) {
9799
+ result.error = propagatedError;
9800
+ }
9801
+ return result;
8799
9802
  }
8800
9803
  function preprocessFractionValues(exercise, userInputs) {
8801
9804
  const result = { ...userInputs };
@@ -8839,6 +9842,8 @@ function validate(exercise, userInputs) {
8839
9842
  }
8840
9843
  return validateExpression(validatedExercise, scalar);
8841
9844
  }
9845
+ case VALIDATION_MODES.MIXED:
9846
+ return validateMixed(validatedExercise, processedInputs);
8842
9847
  default:
8843
9848
  return {
8844
9849
  isCorrect: false,
@@ -8852,18 +9857,27 @@ function getMissingInputs(exercise, userInputs) {
8852
9857
  if (!exercise.validation) {
8853
9858
  return missing;
8854
9859
  }
9860
+ const isEmpty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
8855
9861
  if (exercise.validation.mode === VALIDATION_MODES.SIMPLE && exercise.validation.answers) {
8856
9862
  for (const widgetId of Object.keys(exercise.validation.answers)) {
8857
- if (!(widgetId in userInputs) || userInputs[widgetId] === "") {
9863
+ if (!(widgetId in userInputs) || isEmpty(userInputs[widgetId])) {
8858
9864
  missing.push(widgetId);
8859
9865
  }
8860
9866
  }
8861
9867
  } else if (exercise.validation.mode === VALIDATION_MODES.EXPRESSION && exercise.validation.variables) {
8862
9868
  for (const varName of exercise.validation.variables) {
8863
- if (!(varName in userInputs) || userInputs[varName] === "") {
9869
+ if (!(varName in userInputs) || isEmpty(userInputs[varName])) {
8864
9870
  missing.push(varName);
8865
9871
  }
8866
9872
  }
9873
+ } else if (exercise.validation.mode === VALIDATION_MODES.MIXED) {
9874
+ const keys = /* @__PURE__ */ new Set([
9875
+ ...Object.keys(exercise.validation.answers ?? {}),
9876
+ ...exercise.validation.variables ?? []
9877
+ ]);
9878
+ for (const k of keys) {
9879
+ if (!(k in userInputs) || isEmpty(userInputs[k])) missing.push(k);
9880
+ }
8867
9881
  }
8868
9882
  return missing;
8869
9883
  }
@@ -12933,7 +13947,7 @@ const InlineInput = ({
12933
13947
  id: widgetId,
12934
13948
  accepts: ["drag-option"],
12935
13949
  onDrop: handleDrop,
12936
- className: "inline-input-droppable",
13950
+ className: "inline-widget inline-input-droppable",
12937
13951
  children: isDropped && value !== "" ? (
12938
13952
  // Show as chip only when value came from drag-drop
12939
13953
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -12965,6 +13979,32 @@ const InlineInput = ({
12965
13979
  }
12966
13980
  );
12967
13981
  };
13982
+ const SingleDragOption = ({
13983
+ optionId,
13984
+ value,
13985
+ onOptionClick,
13986
+ className = ""
13987
+ }) => {
13988
+ const { usedOptionIds } = useExerciseDnd();
13989
+ const isUsed = usedOptionIds.has(optionId);
13990
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `drag-option-slot ${className}`.trim(), children: isUsed ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-empty", "aria-hidden": "true" }) : /* @__PURE__ */ jsxRuntime.jsx(
13991
+ Draggable,
13992
+ {
13993
+ id: optionId,
13994
+ type: "drag-option",
13995
+ data: { value, label: value },
13996
+ className: "drag-option-wrapper",
13997
+ children: /* @__PURE__ */ jsxRuntime.jsx(
13998
+ "button",
13999
+ {
14000
+ className: "drag-option",
14001
+ onClick: () => onOptionClick?.(value),
14002
+ children: value
14003
+ }
14004
+ )
14005
+ }
14006
+ ) });
14007
+ };
12968
14008
  const PillBackground = ({
12969
14009
  width = "100%",
12970
14010
  minHeight = "72px",
@@ -13037,36 +14077,32 @@ const DragOptions = ({
13037
14077
  options,
13038
14078
  onOptionClick
13039
14079
  }) => {
13040
- const { usedOptionIds } = useExerciseDnd();
13041
14080
  return /* @__PURE__ */ jsxRuntime.jsx(PillBackground, { children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options", children: options.map((option, idx) => {
13042
14081
  const optionId = `drag-option-${idx}`;
13043
14082
  const optionValue = String(option);
13044
- const isUsed = usedOptionIds.has(optionId);
13045
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-slot", children: isUsed ? (
13046
- // Empty slot - this specific option is in an input
13047
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-empty" })
13048
- ) : (
13049
- // Draggable option
13050
- /* @__PURE__ */ jsxRuntime.jsx(
13051
- Draggable,
13052
- {
13053
- id: optionId,
13054
- type: "drag-option",
13055
- data: { value: optionValue, label: optionValue },
13056
- className: "drag-option-wrapper",
13057
- children: /* @__PURE__ */ jsxRuntime.jsx(
13058
- "button",
13059
- {
13060
- className: "drag-option",
13061
- onClick: () => onOptionClick?.(optionValue),
13062
- children: optionValue
13063
- }
13064
- )
13065
- }
13066
- )
13067
- ) }, optionId);
14083
+ return /* @__PURE__ */ jsxRuntime.jsx(
14084
+ SingleDragOption,
14085
+ {
14086
+ optionId,
14087
+ value: optionValue,
14088
+ onOptionClick
14089
+ },
14090
+ optionId
14091
+ );
13068
14092
  }) }) });
13069
14093
  };
14094
+ const InlineDragOption = ({
14095
+ widgetId,
14096
+ widget
14097
+ }) => {
14098
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "inline-widget", children: /* @__PURE__ */ jsxRuntime.jsx(
14099
+ SingleDragOption,
14100
+ {
14101
+ optionId: widgetId,
14102
+ value: widget.value
14103
+ }
14104
+ ) });
14105
+ };
13070
14106
  const NumericKeypad = ({
13071
14107
  value,
13072
14108
  includeOperators = false,
@@ -13161,7 +14197,7 @@ const FractionInput = ({
13161
14197
  }
13162
14198
  );
13163
14199
  };
13164
- return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
14200
+ return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "inline-widget fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
13165
14201
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator", numValidationStatus) }),
13166
14202
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
13167
14203
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator", denValidationStatus) })
@@ -13593,7 +14629,7 @@ const VisualFraction = ({
13593
14629
  return /* @__PURE__ */ jsxRuntime.jsxs(
13594
14630
  "div",
13595
14631
  {
13596
- className: `visual-fraction-widget visual-fraction-widget--${shape}${statusClass}`,
14632
+ className: `inline-widget visual-fraction-widget visual-fraction-widget--${shape}${statusClass}`,
13597
14633
  style: rootStyle,
13598
14634
  role: "group",
13599
14635
  "aria-label": `${divisions} part ${shape} fraction`,
@@ -13697,10 +14733,10 @@ function buildGroups(assignments) {
13697
14733
  }
13698
14734
  return Array.from(map.values());
13699
14735
  }
13700
- const EMPTY_WIDGETS = {};
13701
- const EMPTY_VALUES = {};
13702
- const EMPTY_DROPPED = /* @__PURE__ */ new Set();
13703
- const NOOP = () => {
14736
+ const EMPTY_WIDGETS$1 = {};
14737
+ const EMPTY_VALUES$1 = {};
14738
+ const EMPTY_DROPPED$1 = /* @__PURE__ */ new Set();
14739
+ const NOOP$1 = () => {
13704
14740
  };
13705
14741
  const MatcherWidget = ({
13706
14742
  widgetId,
@@ -13711,7 +14747,7 @@ const MatcherWidget = ({
13711
14747
  widgets
13712
14748
  }) => {
13713
14749
  const { rows, colors: userColors, groupCount } = widget;
13714
- const widgetsMap = widgets ?? EMPTY_WIDGETS;
14750
+ const widgetsMap = widgets ?? EMPTY_WIDGETS$1;
13715
14751
  const palette = React.useMemo(
13716
14752
  () => resolveGroupPalette(userColors, groupCount),
13717
14753
  [userColors, groupCount]
@@ -13776,9 +14812,9 @@ const MatcherWidget = ({
13776
14812
  {
13777
14813
  item: layoutItem,
13778
14814
  widgets: widgetsMap,
13779
- values: EMPTY_VALUES,
13780
- droppedInputs: EMPTY_DROPPED,
13781
- onChange: NOOP
14815
+ values: EMPTY_VALUES$1,
14816
+ droppedInputs: EMPTY_DROPPED$1,
14817
+ onChange: NOOP$1
13782
14818
  },
13783
14819
  layoutItem.id
13784
14820
  ))
@@ -13790,12 +14826,433 @@ const MatcherWidget = ({
13790
14826
  );
13791
14827
  };
13792
14828
  MatcherWidget.displayName = "MatcherWidget";
14829
+ function isArabic(text) {
14830
+ return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
14831
+ }
14832
+ function hasLatinChars(text) {
14833
+ return /[A-Za-z]/.test(text);
14834
+ }
14835
+ function itemContainsArabic(item) {
14836
+ if (item.type === JSON_TYPES.TEXT && "value" in item) {
14837
+ return isArabic(item.value);
14838
+ }
14839
+ if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14840
+ return isArabic(item.value);
14841
+ }
14842
+ if (item.type === JSON_TYPES.GROUP && "items" in item) {
14843
+ return item.items.some((subItem) => itemContainsArabic(subItem));
14844
+ }
14845
+ return false;
14846
+ }
14847
+ function itemContainsLatin(item) {
14848
+ if (item.type === JSON_TYPES.TEXT && "value" in item) {
14849
+ return hasLatinChars(item.value);
14850
+ }
14851
+ if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14852
+ return hasLatinChars(item.value);
14853
+ }
14854
+ if (item.type === JSON_TYPES.GROUP && "items" in item) {
14855
+ return item.items.some((subItem) => itemContainsLatin(subItem));
14856
+ }
14857
+ return false;
14858
+ }
14859
+ function shouldItemsUseRTL(items) {
14860
+ const hasArabic = items.some(itemContainsArabic);
14861
+ const hasLatin = items.some(itemContainsLatin);
14862
+ return hasArabic && !hasLatin;
14863
+ }
14864
+ function usePairLines({
14865
+ containerRef,
14866
+ pairs,
14867
+ lineDirections,
14868
+ validationStatus,
14869
+ pairStatusByTopId
14870
+ }) {
14871
+ const dotRefs = React.useRef(/* @__PURE__ */ new Map());
14872
+ const [lines, setLines] = React.useState([]);
14873
+ const recalcLines = React.useCallback(() => {
14874
+ const container = containerRef.current;
14875
+ if (!container) return;
14876
+ const cr = container.getBoundingClientRect();
14877
+ const newLines = [];
14878
+ for (const [topId, bottomId] of Object.entries(pairs)) {
14879
+ const topDot = dotRefs.current.get(`top:${topId}`);
14880
+ const bottomDot = dotRefs.current.get(`bottom:${bottomId}`);
14881
+ if (!topDot || !bottomDot) continue;
14882
+ const tr = topDot.getBoundingClientRect();
14883
+ const br = bottomDot.getBoundingClientRect();
14884
+ const status = validationStatus ? pairStatusByTopId[topId] ?? (validationStatus === "correct" ? "correct" : "incorrect") : "idle";
14885
+ const direction = lineDirections[topId] ?? "top-to-bottom";
14886
+ const startRect = direction === "top-to-bottom" ? tr : br;
14887
+ const endRect = direction === "top-to-bottom" ? br : tr;
14888
+ newLines.push({
14889
+ topId,
14890
+ x1: startRect.left - cr.left + startRect.width / 2,
14891
+ y1: startRect.top - cr.top + startRect.height / 2,
14892
+ x2: endRect.left - cr.left + endRect.width / 2,
14893
+ y2: endRect.top - cr.top + endRect.height / 2,
14894
+ status
14895
+ });
14896
+ }
14897
+ setLines(newLines);
14898
+ }, [pairs, lineDirections, validationStatus, pairStatusByTopId]);
14899
+ React.useLayoutEffect(() => {
14900
+ recalcLines();
14901
+ }, [recalcLines]);
14902
+ React.useEffect(() => {
14903
+ const el = containerRef.current;
14904
+ if (!el) return;
14905
+ const ro = new ResizeObserver(recalcLines);
14906
+ ro.observe(el);
14907
+ return () => ro.disconnect();
14908
+ }, [containerRef, recalcLines]);
14909
+ return { dotRefs, lines };
14910
+ }
14911
+ const EMPTY_WIDGETS = {};
14912
+ const EMPTY_VALUES = {};
14913
+ const EMPTY_DROPPED = /* @__PURE__ */ new Set();
14914
+ const NOOP = () => {
14915
+ };
14916
+ function buildInitialState(defaultValue2, topIds, bottomIds) {
14917
+ if (!defaultValue2?.length) return { pairs: {}, lineDirections: {} };
14918
+ const pairs = {};
14919
+ const lineDirections = {};
14920
+ for (const pair of defaultValue2) {
14921
+ if (!Array.isArray(pair) || pair.length !== 2 || pair[0] == null || pair[1] == null) continue;
14922
+ const [first, second] = pair;
14923
+ if (topIds.has(first) && bottomIds.has(second)) {
14924
+ pairs[first] = second;
14925
+ lineDirections[first] = "top-to-bottom";
14926
+ } else if (bottomIds.has(first) && topIds.has(second)) {
14927
+ pairs[second] = first;
14928
+ lineDirections[second] = "bottom-to-top";
14929
+ }
14930
+ }
14931
+ return { pairs, lineDirections };
14932
+ }
14933
+ function findTopForBottom(pairs, bottomId) {
14934
+ return Object.keys(pairs).find((topId) => pairs[topId] === bottomId);
14935
+ }
14936
+ function withLineDirection(lineDirections, pairs, topId, bottomId, direction) {
14937
+ const next = { ...lineDirections };
14938
+ delete next[topId];
14939
+ const existingTop = findTopForBottom(pairs, bottomId);
14940
+ if (existingTop !== void 0) delete next[existingTop];
14941
+ next[topId] = direction;
14942
+ return next;
14943
+ }
14944
+ function getPairIds(pair, topIds, bottomIds) {
14945
+ if (pair.length !== 2) return null;
14946
+ const first = pair[0];
14947
+ const second = pair[1];
14948
+ if (first === void 0 || second === void 0) return null;
14949
+ if (topIds.has(first) && bottomIds.has(second)) return { topId: first, bottomId: second };
14950
+ if (bottomIds.has(first) && topIds.has(second)) return { topId: second, bottomId: first };
14951
+ return null;
14952
+ }
14953
+ function withPair(pairs, topId, bottomId) {
14954
+ const next = { ...pairs };
14955
+ delete next[topId];
14956
+ const existingTop = findTopForBottom(next, bottomId);
14957
+ if (existingTop !== void 0) delete next[existingTop];
14958
+ next[topId] = bottomId;
14959
+ return next;
14960
+ }
14961
+ function resolveCardStatus(ownId, isTop, pairs, pending, validationStatus, pairStatus) {
14962
+ if (validationStatus) {
14963
+ const topId2 = isTop ? ownId : findTopForBottom(pairs, ownId);
14964
+ if (topId2 === void 0 || pairs[topId2] === void 0) return "idle";
14965
+ return pairStatus ?? (validationStatus === "correct" ? "correct" : "incorrect");
14966
+ }
14967
+ if (pending?.side === (isTop ? "top" : "bottom") && pending.id === ownId) return "pending";
14968
+ const topId = isTop ? ownId : findTopForBottom(pairs, ownId);
14969
+ if (topId !== void 0 && pairs[topId] !== void 0) return "paired";
14970
+ return "idle";
14971
+ }
14972
+ function buildDotClass(status, position) {
14973
+ const cls = ["pair-matcher-dot", `pair-matcher-dot--${position}`];
14974
+ if (status !== "idle") {
14975
+ cls.push("pair-matcher-dot--active");
14976
+ if (status !== "paired") cls.push(`pair-matcher-dot--${status}`);
14977
+ }
14978
+ return cls.join(" ");
14979
+ }
14980
+ function buildCardClass(status, isFlashing) {
14981
+ const cls = ["pair-matcher-card"];
14982
+ if (status !== "idle") cls.push(`pair-matcher-card--${status}`);
14983
+ if (isFlashing) cls.push("pair-matcher-card--flash");
14984
+ return cls.join(" ");
14985
+ }
14986
+ function lineStrokeVar(status) {
14987
+ if (status === "correct") return "var(--pm-line-color-correct)";
14988
+ if (status === "incorrect") return "var(--pm-line-color-incorrect)";
14989
+ return "var(--pm-line-color)";
14990
+ }
14991
+ function renderPairMatcherItemContent(item, widgets) {
14992
+ if (item.type === JSON_TYPES.SPACE) {
14993
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "space", children: " " }, item.id);
14994
+ }
14995
+ if (item.type === JSON_TYPES.GROUP) {
14996
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "group", children: item.items.map((subItem) => renderPairMatcherItemContent(subItem, widgets)) }, item.id);
14997
+ }
14998
+ return /* @__PURE__ */ jsxRuntime.jsx(
14999
+ LayoutRenderer,
15000
+ {
15001
+ item,
15002
+ widgets,
15003
+ values: EMPTY_VALUES,
15004
+ droppedInputs: EMPTY_DROPPED,
15005
+ onChange: NOOP
15006
+ },
15007
+ item.id
15008
+ );
15009
+ }
15010
+ const PairMatcherWidget = ({
15011
+ widgetId,
15012
+ widget,
15013
+ onChange,
15014
+ validationStatus = null,
15015
+ validationDetail,
15016
+ defaultValue: defaultValue2,
15017
+ widgets,
15018
+ shuffleItems = true
15019
+ }) => {
15020
+ const { topItems, bottomItems } = widget;
15021
+ const [shuffledTop] = React.useState(
15022
+ () => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
15023
+ );
15024
+ const [shuffledBottom] = React.useState(
15025
+ () => shuffleItems ? lodashExports.shuffle([...bottomItems]) : [...bottomItems]
15026
+ );
15027
+ const widgetsMap = widgets ?? EMPTY_WIDGETS;
15028
+ const topIds = React.useMemo(() => new Set(topItems.map((item) => item.itemId)), [topItems]);
15029
+ const bottomIds = React.useMemo(() => new Set(bottomItems.map((item) => item.itemId)), [bottomItems]);
15030
+ const pairStatusByTopId = React.useMemo(() => {
15031
+ const statuses = {};
15032
+ for (const result of validationDetail?.groupResults ?? []) {
15033
+ const ids2 = getPairIds(result.group, topIds, bottomIds);
15034
+ if (ids2) statuses[ids2.topId] = result.isCorrect ? "correct" : "incorrect";
15035
+ }
15036
+ return statuses;
15037
+ }, [validationDetail?.groupResults, topIds, bottomIds]);
15038
+ const [state, setState] = React.useState(() => ({
15039
+ ...buildInitialState(defaultValue2, topIds, bottomIds),
15040
+ pending: null,
15041
+ flashTopId: null,
15042
+ flashBottomId: null
15043
+ }));
15044
+ const flashTimerRef = React.useRef(null);
15045
+ const handleTopClick = React.useCallback((topId) => {
15046
+ if (validationStatus) return;
15047
+ if (flashTimerRef.current) {
15048
+ clearTimeout(flashTimerRef.current);
15049
+ flashTimerRef.current = null;
15050
+ }
15051
+ setState((prev) => {
15052
+ if (prev.pairs[topId] !== void 0) {
15053
+ const { [topId]: _, ...rest } = prev.pairs;
15054
+ const { [topId]: __, ...restDirections } = prev.lineDirections;
15055
+ if (prev.pending?.side === "bottom") {
15056
+ return {
15057
+ pairs: withPair(rest, topId, prev.pending.id),
15058
+ lineDirections: withLineDirection(restDirections, rest, topId, prev.pending.id, "bottom-to-top"),
15059
+ pending: null,
15060
+ flashTopId: topId,
15061
+ flashBottomId: prev.pending.id
15062
+ };
15063
+ }
15064
+ return {
15065
+ pairs: rest,
15066
+ lineDirections: restDirections,
15067
+ pending: { side: "top", id: topId },
15068
+ flashTopId: null,
15069
+ flashBottomId: null
15070
+ };
15071
+ }
15072
+ if (prev.pending?.side === "bottom") {
15073
+ return {
15074
+ pairs: withPair(prev.pairs, topId, prev.pending.id),
15075
+ lineDirections: withLineDirection(prev.lineDirections, prev.pairs, topId, prev.pending.id, "bottom-to-top"),
15076
+ pending: null,
15077
+ flashTopId: topId,
15078
+ flashBottomId: prev.pending.id
15079
+ };
15080
+ }
15081
+ return {
15082
+ ...prev,
15083
+ pending: prev.pending?.side === "top" && prev.pending.id === topId ? null : { side: "top", id: topId },
15084
+ flashTopId: null,
15085
+ flashBottomId: null
15086
+ };
15087
+ });
15088
+ }, [validationStatus]);
15089
+ const handleBottomClick = React.useCallback((bottomId) => {
15090
+ if (validationStatus) return;
15091
+ if (flashTimerRef.current) {
15092
+ clearTimeout(flashTimerRef.current);
15093
+ flashTimerRef.current = null;
15094
+ }
15095
+ setState((prev) => {
15096
+ const existingTop = findTopForBottom(prev.pairs, bottomId);
15097
+ if (existingTop !== void 0) {
15098
+ const { [existingTop]: _, ...rest } = prev.pairs;
15099
+ const { [existingTop]: __, ...restDirections } = prev.lineDirections;
15100
+ if (prev.pending?.side === "top" && prev.pending.id !== existingTop) {
15101
+ return {
15102
+ pairs: withPair(rest, prev.pending.id, bottomId),
15103
+ lineDirections: withLineDirection(restDirections, rest, prev.pending.id, bottomId, "top-to-bottom"),
15104
+ pending: null,
15105
+ flashTopId: prev.pending.id,
15106
+ flashBottomId: bottomId
15107
+ };
15108
+ }
15109
+ return {
15110
+ pairs: rest,
15111
+ lineDirections: restDirections,
15112
+ pending: { side: "bottom", id: bottomId },
15113
+ flashTopId: null,
15114
+ flashBottomId: null
15115
+ };
15116
+ }
15117
+ if (prev.pending?.side === "top") {
15118
+ return {
15119
+ pairs: withPair(prev.pairs, prev.pending.id, bottomId),
15120
+ lineDirections: withLineDirection(prev.lineDirections, prev.pairs, prev.pending.id, bottomId, "top-to-bottom"),
15121
+ pending: null,
15122
+ flashTopId: prev.pending.id,
15123
+ flashBottomId: bottomId
15124
+ };
15125
+ }
15126
+ return {
15127
+ ...prev,
15128
+ pending: prev.pending?.side === "bottom" && prev.pending.id === bottomId ? null : { side: "bottom", id: bottomId },
15129
+ flashTopId: null,
15130
+ flashBottomId: null
15131
+ };
15132
+ });
15133
+ }, [validationStatus]);
15134
+ React.useEffect(() => {
15135
+ if (validationStatus || state.flashTopId === null || state.flashBottomId === null) return;
15136
+ flashTimerRef.current = setTimeout(() => {
15137
+ flashTimerRef.current = null;
15138
+ setState((current) => {
15139
+ if (current.flashTopId === null && current.flashBottomId === null) return current;
15140
+ return { ...current, flashTopId: null, flashBottomId: null };
15141
+ });
15142
+ }, 650);
15143
+ return () => {
15144
+ if (flashTimerRef.current) {
15145
+ clearTimeout(flashTimerRef.current);
15146
+ flashTimerRef.current = null;
15147
+ }
15148
+ };
15149
+ }, [state.flashTopId, state.flashBottomId, validationStatus]);
15150
+ const onChangeRef = React.useRef(onChange);
15151
+ onChangeRef.current = onChange;
15152
+ const isMounted = React.useRef(false);
15153
+ React.useEffect(() => {
15154
+ if (!isMounted.current) {
15155
+ isMounted.current = true;
15156
+ return;
15157
+ }
15158
+ const value = Object.entries(state.pairs).map(
15159
+ ([topId, bottomId]) => state.lineDirections[topId] === "bottom-to-top" ? [bottomId, topId] : [topId, bottomId]
15160
+ );
15161
+ onChangeRef.current(widgetId, value);
15162
+ }, [state.pairs, state.lineDirections, widgetId]);
15163
+ const containerRef = React.useRef(null);
15164
+ const { dotRefs, lines } = usePairLines({
15165
+ containerRef,
15166
+ pairs: state.pairs,
15167
+ lineDirections: state.lineDirections,
15168
+ validationStatus,
15169
+ pairStatusByTopId
15170
+ });
15171
+ const cols = React.useMemo(
15172
+ () => Math.max(topItems.length, bottomItems.length, 1),
15173
+ [topItems.length, bottomItems.length]
15174
+ );
15175
+ const renderCard = (item, isTop) => {
15176
+ const isRtl = shouldItemsUseRTL(item.content);
15177
+ const pairedTopId = isTop ? item.itemId : findTopForBottom(state.pairs, item.itemId);
15178
+ const pairStatus = pairedTopId ? pairStatusByTopId[pairedTopId] ?? null : null;
15179
+ const status = resolveCardStatus(
15180
+ item.itemId,
15181
+ isTop,
15182
+ state.pairs,
15183
+ state.pending,
15184
+ validationStatus,
15185
+ pairStatus
15186
+ );
15187
+ const isFlashing = isTop ? state.flashTopId === item.itemId : state.flashBottomId === item.itemId;
15188
+ const dotKey = isTop ? `top:${item.itemId}` : `bottom:${item.itemId}`;
15189
+ const dot = /* @__PURE__ */ jsxRuntime.jsx(
15190
+ "span",
15191
+ {
15192
+ className: buildDotClass(status, isTop ? "south" : "north"),
15193
+ ref: (el) => {
15194
+ if (el) dotRefs.current.set(dotKey, el);
15195
+ else dotRefs.current.delete(dotKey);
15196
+ }
15197
+ }
15198
+ );
15199
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "pair-matcher-cell", children: [
15200
+ !isTop && dot,
15201
+ /* @__PURE__ */ jsxRuntime.jsx(
15202
+ "button",
15203
+ {
15204
+ type: "button",
15205
+ className: `${buildCardClass(status, isFlashing)}${isRtl ? " pair-matcher-card--rtl" : ""}`,
15206
+ dir: isRtl ? "rtl" : void 0,
15207
+ onClick: () => isTop ? handleTopClick(item.itemId) : handleBottomClick(item.itemId),
15208
+ "aria-pressed": status !== "idle",
15209
+ "aria-disabled": validationStatus ? "true" : void 0,
15210
+ children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "pair-matcher-card-content", children: item.content.map((li) => renderPairMatcherItemContent(li, widgetsMap)) })
15211
+ }
15212
+ ),
15213
+ isTop && dot
15214
+ ] }, item.id);
15215
+ };
15216
+ return /* @__PURE__ */ jsxRuntime.jsxs(
15217
+ "div",
15218
+ {
15219
+ className: `pair-matcher-widget${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
15220
+ style: { "--pm-cols": cols },
15221
+ ref: containerRef,
15222
+ role: "group",
15223
+ "aria-label": "Pair matcher exercise",
15224
+ children: [
15225
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "pair-matcher-row pair-matcher-row--top", children: shuffledTop.map((item) => renderCard(item, true)) }),
15226
+ /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "pair-matcher-lines", "aria-hidden": "true", children: lines.map((line) => /* @__PURE__ */ jsxRuntime.jsx(
15227
+ "line",
15228
+ {
15229
+ x1: line.x1,
15230
+ y1: line.y1,
15231
+ x2: line.x2,
15232
+ y2: line.y2,
15233
+ stroke: lineStrokeVar(line.status),
15234
+ strokeWidth: "var(--pm-line-width, 4)",
15235
+ strokeLinecap: "round",
15236
+ pathLength: 1,
15237
+ className: "pair-matcher-line"
15238
+ },
15239
+ line.topId
15240
+ )) }),
15241
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "pair-matcher-row pair-matcher-row--bottom", children: shuffledBottom.map((item) => renderCard(item, false)) })
15242
+ ]
15243
+ }
15244
+ );
15245
+ };
15246
+ PairMatcherWidget.displayName = "PairMatcherWidget";
13793
15247
  function isInlineInputWidget(widget) {
13794
15248
  return widget.type === "inline-input";
13795
15249
  }
13796
15250
  function isDragOptionsWidget(widget) {
13797
15251
  return widget.type === "drag-options";
13798
15252
  }
15253
+ function isInlineDragOptionWidget(widget) {
15254
+ return widget.type === "inline-drag-option";
15255
+ }
13799
15256
  function isRadioOptionsWidget(widget) {
13800
15257
  return widget.type === "radio-options";
13801
15258
  }
@@ -13808,6 +15265,9 @@ function isFractionWidget(widget) {
13808
15265
  function isMatcherWidget(widget) {
13809
15266
  return widget.type === "matcher";
13810
15267
  }
15268
+ function isPairMatcherWidget(widget) {
15269
+ return widget.type === "pair-matcher";
15270
+ }
13811
15271
  function isVisualFractionWidget(widget) {
13812
15272
  return widget.type === "visual-fraction";
13813
15273
  }
@@ -13820,6 +15280,7 @@ const WidgetRenderer = ({
13820
15280
  validationStatus = null,
13821
15281
  numValidationStatus = null,
13822
15282
  denValidationStatus = null,
15283
+ validationDetail,
13823
15284
  droppedInputs = /* @__PURE__ */ new Set(),
13824
15285
  widgets,
13825
15286
  onChange,
@@ -13854,6 +15315,15 @@ const WidgetRenderer = ({
13854
15315
  }
13855
15316
  );
13856
15317
  }
15318
+ if (isInlineDragOptionWidget(widget)) {
15319
+ return /* @__PURE__ */ jsxRuntime.jsx(
15320
+ InlineDragOption,
15321
+ {
15322
+ widgetId,
15323
+ widget
15324
+ }
15325
+ );
15326
+ }
13857
15327
  if (isRadioOptionsWidget(widget)) {
13858
15328
  return /* @__PURE__ */ jsxRuntime.jsx(
13859
15329
  RadioOptions,
@@ -13919,6 +15389,20 @@ const WidgetRenderer = ({
13919
15389
  }
13920
15390
  );
13921
15391
  }
15392
+ if (isPairMatcherWidget(widget)) {
15393
+ return /* @__PURE__ */ jsxRuntime.jsx(
15394
+ PairMatcherWidget,
15395
+ {
15396
+ widgetId,
15397
+ widget,
15398
+ onChange,
15399
+ validationStatus,
15400
+ validationDetail,
15401
+ defaultValue: Array.isArray(value) ? value : void 0,
15402
+ widgets
15403
+ }
15404
+ );
15405
+ }
13922
15406
  if (isVisualFractionWidget(widget)) {
13923
15407
  return /* @__PURE__ */ jsxRuntime.jsx(
13924
15408
  VisualFraction,
@@ -14556,6 +16040,13 @@ const NumberLineRenderer = ({
14556
16040
  React.useEffect(() => {
14557
16041
  setCounterCount(defaultCounterCount);
14558
16042
  }, [counter]);
16043
+ const onChangeRef = React.useRef(onChange);
16044
+ onChangeRef.current = onChange;
16045
+ React.useEffect(() => {
16046
+ if (counter !== void 0) {
16047
+ onChangeRef.current(counter.id, String(counterCount));
16048
+ }
16049
+ }, [counterCount, counter?.id]);
14559
16050
  const effectiveStep = React.useMemo(() => {
14560
16051
  if (counter !== void 0 && counterCount > 0) {
14561
16052
  return cleanFloat((max - min) / counterCount);
@@ -14589,10 +16080,17 @@ const NumberLineRenderer = ({
14589
16080
  [visiblePositions]
14590
16081
  );
14591
16082
  const { positionContentHeight, getPositionContentRef } = useMeasuredPositionContentHeight(visiblePositionIds);
16083
+ const nlValidationStatus = React.useMemo(() => {
16084
+ if (!validationDetails || counter === void 0) return null;
16085
+ if (counter.answer === void 0) return null;
16086
+ const counterValidation = validationDetails[counter.id];
16087
+ if (counterValidation === void 0) return null;
16088
+ return counterValidation.isCorrect ? "correct" : "incorrect";
16089
+ }, [validationDetails, counter]);
14592
16090
  return /* @__PURE__ */ jsxRuntime.jsxs(
14593
16091
  "div",
14594
16092
  {
14595
- className: `number-line${isAxisDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
16093
+ className: `number-line${isAxisDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}${nlValidationStatus ? ` number-line--${nlValidationStatus}` : ""}`,
14596
16094
  style: {
14597
16095
  [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
14598
16096
  ...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
@@ -14675,36 +16173,6 @@ const NumberLineRenderer = ({
14675
16173
  }
14676
16174
  );
14677
16175
  };
14678
- function isArabic(text) {
14679
- return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
14680
- }
14681
- function hasLatinChars(text) {
14682
- return /[A-Za-z]/.test(text);
14683
- }
14684
- function itemContainsArabic(item) {
14685
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
14686
- return isArabic(item.value);
14687
- }
14688
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14689
- return isArabic(item.value);
14690
- }
14691
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
14692
- return item.items.some((subItem) => itemContainsArabic(subItem));
14693
- }
14694
- return false;
14695
- }
14696
- function itemContainsLatin(item) {
14697
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
14698
- return hasLatinChars(item.value);
14699
- }
14700
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
14701
- return hasLatinChars(item.value);
14702
- }
14703
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
14704
- return item.items.some((subItem) => itemContainsLatin(subItem));
14705
- }
14706
- return false;
14707
- }
14708
16176
  function isTextItem(item) {
14709
16177
  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;
14710
16178
  }
@@ -14807,9 +16275,10 @@ const LayoutRenderer = ({
14807
16275
  case JSON_TYPES.WIDGET_REF: {
14808
16276
  const widget = widgets[item.widgetId];
14809
16277
  if (!widget) return null;
14810
- const isCorrect = validationDetails?.[item.widgetId];
16278
+ const validationDetail = validationDetails?.[item.widgetId];
16279
+ const isCorrect = validationDetail?.isCorrect;
14811
16280
  const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
14812
- const widgetKey = widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.VISUAL_FRACTION ? `${item.widgetId}-${resetKey}` : item.widgetId;
16281
+ const widgetKey = STATEFUL_WIDGET_TYPES.has(widget.type) ? `${item.widgetId}-${resetKey}` : item.widgetId;
14813
16282
  return /* @__PURE__ */ jsxRuntime.jsx(
14814
16283
  WidgetRenderer,
14815
16284
  {
@@ -14819,8 +16288,9 @@ const LayoutRenderer = ({
14819
16288
  values,
14820
16289
  isDropped: droppedInputs.has(item.widgetId),
14821
16290
  validationStatus,
14822
- numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]),
14823
- denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]),
16291
+ numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]?.isCorrect),
16292
+ denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]?.isCorrect),
16293
+ validationDetail,
14824
16294
  droppedInputs,
14825
16295
  widgets,
14826
16296
  onChange,
@@ -14887,6 +16357,126 @@ const LayoutRenderer = ({
14887
16357
  },
14888
16358
  resetKey
14889
16359
  );
16360
+ case JSON_TYPES.VERTICAL_OPERATION: {
16361
+ const vop = item;
16362
+ const anyRowHasOp = vop.items.some(
16363
+ (x) => x.type === JSON_TYPES.VOP_ROW && x.op !== void 0
16364
+ );
16365
+ let operatorRowIdx = -1;
16366
+ if (!anyRowHasOp && vop.op !== void 0) {
16367
+ const firstSepIdx = vop.items.findIndex((x) => x.type === JSON_TYPES.VOP_SEPARATOR);
16368
+ for (let i = (firstSepIdx === -1 ? vop.items.length : firstSepIdx) - 1; i >= 0; i--) {
16369
+ const curr = vop.items[i];
16370
+ if (curr && curr.type === JSON_TYPES.VOP_ROW) {
16371
+ operatorRowIdx = i;
16372
+ break;
16373
+ }
16374
+ }
16375
+ }
16376
+ const totalCols = vop.cols + 1;
16377
+ return /* @__PURE__ */ jsxRuntime.jsx(
16378
+ "div",
16379
+ {
16380
+ className: "vop",
16381
+ style: { "--vop-cols": totalCols },
16382
+ dir: "ltr",
16383
+ children: vop.items.map((child, idx) => {
16384
+ if (child.type === JSON_TYPES.VOP_SEPARATOR) {
16385
+ const sep = child;
16386
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "vop-sep" }, sep.id);
16387
+ }
16388
+ if (child.type === JSON_TYPES.VOP_CARRY_ROW) {
16389
+ const carry = child;
16390
+ const carryLeadingEmpty = Math.max(0, vop.cols - carry.cells.length);
16391
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "vop-row vop-carry-row", children: [
16392
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-op-cell" }),
16393
+ Array.from({ length: carryLeadingEmpty }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-empty" }, `cpad-${i}`)),
16394
+ carry.cells.map((cell) => {
16395
+ if (cell.kind === "digit") {
16396
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-digit", children: cell.value }, cell.id);
16397
+ }
16398
+ if (cell.kind === "label") {
16399
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-label", children: cell.value }, cell.id);
16400
+ }
16401
+ if (cell.kind === "color") {
16402
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-color", style: { color: cell.color }, children: cell.value }, cell.id);
16403
+ }
16404
+ if (cell.kind === "bold") {
16405
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-bold", children: cell.value }, cell.id);
16406
+ }
16407
+ if (cell.kind === "highlight") {
16408
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-highlight", children: /* @__PURE__ */ jsxRuntime.jsx("mark", { children: cell.value }) }, cell.id);
16409
+ }
16410
+ if (cell.kind === "empty") {
16411
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-empty" }, cell.id);
16412
+ }
16413
+ const widget = widgets[cell.widgetId];
16414
+ if (!widget) return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell" }, cell.id);
16415
+ const isCorrect = validationDetails?.[cell.widgetId]?.isCorrect;
16416
+ const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
16417
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-carry-cell-input", children: /* @__PURE__ */ jsxRuntime.jsx(
16418
+ WidgetRenderer,
16419
+ {
16420
+ widget,
16421
+ widgetId: cell.widgetId,
16422
+ value: values[cell.widgetId] || "",
16423
+ isDropped: droppedInputs.has(cell.widgetId),
16424
+ validationStatus,
16425
+ onChange,
16426
+ onInputFocus
16427
+ }
16428
+ ) }, cell.id);
16429
+ })
16430
+ ] }, carry.id);
16431
+ }
16432
+ const row = child;
16433
+ const showOperator = row.op !== void 0 || idx === operatorRowIdx;
16434
+ const displayOp = row.op ?? vop.op;
16435
+ const leadingEmpty = vop.cols - row.cells.length;
16436
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "vop-row", children: [
16437
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-op-cell", children: showOperator ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "operator vop-op", children: displayOp }) : null }),
16438
+ Array.from({ length: leadingEmpty }).map((_, i) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-empty" }, `pad-${i}`)),
16439
+ row.cells.map((cell) => {
16440
+ if (cell.kind === "digit") {
16441
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-digit", children: cell.value }, cell.id);
16442
+ }
16443
+ if (cell.kind === "label") {
16444
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-label", children: cell.value }, cell.id);
16445
+ }
16446
+ if (cell.kind === "color") {
16447
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-color", style: { color: cell.color }, children: cell.value }, cell.id);
16448
+ }
16449
+ if (cell.kind === "bold") {
16450
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-bold", children: cell.value }, cell.id);
16451
+ }
16452
+ if (cell.kind === "highlight") {
16453
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-highlight", children: /* @__PURE__ */ jsxRuntime.jsx("mark", { children: cell.value }) }, cell.id);
16454
+ }
16455
+ if (cell.kind === "empty") {
16456
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-empty" }, cell.id);
16457
+ }
16458
+ const widget = widgets[cell.widgetId];
16459
+ if (!widget) return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell" }, cell.id);
16460
+ const isCorrect = validationDetails?.[cell.widgetId]?.isCorrect;
16461
+ const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
16462
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "vop-cell vop-cell-input", children: /* @__PURE__ */ jsxRuntime.jsx(
16463
+ WidgetRenderer,
16464
+ {
16465
+ widget,
16466
+ widgetId: cell.widgetId,
16467
+ value: values[cell.widgetId] || "",
16468
+ isDropped: droppedInputs.has(cell.widgetId),
16469
+ validationStatus,
16470
+ onChange,
16471
+ onInputFocus
16472
+ }
16473
+ ) }, cell.id);
16474
+ })
16475
+ ] }, row.id);
16476
+ })
16477
+ }
16478
+ );
16479
+ }
14890
16480
  default:
14891
16481
  return null;
14892
16482
  }
@@ -14903,7 +16493,7 @@ const DEFAULT_RULER_TOOL = {
14903
16493
  function buildInitialValues(json) {
14904
16494
  const initial = {};
14905
16495
  for (const [id, widget] of Object.entries(json.widgets)) {
14906
- if (widget.type === JSON_TYPES.MATCHER) initial[id] = [];
16496
+ if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
14907
16497
  }
14908
16498
  return initial;
14909
16499
  }
@@ -14922,10 +16512,12 @@ const ExerciseRenderer = React.forwardRef(({
14922
16512
  React.useImperativeHandle(ref, () => ({
14923
16513
  submit: () => {
14924
16514
  const result = validate(json, values);
14925
- const details = {};
14926
- for (const [id, v] of Object.entries(result.details)) {
14927
- details[id] = v.isCorrect;
14928
- }
16515
+ const details = { ...result.details };
16516
+ const slotValidation = (id, raw, expected) => ({
16517
+ widgetId: id,
16518
+ userValue: typeof raw === "string" ? raw : void 0,
16519
+ isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
16520
+ });
14929
16521
  for (const [widgetId, widget] of Object.entries(json.widgets)) {
14930
16522
  if (widget.type !== JSON_TYPES.FRACTION) continue;
14931
16523
  if (!(widgetId in details)) continue;
@@ -14933,18 +16525,12 @@ const ExerciseRenderer = React.forwardRef(({
14933
16525
  if (!answer) continue;
14934
16526
  const [expectedNum, expectedDen] = answer.split("/", 2);
14935
16527
  if (!expectedNum || !expectedDen) continue;
14936
- if (details[widgetId]) {
14937
- if (widget.numerator === null) details[`${widgetId}__num`] = true;
14938
- if (widget.denominator === null) details[`${widgetId}__den`] = true;
16528
+ if (details[widgetId]?.isCorrect) {
16529
+ if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
16530
+ if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
14939
16531
  } else {
14940
- if (widget.numerator === null) {
14941
- const u = values[`${widgetId}__num`];
14942
- details[`${widgetId}__num`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedNum);
14943
- }
14944
- if (widget.denominator === null) {
14945
- const u = values[`${widgetId}__den`];
14946
- details[`${widgetId}__den`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedDen);
14947
- }
16532
+ if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
16533
+ if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
14948
16534
  }
14949
16535
  }
14950
16536
  setValidationDetails(details);
@@ -14984,6 +16570,13 @@ const ExerciseRenderer = React.forwardRef(({
14984
16570
  onSubmit?.(values);
14985
16571
  };
14986
16572
  const lineGroups = React.useMemo(() => {
16573
+ const isRTLInlineWidget = (item) => {
16574
+ if (item.type === JSON_TYPES.WIDGET_REF) {
16575
+ const widget = json.widgets[item.widgetId];
16576
+ return !!widget && RTL_INLINE_WIDGET_TYPES.has(widget.type);
16577
+ }
16578
+ return RTL_INLINE_WIDGET_TYPES.has(item.type);
16579
+ };
14987
16580
  const groups = [];
14988
16581
  let currentGroup = [];
14989
16582
  for (const item of json.layout) {
@@ -14991,6 +16584,7 @@ const ExerciseRenderer = React.forwardRef(({
14991
16584
  groups.push({
14992
16585
  items: [item],
14993
16586
  hasArabic: itemContainsArabic(item),
16587
+ hasRTLInlineWidget: false,
14994
16588
  subGroups: []
14995
16589
  // Question text doesn't need sub-grouping
14996
16590
  });
@@ -14999,13 +16593,15 @@ const ExerciseRenderer = React.forwardRef(({
14999
16593
  if (item.type === JSON_TYPES.TABLE) {
15000
16594
  if (currentGroup.length > 0) {
15001
16595
  const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
16596
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
15002
16597
  const subGroups = groupLineItems(currentGroup);
15003
- groups.push({ items: currentGroup, hasArabic, subGroups });
16598
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
15004
16599
  currentGroup = [];
15005
16600
  }
15006
16601
  groups.push({
15007
16602
  items: [item],
15008
16603
  hasArabic: false,
16604
+ hasRTLInlineWidget: false,
15009
16605
  subGroups: []
15010
16606
  });
15011
16607
  continue;
@@ -15013,13 +16609,31 @@ const ExerciseRenderer = React.forwardRef(({
15013
16609
  if (item.type === JSON_TYPES.NUMBER_LINE) {
15014
16610
  if (currentGroup.length > 0) {
15015
16611
  const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
16612
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
16613
+ const subGroups = groupLineItems(currentGroup);
16614
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
16615
+ currentGroup = [];
16616
+ }
16617
+ groups.push({
16618
+ items: [item],
16619
+ hasArabic: false,
16620
+ hasRTLInlineWidget: false,
16621
+ subGroups: []
16622
+ });
16623
+ continue;
16624
+ }
16625
+ if (item.type === JSON_TYPES.VERTICAL_OPERATION) {
16626
+ if (currentGroup.length > 0) {
16627
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
16628
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
15016
16629
  const subGroups = groupLineItems(currentGroup);
15017
- groups.push({ items: currentGroup, hasArabic, subGroups });
16630
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
15018
16631
  currentGroup = [];
15019
16632
  }
15020
16633
  groups.push({
15021
16634
  items: [item],
15022
16635
  hasArabic: false,
16636
+ hasRTLInlineWidget: false,
15023
16637
  subGroups: []
15024
16638
  });
15025
16639
  continue;
@@ -15027,8 +16641,9 @@ const ExerciseRenderer = React.forwardRef(({
15027
16641
  if (item.type === JSON_TYPES.LINEBREAK) {
15028
16642
  if (currentGroup.length > 0) {
15029
16643
  const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
16644
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
15030
16645
  const subGroups = groupLineItems(currentGroup);
15031
- groups.push({ items: currentGroup, hasArabic, subGroups });
16646
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
15032
16647
  currentGroup = [];
15033
16648
  }
15034
16649
  } else {
@@ -15037,11 +16652,12 @@ const ExerciseRenderer = React.forwardRef(({
15037
16652
  }
15038
16653
  if (currentGroup.length > 0) {
15039
16654
  const hasArabic = currentGroup.some((item) => itemContainsArabic(item));
16655
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
15040
16656
  const subGroups = groupLineItems(currentGroup);
15041
- groups.push({ items: currentGroup, hasArabic, subGroups });
16657
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
15042
16658
  }
15043
16659
  return groups;
15044
- }, [json.layout]);
16660
+ }, [json.layout, json.widgets]);
15045
16661
  const droppedInputs = React.useMemo(() => new Set(droppedSources.keys()), [droppedSources]);
15046
16662
  const dragOptionsWidget = json.widgets["_drag_options"];
15047
16663
  const rulerWidget = json.widgets["_ruler"];
@@ -15059,7 +16675,8 @@ const ExerciseRenderer = React.forwardRef(({
15059
16675
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "exercise-layout", children: lineGroups.map((group, groupIdx) => /* @__PURE__ */ jsxRuntime.jsx(
15060
16676
  "div",
15061
16677
  {
15062
- className: `exercise-line ${group.hasArabic ? "exercise-line-arabic" : ""}`,
16678
+ className: "exercise-line",
16679
+ dir: group.hasArabic && group.hasRTLInlineWidget ? "rtl" : void 0,
15063
16680
  children: group.subGroups.length > 0 ? (
15064
16681
  // Render sub-groups for regular lines
15065
16682
  group.subGroups.map((subGroup, subIdx) => subGroup.isTextGroup ? (
@@ -15203,6 +16820,7 @@ exports.HighlightedTextInlineSchema = HighlightedTextInlineSchema;
15203
16820
  exports.HighlightedTextSchema = HighlightedTextSchema;
15204
16821
  exports.ImageInlineSchema = ImageInlineSchema;
15205
16822
  exports.ImageItemSchema = ImageItemSchema;
16823
+ exports.InlineDragOptionSchema = InlineDragOptionSchema;
15206
16824
  exports.InlineInputJSONSchema = InlineInputJSONSchema;
15207
16825
  exports.InlineInputWidgetSchema = InlineInputWidgetSchema;
15208
16826
  exports.InlineNodeSchema = InlineNodeSchema;
@@ -15215,6 +16833,8 @@ exports.LinebreakSchema = LinebreakSchema;
15215
16833
  exports.MatcherBlockSchema = MatcherBlockSchema;
15216
16834
  exports.MatcherItemSchema = MatcherItemSchema;
15217
16835
  exports.MatcherRowSchema = MatcherRowSchema;
16836
+ exports.MatcherWidgetJSONSchema = MatcherWidgetJSONSchema;
16837
+ exports.MixedValidationSchema = MixedValidationSchema;
15218
16838
  exports.NumberLineCounterItemSchema = NumberLineCounterItemSchema;
15219
16839
  exports.NumberLineHighlightItemSchema = NumberLineHighlightItemSchema;
15220
16840
  exports.NumberLineItemSchema = NumberLineItemSchema;
@@ -15223,6 +16843,8 @@ exports.NumericKeypadJSONSchema = NumericKeypadJSONSchema;
15223
16843
  exports.NumericKeypadToolSchema = NumericKeypadToolSchema;
15224
16844
  exports.OperatorItemSchema = OperatorItemSchema;
15225
16845
  exports.OperatorSchema = OperatorSchema;
16846
+ exports.PairMatcherBlockSchema = PairMatcherBlockSchema;
16847
+ exports.PairMatcherWidgetJSONSchema = PairMatcherWidgetJSONSchema;
15226
16848
  exports.ParseError = ParseError;
15227
16849
  exports.QuestionTextSchema = QuestionTextSchema;
15228
16850
  exports.RadioOptionsJSONSchema = RadioOptionsJSONSchema;
@@ -15240,10 +16862,31 @@ exports.VISUAL_FRACTION_DEFAULT_SIZE = VISUAL_FRACTION_DEFAULT_SIZE;
15240
16862
  exports.VISUAL_FRACTION_MAX_DIVISIONS = VISUAL_FRACTION_MAX_DIVISIONS;
15241
16863
  exports.VISUAL_FRACTION_SHAPE = VISUAL_FRACTION_SHAPE;
15242
16864
  exports.VISUAL_FRACTION_SHAPES = VISUAL_FRACTION_SHAPES;
16865
+ exports.VOP_OPERATORS = VOP_OPERATORS;
15243
16866
  exports.ValidationError = ValidationError;
15244
16867
  exports.ValidationJSONSchema = ValidationJSONSchema;
15245
16868
  exports.ValidationNodeSchema = ValidationNodeSchema;
16869
+ exports.VerticalOperationBlockSchema = VerticalOperationBlockSchema;
16870
+ exports.VerticalOperationItemSchema = VerticalOperationItemSchema;
15246
16871
  exports.VisualFractionInlineSchema = VisualFractionInlineSchema;
16872
+ exports.VopBoldCellItemSchema = VopBoldCellItemSchema;
16873
+ exports.VopBoldCellSchema = VopBoldCellSchema;
16874
+ exports.VopCarryRowItemSchema = VopCarryRowItemSchema;
16875
+ exports.VopCarryRowSchema = VopCarryRowSchema;
16876
+ exports.VopCellItemSchema = VopCellItemSchema;
16877
+ exports.VopCellSchema = VopCellSchema;
16878
+ exports.VopColorCellItemSchema = VopColorCellItemSchema;
16879
+ exports.VopColorCellSchema = VopColorCellSchema;
16880
+ exports.VopDigitCellSchema = VopDigitCellSchema;
16881
+ exports.VopEmptyCellSchema = VopEmptyCellSchema;
16882
+ exports.VopHighlightCellItemSchema = VopHighlightCellItemSchema;
16883
+ exports.VopHighlightCellSchema = VopHighlightCellSchema;
16884
+ exports.VopInputCellSchema = VopInputCellSchema;
16885
+ exports.VopLabelCellSchema = VopLabelCellSchema;
16886
+ exports.VopRowItemSchema = VopRowItemSchema;
16887
+ exports.VopRowSchema = VopRowSchema;
16888
+ exports.VopSeparatorItemSchema = VopSeparatorItemSchema;
16889
+ exports.VopSeparatorSchema = VopSeparatorSchema;
15247
16890
  exports.WidgetJSONSchema = WidgetJSONSchema;
15248
16891
  exports.WidgetRefSchema = WidgetRefSchema;
15249
16892
  exports.WidgetRenderer = WidgetRenderer;
@@ -15258,17 +16901,31 @@ exports.createFraction = createFraction;
15258
16901
  exports.createFreeText = createFreeText;
15259
16902
  exports.createHighlightedText = createHighlightedText;
15260
16903
  exports.createImage = createImage;
16904
+ exports.createInlineDragOption = createInlineDragOption;
15261
16905
  exports.createLineBlock = createLineBlock;
15262
16906
  exports.createMatcherBlock = createMatcherBlock;
15263
16907
  exports.createMatcherItem = createMatcherItem;
15264
16908
  exports.createMatcherRow = createMatcherRow;
16909
+ exports.createMixedValidation = createMixedValidation;
15265
16910
  exports.createNumericInput = createNumericInput;
15266
16911
  exports.createNumericKeypad = createNumericKeypad;
16912
+ exports.createPairMatcherBlock = createPairMatcherBlock;
15267
16913
  exports.createSimpleValidation = createSimpleValidation;
15268
16914
  exports.createSpace = createSpace;
15269
16915
  exports.createStringInput = createStringInput;
15270
16916
  exports.createTextBlock = createTextBlock;
16917
+ exports.createVerticalOperationBlock = createVerticalOperationBlock;
15271
16918
  exports.createVisualFraction = createVisualFraction;
16919
+ exports.createVopBoldCell = createVopBoldCell;
16920
+ exports.createVopCarryRow = createVopCarryRow;
16921
+ exports.createVopColorCell = createVopColorCell;
16922
+ exports.createVopDigitCell = createVopDigitCell;
16923
+ exports.createVopEmptyCell = createVopEmptyCell;
16924
+ exports.createVopHighlightCell = createVopHighlightCell;
16925
+ exports.createVopInputCell = createVopInputCell;
16926
+ exports.createVopLabelCell = createVopLabelCell;
16927
+ exports.createVopRow = createVopRow;
16928
+ exports.createVopSeparator = createVopSeparator;
15272
16929
  exports.evaluate = evaluate;
15273
16930
  exports.extractExpressionVariables = extractVariables;
15274
16931
  exports.findValidCombination = findValidCombination;
@@ -15288,6 +16945,8 @@ exports.isGroupItem = isGroupItem;
15288
16945
  exports.isHighlightedText = isHighlightedText;
15289
16946
  exports.isImage = isImage;
15290
16947
  exports.isImageItem = isImageItem;
16948
+ exports.isInlineDragOption = isInlineDragOption;
16949
+ exports.isInlineDragOptionJSON = isInlineDragOptionJSON;
15291
16950
  exports.isInlineInput = isInlineInput;
15292
16951
  exports.isInlineInputJSON = isInlineInputJSON;
15293
16952
  exports.isInlineNode = isInlineNode;
@@ -15296,9 +16955,13 @@ exports.isLinebreak = isLinebreak;
15296
16955
  exports.isMatcherBlock = isMatcherBlock;
15297
16956
  exports.isMatcherItem = isMatcherItem;
15298
16957
  exports.isMatcherRow = isMatcherRow;
16958
+ exports.isMatcherWidget = isMatcherWidget;
16959
+ exports.isMixedValidation = isMixedValidation;
15299
16960
  exports.isNumericKeypad = isNumericKeypad;
15300
16961
  exports.isNumericKeypadJSON = isNumericKeypadJSON;
15301
16962
  exports.isOperatorItem = isOperatorItem;
16963
+ exports.isPairMatcherBlock = isPairMatcherBlock;
16964
+ exports.isPairMatcherWidget = isPairMatcherWidget;
15302
16965
  exports.isQuestionText = isQuestionText;
15303
16966
  exports.isRadioOptionsJSON = isRadioOptionsJSON;
15304
16967
  exports.isSimpleValidation = isSimpleValidation;
@@ -15312,8 +16975,15 @@ exports.isTextItem = isTextItem$1;
15312
16975
  exports.isToolNode = isToolNode;
15313
16976
  exports.isValidExpression = isValidExpression;
15314
16977
  exports.isValidationNode = isValidationNode;
16978
+ exports.isVerticalOperationBlock = isVerticalOperationBlock;
16979
+ exports.isVerticalOperationItem = isVerticalOperationItem;
15315
16980
  exports.isVisualFractionInline = isVisualFractionInline;
16981
+ exports.isVopCarryRow = isVopCarryRow;
16982
+ exports.isVopCell = isVopCell;
16983
+ exports.isVopRow = isVopRow;
16984
+ exports.isVopSeparator = isVopSeparator;
15316
16985
  exports.isWidgetRef = isWidgetRef;
16986
+ exports.normalizeVopOperator = normalizeVopOperator;
15317
16987
  exports.parse = parse;
15318
16988
  exports.parseFractionKey = parseFractionKey;
15319
16989
  exports.parseSafe = parseSafe;
@@ -15328,6 +16998,7 @@ exports.validateExerciseJSON = validateExerciseJSON;
15328
16998
  exports.validateExerciseJSONSafe = validateExerciseJSONSafe;
15329
16999
  exports.validateExpression = validateExpression;
15330
17000
  exports.validateInputFormats = validateInputFormats;
17001
+ exports.validateMixed = validateMixed;
15331
17002
  exports.validateSimple = validateSimple;
15332
17003
  exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
15333
17004
  exports.validateWidgetJSON = validateWidgetJSON;