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