@mathscan/math-exercise-engine 1.1.17 → 1.2.1
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 +186 -164
- package/dist/index.js +296 -239
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +296 -239
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +84 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -392,13 +392,9 @@ const MATCHER = createToken({
|
|
|
392
392
|
name: "MATCHER",
|
|
393
393
|
pattern: /MATCHER/
|
|
394
394
|
});
|
|
395
|
-
const
|
|
396
|
-
name: "
|
|
397
|
-
pattern: /
|
|
398
|
-
});
|
|
399
|
-
const BOTTOM_ROW = createToken({
|
|
400
|
-
name: "BOTTOM_ROW",
|
|
401
|
-
pattern: /BOTTOM_ROW/
|
|
395
|
+
const GROUP = createToken({
|
|
396
|
+
name: "GROUP",
|
|
397
|
+
pattern: /GROUP/
|
|
402
398
|
});
|
|
403
399
|
const ITEM = createToken({
|
|
404
400
|
name: "ITEM",
|
|
@@ -554,8 +550,7 @@ const allTokens = [
|
|
|
554
550
|
ARROW,
|
|
555
551
|
TABLE,
|
|
556
552
|
ROW,
|
|
557
|
-
|
|
558
|
-
BOTTOM_ROW,
|
|
553
|
+
GROUP,
|
|
559
554
|
CELL,
|
|
560
555
|
COUNTER,
|
|
561
556
|
CLOCK,
|
|
@@ -1063,39 +1058,19 @@ class MathExerciseParser extends CstParser {
|
|
|
1063
1058
|
this.MANY3(() => {
|
|
1064
1059
|
this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
1065
1060
|
});
|
|
1066
|
-
this.SUBRULE(this.
|
|
1067
|
-
this.
|
|
1061
|
+
this.SUBRULE(this.pairMatcherGroupSection);
|
|
1062
|
+
this.SUBRULE2(this.pairMatcherGroupSection);
|
|
1068
1063
|
this.SUBRULE(this.matcherAnswers);
|
|
1069
1064
|
this.CONSUME(RBrace);
|
|
1070
1065
|
this.MANY4(() => {
|
|
1071
1066
|
this.OR4([{ ALT: () => this.CONSUME4(Newline) }, { ALT: () => this.CONSUME4(Space) }]);
|
|
1072
1067
|
});
|
|
1073
1068
|
});
|
|
1074
|
-
this.
|
|
1075
|
-
this.MANY(() => {
|
|
1076
|
-
this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
1077
|
-
});
|
|
1078
|
-
this.CONSUME(TOP_ROW);
|
|
1079
|
-
this.MANY2(() => {
|
|
1080
|
-
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
1081
|
-
});
|
|
1082
|
-
this.CONSUME(LBrace);
|
|
1083
|
-
this.MANY3(() => {
|
|
1084
|
-
this.OR3([{ ALT: () => this.CONSUME3(Space) }, { ALT: () => this.CONSUME3(Newline) }]);
|
|
1085
|
-
});
|
|
1086
|
-
this.AT_LEAST_ONE(() => {
|
|
1087
|
-
this.SUBRULE(this.matcherItem);
|
|
1088
|
-
});
|
|
1089
|
-
this.CONSUME(RBrace);
|
|
1090
|
-
this.MANY4(() => {
|
|
1091
|
-
this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
|
|
1092
|
-
});
|
|
1093
|
-
});
|
|
1094
|
-
this.pairMatcherBottomSection = this.RULE("pairMatcherBottomSection", () => {
|
|
1069
|
+
this.pairMatcherGroupSection = this.RULE("pairMatcherGroupSection", () => {
|
|
1095
1070
|
this.MANY(() => {
|
|
1096
1071
|
this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
|
|
1097
1072
|
});
|
|
1098
|
-
this.CONSUME(
|
|
1073
|
+
this.CONSUME(GROUP);
|
|
1099
1074
|
this.MANY2(() => {
|
|
1100
1075
|
this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
|
|
1101
1076
|
});
|
|
@@ -2588,20 +2563,24 @@ function isMatcherBlock(value) {
|
|
|
2588
2563
|
const PairMatcherBlockSchema = z.object({
|
|
2589
2564
|
type: z.literal(AST_TYPES.PAIR_MATCHER_BLOCK),
|
|
2590
2565
|
name: z.string().min(1),
|
|
2591
|
-
/**
|
|
2592
|
-
|
|
2593
|
-
/**
|
|
2594
|
-
|
|
2566
|
+
/** Row layout (top/bottom, default) or column layout (left/right). */
|
|
2567
|
+
layout: z.enum(["rows", "columns"]).optional(),
|
|
2568
|
+
/** Exactly two groups, in declaration order — neither more nor fewer. */
|
|
2569
|
+
groups: z.tuple([
|
|
2570
|
+
z.array(MatcherItemSchema).min(1),
|
|
2571
|
+
z.array(MatcherItemSchema).min(1)
|
|
2572
|
+
]),
|
|
2595
2573
|
/**
|
|
2596
|
-
* Each answer pair is [
|
|
2574
|
+
* Each answer pair is [groups[0] id, groups[1] id].
|
|
2597
2575
|
* At least one pair is required.
|
|
2598
2576
|
*/
|
|
2599
2577
|
answers: z.array(z.tuple([z.string().min(1), z.string().min(1)])).min(1)
|
|
2600
2578
|
}).superRefine((data, ctx) => {
|
|
2579
|
+
const [groupAItems, groupBItems] = data.groups;
|
|
2601
2580
|
const idRegistry = /* @__PURE__ */ new Map();
|
|
2602
2581
|
const sections = [
|
|
2603
|
-
{ key:
|
|
2604
|
-
{ key:
|
|
2582
|
+
{ key: 0, items: groupAItems },
|
|
2583
|
+
{ key: 1, items: groupBItems }
|
|
2605
2584
|
];
|
|
2606
2585
|
for (const { key: key2, items } of sections) {
|
|
2607
2586
|
for (let i = 0; i < items.length; i++) {
|
|
@@ -2611,59 +2590,59 @@ const PairMatcherBlockSchema = z.object({
|
|
|
2611
2590
|
const isCrossSection = existing.section !== key2;
|
|
2612
2591
|
ctx.addIssue({
|
|
2613
2592
|
code: z.ZodIssueCode.custom,
|
|
2614
|
-
message: isCrossSection ? `itemId "${itemId}" appears in both
|
|
2615
|
-
path: [key2, i, "itemId"]
|
|
2593
|
+
message: isCrossSection ? `itemId "${itemId}" appears in both groups — ids must be globally unique` : `Duplicate itemId "${itemId}" in group ${key2}`,
|
|
2594
|
+
path: ["groups", key2, i, "itemId"]
|
|
2616
2595
|
});
|
|
2617
2596
|
} else {
|
|
2618
2597
|
idRegistry.set(itemId, { section: key2, index: i });
|
|
2619
2598
|
}
|
|
2620
2599
|
}
|
|
2621
2600
|
}
|
|
2622
|
-
const
|
|
2623
|
-
const
|
|
2624
|
-
const
|
|
2625
|
-
const
|
|
2626
|
-
data.answers.forEach(([
|
|
2627
|
-
if (!
|
|
2601
|
+
const groupAIds = new Set(groupAItems.map((it) => it.itemId));
|
|
2602
|
+
const groupBIds = new Set(groupBItems.map((it) => it.itemId));
|
|
2603
|
+
const usedGroupAIds = /* @__PURE__ */ new Set();
|
|
2604
|
+
const usedGroupBIds = /* @__PURE__ */ new Set();
|
|
2605
|
+
data.answers.forEach(([aId, bId], i) => {
|
|
2606
|
+
if (!groupAIds.has(aId)) {
|
|
2628
2607
|
ctx.addIssue({
|
|
2629
2608
|
code: z.ZodIssueCode.custom,
|
|
2630
|
-
message: `Answer pair [${
|
|
2609
|
+
message: `Answer pair [${aId}, ${bId}]: "${aId}" is not declared in groups[0]`,
|
|
2631
2610
|
path: ["answers", i, 0]
|
|
2632
2611
|
});
|
|
2633
2612
|
}
|
|
2634
|
-
if (!
|
|
2613
|
+
if (!groupBIds.has(bId)) {
|
|
2635
2614
|
ctx.addIssue({
|
|
2636
2615
|
code: z.ZodIssueCode.custom,
|
|
2637
|
-
message: `Answer pair [${
|
|
2616
|
+
message: `Answer pair [${aId}, ${bId}]: "${bId}" is not declared in groups[1]`,
|
|
2638
2617
|
path: ["answers", i, 1]
|
|
2639
2618
|
});
|
|
2640
2619
|
}
|
|
2641
|
-
if (
|
|
2620
|
+
if (usedGroupAIds.has(aId)) {
|
|
2642
2621
|
ctx.addIssue({
|
|
2643
2622
|
code: z.ZodIssueCode.custom,
|
|
2644
|
-
message: `
|
|
2623
|
+
message: `id "${aId}" appears in more than one answer pair`,
|
|
2645
2624
|
path: ["answers", i, 0]
|
|
2646
2625
|
});
|
|
2647
2626
|
} else {
|
|
2648
|
-
|
|
2627
|
+
usedGroupAIds.add(aId);
|
|
2649
2628
|
}
|
|
2650
|
-
if (
|
|
2629
|
+
if (usedGroupBIds.has(bId)) {
|
|
2651
2630
|
ctx.addIssue({
|
|
2652
2631
|
code: z.ZodIssueCode.custom,
|
|
2653
|
-
message: `
|
|
2632
|
+
message: `id "${bId}" appears in more than one answer pair`,
|
|
2654
2633
|
path: ["answers", i, 1]
|
|
2655
2634
|
});
|
|
2656
2635
|
} else {
|
|
2657
|
-
|
|
2636
|
+
usedGroupBIds.add(bId);
|
|
2658
2637
|
}
|
|
2659
2638
|
});
|
|
2660
2639
|
});
|
|
2661
|
-
function createPairMatcherBlock(name,
|
|
2640
|
+
function createPairMatcherBlock(name, groups, answers, layout) {
|
|
2662
2641
|
return PairMatcherBlockSchema.parse({
|
|
2663
2642
|
type: AST_TYPES.PAIR_MATCHER_BLOCK,
|
|
2664
2643
|
name,
|
|
2665
|
-
|
|
2666
|
-
|
|
2644
|
+
layout,
|
|
2645
|
+
groups,
|
|
2667
2646
|
answers
|
|
2668
2647
|
});
|
|
2669
2648
|
}
|
|
@@ -2850,7 +2829,7 @@ const ExerciseDocumentSchema = z.object({
|
|
|
2850
2829
|
} else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
|
|
2851
2830
|
const pm = block;
|
|
2852
2831
|
record(pm.name, ["lines", bi, "name"]);
|
|
2853
|
-
|
|
2832
|
+
pm.groups.flat().forEach((item, ii) => {
|
|
2854
2833
|
visitInlines(item.inlines, ["lines", bi, "items", ii, "inlines"]);
|
|
2855
2834
|
});
|
|
2856
2835
|
} else if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
@@ -3063,24 +3042,14 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
3063
3042
|
pairMatcher(ctx) {
|
|
3064
3043
|
const params = this.visit(ctx.parameterList);
|
|
3065
3044
|
const name = params.name;
|
|
3066
|
-
const
|
|
3067
|
-
const
|
|
3045
|
+
const layout = params.layout;
|
|
3046
|
+
const [firstGroup, secondGroup] = ctx.pairMatcherGroupSection;
|
|
3047
|
+
const groupAItems = this.visit(firstGroup);
|
|
3048
|
+
const groupBItems = this.visit(secondGroup);
|
|
3068
3049
|
const rawAnswers = this.visit(ctx.matcherAnswers);
|
|
3069
|
-
return createPairMatcherBlock(name,
|
|
3050
|
+
return createPairMatcherBlock(name, [groupAItems, groupBItems], rawAnswers, layout);
|
|
3070
3051
|
}
|
|
3071
|
-
|
|
3072
|
-
return this.visitMatcherItemList(ctx);
|
|
3073
|
-
}
|
|
3074
|
-
pairMatcherBottomSection(ctx) {
|
|
3075
|
-
return this.visitMatcherItemList(ctx);
|
|
3076
|
-
}
|
|
3077
|
-
/**
|
|
3078
|
-
* Maps `ctx.matcherItem` CST nodes to MatcherItem AST nodes.
|
|
3079
|
-
* Shared by pairMatcherTopSection and pairMatcherBottomSection because their
|
|
3080
|
-
* grammar rules are structurally identical — both are `matcherItem+` lists
|
|
3081
|
-
* wrapped in a labelled section keyword.
|
|
3082
|
-
*/
|
|
3083
|
-
visitMatcherItemList(ctx) {
|
|
3052
|
+
pairMatcherGroupSection(ctx) {
|
|
3084
3053
|
return ctx.matcherItem ? ctx.matcherItem.map((i) => this.visit(i)) : [];
|
|
3085
3054
|
}
|
|
3086
3055
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -3872,8 +3841,12 @@ const MatcherWidgetJSONSchema = z.object({
|
|
|
3872
3841
|
});
|
|
3873
3842
|
const PairMatcherWidgetJSONSchema = z.object({
|
|
3874
3843
|
type: z.literal(JSON_TYPES.PAIR_MATCHER),
|
|
3875
|
-
|
|
3876
|
-
|
|
3844
|
+
/** Row layout (top/bottom, default) or column layout (left/right). */
|
|
3845
|
+
layout: z.enum(["rows", "columns"]).optional(),
|
|
3846
|
+
groups: z.tuple([
|
|
3847
|
+
z.array(MatcherItemContentSchema).min(1),
|
|
3848
|
+
z.array(MatcherItemContentSchema).min(1)
|
|
3849
|
+
])
|
|
3877
3850
|
});
|
|
3878
3851
|
const VisualFractionJSONSchema = z.object({
|
|
3879
3852
|
type: z.literal(JSON_TYPES.VISUAL_FRACTION),
|
|
@@ -9833,7 +9806,7 @@ function collectAllInlines(blocks) {
|
|
|
9833
9806
|
}
|
|
9834
9807
|
} else if (block.type === AST_TYPES.PAIR_MATCHER_BLOCK) {
|
|
9835
9808
|
const pm = block;
|
|
9836
|
-
for (const item of
|
|
9809
|
+
for (const item of pm.groups.flat()) {
|
|
9837
9810
|
result.push(...item.inlines);
|
|
9838
9811
|
}
|
|
9839
9812
|
}
|
|
@@ -9940,10 +9913,14 @@ function buildMatcherWidget(matcher, idGenerator, inlineDragIds) {
|
|
|
9940
9913
|
return result;
|
|
9941
9914
|
}
|
|
9942
9915
|
function buildPairMatcherWidget(pm, idGenerator, inlineDragIds) {
|
|
9916
|
+
const [groupAItems, groupBItems] = pm.groups;
|
|
9943
9917
|
return {
|
|
9944
9918
|
type: JSON_TYPES.PAIR_MATCHER,
|
|
9945
|
-
|
|
9946
|
-
|
|
9919
|
+
...pm.layout !== void 0 && { layout: pm.layout },
|
|
9920
|
+
groups: [
|
|
9921
|
+
mapMatcherItemList(groupAItems, idGenerator, inlineDragIds),
|
|
9922
|
+
mapMatcherItemList(groupBItems, idGenerator, inlineDragIds)
|
|
9923
|
+
]
|
|
9947
9924
|
};
|
|
9948
9925
|
}
|
|
9949
9926
|
function transformVerticalOperation(vop, idGenerator) {
|
|
@@ -15449,13 +15426,18 @@ function applyDropToInput(targetId, item, onChange) {
|
|
|
15449
15426
|
function isUndoDrop(event, dragId) {
|
|
15450
15427
|
return event.active.id === dragId && !event.over;
|
|
15451
15428
|
}
|
|
15429
|
+
const LockContext = createContext(false);
|
|
15430
|
+
const ExerciseLockProvider = ({ locked, children }) => /* @__PURE__ */ jsx(LockContext.Provider, { value: locked, children });
|
|
15431
|
+
const useExerciseLocked = () => useContext(LockContext);
|
|
15452
15432
|
const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
|
|
15453
15433
|
const { droppedSources } = useExerciseDnd();
|
|
15434
|
+
const locked = useExerciseLocked();
|
|
15454
15435
|
const sourceOptionId = droppedSources.get(widgetId);
|
|
15455
15436
|
const dragId = `${widgetId}__chip`;
|
|
15456
15437
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
15457
15438
|
id: dragId,
|
|
15458
|
-
data: { type: "placed-chip", widgetId, label: value, sourceOptionId }
|
|
15439
|
+
data: { type: "placed-chip", widgetId, label: value, sourceOptionId },
|
|
15440
|
+
disabled: locked
|
|
15459
15441
|
});
|
|
15460
15442
|
useDndMonitor({
|
|
15461
15443
|
onDragEnd(event) {
|
|
@@ -15468,10 +15450,10 @@ const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
|
|
|
15468
15450
|
ref: setNodeRef,
|
|
15469
15451
|
className: `inline-input-chip ${className}${isDragging ? " dragging" : ""}`.trim(),
|
|
15470
15452
|
onClick: (e) => {
|
|
15471
|
-
if (e.detail !== 0) onRemove();
|
|
15453
|
+
if (!locked && e.detail !== 0) onRemove();
|
|
15472
15454
|
},
|
|
15473
|
-
title: "Drag to move, or click to remove",
|
|
15474
|
-
style: { touchAction: "none", cursor: "grab" },
|
|
15455
|
+
title: locked ? void 0 : "Drag to move, or click to remove",
|
|
15456
|
+
style: { touchAction: "none", cursor: locked ? "default" : "grab" },
|
|
15475
15457
|
...listeners,
|
|
15476
15458
|
...attributes,
|
|
15477
15459
|
children: value
|
|
@@ -15491,6 +15473,7 @@ const InlineInput = ({
|
|
|
15491
15473
|
onFocus
|
|
15492
15474
|
}) => {
|
|
15493
15475
|
const playSound = useSound();
|
|
15476
|
+
const locked = useExerciseLocked();
|
|
15494
15477
|
const handleChange = useCallback((newValue, fromDrop = false, sourceOptionId) => {
|
|
15495
15478
|
onChange(widgetId, newValue, fromDrop, sourceOptionId);
|
|
15496
15479
|
}, [onChange, widgetId]);
|
|
@@ -15506,6 +15489,7 @@ const InlineInput = ({
|
|
|
15506
15489
|
id: widgetId,
|
|
15507
15490
|
accepts: ["drag-option", "placed-chip"],
|
|
15508
15491
|
onDrop: handleDrop,
|
|
15492
|
+
disabled: locked,
|
|
15509
15493
|
className: "inline-widget inline-input-droppable",
|
|
15510
15494
|
children: isDropped && value !== "" ? (
|
|
15511
15495
|
// Show as chip only when value came from drag-drop
|
|
@@ -15528,8 +15512,11 @@ const InlineInput = ({
|
|
|
15528
15512
|
className: `inline-input${statusClass}`,
|
|
15529
15513
|
value,
|
|
15530
15514
|
onChange: (e) => handleChange(e.target.value, false),
|
|
15531
|
-
onMouseDown: () =>
|
|
15515
|
+
onMouseDown: () => {
|
|
15516
|
+
if (!locked) playSound("input-select");
|
|
15517
|
+
},
|
|
15532
15518
|
onFocus: () => onFocus?.(widgetId),
|
|
15519
|
+
readOnly: locked,
|
|
15533
15520
|
maxLength: expectedLength,
|
|
15534
15521
|
style: { width: dynamicWidth },
|
|
15535
15522
|
placeholder
|
|
@@ -15546,6 +15533,7 @@ const SingleDragOption = ({
|
|
|
15546
15533
|
className = ""
|
|
15547
15534
|
}) => {
|
|
15548
15535
|
const { usedOptionIds } = useExerciseDnd();
|
|
15536
|
+
const locked = useExerciseLocked();
|
|
15549
15537
|
const isUsed = usedOptionIds.has(optionId);
|
|
15550
15538
|
return /* @__PURE__ */ jsx("div", { className: `drag-option-slot ${className}`.trim(), children: isUsed ? /* @__PURE__ */ jsx("div", { className: "drag-option-empty", "aria-hidden": "true" }) : /* @__PURE__ */ jsx(
|
|
15551
15539
|
Draggable,
|
|
@@ -15553,12 +15541,16 @@ const SingleDragOption = ({
|
|
|
15553
15541
|
id: optionId,
|
|
15554
15542
|
type: "drag-option",
|
|
15555
15543
|
data: { value, label: value },
|
|
15544
|
+
disabled: locked,
|
|
15556
15545
|
className: "drag-option-wrapper",
|
|
15557
15546
|
children: /* @__PURE__ */ jsx(
|
|
15558
15547
|
"button",
|
|
15559
15548
|
{
|
|
15560
15549
|
className: "drag-option",
|
|
15561
|
-
onClick: () =>
|
|
15550
|
+
onClick: () => {
|
|
15551
|
+
if (!locked) onOptionClick?.(value);
|
|
15552
|
+
},
|
|
15553
|
+
"aria-disabled": locked ? "true" : void 0,
|
|
15562
15554
|
children: value
|
|
15563
15555
|
}
|
|
15564
15556
|
)
|
|
@@ -15672,31 +15664,34 @@ const NumericKeypad = ({
|
|
|
15672
15664
|
onChange
|
|
15673
15665
|
}) => {
|
|
15674
15666
|
const playSound = useSound();
|
|
15667
|
+
const locked = useExerciseLocked();
|
|
15675
15668
|
const handleDigit = (digit) => {
|
|
15669
|
+
if (locked) return;
|
|
15676
15670
|
playSound("keypad-press");
|
|
15677
15671
|
onChange(value + digit);
|
|
15678
15672
|
};
|
|
15679
15673
|
const handleBackspace = () => {
|
|
15674
|
+
if (locked) return;
|
|
15680
15675
|
playSound("keypad-press");
|
|
15681
15676
|
onChange(value.slice(0, -1));
|
|
15682
15677
|
};
|
|
15683
15678
|
return /* @__PURE__ */ jsxs("div", { className: `numeric-keypad ${includeOperators ? "numeric-keypad-with-operators" : ""}`, children: [
|
|
15684
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
|
|
15685
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
|
|
15686
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
|
|
15687
|
-
includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
|
|
15688
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
|
|
15689
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
|
|
15690
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
|
|
15691
|
-
includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
|
|
15692
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
|
|
15693
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
|
|
15694
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
|
|
15695
|
-
includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
|
|
15696
|
-
includeDecimal ? /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsx("div", {}),
|
|
15697
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
|
|
15698
|
-
/* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
|
|
15699
|
-
includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
|
|
15679
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
|
|
15680
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
|
|
15681
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
|
|
15682
|
+
includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
|
|
15683
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
|
|
15684
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
|
|
15685
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
|
|
15686
|
+
includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
|
|
15687
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
|
|
15688
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
|
|
15689
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
|
|
15690
|
+
includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
|
|
15691
|
+
includeDecimal ? /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsx("div", {}),
|
|
15692
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
|
|
15693
|
+
/* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
|
|
15694
|
+
includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
|
|
15700
15695
|
] });
|
|
15701
15696
|
};
|
|
15702
15697
|
const FractionInput = ({
|
|
@@ -15713,6 +15708,7 @@ const FractionInput = ({
|
|
|
15713
15708
|
placeholder
|
|
15714
15709
|
}) => {
|
|
15715
15710
|
const playSound = useSound();
|
|
15711
|
+
const locked = useExerciseLocked();
|
|
15716
15712
|
const numKey = getFractionKey(widgetId, "num");
|
|
15717
15713
|
const denKey = getFractionKey(widgetId, "den");
|
|
15718
15714
|
const numValue = values[numKey] ?? "";
|
|
@@ -15733,6 +15729,7 @@ const FractionInput = ({
|
|
|
15733
15729
|
id: subKey,
|
|
15734
15730
|
accepts: ["drag-option", "placed-chip"],
|
|
15735
15731
|
onDrop: handleDrop(subKey),
|
|
15732
|
+
disabled: locked,
|
|
15736
15733
|
className: "fraction-slot-droppable",
|
|
15737
15734
|
children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsx(
|
|
15738
15735
|
PlacedInputChip,
|
|
@@ -15750,8 +15747,11 @@ const FractionInput = ({
|
|
|
15750
15747
|
className: `inline-input fraction-input${slotClass}`,
|
|
15751
15748
|
value: currentValue,
|
|
15752
15749
|
onChange: (e) => onChange(subKey, e.target.value, false),
|
|
15753
|
-
onMouseDown: () =>
|
|
15750
|
+
onMouseDown: () => {
|
|
15751
|
+
if (!locked) playSound("input-select");
|
|
15752
|
+
},
|
|
15754
15753
|
onFocus: () => onFocus?.(subKey),
|
|
15754
|
+
readOnly: locked,
|
|
15755
15755
|
maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
|
|
15756
15756
|
size: placeholder ? placeholder.length : void 0,
|
|
15757
15757
|
"aria-label": label,
|
|
@@ -15982,12 +15982,13 @@ function getEqualTrianglePartPath(index, divisions) {
|
|
|
15982
15982
|
function getGridColumnCount(divisions) {
|
|
15983
15983
|
return Math.ceil(Math.sqrt(divisions));
|
|
15984
15984
|
}
|
|
15985
|
-
function getInteractiveProps(part, onPartClick) {
|
|
15985
|
+
function getInteractiveProps(part, onPartClick, locked = false) {
|
|
15986
15986
|
if (!part.isInteractiveSlot) return {};
|
|
15987
15987
|
return {
|
|
15988
15988
|
onClick: () => onPartClick(part.index),
|
|
15989
15989
|
role: "button",
|
|
15990
15990
|
"aria-pressed": part.isSelected,
|
|
15991
|
+
"aria-disabled": locked ? "true" : void 0,
|
|
15991
15992
|
"aria-label": getPartAriaLabel(part),
|
|
15992
15993
|
tabIndex: 0,
|
|
15993
15994
|
onKeyDown: (event) => {
|
|
@@ -16007,6 +16008,7 @@ const BarShape = ({
|
|
|
16007
16008
|
orientation,
|
|
16008
16009
|
onPartClick
|
|
16009
16010
|
}) => {
|
|
16011
|
+
const locked = useExerciseLocked();
|
|
16010
16012
|
const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
|
|
16011
16013
|
return /* @__PURE__ */ jsx(
|
|
16012
16014
|
"div",
|
|
@@ -16018,7 +16020,7 @@ const BarShape = ({
|
|
|
16018
16020
|
{
|
|
16019
16021
|
className: part.className,
|
|
16020
16022
|
style: part.style,
|
|
16021
|
-
...getInteractiveProps(part, onPartClick)
|
|
16023
|
+
...getInteractiveProps(part, onPartClick, locked)
|
|
16022
16024
|
},
|
|
16023
16025
|
part.index
|
|
16024
16026
|
))
|
|
@@ -16030,87 +16032,96 @@ const GridShape = ({
|
|
|
16030
16032
|
parts,
|
|
16031
16033
|
divisions,
|
|
16032
16034
|
onPartClick
|
|
16033
|
-
}) =>
|
|
16034
|
-
|
|
16035
|
-
|
|
16036
|
-
|
|
16037
|
-
|
|
16038
|
-
"
|
|
16039
|
-
|
|
16040
|
-
|
|
16041
|
-
|
|
16042
|
-
|
|
16043
|
-
|
|
16044
|
-
"div",
|
|
16045
|
-
{
|
|
16046
|
-
className: part.className,
|
|
16047
|
-
style: part.style,
|
|
16048
|
-
...getInteractiveProps(part, onPartClick)
|
|
16035
|
+
}) => {
|
|
16036
|
+
const locked = useExerciseLocked();
|
|
16037
|
+
return /* @__PURE__ */ jsx(
|
|
16038
|
+
"div",
|
|
16039
|
+
{
|
|
16040
|
+
className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
|
|
16041
|
+
style: {
|
|
16042
|
+
"--visual-fraction-divisions": divisions,
|
|
16043
|
+
"--visual-fraction-grid-cols": getGridColumnCount(divisions),
|
|
16044
|
+
// Safe integer division: zod schema enforces divisions = perfect square.
|
|
16045
|
+
"--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
|
|
16049
16046
|
},
|
|
16050
|
-
part
|
|
16051
|
-
|
|
16052
|
-
|
|
16053
|
-
|
|
16047
|
+
children: parts.map((part) => /* @__PURE__ */ jsx(
|
|
16048
|
+
"div",
|
|
16049
|
+
{
|
|
16050
|
+
className: part.className,
|
|
16051
|
+
style: part.style,
|
|
16052
|
+
...getInteractiveProps(part, onPartClick, locked)
|
|
16053
|
+
},
|
|
16054
|
+
part.index
|
|
16055
|
+
))
|
|
16056
|
+
}
|
|
16057
|
+
);
|
|
16058
|
+
};
|
|
16054
16059
|
GridShape.displayName = "GridShape";
|
|
16055
16060
|
const CircleShape = ({
|
|
16056
16061
|
parts,
|
|
16057
16062
|
divisions,
|
|
16058
16063
|
onPartClick
|
|
16059
|
-
}) =>
|
|
16060
|
-
|
|
16061
|
-
|
|
16062
|
-
|
|
16063
|
-
|
|
16064
|
-
|
|
16065
|
-
|
|
16066
|
-
|
|
16067
|
-
|
|
16068
|
-
|
|
16069
|
-
|
|
16070
|
-
|
|
16071
|
-
|
|
16072
|
-
|
|
16073
|
-
|
|
16074
|
-
|
|
16075
|
-
|
|
16076
|
-
|
|
16077
|
-
|
|
16078
|
-
|
|
16079
|
-
|
|
16080
|
-
|
|
16081
|
-
|
|
16082
|
-
|
|
16083
|
-
|
|
16084
|
-
|
|
16085
|
-
|
|
16086
|
-
|
|
16087
|
-
|
|
16088
|
-
|
|
16089
|
-
|
|
16090
|
-
|
|
16091
|
-
)
|
|
16064
|
+
}) => {
|
|
16065
|
+
const locked = useExerciseLocked();
|
|
16066
|
+
return /* @__PURE__ */ jsx(
|
|
16067
|
+
"svg",
|
|
16068
|
+
{
|
|
16069
|
+
className: "visual-fraction-widget__shape visual-fraction-widget__circle",
|
|
16070
|
+
viewBox: "0 0 100 100",
|
|
16071
|
+
children: parts.map((part) => {
|
|
16072
|
+
const sectorProps = getCircleSectorProps(part.index, divisions);
|
|
16073
|
+
const className = `${part.className} visual-fraction-widget__sector`;
|
|
16074
|
+
const interactiveProps = getInteractiveProps(part, onPartClick, locked);
|
|
16075
|
+
return sectorProps.kind === "circle" ? /* @__PURE__ */ jsx(
|
|
16076
|
+
"circle",
|
|
16077
|
+
{
|
|
16078
|
+
className,
|
|
16079
|
+
style: part.style,
|
|
16080
|
+
cx: "50",
|
|
16081
|
+
cy: "50",
|
|
16082
|
+
r: "48",
|
|
16083
|
+
...interactiveProps
|
|
16084
|
+
},
|
|
16085
|
+
part.index
|
|
16086
|
+
) : /* @__PURE__ */ jsx(
|
|
16087
|
+
"path",
|
|
16088
|
+
{
|
|
16089
|
+
className,
|
|
16090
|
+
style: part.style,
|
|
16091
|
+
d: sectorProps.d,
|
|
16092
|
+
...interactiveProps
|
|
16093
|
+
},
|
|
16094
|
+
part.index
|
|
16095
|
+
);
|
|
16096
|
+
})
|
|
16097
|
+
}
|
|
16098
|
+
);
|
|
16099
|
+
};
|
|
16092
16100
|
CircleShape.displayName = "CircleShape";
|
|
16093
16101
|
const TriangleShape = ({
|
|
16094
16102
|
parts,
|
|
16095
16103
|
divisions,
|
|
16096
16104
|
onPartClick
|
|
16097
|
-
}) =>
|
|
16098
|
-
|
|
16099
|
-
|
|
16100
|
-
|
|
16101
|
-
|
|
16102
|
-
|
|
16103
|
-
"
|
|
16104
|
-
|
|
16105
|
-
|
|
16106
|
-
|
|
16107
|
-
|
|
16108
|
-
|
|
16109
|
-
|
|
16110
|
-
|
|
16111
|
-
|
|
16112
|
-
|
|
16113
|
-
)
|
|
16105
|
+
}) => {
|
|
16106
|
+
const locked = useExerciseLocked();
|
|
16107
|
+
return /* @__PURE__ */ jsx(
|
|
16108
|
+
"svg",
|
|
16109
|
+
{
|
|
16110
|
+
className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
|
|
16111
|
+
viewBox: "0 0 100 100",
|
|
16112
|
+
children: parts.map((part) => /* @__PURE__ */ jsx(
|
|
16113
|
+
"path",
|
|
16114
|
+
{
|
|
16115
|
+
className: `${part.className} visual-fraction-widget__sector`,
|
|
16116
|
+
style: part.style,
|
|
16117
|
+
d: getEqualTrianglePartPath(part.index, divisions),
|
|
16118
|
+
...getInteractiveProps(part, onPartClick, locked)
|
|
16119
|
+
},
|
|
16120
|
+
part.index
|
|
16121
|
+
))
|
|
16122
|
+
}
|
|
16123
|
+
);
|
|
16124
|
+
};
|
|
16114
16125
|
TriangleShape.displayName = "TriangleShape";
|
|
16115
16126
|
const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsx(BarShape, { parts, divisions, orientation, onPartClick });
|
|
16116
16127
|
const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(CircleShape, { parts, divisions, onPartClick });
|
|
@@ -16154,12 +16165,14 @@ const VisualFraction = ({
|
|
|
16154
16165
|
onChange
|
|
16155
16166
|
});
|
|
16156
16167
|
const playSound = useSound();
|
|
16168
|
+
const locked = useExerciseLocked();
|
|
16157
16169
|
const handlePartClickWithSound = useCallback(
|
|
16158
16170
|
(index) => {
|
|
16171
|
+
if (locked) return;
|
|
16159
16172
|
if (answer.length > 0 && index >= filledCount) playSound("fraction-shade");
|
|
16160
16173
|
handlePartClick(index);
|
|
16161
16174
|
},
|
|
16162
|
-
[answer.length, filledCount, playSound, handlePartClick]
|
|
16175
|
+
[locked, answer.length, filledCount, playSound, handlePartClick]
|
|
16163
16176
|
);
|
|
16164
16177
|
const parts = useMemo(
|
|
16165
16178
|
() => Array.from({ length: divisions }, (_, i) => {
|
|
@@ -16234,6 +16247,7 @@ const GridSymmetry = ({
|
|
|
16234
16247
|
validationStatus = null,
|
|
16235
16248
|
onChange
|
|
16236
16249
|
}) => {
|
|
16250
|
+
const exerciseLocked = useExerciseLocked();
|
|
16237
16251
|
const initialKeys = useMemo(() => new Set(widget.cells.map(gridCellKey)), [widget.cells]);
|
|
16238
16252
|
const initialInteractiveCells = useMemo(
|
|
16239
16253
|
() => widget.cells.filter(
|
|
@@ -16244,6 +16258,7 @@ const GridSymmetry = ({
|
|
|
16244
16258
|
const selectedCells = value ?? initialInteractiveCells;
|
|
16245
16259
|
const selectedKeys = useMemo(() => new Set(selectedCells.map(gridCellKey)), [selectedCells]);
|
|
16246
16260
|
const toggleCell = (cell) => {
|
|
16261
|
+
if (exerciseLocked) return;
|
|
16247
16262
|
const key2 = gridCellKey(cell);
|
|
16248
16263
|
if (initialKeys.has(key2)) return;
|
|
16249
16264
|
const next = selectedKeys.has(key2) ? selectedCells.filter((candidate) => gridCellKey(candidate) !== key2) : [...selectedCells, cell];
|
|
@@ -16284,6 +16299,7 @@ const GridSymmetry = ({
|
|
|
16284
16299
|
className,
|
|
16285
16300
|
"aria-label": label,
|
|
16286
16301
|
"aria-pressed": selected,
|
|
16302
|
+
"aria-disabled": exerciseLocked ? "true" : void 0,
|
|
16287
16303
|
onClick: () => toggleCell(cell)
|
|
16288
16304
|
},
|
|
16289
16305
|
key2
|
|
@@ -16323,6 +16339,7 @@ const RadioOptions = ({
|
|
|
16323
16339
|
onChange
|
|
16324
16340
|
}) => {
|
|
16325
16341
|
const playSound = useSound();
|
|
16342
|
+
const locked = useExerciseLocked();
|
|
16326
16343
|
return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
|
|
16327
16344
|
const isSelected = value === option;
|
|
16328
16345
|
const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
|
|
@@ -16331,9 +16348,11 @@ const RadioOptions = ({
|
|
|
16331
16348
|
{
|
|
16332
16349
|
className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
|
|
16333
16350
|
onClick: () => {
|
|
16351
|
+
if (locked) return;
|
|
16334
16352
|
playSound("radio-select");
|
|
16335
16353
|
onChange(name, option);
|
|
16336
16354
|
},
|
|
16355
|
+
"aria-disabled": locked ? "true" : void 0,
|
|
16337
16356
|
type: "button",
|
|
16338
16357
|
children: option
|
|
16339
16358
|
},
|
|
@@ -16345,7 +16364,10 @@ const Btn = ({
|
|
|
16345
16364
|
label,
|
|
16346
16365
|
extraClass = "keypad-btn-digit",
|
|
16347
16366
|
onClick
|
|
16348
|
-
}) =>
|
|
16367
|
+
}) => {
|
|
16368
|
+
const locked = useExerciseLocked();
|
|
16369
|
+
return /* @__PURE__ */ jsx("button", { className: `keypad-btn ${extraClass}`, disabled: locked, onClick, children: /* @__PURE__ */ jsx("span", { children: label }) });
|
|
16370
|
+
};
|
|
16349
16371
|
const DiamondKeypad = ({ value, onChange }) => {
|
|
16350
16372
|
const playSound = useSound();
|
|
16351
16373
|
const append = (ch) => {
|
|
@@ -16432,8 +16454,10 @@ const MatcherWidget = ({
|
|
|
16432
16454
|
() => defaultValue2 && defaultValue2.length > 0 ? assignmentsFromValue(defaultValue2, itemIds, groupCount) : initialAssignments(itemIds)
|
|
16433
16455
|
);
|
|
16434
16456
|
const playSound = useSound();
|
|
16457
|
+
const locked = useExerciseLocked();
|
|
16435
16458
|
const handleCardClick = useCallback(
|
|
16436
16459
|
(itemId) => {
|
|
16460
|
+
if (locked) return;
|
|
16437
16461
|
playSound("matcher-select");
|
|
16438
16462
|
setAssignments((prev) => {
|
|
16439
16463
|
const current = prev[itemId] ?? null;
|
|
@@ -16441,7 +16465,7 @@ const MatcherWidget = ({
|
|
|
16441
16465
|
return { ...prev, [itemId]: next };
|
|
16442
16466
|
});
|
|
16443
16467
|
},
|
|
16444
|
-
[groupCount, playSound]
|
|
16468
|
+
[groupCount, playSound, locked]
|
|
16445
16469
|
);
|
|
16446
16470
|
const onChangeRef = useRef(onChange);
|
|
16447
16471
|
onChangeRef.current = onChange;
|
|
@@ -16478,6 +16502,7 @@ const MatcherWidget = ({
|
|
|
16478
16502
|
"--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
|
|
16479
16503
|
} : void 0,
|
|
16480
16504
|
onClick: () => handleCardClick(item.itemId),
|
|
16505
|
+
"aria-disabled": locked ? "true" : void 0,
|
|
16481
16506
|
"aria-pressed": isSelected,
|
|
16482
16507
|
"aria-label": isSelected ? `Assigned to group ${colorIdx + 1}` : "Not assigned",
|
|
16483
16508
|
children: item.content.map((layoutItem) => /* @__PURE__ */ jsx(
|
|
@@ -16690,7 +16715,10 @@ const PairMatcherWidget = ({
|
|
|
16690
16715
|
widgets,
|
|
16691
16716
|
shuffleItems = true
|
|
16692
16717
|
}) => {
|
|
16693
|
-
const {
|
|
16718
|
+
const { groups, layout } = widget;
|
|
16719
|
+
const [topItems, bottomItems] = groups;
|
|
16720
|
+
const isColumns = layout === "columns";
|
|
16721
|
+
const exerciseLocked = useExerciseLocked();
|
|
16694
16722
|
const [shuffledTop] = useState(
|
|
16695
16723
|
() => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
|
|
16696
16724
|
);
|
|
@@ -16734,8 +16762,9 @@ const PairMatcherWidget = ({
|
|
|
16734
16762
|
}
|
|
16735
16763
|
soundPrevRef.current = { count, pendingId, flashId };
|
|
16736
16764
|
}, [state.pairs, state.pending, state.flashTopId, playSound]);
|
|
16765
|
+
const interactionLocked = validationStatus !== null || exerciseLocked;
|
|
16737
16766
|
const handleTopClick = useCallback((topId) => {
|
|
16738
|
-
if (
|
|
16767
|
+
if (interactionLocked) return;
|
|
16739
16768
|
if (flashTimerRef.current) {
|
|
16740
16769
|
clearTimeout(flashTimerRef.current);
|
|
16741
16770
|
flashTimerRef.current = null;
|
|
@@ -16777,9 +16806,9 @@ const PairMatcherWidget = ({
|
|
|
16777
16806
|
flashBottomId: null
|
|
16778
16807
|
};
|
|
16779
16808
|
});
|
|
16780
|
-
}, [
|
|
16809
|
+
}, [interactionLocked]);
|
|
16781
16810
|
const handleBottomClick = useCallback((bottomId) => {
|
|
16782
|
-
if (
|
|
16811
|
+
if (interactionLocked) return;
|
|
16783
16812
|
if (flashTimerRef.current) {
|
|
16784
16813
|
clearTimeout(flashTimerRef.current);
|
|
16785
16814
|
flashTimerRef.current = null;
|
|
@@ -16822,9 +16851,13 @@ const PairMatcherWidget = ({
|
|
|
16822
16851
|
flashBottomId: null
|
|
16823
16852
|
};
|
|
16824
16853
|
});
|
|
16825
|
-
}, [
|
|
16854
|
+
}, [interactionLocked]);
|
|
16826
16855
|
useEffect(() => {
|
|
16827
|
-
if (
|
|
16856
|
+
if (state.flashTopId === null || state.flashBottomId === null) return;
|
|
16857
|
+
if (interactionLocked) {
|
|
16858
|
+
setState((current) => ({ ...current, flashTopId: null, flashBottomId: null }));
|
|
16859
|
+
return;
|
|
16860
|
+
}
|
|
16828
16861
|
flashTimerRef.current = setTimeout(() => {
|
|
16829
16862
|
flashTimerRef.current = null;
|
|
16830
16863
|
setState((current) => {
|
|
@@ -16838,7 +16871,7 @@ const PairMatcherWidget = ({
|
|
|
16838
16871
|
flashTimerRef.current = null;
|
|
16839
16872
|
}
|
|
16840
16873
|
};
|
|
16841
|
-
}, [state.flashTopId, state.flashBottomId,
|
|
16874
|
+
}, [state.flashTopId, state.flashBottomId, interactionLocked]);
|
|
16842
16875
|
const onChangeRef = useRef(onChange);
|
|
16843
16876
|
onChangeRef.current = onChange;
|
|
16844
16877
|
const isMounted = useRef(false);
|
|
@@ -16861,8 +16894,8 @@ const PairMatcherWidget = ({
|
|
|
16861
16894
|
pairStatusByTopId
|
|
16862
16895
|
});
|
|
16863
16896
|
const cols = useMemo(
|
|
16864
|
-
() => Math.max(topItems.length, bottomItems.length, 1),
|
|
16865
|
-
[topItems.length, bottomItems.length]
|
|
16897
|
+
() => isColumns ? null : Math.max(topItems.length, bottomItems.length, 1),
|
|
16898
|
+
[isColumns, topItems.length, bottomItems.length]
|
|
16866
16899
|
);
|
|
16867
16900
|
const renderCard = (item, isTop) => {
|
|
16868
16901
|
const isRtl = shouldItemsUseRTL(item.content);
|
|
@@ -16898,7 +16931,7 @@ const PairMatcherWidget = ({
|
|
|
16898
16931
|
dir: isRtl ? "rtl" : void 0,
|
|
16899
16932
|
onClick: () => isTop ? handleTopClick(item.itemId) : handleBottomClick(item.itemId),
|
|
16900
16933
|
"aria-pressed": status !== "idle",
|
|
16901
|
-
"aria-disabled":
|
|
16934
|
+
"aria-disabled": interactionLocked ? "true" : void 0,
|
|
16902
16935
|
children: /* @__PURE__ */ jsx("span", { className: "pair-matcher-card-content", children: item.content.map((li) => renderPairMatcherItemContent(li, widgetsMap)) })
|
|
16903
16936
|
}
|
|
16904
16937
|
),
|
|
@@ -16908,8 +16941,8 @@ const PairMatcherWidget = ({
|
|
|
16908
16941
|
return /* @__PURE__ */ jsxs(
|
|
16909
16942
|
"div",
|
|
16910
16943
|
{
|
|
16911
|
-
className: `pair-matcher-widget${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
|
|
16912
|
-
style: { "--pm-cols": cols },
|
|
16944
|
+
className: `pair-matcher-widget${isColumns ? " pair-matcher-widget--columns" : ""}${validationStatus ? ` pair-matcher-widget--${validationStatus}` : ""}`,
|
|
16945
|
+
style: cols === null ? {} : { "--pm-cols": cols },
|
|
16913
16946
|
ref: containerRef,
|
|
16914
16947
|
role: "group",
|
|
16915
16948
|
"aria-label": "Pair matcher exercise",
|
|
@@ -17437,6 +17470,7 @@ const DraggableCursor = ({
|
|
|
17437
17470
|
currentValue,
|
|
17438
17471
|
validationStatus
|
|
17439
17472
|
}) => {
|
|
17473
|
+
const locked = useExerciseLocked();
|
|
17440
17474
|
const [isDragging, setIsDragging] = useState(false);
|
|
17441
17475
|
const isDraggingRef = useRef(false);
|
|
17442
17476
|
const currentValueRef = useRef(currentValue);
|
|
@@ -17458,13 +17492,14 @@ const DraggableCursor = ({
|
|
|
17458
17492
|
return snapTicks.length > 0 ? snapToNearest(raw, snapTicks) : cleanFloat(raw);
|
|
17459
17493
|
}, [axisRowRef, min, max, snapTicks]);
|
|
17460
17494
|
const handlePointerDown = useCallback((e) => {
|
|
17495
|
+
if (locked) return;
|
|
17461
17496
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
17462
17497
|
e.stopPropagation();
|
|
17463
17498
|
isDraggingRef.current = true;
|
|
17464
17499
|
dragStartValueRef.current = currentValue;
|
|
17465
17500
|
lastSoundValueRef.current = currentValue;
|
|
17466
17501
|
setIsDragging(true);
|
|
17467
|
-
}, [currentValue]);
|
|
17502
|
+
}, [currentValue, locked]);
|
|
17468
17503
|
const updateCursorValue = useCallback((clientX) => {
|
|
17469
17504
|
const next = snap(clientX);
|
|
17470
17505
|
if (!almostEqual(next, lastSoundValueRef.current)) {
|
|
@@ -17492,7 +17527,7 @@ const DraggableCursor = ({
|
|
|
17492
17527
|
}
|
|
17493
17528
|
}, [updateCursorValue, playSound]);
|
|
17494
17529
|
const handleKeyDown = useCallback((e) => {
|
|
17495
|
-
if (!snapTicks.length) return;
|
|
17530
|
+
if (locked || !snapTicks.length) return;
|
|
17496
17531
|
const sorted = [...snapTicks].sort((a, b) => a - b);
|
|
17497
17532
|
const idx = sorted.findIndex((t) => t >= currentValue - 1e-9);
|
|
17498
17533
|
const first = sorted[0] ?? currentValue;
|
|
@@ -17514,7 +17549,7 @@ const DraggableCursor = ({
|
|
|
17514
17549
|
}
|
|
17515
17550
|
onChange(cursor.id, String(next));
|
|
17516
17551
|
}
|
|
17517
|
-
}, [snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
|
|
17552
|
+
}, [locked, snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
|
|
17518
17553
|
const ballClass = [
|
|
17519
17554
|
"number-line-cursor-ball",
|
|
17520
17555
|
isDragging ? "number-line-cursor-ball--dragging" : "",
|
|
@@ -17563,6 +17598,7 @@ const DraggableCursor = ({
|
|
|
17563
17598
|
"aria-valuemax": max,
|
|
17564
17599
|
"aria-valuenow": currentValue,
|
|
17565
17600
|
"aria-label": sliderAriaLabel,
|
|
17601
|
+
"aria-disabled": locked ? "true" : void 0,
|
|
17566
17602
|
tabIndex: 0
|
|
17567
17603
|
}
|
|
17568
17604
|
)
|
|
@@ -17603,9 +17639,11 @@ const CursorBalls = ({
|
|
|
17603
17639
|
}) });
|
|
17604
17640
|
};
|
|
17605
17641
|
const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
|
|
17642
|
+
const locked = useExerciseLocked();
|
|
17606
17643
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
17607
17644
|
id: widgetId,
|
|
17608
|
-
data: { type: "placed-chip", widgetId, label }
|
|
17645
|
+
data: { type: "placed-chip", widgetId, label },
|
|
17646
|
+
disabled: locked
|
|
17609
17647
|
});
|
|
17610
17648
|
return /* @__PURE__ */ jsx(
|
|
17611
17649
|
"div",
|
|
@@ -17630,6 +17668,7 @@ const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
|
|
|
17630
17668
|
type: "button",
|
|
17631
17669
|
className: "number-line-placed-chip-remove",
|
|
17632
17670
|
"aria-label": "Remove",
|
|
17671
|
+
disabled: locked,
|
|
17633
17672
|
onClick: onRemove,
|
|
17634
17673
|
children: "✕"
|
|
17635
17674
|
}
|
|
@@ -17669,6 +17708,7 @@ const FreeDragAxis = ({
|
|
|
17669
17708
|
cursorValidationStatuses,
|
|
17670
17709
|
snapKey
|
|
17671
17710
|
}) => {
|
|
17711
|
+
const axisLocked = useExerciseLocked();
|
|
17672
17712
|
const axisRef = useRef(null);
|
|
17673
17713
|
const snapTicks = useMemo(
|
|
17674
17714
|
() => getSnapTicks(majorTicks, minorTicks),
|
|
@@ -17724,7 +17764,7 @@ const FreeDragAxis = ({
|
|
|
17724
17764
|
return changed ? next : prev;
|
|
17725
17765
|
});
|
|
17726
17766
|
}, [values]);
|
|
17727
|
-
const { setNodeRef } = useDroppable({ id: axisDropId });
|
|
17767
|
+
const { setNodeRef } = useDroppable({ id: axisDropId, disabled: axisLocked });
|
|
17728
17768
|
const mergedRef = useCallback((node) => {
|
|
17729
17769
|
axisRef.current = node;
|
|
17730
17770
|
setNodeRef(node);
|
|
@@ -18038,6 +18078,7 @@ const CounterControls = ({
|
|
|
18038
18078
|
onIncrement
|
|
18039
18079
|
}) => {
|
|
18040
18080
|
const playSound = useSound();
|
|
18081
|
+
const locked = useExerciseLocked();
|
|
18041
18082
|
return /* @__PURE__ */ jsxs("div", { className: "number-line-counter", "aria-label": counter.name ?? "Counter", children: [
|
|
18042
18083
|
/* @__PURE__ */ jsxs("div", { className: "number-line-counter-controls", children: [
|
|
18043
18084
|
/* @__PURE__ */ jsx(
|
|
@@ -18046,7 +18087,7 @@ const CounterControls = ({
|
|
|
18046
18087
|
type: "button",
|
|
18047
18088
|
className: "number-line-counter-btn",
|
|
18048
18089
|
"aria-label": "Decrease",
|
|
18049
|
-
disabled: count <= counter.min,
|
|
18090
|
+
disabled: locked || count <= counter.min,
|
|
18050
18091
|
onClick: () => {
|
|
18051
18092
|
playSound("counter-step");
|
|
18052
18093
|
onDecrement();
|
|
@@ -18061,7 +18102,7 @@ const CounterControls = ({
|
|
|
18061
18102
|
type: "button",
|
|
18062
18103
|
className: "number-line-counter-btn",
|
|
18063
18104
|
"aria-label": "Increase",
|
|
18064
|
-
disabled: count >= counter.max,
|
|
18105
|
+
disabled: locked || count >= counter.max,
|
|
18065
18106
|
onClick: () => {
|
|
18066
18107
|
playSound("counter-step");
|
|
18067
18108
|
onIncrement();
|
|
@@ -18645,6 +18686,7 @@ const ClockRenderer = ({
|
|
|
18645
18686
|
validationStatus,
|
|
18646
18687
|
onChange
|
|
18647
18688
|
}) => {
|
|
18689
|
+
const locked = useExerciseLocked();
|
|
18648
18690
|
const format = item.format ?? "24h";
|
|
18649
18691
|
const interactive = item.mode === "interactive";
|
|
18650
18692
|
const snap = interactive && "snap" in item ? item.snap ?? 1 : 1;
|
|
@@ -18690,18 +18732,18 @@ const ClockRenderer = ({
|
|
|
18690
18732
|
}
|
|
18691
18733
|
}, [snap, hours, minutes, item.mode, item.name, onChange, playSound]);
|
|
18692
18734
|
const onPointerDown = (hand) => (e) => {
|
|
18693
|
-
if (!interactive) return;
|
|
18735
|
+
if (!interactive || locked) return;
|
|
18694
18736
|
e.preventDefault();
|
|
18695
18737
|
e.target.setPointerCapture?.(e.pointerId);
|
|
18696
18738
|
draggingRef.current = hand;
|
|
18697
18739
|
updateFromPointer(e.clientX, e.clientY);
|
|
18698
18740
|
};
|
|
18699
18741
|
const onPointerMove = (e) => {
|
|
18700
|
-
if (!interactive || !draggingRef.current) return;
|
|
18742
|
+
if (!interactive || locked || !draggingRef.current) return;
|
|
18701
18743
|
updateFromPointer(e.clientX, e.clientY);
|
|
18702
18744
|
};
|
|
18703
18745
|
const onPointerUp = (e) => {
|
|
18704
|
-
if (!interactive) return;
|
|
18746
|
+
if (!interactive || locked) return;
|
|
18705
18747
|
e.target.releasePointerCapture?.(e.pointerId);
|
|
18706
18748
|
draggingRef.current = null;
|
|
18707
18749
|
};
|
|
@@ -19253,38 +19295,45 @@ const ExerciseRenderer = forwardRef(({
|
|
|
19253
19295
|
const [droppedSources, setDroppedSources] = useState(/* @__PURE__ */ new Map());
|
|
19254
19296
|
const [focusedInputId, setFocusedInputId] = useState(null);
|
|
19255
19297
|
const [validationDetails, setValidationDetails] = useState(null);
|
|
19298
|
+
const rootRef = useRef(null);
|
|
19256
19299
|
const [resetKey, setResetKey] = useState(0);
|
|
19257
19300
|
const [revealed, setRevealed] = useState(false);
|
|
19258
19301
|
const playSound = useSoundPlayer(soundEnabled, sounds);
|
|
19259
|
-
|
|
19260
|
-
|
|
19261
|
-
|
|
19262
|
-
|
|
19263
|
-
|
|
19264
|
-
|
|
19265
|
-
|
|
19266
|
-
|
|
19267
|
-
|
|
19268
|
-
|
|
19269
|
-
|
|
19270
|
-
|
|
19271
|
-
|
|
19272
|
-
|
|
19273
|
-
|
|
19274
|
-
|
|
19275
|
-
|
|
19276
|
-
|
|
19277
|
-
|
|
19278
|
-
|
|
19279
|
-
|
|
19280
|
-
|
|
19281
|
-
|
|
19282
|
-
|
|
19302
|
+
const externallyLocked = useExerciseLocked();
|
|
19303
|
+
const locked = validationDetails !== null || externallyLocked;
|
|
19304
|
+
const lastResultRef = useRef(null);
|
|
19305
|
+
const runSubmit = () => {
|
|
19306
|
+
if (locked) return lastResultRef.current ?? validate(json, values);
|
|
19307
|
+
const result = validate(json, values);
|
|
19308
|
+
playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
|
|
19309
|
+
const details = { ...result.details };
|
|
19310
|
+
const slotValidation = (id, raw, expected) => ({
|
|
19311
|
+
widgetId: id,
|
|
19312
|
+
userValue: typeof raw === "string" ? raw : void 0,
|
|
19313
|
+
isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
|
|
19314
|
+
});
|
|
19315
|
+
for (const [widgetId, widget] of Object.entries(json.widgets)) {
|
|
19316
|
+
if (widget.type !== JSON_TYPES.FRACTION) continue;
|
|
19317
|
+
if (!(widgetId in details)) continue;
|
|
19318
|
+
const answer = json.validation?.answers?.[widgetId];
|
|
19319
|
+
if (!answer) continue;
|
|
19320
|
+
const [expectedNum, expectedDen] = answer.split("/", 2);
|
|
19321
|
+
if (!expectedNum || !expectedDen) continue;
|
|
19322
|
+
if (details[widgetId]?.isCorrect) {
|
|
19323
|
+
if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
|
|
19324
|
+
if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
|
|
19325
|
+
} else {
|
|
19326
|
+
if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
|
|
19327
|
+
if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
|
|
19283
19328
|
}
|
|
19284
|
-
|
|
19285
|
-
|
|
19286
|
-
|
|
19287
|
-
|
|
19329
|
+
}
|
|
19330
|
+
setValidationDetails(details);
|
|
19331
|
+
onSubmit?.(values);
|
|
19332
|
+
lastResultRef.current = result;
|
|
19333
|
+
return result;
|
|
19334
|
+
};
|
|
19335
|
+
useImperativeHandle(ref, () => ({
|
|
19336
|
+
submit: runSubmit,
|
|
19288
19337
|
getValues: () => values,
|
|
19289
19338
|
getCompletion: () => {
|
|
19290
19339
|
const answerable = getAnswerableInputs(json);
|
|
@@ -19327,14 +19376,20 @@ const ExerciseRenderer = forwardRef(({
|
|
|
19327
19376
|
setResetKey((k) => k + 1);
|
|
19328
19377
|
},
|
|
19329
19378
|
focusFirstInput: () => {
|
|
19330
|
-
const container =
|
|
19331
|
-
|
|
19332
|
-
|
|
19333
|
-
|
|
19379
|
+
const container = rootRef.current;
|
|
19380
|
+
if (!container) return;
|
|
19381
|
+
const candidates = container.querySelectorAll(
|
|
19382
|
+
'input[type="text"], input[type="number"], textarea, select'
|
|
19383
|
+
);
|
|
19384
|
+
for (const el of Array.from(candidates)) {
|
|
19385
|
+
if (el.readOnly || el.disabled) continue;
|
|
19386
|
+
el.focus();
|
|
19387
|
+
return;
|
|
19334
19388
|
}
|
|
19335
19389
|
}
|
|
19336
|
-
}), [json, values, onSubmit, playSound]);
|
|
19390
|
+
}), [json, values, onSubmit, playSound, locked]);
|
|
19337
19391
|
const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
|
|
19392
|
+
if (locked) return;
|
|
19338
19393
|
setRevealed(false);
|
|
19339
19394
|
const next = { ...valuesRef.current, [widgetId]: value };
|
|
19340
19395
|
valuesRef.current = next;
|
|
@@ -19351,7 +19406,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
19351
19406
|
onChange?.(next);
|
|
19352
19407
|
};
|
|
19353
19408
|
const handleSubmit = () => {
|
|
19354
|
-
|
|
19409
|
+
runSubmit();
|
|
19355
19410
|
};
|
|
19356
19411
|
const lineGroups = useMemo(() => {
|
|
19357
19412
|
const isRTLInlineWidget = (item) => {
|
|
@@ -19466,7 +19521,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
19466
19521
|
if (setSquareWidget) add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
|
|
19467
19522
|
return { tools: list, toolsMinHeight: maxH };
|
|
19468
19523
|
}, [rulerWidget, protractorWidget, setSquareWidget]);
|
|
19469
|
-
return /* @__PURE__ */ jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsx(ExerciseDndProvider, { droppedSources, children: /* @__PURE__ */ jsxs("div", { className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${className}`, children: [
|
|
19524
|
+
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: [
|
|
19470
19525
|
/* @__PURE__ */ jsxs(
|
|
19471
19526
|
"div",
|
|
19472
19527
|
{
|
|
@@ -19562,8 +19617,8 @@ const ExerciseRenderer = forwardRef(({
|
|
|
19562
19617
|
}
|
|
19563
19618
|
}
|
|
19564
19619
|
) }),
|
|
19565
|
-
showSubmitButton && /* @__PURE__ */ jsx("button", { className: "exercise-submit-btn", onClick: handleSubmit, children: "Check Answer" })
|
|
19566
|
-
] }) }) });
|
|
19620
|
+
showSubmitButton && /* @__PURE__ */ jsx("button", { className: "exercise-submit-btn", disabled: locked, onClick: handleSubmit, children: "Check Answer" })
|
|
19621
|
+
] }) }) }) });
|
|
19567
19622
|
});
|
|
19568
19623
|
ExerciseRenderer.displayName = "ExerciseRenderer";
|
|
19569
19624
|
function renderExercise(options) {
|
|
@@ -19620,6 +19675,7 @@ export {
|
|
|
19620
19675
|
EngineError,
|
|
19621
19676
|
ExerciseDocumentSchema,
|
|
19622
19677
|
ExerciseJSONSchema,
|
|
19678
|
+
ExerciseLockProvider,
|
|
19623
19679
|
ExerciseMetaSchema,
|
|
19624
19680
|
ExerciseRenderer,
|
|
19625
19681
|
ExpressionValidationSchema,
|
|
@@ -19842,6 +19898,7 @@ export {
|
|
|
19842
19898
|
toJSON,
|
|
19843
19899
|
tokenize,
|
|
19844
19900
|
tokenizeSafe,
|
|
19901
|
+
useExerciseLocked,
|
|
19845
19902
|
useSound,
|
|
19846
19903
|
validate,
|
|
19847
19904
|
validateBooleanExpression,
|