@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/README.md +44 -11
- package/dist/index.d.ts +613 -194
- package/dist/index.js +724 -163
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +724 -163
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +135 -0
- package/package.json +19 -19
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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 (
|
|
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: () =>
|
|
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: () =>
|
|
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: () =>
|
|
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
|
-
}) =>
|
|
15681
|
-
|
|
15682
|
-
|
|
15683
|
-
|
|
15684
|
-
|
|
15685
|
-
"
|
|
15686
|
-
|
|
15687
|
-
|
|
15688
|
-
|
|
15689
|
-
|
|
15690
|
-
|
|
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
|
|
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
|
-
}) =>
|
|
15707
|
-
|
|
15708
|
-
|
|
15709
|
-
|
|
15710
|
-
|
|
15711
|
-
|
|
15712
|
-
|
|
15713
|
-
|
|
15714
|
-
|
|
15715
|
-
|
|
15716
|
-
|
|
15717
|
-
|
|
15718
|
-
|
|
15719
|
-
|
|
15720
|
-
|
|
15721
|
-
|
|
15722
|
-
|
|
15723
|
-
|
|
15724
|
-
|
|
15725
|
-
|
|
15726
|
-
|
|
15727
|
-
|
|
15728
|
-
|
|
15729
|
-
|
|
15730
|
-
|
|
15731
|
-
|
|
15732
|
-
|
|
15733
|
-
|
|
15734
|
-
|
|
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
|
-
}) =>
|
|
15745
|
-
|
|
15746
|
-
|
|
15747
|
-
|
|
15748
|
-
|
|
15749
|
-
|
|
15750
|
-
"
|
|
15751
|
-
|
|
15752
|
-
|
|
15753
|
-
|
|
15754
|
-
|
|
15755
|
-
|
|
15756
|
-
|
|
15757
|
-
|
|
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
|
-
}) =>
|
|
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 (
|
|
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
|
-
}, [
|
|
16830
|
+
}, [interactionLocked]);
|
|
16340
16831
|
const handleBottomClick = useCallback((bottomId) => {
|
|
16341
|
-
if (
|
|
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
|
-
}, [
|
|
16875
|
+
}, [interactionLocked]);
|
|
16385
16876
|
useEffect(() => {
|
|
16386
|
-
if (
|
|
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,
|
|
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":
|
|
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
|
-
|
|
18793
|
-
|
|
18794
|
-
|
|
18795
|
-
|
|
18796
|
-
|
|
18797
|
-
|
|
18798
|
-
|
|
18799
|
-
|
|
18800
|
-
|
|
18801
|
-
|
|
18802
|
-
|
|
18803
|
-
|
|
18804
|
-
|
|
18805
|
-
|
|
18806
|
-
|
|
18807
|
-
|
|
18808
|
-
|
|
18809
|
-
|
|
18810
|
-
|
|
18811
|
-
|
|
18812
|
-
|
|
18813
|
-
|
|
18814
|
-
|
|
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
|
-
|
|
18818
|
-
|
|
18819
|
-
|
|
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 =
|
|
18864
|
-
|
|
18865
|
-
|
|
18866
|
-
|
|
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
|
-
|
|
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,
|