@mathscan/math-exercise-engine 1.1.16 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const chevrotain = require("chevrotain");
4
3
  const zod = require("zod");
4
+ const chevrotain = require("chevrotain");
5
5
  const mathjs = require("mathjs");
6
6
  const jsxRuntime = require("react/jsx-runtime");
7
7
  const React = require("react");
@@ -56,6 +56,8 @@ const AST_TYPES = {
56
56
  VOP_CARRY_ROW: "VopCarryRow",
57
57
  /** Number line layout block */
58
58
  NUMBER_LINE_BLOCK: "NumberLineBlock",
59
+ /** Symmetry exercise on a square grid */
60
+ GRID_SYMMETRY_BLOCK: "GridSymmetryBlock",
59
61
  /** A position marker on the number line */
60
62
  NUMBER_LINE_POSITION: "NumberLinePosition",
61
63
  /** A draggable cursor on the number line */
@@ -187,6 +189,8 @@ const JSON_TYPES = {
187
189
  FRACTION: "fraction",
188
190
  /** Visual fraction widget */
189
191
  VISUAL_FRACTION: "visual-fraction",
192
+ /** Interactive symmetry exercise on a grid */
193
+ GRID_SYMMETRY: "grid-symmetry",
190
194
  /** Ruler geometry tool widget */
191
195
  RULER: "ruler",
192
196
  /** Protractor geometry tool widget */
@@ -201,9 +205,11 @@ const SET_SQUARE_WIDGET_ID = "_set_square";
201
205
  const STATEFUL_WIDGET_TYPES = /* @__PURE__ */ new Set([
202
206
  JSON_TYPES.MATCHER,
203
207
  JSON_TYPES.PAIR_MATCHER,
204
- JSON_TYPES.VISUAL_FRACTION
208
+ JSON_TYPES.VISUAL_FRACTION,
209
+ JSON_TYPES.GRID_SYMMETRY
205
210
  ]);
206
211
  const INLINE_DRAG_OPT_PREFIX = "_inline_drag_opt_";
212
+ const GRID_SYMMETRY_WIDGET_PREFIX = "_grid_symmetry_";
207
213
  const RTL_INLINE_WIDGET_TYPES = /* @__PURE__ */ new Set([
208
214
  JSON_TYPES.INLINE_INPUT,
209
215
  JSON_TYPES.INLINE_DRAG_OPTION,
@@ -246,6 +252,11 @@ function parseFractionKey(key2) {
246
252
  if (!match) return null;
247
253
  return { widgetId: match[1], part: match[2] };
248
254
  }
255
+ const GridCellSchema = zod.z.tuple([
256
+ zod.z.number().int().positive(),
257
+ zod.z.number().int().positive()
258
+ ]);
259
+ const gridCellKey = ([row, col]) => `${row},${col}`;
249
260
  const Newline = chevrotain.createToken({
250
261
  name: "Newline",
251
262
  pattern: /\r?\n/,
@@ -327,6 +338,10 @@ const NUMBER_LINE = chevrotain.createToken({
327
338
  name: "NUMBER_LINE",
328
339
  pattern: /NUMBER_LINE/
329
340
  });
341
+ const GRID_SYMMETRY = chevrotain.createToken({
342
+ name: "GRID_SYMMETRY",
343
+ pattern: /GRID_SYMMETRY/
344
+ });
330
345
  const POSITION = chevrotain.createToken({
331
346
  name: "POSITION",
332
347
  pattern: /POSITION/
@@ -535,6 +550,7 @@ const allTokens = [
535
550
  W_N_I,
536
551
  NUMERIC_INPUT,
537
552
  NUMBER_LINE,
553
+ GRID_SYMMETRY,
538
554
  POSITION,
539
555
  CURSOR,
540
556
  ARROW,
@@ -710,6 +726,10 @@ class MathExerciseParser extends chevrotain.CstParser {
710
726
  GATE: this.BACKTRACK(this.numberLine),
711
727
  ALT: () => this.SUBRULE(this.numberLine)
712
728
  },
729
+ {
730
+ GATE: this.BACKTRACK(this.gridSymmetry),
731
+ ALT: () => this.SUBRULE(this.gridSymmetry)
732
+ },
713
733
  {
714
734
  GATE: this.BACKTRACK(this.pairMatcher),
715
735
  ALT: () => this.SUBRULE(this.pairMatcher)
@@ -725,6 +745,18 @@ class MathExerciseParser extends chevrotain.CstParser {
725
745
  { ALT: () => this.SUBRULE(this.line) }
726
746
  ]);
727
747
  });
748
+ this.gridSymmetry = this.RULE("gridSymmetry", () => {
749
+ this.MANY(() => {
750
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
751
+ });
752
+ this.CONSUME(GRID_SYMMETRY);
753
+ this.CONSUME(LBracket);
754
+ this.SUBRULE(this.parameterList);
755
+ this.CONSUME(RBracket);
756
+ this.MANY2(() => {
757
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
758
+ });
759
+ });
728
760
  this.table = this.RULE("table", () => {
729
761
  this.MANY(() => {
730
762
  this.OR2([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
@@ -1438,27 +1470,51 @@ class MathExerciseParser extends chevrotain.CstParser {
1438
1470
  });
1439
1471
  this.parameterList = this.RULE("parameterList", () => {
1440
1472
  this.MANY(() => {
1441
- this.CONSUME(Space);
1473
+ this.OR([
1474
+ { ALT: () => this.CONSUME(Space) },
1475
+ { ALT: () => this.CONSUME(Newline) }
1476
+ ]);
1442
1477
  });
1443
1478
  this.SUBRULE(this.parameter);
1444
1479
  this.MANY2(() => {
1445
1480
  this.MANY3(() => {
1446
- this.CONSUME2(Space);
1481
+ this.OR2([
1482
+ { ALT: () => this.CONSUME2(Space) },
1483
+ { ALT: () => this.CONSUME2(Newline) }
1484
+ ]);
1447
1485
  });
1448
1486
  this.CONSUME(Comma);
1449
1487
  this.MANY4(() => {
1450
- this.CONSUME3(Space);
1488
+ this.OR3([
1489
+ { ALT: () => this.CONSUME3(Space) },
1490
+ { ALT: () => this.CONSUME3(Newline) }
1491
+ ]);
1451
1492
  });
1452
1493
  this.SUBRULE2(this.parameter);
1453
1494
  });
1454
1495
  this.MANY5(() => {
1455
- this.CONSUME4(Space);
1496
+ this.OR4([
1497
+ { ALT: () => this.CONSUME4(Space) },
1498
+ { ALT: () => this.CONSUME4(Newline) }
1499
+ ]);
1456
1500
  });
1457
1501
  });
1458
1502
  this.parameter = this.RULE("parameter", () => {
1459
1503
  this.CONSUME(Identifier, { LABEL: "key" });
1504
+ this.MANY(() => {
1505
+ this.OR([
1506
+ { ALT: () => this.CONSUME(Space) },
1507
+ { ALT: () => this.CONSUME(Newline) }
1508
+ ]);
1509
+ });
1460
1510
  this.CONSUME(Equals);
1461
- this.OR([
1511
+ this.MANY2(() => {
1512
+ this.OR2([
1513
+ { ALT: () => this.CONSUME2(Space) },
1514
+ { ALT: () => this.CONSUME2(Newline) }
1515
+ ]);
1516
+ });
1517
+ this.OR3([
1462
1518
  // String value
1463
1519
  { ALT: () => this.CONSUME(StringLiteral, { LABEL: "value" }) },
1464
1520
  // Number value
@@ -1467,6 +1523,11 @@ class MathExerciseParser extends chevrotain.CstParser {
1467
1523
  { ALT: () => this.CONSUME2(Identifier, { LABEL: "value" }) },
1468
1524
  // Tuple value: (start, end)
1469
1525
  { ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
1526
+ // Number-pair list enclosed in braces: {[1, 2], [3, 4]}
1527
+ {
1528
+ GATE: () => this.isBracedNumberPairListAhead(),
1529
+ ALT: () => this.SUBRULE(this.bracedNumberPairList, { LABEL: "value" })
1530
+ },
1470
1531
  // Curly-brace string list: {"a", "b", "c"}
1471
1532
  { ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
1472
1533
  // Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
@@ -1522,8 +1583,52 @@ class MathExerciseParser extends chevrotain.CstParser {
1522
1583
  this.SUBRULE(this.stringList);
1523
1584
  this.CONSUME(RBrace);
1524
1585
  });
1586
+ this.bracedNumberPairList = this.RULE("bracedNumberPairList", () => {
1587
+ this.CONSUME(LBrace);
1588
+ this.MANY(() => {
1589
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1590
+ });
1591
+ this.OPTION(() => {
1592
+ this.SUBRULE(this.numberPair);
1593
+ this.MANY2(() => {
1594
+ this.MANY3(() => {
1595
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1596
+ });
1597
+ this.CONSUME(Comma);
1598
+ this.MANY4(() => {
1599
+ this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
1600
+ });
1601
+ this.SUBRULE2(this.numberPair);
1602
+ });
1603
+ });
1604
+ this.MANY5(() => {
1605
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1606
+ });
1607
+ this.CONSUME(RBrace);
1608
+ });
1609
+ this.numberPair = this.RULE("numberPair", () => {
1610
+ this.CONSUME(LBracket);
1611
+ this.MANY(() => this.CONSUME(Space));
1612
+ this.CONSUME(NumberLiteral, { LABEL: "row" });
1613
+ this.MANY2(() => this.CONSUME2(Space));
1614
+ this.CONSUME(Comma);
1615
+ this.MANY3(() => this.CONSUME3(Space));
1616
+ this.CONSUME2(NumberLiteral, { LABEL: "col" });
1617
+ this.MANY4(() => this.CONSUME4(Space));
1618
+ this.CONSUME(RBracket);
1619
+ });
1525
1620
  this.performSelfAnalysis();
1526
1621
  }
1622
+ /** Distinguish `{[number, number], ...}` and the valid empty form `{}` from string lists. */
1623
+ isBracedNumberPairListAhead() {
1624
+ if (this.LA(1).tokenType !== LBrace) return false;
1625
+ let offset = 2;
1626
+ while (this.LA(offset).tokenType === Space || this.LA(offset).tokenType === Newline) {
1627
+ offset++;
1628
+ }
1629
+ const firstContentToken = this.LA(offset).tokenType;
1630
+ return firstContentToken === LBracket || firstContentToken === RBrace;
1631
+ }
1527
1632
  }
1528
1633
  const parser = new MathExerciseParser();
1529
1634
  function parseOrThrow(schema, data, label) {
@@ -2068,6 +2173,135 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style, lab
2068
2173
  positions
2069
2174
  });
2070
2175
  }
2176
+ const GRID_SYMMETRY_AXES = ["vertical", "horizontal"];
2177
+ const GRID_SYMMETRY_INTERACTIVE_AREAS = ["left", "right", "top", "bottom"];
2178
+ const GRID_SYMMETRY_MAX_ROWS = 20;
2179
+ const GRID_SYMMETRY_MAX_COLS = 20;
2180
+ const GRID_SYMMETRY_MIN_CELL_SIZE = 0.25;
2181
+ const GRID_SYMMETRY_MAX_CELL_SIZE = 2;
2182
+ function isCellInInteractiveArea(cell, rows, cols, area) {
2183
+ const [row, col] = cell;
2184
+ switch (area) {
2185
+ case "left":
2186
+ return col <= Math.floor(cols / 2);
2187
+ case "right":
2188
+ return col > Math.ceil(cols / 2);
2189
+ case "top":
2190
+ return row <= Math.floor(rows / 2);
2191
+ case "bottom":
2192
+ return row > Math.ceil(rows / 2);
2193
+ }
2194
+ }
2195
+ function reflectGridSymmetryCell(cell, rows, cols, axis) {
2196
+ const [row, col] = cell;
2197
+ return axis === "vertical" ? [row, cols + 1 - col] : [rows + 1 - row, col];
2198
+ }
2199
+ function computeGridSymmetryExpectedCells(data) {
2200
+ const seen = /* @__PURE__ */ new Set();
2201
+ const expected = [];
2202
+ for (const cell of data.cells) {
2203
+ if (isCellInInteractiveArea(cell, data.rows, data.cols, data.interactiveArea)) continue;
2204
+ const reflected = reflectGridSymmetryCell(cell, data.rows, data.cols, data.axis);
2205
+ if (!isCellInInteractiveArea(reflected, data.rows, data.cols, data.interactiveArea)) continue;
2206
+ const key2 = gridCellKey(reflected);
2207
+ if (!seen.has(key2)) {
2208
+ seen.add(key2);
2209
+ expected.push(reflected);
2210
+ }
2211
+ }
2212
+ return expected.sort(([ar, ac], [br, bc]) => ar - br || ac - bc);
2213
+ }
2214
+ function computeGridSymmetryCellsToFill(data) {
2215
+ const initial = new Set(data.cells.map(gridCellKey));
2216
+ return computeGridSymmetryExpectedCells(data).filter((cell) => !initial.has(gridCellKey(cell)));
2217
+ }
2218
+ const GridSymmetryBlockSchema = zod.z.object({
2219
+ type: zod.z.literal(AST_TYPES.GRID_SYMMETRY_BLOCK),
2220
+ rows: zod.z.number().int("The number of rows must be a whole number.").positive("The number of rows must be greater than 0.").max(GRID_SYMMETRY_MAX_ROWS, {
2221
+ message: `The number of rows cannot exceed ${GRID_SYMMETRY_MAX_ROWS}.`
2222
+ }),
2223
+ cols: zod.z.number().int("The number of columns must be a whole number.").positive("The number of columns must be greater than 0.").max(GRID_SYMMETRY_MAX_COLS, {
2224
+ message: `The number of columns cannot exceed ${GRID_SYMMETRY_MAX_COLS}.`
2225
+ }),
2226
+ axis: zod.z.enum(GRID_SYMMETRY_AXES),
2227
+ interactiveArea: zod.z.enum(GRID_SYMMETRY_INTERACTIVE_AREAS),
2228
+ cellSize: zod.z.number().finite().min(
2229
+ GRID_SYMMETRY_MIN_CELL_SIZE,
2230
+ `Cell size must be between ${GRID_SYMMETRY_MIN_CELL_SIZE} and ${GRID_SYMMETRY_MAX_CELL_SIZE}.`
2231
+ ).max(
2232
+ GRID_SYMMETRY_MAX_CELL_SIZE,
2233
+ `Cell size must be between ${GRID_SYMMETRY_MIN_CELL_SIZE} and ${GRID_SYMMETRY_MAX_CELL_SIZE}.`
2234
+ ).default(1),
2235
+ cells: zod.z.array(GridCellSchema)
2236
+ }).superRefine((data, ctx) => {
2237
+ const compatible = data.axis === "vertical" ? data.interactiveArea === "left" || data.interactiveArea === "right" : data.interactiveArea === "top" || data.interactiveArea === "bottom";
2238
+ if (!compatible) {
2239
+ ctx.addIssue({
2240
+ code: zod.z.ZodIssueCode.custom,
2241
+ path: ["interactiveArea"],
2242
+ message: data.axis === "vertical" ? 'For a vertical axis, the interactive area must be "left" or "right".' : 'For a horizontal axis, the interactive area must be "top" or "bottom".'
2243
+ });
2244
+ }
2245
+ if (data.axis === "vertical" && data.cols % 2 !== 0) {
2246
+ ctx.addIssue({
2247
+ code: zod.z.ZodIssueCode.custom,
2248
+ path: ["cols"],
2249
+ message: `For a vertical axis, the number of columns must be even. Received: ${data.cols}.`
2250
+ });
2251
+ }
2252
+ if (data.axis === "horizontal" && data.rows % 2 !== 0) {
2253
+ ctx.addIssue({
2254
+ code: zod.z.ZodIssueCode.custom,
2255
+ path: ["rows"],
2256
+ message: `For a horizontal axis, the number of rows must be even. Received: ${data.rows}.`
2257
+ });
2258
+ }
2259
+ const seen = /* @__PURE__ */ new Set();
2260
+ data.cells.forEach((cell, index) => {
2261
+ const [row, col] = cell;
2262
+ if (row > data.rows || col > data.cols) {
2263
+ ctx.addIssue({
2264
+ code: zod.z.ZodIssueCode.custom,
2265
+ path: ["cells", index],
2266
+ message: `Cell [${row},${col}] is outside the ${data.rows} × ${data.cols} grid.`
2267
+ });
2268
+ }
2269
+ const key2 = gridCellKey(cell);
2270
+ if (seen.has(key2)) {
2271
+ ctx.addIssue({
2272
+ code: zod.z.ZodIssueCode.custom,
2273
+ path: ["cells", index],
2274
+ message: `Cell [${row},${col}] is listed more than once.`
2275
+ });
2276
+ }
2277
+ seen.add(key2);
2278
+ });
2279
+ if (!compatible) return;
2280
+ const expected = new Set(computeGridSymmetryExpectedCells(data).map(gridCellKey));
2281
+ data.cells.forEach((cell, index) => {
2282
+ if (isCellInInteractiveArea(cell, data.rows, data.cols, data.interactiveArea) && !expected.has(gridCellKey(cell))) {
2283
+ ctx.addIssue({
2284
+ code: zod.z.ZodIssueCode.custom,
2285
+ path: ["cells", index],
2286
+ message: `Prefilled cell [${cell[0]},${cell[1]}] does not match the expected symmetry.`
2287
+ });
2288
+ }
2289
+ });
2290
+ });
2291
+ function createGridSymmetryBlock(rows, cols, axis, interactiveArea, cells, cellSize = 1) {
2292
+ return GridSymmetryBlockSchema.parse({
2293
+ type: AST_TYPES.GRID_SYMMETRY_BLOCK,
2294
+ rows,
2295
+ cols,
2296
+ axis,
2297
+ interactiveArea,
2298
+ cellSize,
2299
+ cells
2300
+ });
2301
+ }
2302
+ function isGridSymmetryBlock(value) {
2303
+ return GridSymmetryBlockSchema.safeParse(value).success;
2304
+ }
2071
2305
  const TableCellSchema = zod.z.object({
2072
2306
  type: zod.z.literal(AST_TYPES.TABLE_CELL),
2073
2307
  colspan: zod.z.number().int().positive().optional(),
@@ -2567,7 +2801,7 @@ function isMixedValidation(value) {
2567
2801
  function isValidationNode(value) {
2568
2802
  return ValidationNodeSchema.safeParse(value).success;
2569
2803
  }
2570
- const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema]);
2804
+ const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema, MatcherBlockSchema, PairMatcherBlockSchema, VerticalOperationBlockSchema, GridSymmetryBlockSchema]);
2571
2805
  const ExerciseDocumentSchema = zod.z.object({
2572
2806
  type: zod.z.literal(AST_TYPES.EXERCISE_DOCUMENT),
2573
2807
  question: TextBlockSchema.optional(),
@@ -2669,12 +2903,24 @@ class NaturalDSLVisitor extends BaseCstVisitor {
2669
2903
  if (ctx.table) return this.visit(ctx.table);
2670
2904
  if (ctx.radioButtons) return this.visit(ctx.radioButtons);
2671
2905
  if (ctx.numberLine) return this.visit(ctx.numberLine);
2906
+ if (ctx.gridSymmetry) return this.visit(ctx.gridSymmetry);
2672
2907
  if (ctx.pairMatcher) return this.visit(ctx.pairMatcher);
2673
2908
  if (ctx.matcher) return this.visit(ctx.matcher);
2674
2909
  if (ctx.verticalOperation) return this.visit(ctx.verticalOperation);
2675
2910
  if (ctx.line) return this.visit(ctx.line);
2676
2911
  throw new Error("Unknown content block");
2677
2912
  }
2913
+ gridSymmetry(ctx) {
2914
+ const params = this.visit(ctx.parameterList);
2915
+ return createGridSymmetryBlock(
2916
+ Number(params.rows),
2917
+ Number(params.cols),
2918
+ params.axis,
2919
+ params.interactiveArea,
2920
+ params.cells,
2921
+ params.cellSize === void 0 ? 1 : Number(params.cellSize)
2922
+ );
2923
+ }
2678
2924
  radioButtons(ctx) {
2679
2925
  const params = this.visit(ctx.parameterList);
2680
2926
  const name = params.name;
@@ -3263,6 +3509,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
3263
3509
  curlyStringList(ctx) {
3264
3510
  return this.visit(ctx.stringList);
3265
3511
  }
3512
+ bracedNumberPairList(ctx) {
3513
+ return ctx.numberPair ? ctx.numberPair.map((pair) => this.visit(pair)) : [];
3514
+ }
3515
+ numberPair(ctx) {
3516
+ return [
3517
+ parseFloat(ctx.row[0].image),
3518
+ parseFloat(ctx.col[0].image)
3519
+ ];
3520
+ }
3266
3521
  // ═══════════════════════════════════════════════════════════════════════════
3267
3522
  // Helpers
3268
3523
  // ═══════════════════════════════════════════════════════════════════════════
@@ -3639,6 +3894,34 @@ const VisualFractionJSONSchema = zod.z.object({
3639
3894
  */
3640
3895
  size: zod.z.number().positive().optional()
3641
3896
  }).superRefine(validateVisualFractionCapacity);
3897
+ const GridSymmetryJSONSchema = zod.z.object({
3898
+ type: zod.z.literal(JSON_TYPES.GRID_SYMMETRY),
3899
+ rows: zod.z.number().int().positive(),
3900
+ cols: zod.z.number().int().positive(),
3901
+ axis: zod.z.enum(["vertical", "horizontal"]),
3902
+ interactiveArea: zod.z.enum(["left", "right", "top", "bottom"]),
3903
+ cellSize: zod.z.number().finite().positive().default(1),
3904
+ cells: zod.z.array(GridCellSchema)
3905
+ }).superRefine((data, ctx) => {
3906
+ const result = GridSymmetryBlockSchema.safeParse({
3907
+ type: AST_TYPES.GRID_SYMMETRY_BLOCK,
3908
+ rows: data.rows,
3909
+ cols: data.cols,
3910
+ axis: data.axis,
3911
+ interactiveArea: data.interactiveArea,
3912
+ cellSize: data.cellSize,
3913
+ cells: data.cells
3914
+ });
3915
+ if (!result.success) {
3916
+ for (const issue of result.error.issues) {
3917
+ ctx.addIssue({
3918
+ code: zod.z.ZodIssueCode.custom,
3919
+ path: issue.path,
3920
+ message: issue.message
3921
+ });
3922
+ }
3923
+ }
3924
+ });
3642
3925
  const RulerWidgetSchema = zod.z.object({
3643
3926
  type: zod.z.literal(JSON_TYPES.RULER),
3644
3927
  size: zod.z.number().optional()
@@ -3661,6 +3944,7 @@ const WidgetJSONSchema = zod.z.union([
3661
3944
  MatcherWidgetJSONSchema,
3662
3945
  PairMatcherWidgetJSONSchema,
3663
3946
  VisualFractionJSONSchema,
3947
+ GridSymmetryJSONSchema,
3664
3948
  RulerWidgetSchema,
3665
3949
  ProtractorWidgetSchema,
3666
3950
  SetSquareWidgetSchema
@@ -3676,7 +3960,8 @@ const ValidationJSONSchema = zod.z.object({
3676
3960
  zod.z.string(),
3677
3961
  zod.z.number(),
3678
3962
  zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1),
3679
- zod.z.array(zod.z.number().int().nonnegative())
3963
+ zod.z.array(zod.z.number().int().nonnegative()),
3964
+ zod.z.array(GridCellSchema)
3680
3965
  ])
3681
3966
  ).optional(),
3682
3967
  expression: zod.z.string().optional(),
@@ -3774,6 +4059,9 @@ function isNumericKeypadJSON(widget) {
3774
4059
  function isFractionJSON(widget) {
3775
4060
  return widget.type === JSON_TYPES.FRACTION;
3776
4061
  }
4062
+ function isGridSymmetryJSON(widget) {
4063
+ return widget.type === JSON_TYPES.GRID_SYMMETRY;
4064
+ }
3777
4065
  function isRulerWidget(widget) {
3778
4066
  return widget.type === JSON_TYPES.RULER;
3779
4067
  }
@@ -9281,6 +9569,7 @@ function toJSON(ast) {
9281
9569
  const idGenerator = new LayoutIdGenerator();
9282
9570
  const widgets = {};
9283
9571
  const answers = {};
9572
+ let gridSymmetryCounter = 0;
9284
9573
  const allInlines = collectAllInlines(ast.lines);
9285
9574
  const inlineDragIds = /* @__PURE__ */ new Map();
9286
9575
  {
@@ -9335,6 +9624,34 @@ function toJSON(ast) {
9335
9624
  });
9336
9625
  } else if (block.type === AST_TYPES.VERTICAL_OPERATION_BLOCK) {
9337
9626
  layout.push(transformVerticalOperation(block, idGenerator));
9627
+ } else if (block.type === AST_TYPES.GRID_SYMMETRY_BLOCK) {
9628
+ const grid = block;
9629
+ const widgetId = `${GRID_SYMMETRY_WIDGET_PREFIX}${gridSymmetryCounter++}`;
9630
+ widgets[widgetId] = {
9631
+ type: JSON_TYPES.GRID_SYMMETRY,
9632
+ rows: grid.rows,
9633
+ cols: grid.cols,
9634
+ axis: grid.axis,
9635
+ interactiveArea: grid.interactiveArea,
9636
+ cellSize: grid.cellSize,
9637
+ cells: grid.cells
9638
+ };
9639
+ const prefilled = grid.cells.filter((cell) => isCellInInteractiveArea(
9640
+ cell,
9641
+ grid.rows,
9642
+ grid.cols,
9643
+ grid.interactiveArea
9644
+ ));
9645
+ answers[widgetId] = [...prefilled, ...computeGridSymmetryCellsToFill(grid)].sort(([ar, ac], [br, bc]) => ar - br || ac - bc);
9646
+ layout.push({
9647
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
9648
+ type: JSON_TYPES.WIDGET_REF,
9649
+ widgetId
9650
+ });
9651
+ layout.push({
9652
+ id: idGenerator.generate(JSON_TYPES.LINEBREAK),
9653
+ type: JSON_TYPES.LINEBREAK
9654
+ });
9338
9655
  } else if (block.type === AST_TYPES.RADIO_OPTIONS) {
9339
9656
  const radioBlock = block;
9340
9657
  layout.push({
@@ -9932,6 +10249,18 @@ function areVisualFractionCountsEqual(expected, result) {
9932
10249
  if (expected.length !== result.length) return false;
9933
10250
  return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
9934
10251
  }
10252
+ function isValidGridSymmetryCell(value, rows, cols) {
10253
+ if (!Array.isArray(value) || value.length !== 2) return false;
10254
+ const [row, col] = value;
10255
+ return Number.isInteger(row) && Number.isInteger(col) && row >= 1 && row <= rows && col >= 1 && col <= cols;
10256
+ }
10257
+ function areGridSymmetryCellsEqual(expected, actual, rows, cols) {
10258
+ if (!Array.isArray(actual) || actual.length !== expected.length) return false;
10259
+ if (!actual.every((cell) => isValidGridSymmetryCell(cell, rows, cols))) return false;
10260
+ const actualKeys = new Set(actual.map(gridCellKey));
10261
+ if (actualKeys.size !== actual.length) return false;
10262
+ return expected.every((cell) => actualKeys.has(gridCellKey(cell)));
10263
+ }
9935
10264
  const math$1 = mathjs.create(mathjs.all);
9936
10265
  function collectNumberLines(items) {
9937
10266
  const result = [];
@@ -10005,6 +10334,20 @@ function validateSimple(exercise, userInputs) {
10005
10334
  else allCorrect = false;
10006
10335
  continue;
10007
10336
  }
10337
+ if (widget?.type === JSON_TYPES.GRID_SYMMETRY) {
10338
+ const userValue2 = userInputs[widgetId];
10339
+ const isCorrect2 = areGridSymmetryCellsEqual(
10340
+ expected,
10341
+ userValue2,
10342
+ widget.rows,
10343
+ widget.cols
10344
+ );
10345
+ details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
10346
+ totalCount++;
10347
+ if (isCorrect2) correctCount++;
10348
+ else allCorrect = false;
10349
+ continue;
10350
+ }
10008
10351
  totalCount++;
10009
10352
  const userValue = userInputs[widgetId];
10010
10353
  const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
@@ -10435,9 +10778,19 @@ function getMissingInputs(exercise, userInputs) {
10435
10778
  }
10436
10779
  const inputs = preprocessFractionValues(exercise, userInputs);
10437
10780
  const isEmpty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
10781
+ const isMissingAnswer = (widgetId) => {
10782
+ const value = inputs[widgetId];
10783
+ const widget = exercise.widgets[widgetId];
10784
+ if (widget?.type === JSON_TYPES.GRID_SYMMETRY) {
10785
+ const expected = exercise.validation?.answers?.[widgetId];
10786
+ const expectedCount = Array.isArray(expected) ? expected.length : 0;
10787
+ return !Array.isArray(value) || value.length < expectedCount;
10788
+ }
10789
+ return !(widgetId in inputs) || isEmpty(value);
10790
+ };
10438
10791
  if (exercise.validation.mode === VALIDATION_MODES.SIMPLE && exercise.validation.answers) {
10439
10792
  for (const widgetId of Object.keys(exercise.validation.answers)) {
10440
- if (!(widgetId in inputs) || isEmpty(inputs[widgetId])) {
10793
+ if (isMissingAnswer(widgetId)) {
10441
10794
  missing.push(widgetId);
10442
10795
  }
10443
10796
  }
@@ -10453,7 +10806,7 @@ function getMissingInputs(exercise, userInputs) {
10453
10806
  ...exercise.validation.variables ?? []
10454
10807
  ]);
10455
10808
  for (const k of keys) {
10456
- if (!(k in inputs) || isEmpty(inputs[k])) missing.push(k);
10809
+ if (isMissingAnswer(k)) missing.push(k);
10457
10810
  }
10458
10811
  }
10459
10812
  return missing;
@@ -15098,13 +15451,18 @@ function applyDropToInput(targetId, item, onChange) {
15098
15451
  function isUndoDrop(event, dragId) {
15099
15452
  return event.active.id === dragId && !event.over;
15100
15453
  }
15454
+ const LockContext = React.createContext(false);
15455
+ const ExerciseLockProvider = ({ locked, children }) => /* @__PURE__ */ jsxRuntime.jsx(LockContext.Provider, { value: locked, children });
15456
+ const useExerciseLocked = () => React.useContext(LockContext);
15101
15457
  const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
15102
15458
  const { droppedSources } = useExerciseDnd();
15459
+ const locked = useExerciseLocked();
15103
15460
  const sourceOptionId = droppedSources.get(widgetId);
15104
15461
  const dragId = `${widgetId}__chip`;
15105
15462
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
15106
15463
  id: dragId,
15107
- data: { type: "placed-chip", widgetId, label: value, sourceOptionId }
15464
+ data: { type: "placed-chip", widgetId, label: value, sourceOptionId },
15465
+ disabled: locked
15108
15466
  });
15109
15467
  useDndMonitor({
15110
15468
  onDragEnd(event) {
@@ -15117,10 +15475,10 @@ const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
15117
15475
  ref: setNodeRef,
15118
15476
  className: `inline-input-chip ${className}${isDragging ? " dragging" : ""}`.trim(),
15119
15477
  onClick: (e) => {
15120
- if (e.detail !== 0) onRemove();
15478
+ if (!locked && e.detail !== 0) onRemove();
15121
15479
  },
15122
- title: "Drag to move, or click to remove",
15123
- style: { touchAction: "none", cursor: "grab" },
15480
+ title: locked ? void 0 : "Drag to move, or click to remove",
15481
+ style: { touchAction: "none", cursor: locked ? "default" : "grab" },
15124
15482
  ...listeners,
15125
15483
  ...attributes,
15126
15484
  children: value
@@ -15140,6 +15498,7 @@ const InlineInput = ({
15140
15498
  onFocus
15141
15499
  }) => {
15142
15500
  const playSound = useSound();
15501
+ const locked = useExerciseLocked();
15143
15502
  const handleChange = React.useCallback((newValue, fromDrop = false, sourceOptionId) => {
15144
15503
  onChange(widgetId, newValue, fromDrop, sourceOptionId);
15145
15504
  }, [onChange, widgetId]);
@@ -15155,6 +15514,7 @@ const InlineInput = ({
15155
15514
  id: widgetId,
15156
15515
  accepts: ["drag-option", "placed-chip"],
15157
15516
  onDrop: handleDrop,
15517
+ disabled: locked,
15158
15518
  className: "inline-widget inline-input-droppable",
15159
15519
  children: isDropped && value !== "" ? (
15160
15520
  // Show as chip only when value came from drag-drop
@@ -15177,8 +15537,11 @@ const InlineInput = ({
15177
15537
  className: `inline-input${statusClass}`,
15178
15538
  value,
15179
15539
  onChange: (e) => handleChange(e.target.value, false),
15180
- onMouseDown: () => playSound("input-select"),
15540
+ onMouseDown: () => {
15541
+ if (!locked) playSound("input-select");
15542
+ },
15181
15543
  onFocus: () => onFocus?.(widgetId),
15544
+ readOnly: locked,
15182
15545
  maxLength: expectedLength,
15183
15546
  style: { width: dynamicWidth },
15184
15547
  placeholder
@@ -15195,6 +15558,7 @@ const SingleDragOption = ({
15195
15558
  className = ""
15196
15559
  }) => {
15197
15560
  const { usedOptionIds } = useExerciseDnd();
15561
+ const locked = useExerciseLocked();
15198
15562
  const isUsed = usedOptionIds.has(optionId);
15199
15563
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `drag-option-slot ${className}`.trim(), children: isUsed ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-empty", "aria-hidden": "true" }) : /* @__PURE__ */ jsxRuntime.jsx(
15200
15564
  Draggable,
@@ -15202,12 +15566,16 @@ const SingleDragOption = ({
15202
15566
  id: optionId,
15203
15567
  type: "drag-option",
15204
15568
  data: { value, label: value },
15569
+ disabled: locked,
15205
15570
  className: "drag-option-wrapper",
15206
15571
  children: /* @__PURE__ */ jsxRuntime.jsx(
15207
15572
  "button",
15208
15573
  {
15209
15574
  className: "drag-option",
15210
- onClick: () => onOptionClick?.(value),
15575
+ onClick: () => {
15576
+ if (!locked) onOptionClick?.(value);
15577
+ },
15578
+ "aria-disabled": locked ? "true" : void 0,
15211
15579
  children: value
15212
15580
  }
15213
15581
  )
@@ -15321,31 +15689,34 @@ const NumericKeypad = ({
15321
15689
  onChange
15322
15690
  }) => {
15323
15691
  const playSound = useSound();
15692
+ const locked = useExerciseLocked();
15324
15693
  const handleDigit = (digit) => {
15694
+ if (locked) return;
15325
15695
  playSound("keypad-press");
15326
15696
  onChange(value + digit);
15327
15697
  };
15328
15698
  const handleBackspace = () => {
15699
+ if (locked) return;
15329
15700
  playSound("keypad-press");
15330
15701
  onChange(value.slice(0, -1));
15331
15702
  };
15332
15703
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `numeric-keypad ${includeOperators ? "numeric-keypad-with-operators" : ""}`, children: [
15333
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
15334
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
15335
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
15336
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
15337
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
15338
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
15339
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
15340
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
15341
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
15342
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
15343
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
15344
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
15345
- includeDecimal ? /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsxRuntime.jsx("div", {}),
15346
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
15347
- /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
15348
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
15704
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
15705
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
15706
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
15707
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
15708
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
15709
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
15710
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
15711
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
15712
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
15713
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
15714
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
15715
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
15716
+ includeDecimal ? /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsxRuntime.jsx("div", {}),
15717
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
15718
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
15719
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
15349
15720
  ] });
15350
15721
  };
15351
15722
  const FractionInput = ({
@@ -15362,6 +15733,7 @@ const FractionInput = ({
15362
15733
  placeholder
15363
15734
  }) => {
15364
15735
  const playSound = useSound();
15736
+ const locked = useExerciseLocked();
15365
15737
  const numKey = getFractionKey(widgetId, "num");
15366
15738
  const denKey = getFractionKey(widgetId, "den");
15367
15739
  const numValue = values[numKey] ?? "";
@@ -15382,6 +15754,7 @@ const FractionInput = ({
15382
15754
  id: subKey,
15383
15755
  accepts: ["drag-option", "placed-chip"],
15384
15756
  onDrop: handleDrop(subKey),
15757
+ disabled: locked,
15385
15758
  className: "fraction-slot-droppable",
15386
15759
  children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsxRuntime.jsx(
15387
15760
  PlacedInputChip,
@@ -15399,8 +15772,11 @@ const FractionInput = ({
15399
15772
  className: `inline-input fraction-input${slotClass}`,
15400
15773
  value: currentValue,
15401
15774
  onChange: (e) => onChange(subKey, e.target.value, false),
15402
- onMouseDown: () => playSound("input-select"),
15775
+ onMouseDown: () => {
15776
+ if (!locked) playSound("input-select");
15777
+ },
15403
15778
  onFocus: () => onFocus?.(subKey),
15779
+ readOnly: locked,
15404
15780
  maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
15405
15781
  size: placeholder ? placeholder.length : void 0,
15406
15782
  "aria-label": label,
@@ -15631,12 +16007,13 @@ function getEqualTrianglePartPath(index, divisions) {
15631
16007
  function getGridColumnCount(divisions) {
15632
16008
  return Math.ceil(Math.sqrt(divisions));
15633
16009
  }
15634
- function getInteractiveProps(part, onPartClick) {
16010
+ function getInteractiveProps(part, onPartClick, locked = false) {
15635
16011
  if (!part.isInteractiveSlot) return {};
15636
16012
  return {
15637
16013
  onClick: () => onPartClick(part.index),
15638
16014
  role: "button",
15639
16015
  "aria-pressed": part.isSelected,
16016
+ "aria-disabled": locked ? "true" : void 0,
15640
16017
  "aria-label": getPartAriaLabel(part),
15641
16018
  tabIndex: 0,
15642
16019
  onKeyDown: (event) => {
@@ -15656,6 +16033,7 @@ const BarShape = ({
15656
16033
  orientation,
15657
16034
  onPartClick
15658
16035
  }) => {
16036
+ const locked = useExerciseLocked();
15659
16037
  const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
15660
16038
  return /* @__PURE__ */ jsxRuntime.jsx(
15661
16039
  "div",
@@ -15667,7 +16045,7 @@ const BarShape = ({
15667
16045
  {
15668
16046
  className: part.className,
15669
16047
  style: part.style,
15670
- ...getInteractiveProps(part, onPartClick)
16048
+ ...getInteractiveProps(part, onPartClick, locked)
15671
16049
  },
15672
16050
  part.index
15673
16051
  ))
@@ -15679,87 +16057,96 @@ const GridShape = ({
15679
16057
  parts,
15680
16058
  divisions,
15681
16059
  onPartClick
15682
- }) => /* @__PURE__ */ jsxRuntime.jsx(
15683
- "div",
15684
- {
15685
- className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
15686
- style: {
15687
- "--visual-fraction-divisions": divisions,
15688
- "--visual-fraction-grid-cols": getGridColumnCount(divisions),
15689
- // Safe integer division: zod schema enforces divisions = perfect square.
15690
- "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
15691
- },
15692
- children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
15693
- "div",
15694
- {
15695
- className: part.className,
15696
- style: part.style,
15697
- ...getInteractiveProps(part, onPartClick)
16060
+ }) => {
16061
+ const locked = useExerciseLocked();
16062
+ return /* @__PURE__ */ jsxRuntime.jsx(
16063
+ "div",
16064
+ {
16065
+ className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
16066
+ style: {
16067
+ "--visual-fraction-divisions": divisions,
16068
+ "--visual-fraction-grid-cols": getGridColumnCount(divisions),
16069
+ // Safe integer division: zod schema enforces divisions = perfect square.
16070
+ "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
15698
16071
  },
15699
- part.index
15700
- ))
15701
- }
15702
- );
16072
+ children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
16073
+ "div",
16074
+ {
16075
+ className: part.className,
16076
+ style: part.style,
16077
+ ...getInteractiveProps(part, onPartClick, locked)
16078
+ },
16079
+ part.index
16080
+ ))
16081
+ }
16082
+ );
16083
+ };
15703
16084
  GridShape.displayName = "GridShape";
15704
16085
  const CircleShape = ({
15705
16086
  parts,
15706
16087
  divisions,
15707
16088
  onPartClick
15708
- }) => /* @__PURE__ */ jsxRuntime.jsx(
15709
- "svg",
15710
- {
15711
- className: "visual-fraction-widget__shape visual-fraction-widget__circle",
15712
- viewBox: "0 0 100 100",
15713
- children: parts.map((part) => {
15714
- const sectorProps = getCircleSectorProps(part.index, divisions);
15715
- const className = `${part.className} visual-fraction-widget__sector`;
15716
- const interactiveProps = getInteractiveProps(part, onPartClick);
15717
- return sectorProps.kind === "circle" ? /* @__PURE__ */ jsxRuntime.jsx(
15718
- "circle",
15719
- {
15720
- className,
15721
- style: part.style,
15722
- cx: "50",
15723
- cy: "50",
15724
- r: "48",
15725
- ...interactiveProps
15726
- },
15727
- part.index
15728
- ) : /* @__PURE__ */ jsxRuntime.jsx(
15729
- "path",
15730
- {
15731
- className,
15732
- style: part.style,
15733
- d: sectorProps.d,
15734
- ...interactiveProps
15735
- },
15736
- part.index
15737
- );
15738
- })
15739
- }
15740
- );
16089
+ }) => {
16090
+ const locked = useExerciseLocked();
16091
+ return /* @__PURE__ */ jsxRuntime.jsx(
16092
+ "svg",
16093
+ {
16094
+ className: "visual-fraction-widget__shape visual-fraction-widget__circle",
16095
+ viewBox: "0 0 100 100",
16096
+ children: parts.map((part) => {
16097
+ const sectorProps = getCircleSectorProps(part.index, divisions);
16098
+ const className = `${part.className} visual-fraction-widget__sector`;
16099
+ const interactiveProps = getInteractiveProps(part, onPartClick, locked);
16100
+ return sectorProps.kind === "circle" ? /* @__PURE__ */ jsxRuntime.jsx(
16101
+ "circle",
16102
+ {
16103
+ className,
16104
+ style: part.style,
16105
+ cx: "50",
16106
+ cy: "50",
16107
+ r: "48",
16108
+ ...interactiveProps
16109
+ },
16110
+ part.index
16111
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
16112
+ "path",
16113
+ {
16114
+ className,
16115
+ style: part.style,
16116
+ d: sectorProps.d,
16117
+ ...interactiveProps
16118
+ },
16119
+ part.index
16120
+ );
16121
+ })
16122
+ }
16123
+ );
16124
+ };
15741
16125
  CircleShape.displayName = "CircleShape";
15742
16126
  const TriangleShape = ({
15743
16127
  parts,
15744
16128
  divisions,
15745
16129
  onPartClick
15746
- }) => /* @__PURE__ */ jsxRuntime.jsx(
15747
- "svg",
15748
- {
15749
- className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
15750
- viewBox: "0 0 100 100",
15751
- children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
15752
- "path",
15753
- {
15754
- className: `${part.className} visual-fraction-widget__sector`,
15755
- style: part.style,
15756
- d: getEqualTrianglePartPath(part.index, divisions),
15757
- ...getInteractiveProps(part, onPartClick)
15758
- },
15759
- part.index
15760
- ))
15761
- }
15762
- );
16130
+ }) => {
16131
+ const locked = useExerciseLocked();
16132
+ return /* @__PURE__ */ jsxRuntime.jsx(
16133
+ "svg",
16134
+ {
16135
+ className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
16136
+ viewBox: "0 0 100 100",
16137
+ children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
16138
+ "path",
16139
+ {
16140
+ className: `${part.className} visual-fraction-widget__sector`,
16141
+ style: part.style,
16142
+ d: getEqualTrianglePartPath(part.index, divisions),
16143
+ ...getInteractiveProps(part, onPartClick, locked)
16144
+ },
16145
+ part.index
16146
+ ))
16147
+ }
16148
+ );
16149
+ };
15763
16150
  TriangleShape.displayName = "TriangleShape";
15764
16151
  const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(BarShape, { parts, divisions, orientation, onPartClick });
15765
16152
  const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(CircleShape, { parts, divisions, onPartClick });
@@ -15803,12 +16190,14 @@ const VisualFraction = ({
15803
16190
  onChange
15804
16191
  });
15805
16192
  const playSound = useSound();
16193
+ const locked = useExerciseLocked();
15806
16194
  const handlePartClickWithSound = React.useCallback(
15807
16195
  (index) => {
16196
+ if (locked) return;
15808
16197
  if (answer.length > 0 && index >= filledCount) playSound("fraction-shade");
15809
16198
  handlePartClick(index);
15810
16199
  },
15811
- [answer.length, filledCount, playSound, handlePartClick]
16200
+ [locked, answer.length, filledCount, playSound, handlePartClick]
15812
16201
  );
15813
16202
  const parts = React.useMemo(
15814
16203
  () => Array.from({ length: divisions }, (_, i) => {
@@ -15876,6 +16265,97 @@ function getSizeStyle(size, _shape) {
15876
16265
  "--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
15877
16266
  };
15878
16267
  }
16268
+ const GridSymmetry = ({
16269
+ widgetId,
16270
+ widget,
16271
+ value,
16272
+ validationStatus = null,
16273
+ onChange
16274
+ }) => {
16275
+ const exerciseLocked = useExerciseLocked();
16276
+ const initialKeys = React.useMemo(() => new Set(widget.cells.map(gridCellKey)), [widget.cells]);
16277
+ const initialInteractiveCells = React.useMemo(
16278
+ () => widget.cells.filter(
16279
+ (cell) => isCellInInteractiveArea(cell, widget.rows, widget.cols, widget.interactiveArea)
16280
+ ),
16281
+ [widget.cells, widget.rows, widget.cols, widget.interactiveArea]
16282
+ );
16283
+ const selectedCells = value ?? initialInteractiveCells;
16284
+ const selectedKeys = React.useMemo(() => new Set(selectedCells.map(gridCellKey)), [selectedCells]);
16285
+ const toggleCell = (cell) => {
16286
+ if (exerciseLocked) return;
16287
+ const key2 = gridCellKey(cell);
16288
+ if (initialKeys.has(key2)) return;
16289
+ const next = selectedKeys.has(key2) ? selectedCells.filter((candidate) => gridCellKey(candidate) !== key2) : [...selectedCells, cell];
16290
+ onChange(widgetId, next);
16291
+ };
16292
+ const statusClass = validationStatus ? ` grid-symmetry--${validationStatus}` : "";
16293
+ const cells = [];
16294
+ for (let row = 1; row <= widget.rows; row++) {
16295
+ for (let col = 1; col <= widget.cols; col++) {
16296
+ const cell = [row, col];
16297
+ const key2 = gridCellKey(cell);
16298
+ const interactive = isCellInInteractiveArea(
16299
+ cell,
16300
+ widget.rows,
16301
+ widget.cols,
16302
+ widget.interactiveArea
16303
+ );
16304
+ const initial = initialKeys.has(key2);
16305
+ const selected = selectedKeys.has(key2);
16306
+ const locked = initial || !interactive;
16307
+ const roleClass = initial ? interactive ? " grid-symmetry__cell--prefilled" : " grid-symmetry__cell--reference" : selected ? " grid-symmetry__cell--answer" : "";
16308
+ const className = `grid-symmetry__cell${interactive ? " grid-symmetry__cell--interactive" : ""}${locked ? "" : " grid-symmetry__cell--editable"}${selected || initial ? " grid-symmetry__cell--selected" : ""}${roleClass}`;
16309
+ const label = `Row ${row}, column ${col}`;
16310
+ cells.push(
16311
+ locked ? /* @__PURE__ */ jsxRuntime.jsx(
16312
+ "span",
16313
+ {
16314
+ className,
16315
+ role: initial ? "img" : void 0,
16316
+ "aria-label": initial ? `${label}, filled` : void 0,
16317
+ "aria-hidden": initial ? void 0 : true
16318
+ },
16319
+ key2
16320
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
16321
+ "button",
16322
+ {
16323
+ type: "button",
16324
+ className,
16325
+ "aria-label": label,
16326
+ "aria-pressed": selected,
16327
+ "aria-disabled": exerciseLocked ? "true" : void 0,
16328
+ onClick: () => toggleCell(cell)
16329
+ },
16330
+ key2
16331
+ )
16332
+ );
16333
+ }
16334
+ }
16335
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `grid-symmetry${statusClass}`, "data-testid": "grid-symmetry", children: /* @__PURE__ */ jsxRuntime.jsxs(
16336
+ "div",
16337
+ {
16338
+ className: "grid-symmetry__grid",
16339
+ style: {
16340
+ "--grid-symmetry-cell-size": `calc(48px * ${widget.cellSize})`,
16341
+ gridTemplateColumns: `repeat(${widget.cols}, var(--grid-symmetry-cell-size))`,
16342
+ gridTemplateRows: `repeat(${widget.rows}, var(--grid-symmetry-cell-size))`
16343
+ },
16344
+ role: "group",
16345
+ "aria-label": `Symmetry grid, ${widget.rows} rows by ${widget.cols} columns`,
16346
+ children: [
16347
+ cells,
16348
+ /* @__PURE__ */ jsxRuntime.jsx(
16349
+ "span",
16350
+ {
16351
+ className: `grid-symmetry__axis grid-symmetry__axis--${widget.axis}`,
16352
+ "aria-hidden": "true"
16353
+ }
16354
+ )
16355
+ ]
16356
+ }
16357
+ ) });
16358
+ };
15879
16359
  const RadioOptions = ({
15880
16360
  name,
15881
16361
  options,
@@ -15884,6 +16364,7 @@ const RadioOptions = ({
15884
16364
  onChange
15885
16365
  }) => {
15886
16366
  const playSound = useSound();
16367
+ const locked = useExerciseLocked();
15887
16368
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
15888
16369
  const isSelected = value === option;
15889
16370
  const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
@@ -15892,9 +16373,11 @@ const RadioOptions = ({
15892
16373
  {
15893
16374
  className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
15894
16375
  onClick: () => {
16376
+ if (locked) return;
15895
16377
  playSound("radio-select");
15896
16378
  onChange(name, option);
15897
16379
  },
16380
+ "aria-disabled": locked ? "true" : void 0,
15898
16381
  type: "button",
15899
16382
  children: option
15900
16383
  },
@@ -15906,7 +16389,10 @@ const Btn = ({
15906
16389
  label,
15907
16390
  extraClass = "keypad-btn-digit",
15908
16391
  onClick
15909
- }) => /* @__PURE__ */ jsxRuntime.jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsxRuntime.jsx("span", { children: label }) });
16392
+ }) => {
16393
+ const locked = useExerciseLocked();
16394
+ return /* @__PURE__ */ jsxRuntime.jsx("button", { className: `keypad-btn ${extraClass}`, disabled: locked, onClick, children: /* @__PURE__ */ jsxRuntime.jsx("span", { children: label }) });
16395
+ };
15910
16396
  const DiamondKeypad = ({ value, onChange }) => {
15911
16397
  const playSound = useSound();
15912
16398
  const append = (ch) => {
@@ -15993,8 +16479,10 @@ const MatcherWidget = ({
15993
16479
  () => defaultValue2 && defaultValue2.length > 0 ? assignmentsFromValue(defaultValue2, itemIds, groupCount) : initialAssignments(itemIds)
15994
16480
  );
15995
16481
  const playSound = useSound();
16482
+ const locked = useExerciseLocked();
15996
16483
  const handleCardClick = React.useCallback(
15997
16484
  (itemId) => {
16485
+ if (locked) return;
15998
16486
  playSound("matcher-select");
15999
16487
  setAssignments((prev) => {
16000
16488
  const current = prev[itemId] ?? null;
@@ -16002,7 +16490,7 @@ const MatcherWidget = ({
16002
16490
  return { ...prev, [itemId]: next };
16003
16491
  });
16004
16492
  },
16005
- [groupCount, playSound]
16493
+ [groupCount, playSound, locked]
16006
16494
  );
16007
16495
  const onChangeRef = React.useRef(onChange);
16008
16496
  onChangeRef.current = onChange;
@@ -16039,6 +16527,7 @@ const MatcherWidget = ({
16039
16527
  "--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
16040
16528
  } : void 0,
16041
16529
  onClick: () => handleCardClick(item.itemId),
16530
+ "aria-disabled": locked ? "true" : void 0,
16042
16531
  "aria-pressed": isSelected,
16043
16532
  "aria-label": isSelected ? `Assigned to group ${colorIdx + 1}` : "Not assigned",
16044
16533
  children: item.content.map((layoutItem) => /* @__PURE__ */ jsxRuntime.jsx(
@@ -16252,6 +16741,7 @@ const PairMatcherWidget = ({
16252
16741
  shuffleItems = true
16253
16742
  }) => {
16254
16743
  const { topItems, bottomItems } = widget;
16744
+ const exerciseLocked = useExerciseLocked();
16255
16745
  const [shuffledTop] = React.useState(
16256
16746
  () => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
16257
16747
  );
@@ -16295,8 +16785,9 @@ const PairMatcherWidget = ({
16295
16785
  }
16296
16786
  soundPrevRef.current = { count, pendingId, flashId };
16297
16787
  }, [state.pairs, state.pending, state.flashTopId, playSound]);
16788
+ const interactionLocked = validationStatus !== null || exerciseLocked;
16298
16789
  const handleTopClick = React.useCallback((topId) => {
16299
- if (validationStatus) return;
16790
+ if (interactionLocked) return;
16300
16791
  if (flashTimerRef.current) {
16301
16792
  clearTimeout(flashTimerRef.current);
16302
16793
  flashTimerRef.current = null;
@@ -16338,9 +16829,9 @@ const PairMatcherWidget = ({
16338
16829
  flashBottomId: null
16339
16830
  };
16340
16831
  });
16341
- }, [validationStatus]);
16832
+ }, [interactionLocked]);
16342
16833
  const handleBottomClick = React.useCallback((bottomId) => {
16343
- if (validationStatus) return;
16834
+ if (interactionLocked) return;
16344
16835
  if (flashTimerRef.current) {
16345
16836
  clearTimeout(flashTimerRef.current);
16346
16837
  flashTimerRef.current = null;
@@ -16383,9 +16874,13 @@ const PairMatcherWidget = ({
16383
16874
  flashBottomId: null
16384
16875
  };
16385
16876
  });
16386
- }, [validationStatus]);
16877
+ }, [interactionLocked]);
16387
16878
  React.useEffect(() => {
16388
- if (validationStatus || state.flashTopId === null || state.flashBottomId === null) return;
16879
+ if (state.flashTopId === null || state.flashBottomId === null) return;
16880
+ if (interactionLocked) {
16881
+ setState((current) => ({ ...current, flashTopId: null, flashBottomId: null }));
16882
+ return;
16883
+ }
16389
16884
  flashTimerRef.current = setTimeout(() => {
16390
16885
  flashTimerRef.current = null;
16391
16886
  setState((current) => {
@@ -16399,7 +16894,7 @@ const PairMatcherWidget = ({
16399
16894
  flashTimerRef.current = null;
16400
16895
  }
16401
16896
  };
16402
- }, [state.flashTopId, state.flashBottomId, validationStatus]);
16897
+ }, [state.flashTopId, state.flashBottomId, interactionLocked]);
16403
16898
  const onChangeRef = React.useRef(onChange);
16404
16899
  onChangeRef.current = onChange;
16405
16900
  const isMounted = React.useRef(false);
@@ -16459,7 +16954,7 @@ const PairMatcherWidget = ({
16459
16954
  dir: isRtl ? "rtl" : void 0,
16460
16955
  onClick: () => isTop ? handleTopClick(item.itemId) : handleBottomClick(item.itemId),
16461
16956
  "aria-pressed": status !== "idle",
16462
- "aria-disabled": validationStatus ? "true" : void 0,
16957
+ "aria-disabled": interactionLocked ? "true" : void 0,
16463
16958
  children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "pair-matcher-card-content", children: item.content.map((li) => renderPairMatcherItemContent(li, widgetsMap)) })
16464
16959
  }
16465
16960
  ),
@@ -16524,6 +17019,9 @@ function isPairMatcherWidget(widget) {
16524
17019
  function isVisualFractionWidget(widget) {
16525
17020
  return widget.type === "visual-fraction";
16526
17021
  }
17022
+ function isGridSymmetryWidget(widget) {
17023
+ return widget.type === "grid-symmetry";
17024
+ }
16527
17025
  const WidgetRenderer = ({
16528
17026
  widget,
16529
17027
  widgetId,
@@ -16668,6 +17166,21 @@ const WidgetRenderer = ({
16668
17166
  }
16669
17167
  );
16670
17168
  }
17169
+ if (isGridSymmetryWidget(widget)) {
17170
+ const cells = Array.isArray(value) && value.every(
17171
+ (cell) => Array.isArray(cell) && cell.length === 2 && cell.every(Number.isInteger)
17172
+ ) ? value : void 0;
17173
+ return /* @__PURE__ */ jsxRuntime.jsx(
17174
+ GridSymmetry,
17175
+ {
17176
+ widgetId,
17177
+ widget,
17178
+ value: cells,
17179
+ validationStatus,
17180
+ onChange: (id, nextValue) => onChange(id, nextValue)
17181
+ }
17182
+ );
17183
+ }
16671
17184
  return null;
16672
17185
  };
16673
17186
  const ExpandDiamondIcon = () => {
@@ -16980,6 +17493,7 @@ const DraggableCursor = ({
16980
17493
  currentValue,
16981
17494
  validationStatus
16982
17495
  }) => {
17496
+ const locked = useExerciseLocked();
16983
17497
  const [isDragging, setIsDragging] = React.useState(false);
16984
17498
  const isDraggingRef = React.useRef(false);
16985
17499
  const currentValueRef = React.useRef(currentValue);
@@ -17001,13 +17515,14 @@ const DraggableCursor = ({
17001
17515
  return snapTicks.length > 0 ? snapToNearest(raw, snapTicks) : cleanFloat(raw);
17002
17516
  }, [axisRowRef, min, max, snapTicks]);
17003
17517
  const handlePointerDown = React.useCallback((e) => {
17518
+ if (locked) return;
17004
17519
  e.currentTarget.setPointerCapture(e.pointerId);
17005
17520
  e.stopPropagation();
17006
17521
  isDraggingRef.current = true;
17007
17522
  dragStartValueRef.current = currentValue;
17008
17523
  lastSoundValueRef.current = currentValue;
17009
17524
  setIsDragging(true);
17010
- }, [currentValue]);
17525
+ }, [currentValue, locked]);
17011
17526
  const updateCursorValue = React.useCallback((clientX) => {
17012
17527
  const next = snap(clientX);
17013
17528
  if (!almostEqual(next, lastSoundValueRef.current)) {
@@ -17035,7 +17550,7 @@ const DraggableCursor = ({
17035
17550
  }
17036
17551
  }, [updateCursorValue, playSound]);
17037
17552
  const handleKeyDown = React.useCallback((e) => {
17038
- if (!snapTicks.length) return;
17553
+ if (locked || !snapTicks.length) return;
17039
17554
  const sorted = [...snapTicks].sort((a, b) => a - b);
17040
17555
  const idx = sorted.findIndex((t) => t >= currentValue - 1e-9);
17041
17556
  const first = sorted[0] ?? currentValue;
@@ -17057,7 +17572,7 @@ const DraggableCursor = ({
17057
17572
  }
17058
17573
  onChange(cursor.id, String(next));
17059
17574
  }
17060
- }, [snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
17575
+ }, [locked, snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
17061
17576
  const ballClass = [
17062
17577
  "number-line-cursor-ball",
17063
17578
  isDragging ? "number-line-cursor-ball--dragging" : "",
@@ -17106,6 +17621,7 @@ const DraggableCursor = ({
17106
17621
  "aria-valuemax": max,
17107
17622
  "aria-valuenow": currentValue,
17108
17623
  "aria-label": sliderAriaLabel,
17624
+ "aria-disabled": locked ? "true" : void 0,
17109
17625
  tabIndex: 0
17110
17626
  }
17111
17627
  )
@@ -17146,9 +17662,11 @@ const CursorBalls = ({
17146
17662
  }) });
17147
17663
  };
17148
17664
  const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
17665
+ const locked = useExerciseLocked();
17149
17666
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
17150
17667
  id: widgetId,
17151
- data: { type: "placed-chip", widgetId, label }
17668
+ data: { type: "placed-chip", widgetId, label },
17669
+ disabled: locked
17152
17670
  });
17153
17671
  return /* @__PURE__ */ jsxRuntime.jsx(
17154
17672
  "div",
@@ -17173,6 +17691,7 @@ const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
17173
17691
  type: "button",
17174
17692
  className: "number-line-placed-chip-remove",
17175
17693
  "aria-label": "Remove",
17694
+ disabled: locked,
17176
17695
  onClick: onRemove,
17177
17696
  children: "✕"
17178
17697
  }
@@ -17212,6 +17731,7 @@ const FreeDragAxis = ({
17212
17731
  cursorValidationStatuses,
17213
17732
  snapKey
17214
17733
  }) => {
17734
+ const axisLocked = useExerciseLocked();
17215
17735
  const axisRef = React.useRef(null);
17216
17736
  const snapTicks = React.useMemo(
17217
17737
  () => getSnapTicks(majorTicks, minorTicks),
@@ -17267,7 +17787,7 @@ const FreeDragAxis = ({
17267
17787
  return changed ? next : prev;
17268
17788
  });
17269
17789
  }, [values]);
17270
- const { setNodeRef } = useDroppable({ id: axisDropId });
17790
+ const { setNodeRef } = useDroppable({ id: axisDropId, disabled: axisLocked });
17271
17791
  const mergedRef = React.useCallback((node) => {
17272
17792
  axisRef.current = node;
17273
17793
  setNodeRef(node);
@@ -17581,6 +18101,7 @@ const CounterControls = ({
17581
18101
  onIncrement
17582
18102
  }) => {
17583
18103
  const playSound = useSound();
18104
+ const locked = useExerciseLocked();
17584
18105
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-counter", "aria-label": counter.name ?? "Counter", children: [
17585
18106
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-counter-controls", children: [
17586
18107
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -17589,7 +18110,7 @@ const CounterControls = ({
17589
18110
  type: "button",
17590
18111
  className: "number-line-counter-btn",
17591
18112
  "aria-label": "Decrease",
17592
- disabled: count <= counter.min,
18113
+ disabled: locked || count <= counter.min,
17593
18114
  onClick: () => {
17594
18115
  playSound("counter-step");
17595
18116
  onDecrement();
@@ -17604,7 +18125,7 @@ const CounterControls = ({
17604
18125
  type: "button",
17605
18126
  className: "number-line-counter-btn",
17606
18127
  "aria-label": "Increase",
17607
- disabled: count >= counter.max,
18128
+ disabled: locked || count >= counter.max,
17608
18129
  onClick: () => {
17609
18130
  playSound("counter-step");
17610
18131
  onIncrement();
@@ -18188,6 +18709,7 @@ const ClockRenderer = ({
18188
18709
  validationStatus,
18189
18710
  onChange
18190
18711
  }) => {
18712
+ const locked = useExerciseLocked();
18191
18713
  const format = item.format ?? "24h";
18192
18714
  const interactive = item.mode === "interactive";
18193
18715
  const snap = interactive && "snap" in item ? item.snap ?? 1 : 1;
@@ -18233,18 +18755,18 @@ const ClockRenderer = ({
18233
18755
  }
18234
18756
  }, [snap, hours, minutes, item.mode, item.name, onChange, playSound]);
18235
18757
  const onPointerDown = (hand) => (e) => {
18236
- if (!interactive) return;
18758
+ if (!interactive || locked) return;
18237
18759
  e.preventDefault();
18238
18760
  e.target.setPointerCapture?.(e.pointerId);
18239
18761
  draggingRef.current = hand;
18240
18762
  updateFromPointer(e.clientX, e.clientY);
18241
18763
  };
18242
18764
  const onPointerMove = (e) => {
18243
- if (!interactive || !draggingRef.current) return;
18765
+ if (!interactive || locked || !draggingRef.current) return;
18244
18766
  updateFromPointer(e.clientX, e.clientY);
18245
18767
  };
18246
18768
  const onPointerUp = (e) => {
18247
- if (!interactive) return;
18769
+ if (!interactive || locked) return;
18248
18770
  e.target.releasePointerCapture?.(e.pointerId);
18249
18771
  draggingRef.current = null;
18250
18772
  };
@@ -18732,6 +19254,14 @@ function buildInitialValues(json) {
18732
19254
  const initial = {};
18733
19255
  for (const [id, widget] of Object.entries(json.widgets)) {
18734
19256
  if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) initial[id] = [];
19257
+ if (widget.type === JSON_TYPES.GRID_SYMMETRY) {
19258
+ initial[id] = widget.cells.filter((cell) => isCellInInteractiveArea(
19259
+ cell,
19260
+ widget.rows,
19261
+ widget.cols,
19262
+ widget.interactiveArea
19263
+ ));
19264
+ }
18735
19265
  }
18736
19266
  return initial;
18737
19267
  }
@@ -18788,38 +19318,45 @@ const ExerciseRenderer = React.forwardRef(({
18788
19318
  const [droppedSources, setDroppedSources] = React.useState(/* @__PURE__ */ new Map());
18789
19319
  const [focusedInputId, setFocusedInputId] = React.useState(null);
18790
19320
  const [validationDetails, setValidationDetails] = React.useState(null);
19321
+ const rootRef = React.useRef(null);
18791
19322
  const [resetKey, setResetKey] = React.useState(0);
18792
19323
  const [revealed, setRevealed] = React.useState(false);
18793
19324
  const playSound = useSoundPlayer(soundEnabled, sounds);
18794
- React.useImperativeHandle(ref, () => ({
18795
- submit: () => {
18796
- const result = validate(json, values);
18797
- playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
18798
- const details = { ...result.details };
18799
- const slotValidation = (id, raw, expected) => ({
18800
- widgetId: id,
18801
- userValue: typeof raw === "string" ? raw : void 0,
18802
- isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
18803
- });
18804
- for (const [widgetId, widget] of Object.entries(json.widgets)) {
18805
- if (widget.type !== JSON_TYPES.FRACTION) continue;
18806
- if (!(widgetId in details)) continue;
18807
- const answer = json.validation?.answers?.[widgetId];
18808
- if (!answer) continue;
18809
- const [expectedNum, expectedDen] = answer.split("/", 2);
18810
- if (!expectedNum || !expectedDen) continue;
18811
- if (details[widgetId]?.isCorrect) {
18812
- if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
18813
- if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
18814
- } else {
18815
- if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
18816
- if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
18817
- }
19325
+ const externallyLocked = useExerciseLocked();
19326
+ const locked = validationDetails !== null || externallyLocked;
19327
+ const lastResultRef = React.useRef(null);
19328
+ const runSubmit = () => {
19329
+ if (locked) return lastResultRef.current ?? validate(json, values);
19330
+ const result = validate(json, values);
19331
+ playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
19332
+ const details = { ...result.details };
19333
+ const slotValidation = (id, raw, expected) => ({
19334
+ widgetId: id,
19335
+ userValue: typeof raw === "string" ? raw : void 0,
19336
+ isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
19337
+ });
19338
+ for (const [widgetId, widget] of Object.entries(json.widgets)) {
19339
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
19340
+ if (!(widgetId in details)) continue;
19341
+ const answer = json.validation?.answers?.[widgetId];
19342
+ if (!answer) continue;
19343
+ const [expectedNum, expectedDen] = answer.split("/", 2);
19344
+ if (!expectedNum || !expectedDen) continue;
19345
+ if (details[widgetId]?.isCorrect) {
19346
+ if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
19347
+ if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
19348
+ } else {
19349
+ if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
19350
+ if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
18818
19351
  }
18819
- setValidationDetails(details);
18820
- onSubmit?.(values);
18821
- return result;
18822
- },
19352
+ }
19353
+ setValidationDetails(details);
19354
+ onSubmit?.(values);
19355
+ lastResultRef.current = result;
19356
+ return result;
19357
+ };
19358
+ React.useImperativeHandle(ref, () => ({
19359
+ submit: runSubmit,
18823
19360
  getValues: () => values,
18824
19361
  getCompletion: () => {
18825
19362
  const answerable = getAnswerableInputs(json);
@@ -18862,14 +19399,20 @@ const ExerciseRenderer = React.forwardRef(({
18862
19399
  setResetKey((k) => k + 1);
18863
19400
  },
18864
19401
  focusFirstInput: () => {
18865
- const container = document.querySelector(".math-exercise-renderer");
18866
- const firstInput = container?.querySelector('input[type="text"], input[type="number"], textarea, select');
18867
- if (firstInput instanceof HTMLElement) {
18868
- firstInput.focus();
19402
+ const container = rootRef.current;
19403
+ if (!container) return;
19404
+ const candidates = container.querySelectorAll(
19405
+ 'input[type="text"], input[type="number"], textarea, select'
19406
+ );
19407
+ for (const el of Array.from(candidates)) {
19408
+ if (el.readOnly || el.disabled) continue;
19409
+ el.focus();
19410
+ return;
18869
19411
  }
18870
19412
  }
18871
- }), [json, values, onSubmit, playSound]);
19413
+ }), [json, values, onSubmit, playSound, locked]);
18872
19414
  const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
19415
+ if (locked) return;
18873
19416
  setRevealed(false);
18874
19417
  const next = { ...valuesRef.current, [widgetId]: value };
18875
19418
  valuesRef.current = next;
@@ -18886,7 +19429,7 @@ const ExerciseRenderer = React.forwardRef(({
18886
19429
  onChange?.(next);
18887
19430
  };
18888
19431
  const handleSubmit = () => {
18889
- onSubmit?.(values);
19432
+ runSubmit();
18890
19433
  };
18891
19434
  const lineGroups = React.useMemo(() => {
18892
19435
  const isRTLInlineWidget = (item) => {
@@ -19001,7 +19544,7 @@ const ExerciseRenderer = React.forwardRef(({
19001
19544
  if (setSquareWidget) add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
19002
19545
  return { tools: list, toolsMinHeight: maxH };
19003
19546
  }, [rulerWidget, protractorWidget, setSquareWidget]);
19004
- return /* @__PURE__ */ jsxRuntime.jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsxRuntime.jsx(ExerciseDndProvider, { droppedSources, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${className}`, children: [
19547
+ return /* @__PURE__ */ jsxRuntime.jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsxRuntime.jsx(ExerciseLockProvider, { locked, children: /* @__PURE__ */ jsxRuntime.jsx(ExerciseDndProvider, { droppedSources, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: rootRef, className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`, children: [
19005
19548
  /* @__PURE__ */ jsxRuntime.jsxs(
19006
19549
  "div",
19007
19550
  {
@@ -19097,8 +19640,8 @@ const ExerciseRenderer = React.forwardRef(({
19097
19640
  }
19098
19641
  }
19099
19642
  ) }),
19100
- showSubmitButton && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "exercise-submit-btn", onClick: handleSubmit, children: "Check Answer" })
19101
- ] }) }) });
19643
+ showSubmitButton && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "exercise-submit-btn", disabled: locked, onClick: handleSubmit, children: "Check Answer" })
19644
+ ] }) }) }) });
19102
19645
  });
19103
19646
  ExerciseRenderer.displayName = "ExerciseRenderer";
19104
19647
  function renderExercise(options) {
@@ -19154,12 +19697,22 @@ exports.DragOptionsToolSchema = DragOptionsToolSchema;
19154
19697
  exports.EngineError = EngineError;
19155
19698
  exports.ExerciseDocumentSchema = ExerciseDocumentSchema;
19156
19699
  exports.ExerciseJSONSchema = ExerciseJSONSchema;
19700
+ exports.ExerciseLockProvider = ExerciseLockProvider;
19157
19701
  exports.ExerciseMetaSchema = ExerciseMetaSchema;
19158
19702
  exports.ExerciseRenderer = ExerciseRenderer;
19159
19703
  exports.ExpressionValidationSchema = ExpressionValidationSchema;
19160
19704
  exports.FractionInlineSchema = FractionInlineSchema;
19161
19705
  exports.FractionJSONSchema = FractionJSONSchema;
19162
19706
  exports.FreeTextInlineSchema = FreeTextInlineSchema;
19707
+ exports.GRID_SYMMETRY_AXES = GRID_SYMMETRY_AXES;
19708
+ exports.GRID_SYMMETRY_INTERACTIVE_AREAS = GRID_SYMMETRY_INTERACTIVE_AREAS;
19709
+ exports.GRID_SYMMETRY_MAX_CELL_SIZE = GRID_SYMMETRY_MAX_CELL_SIZE;
19710
+ exports.GRID_SYMMETRY_MAX_COLS = GRID_SYMMETRY_MAX_COLS;
19711
+ exports.GRID_SYMMETRY_MAX_ROWS = GRID_SYMMETRY_MAX_ROWS;
19712
+ exports.GRID_SYMMETRY_MIN_CELL_SIZE = GRID_SYMMETRY_MIN_CELL_SIZE;
19713
+ exports.GridCellSchema = GridCellSchema;
19714
+ exports.GridSymmetryBlockSchema = GridSymmetryBlockSchema;
19715
+ exports.GridSymmetryJSONSchema = GridSymmetryJSONSchema;
19163
19716
  exports.GroupItemSchema = GroupItemSchema;
19164
19717
  exports.HighlightedTextInlineSchema = HighlightedTextInlineSchema;
19165
19718
  exports.HighlightedTextSchema = HighlightedTextSchema;
@@ -19246,6 +19799,8 @@ exports.WidgetRefSchema = WidgetRefSchema;
19246
19799
  exports.WidgetRenderer = WidgetRenderer;
19247
19800
  exports.compile = compile;
19248
19801
  exports.compileWithAST = compileWithAST;
19802
+ exports.computeGridSymmetryCellsToFill = computeGridSymmetryCellsToFill;
19803
+ exports.computeGridSymmetryExpectedCells = computeGridSymmetryExpectedCells;
19249
19804
  exports.createBoldText = createBoldText;
19250
19805
  exports.createColorText = createColorText;
19251
19806
  exports.createDisplayClock = createDisplayClock;
@@ -19254,6 +19809,7 @@ exports.createExerciseDocument = createExerciseDocument;
19254
19809
  exports.createExpressionValidation = createExpressionValidation;
19255
19810
  exports.createFraction = createFraction;
19256
19811
  exports.createFreeText = createFreeText;
19812
+ exports.createGridSymmetryBlock = createGridSymmetryBlock;
19257
19813
  exports.createHighlightedText = createHighlightedText;
19258
19814
  exports.createImage = createImage;
19259
19815
  exports.createInlineDragOption = createInlineDragOption;
@@ -19292,6 +19848,7 @@ exports.getAnswerableInputs = getAnswerableInputs;
19292
19848
  exports.getFractionKey = getFractionKey;
19293
19849
  exports.getMissingInputs = getMissingInputs;
19294
19850
  exports.isBoldText = isBoldText;
19851
+ exports.isCellInInteractiveArea = isCellInInteractiveArea;
19295
19852
  exports.isClock = isClock;
19296
19853
  exports.isClockItem = isClockItem;
19297
19854
  exports.isColorText = isColorText;
@@ -19303,6 +19860,8 @@ exports.isFraction = isFraction;
19303
19860
  exports.isFractionJSON = isFractionJSON;
19304
19861
  exports.isFractionKey = isFractionKey;
19305
19862
  exports.isFreeText = isFreeText;
19863
+ exports.isGridSymmetryBlock = isGridSymmetryBlock;
19864
+ exports.isGridSymmetryJSON = isGridSymmetryJSON;
19306
19865
  exports.isGroupItem = isGroupItem;
19307
19866
  exports.isHighlightedText = isHighlightedText;
19308
19867
  exports.isImage = isImage;
@@ -19355,11 +19914,13 @@ exports.normalizeVopOperator = normalizeVopOperator;
19355
19914
  exports.parse = parse;
19356
19915
  exports.parseFractionKey = parseFractionKey;
19357
19916
  exports.parseSafe = parseSafe;
19917
+ exports.reflectGridSymmetryCell = reflectGridSymmetryCell;
19358
19918
  exports.renderExercise = renderExercise;
19359
19919
  exports.toAST = toAST;
19360
19920
  exports.toJSON = toJSON;
19361
19921
  exports.tokenize = tokenize;
19362
19922
  exports.tokenizeSafe = tokenizeSafe;
19923
+ exports.useExerciseLocked = useExerciseLocked;
19363
19924
  exports.useSound = useSound;
19364
19925
  exports.validate = validate;
19365
19926
  exports.validateBooleanExpression = validateBooleanExpression;