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