@mathscan/math-exercise-engine 1.0.12 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +920 -13
- package/dist/index.js +262 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +262 -10
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +102 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -21,6 +21,8 @@ const AST_TYPES = {
|
|
|
21
21
|
BOLD_TEXT: "BoldText",
|
|
22
22
|
/** Inline input widget (string or numeric) */
|
|
23
23
|
INLINE_INPUT: "InlineInput",
|
|
24
|
+
/** Inline fraction widget (numerator/denominator with optional inputs) */
|
|
25
|
+
FRACTION: "Fraction",
|
|
24
26
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
25
27
|
// Blocks
|
|
26
28
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -112,7 +114,9 @@ const JSON_TYPES = {
|
|
|
112
114
|
/** Radio button options widget */
|
|
113
115
|
RADIO_OPTIONS: "radio-options",
|
|
114
116
|
/** Numeric keypad widget */
|
|
115
|
-
NUMERIC_KEYPAD: "numeric-keypad"
|
|
117
|
+
NUMERIC_KEYPAD: "numeric-keypad",
|
|
118
|
+
/** Inline fraction widget */
|
|
119
|
+
FRACTION: "fraction"
|
|
116
120
|
};
|
|
117
121
|
const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
|
|
118
122
|
const VALIDATION_MODES = {
|
|
@@ -134,6 +138,17 @@ const SIDE_PAD = 7;
|
|
|
134
138
|
const CSS_VAR_SIDE_PAD = "--nl-side-pad";
|
|
135
139
|
const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
|
|
136
140
|
const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
|
|
141
|
+
function getFractionKey(widgetId, part) {
|
|
142
|
+
return `${widgetId}__${part}`;
|
|
143
|
+
}
|
|
144
|
+
function isFractionKey(key2) {
|
|
145
|
+
return key2.endsWith("__num") || key2.endsWith("__den");
|
|
146
|
+
}
|
|
147
|
+
function parseFractionKey(key2) {
|
|
148
|
+
const match = key2.match(/^(.+)__(num|den)$/);
|
|
149
|
+
if (!match) return null;
|
|
150
|
+
return { widgetId: match[1], part: match[2] };
|
|
151
|
+
}
|
|
137
152
|
const Newline = createToken({
|
|
138
153
|
name: "Newline",
|
|
139
154
|
pattern: /\r?\n/,
|
|
@@ -235,6 +250,10 @@ const HIGHLIGHT = createToken({
|
|
|
235
250
|
name: "HIGHLIGHT",
|
|
236
251
|
pattern: /HIGHLIGHT(?!_TEXT)/
|
|
237
252
|
});
|
|
253
|
+
const FRACTION = createToken({
|
|
254
|
+
name: "FRACTION",
|
|
255
|
+
pattern: /FRACTION/
|
|
256
|
+
});
|
|
238
257
|
const NumberLiteral = createToken({
|
|
239
258
|
name: "NumberLiteral",
|
|
240
259
|
pattern: /-?\d+(\.\d+)?/
|
|
@@ -344,6 +363,7 @@ const allTokens = [
|
|
|
344
363
|
CELL,
|
|
345
364
|
COUNTER,
|
|
346
365
|
HIGHLIGHT,
|
|
366
|
+
FRACTION,
|
|
347
367
|
// Literals
|
|
348
368
|
NumberLiteral,
|
|
349
369
|
StringLiteral,
|
|
@@ -678,6 +698,8 @@ class MathExerciseParser extends CstParser {
|
|
|
678
698
|
{ ALT: () => this.SUBRULE(this.colorText) },
|
|
679
699
|
// BOLD_TEXT (for bold text)
|
|
680
700
|
{ ALT: () => this.SUBRULE(this.boldText) },
|
|
701
|
+
// FRACTION (inline fraction widget)
|
|
702
|
+
{ ALT: () => this.SUBRULE(this.fraction) },
|
|
681
703
|
// W_S_I (inline input widget)
|
|
682
704
|
{ ALT: () => this.SUBRULE(this.inlineInput) }
|
|
683
705
|
]);
|
|
@@ -703,6 +725,12 @@ class MathExerciseParser extends CstParser {
|
|
|
703
725
|
this.SUBRULE(this.parameterList);
|
|
704
726
|
this.CONSUME(RBracket);
|
|
705
727
|
});
|
|
728
|
+
this.fraction = this.RULE("fraction", () => {
|
|
729
|
+
this.CONSUME(FRACTION);
|
|
730
|
+
this.CONSUME(LBracket);
|
|
731
|
+
this.SUBRULE(this.parameterList);
|
|
732
|
+
this.CONSUME(RBracket);
|
|
733
|
+
});
|
|
706
734
|
this.inlineInput = this.RULE("inlineInput", () => {
|
|
707
735
|
this.OR([
|
|
708
736
|
{ ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
|
|
@@ -885,13 +913,22 @@ const InlineInputWidgetSchema = z.discriminatedUnion("inputType", [
|
|
|
885
913
|
answer: z.union([z.string(), z.number()]).optional()
|
|
886
914
|
})
|
|
887
915
|
]);
|
|
916
|
+
const FractionInlineSchema = z.object({
|
|
917
|
+
type: z.literal(AST_TYPES.FRACTION),
|
|
918
|
+
name: z.string(),
|
|
919
|
+
numerator: z.number().optional(),
|
|
920
|
+
denominator: z.number().optional(),
|
|
921
|
+
answer: z.string().optional(),
|
|
922
|
+
simplified: z.boolean().default(true)
|
|
923
|
+
});
|
|
888
924
|
const InlineNodeSchema = z.union([
|
|
889
925
|
FreeTextInlineSchema,
|
|
890
926
|
SpaceInlineSchema,
|
|
891
927
|
HighlightedTextInlineSchema,
|
|
892
928
|
ColorTextInlineSchema,
|
|
893
929
|
BoldTextInlineSchema,
|
|
894
|
-
InlineInputWidgetSchema
|
|
930
|
+
InlineInputWidgetSchema,
|
|
931
|
+
FractionInlineSchema
|
|
895
932
|
]);
|
|
896
933
|
function createFreeText(value) {
|
|
897
934
|
return FreeTextInlineSchema.parse({
|
|
@@ -944,6 +981,13 @@ function createNumericInput(name, digits, answer) {
|
|
|
944
981
|
answer
|
|
945
982
|
});
|
|
946
983
|
}
|
|
984
|
+
function createFraction(name, options = {}) {
|
|
985
|
+
return FractionInlineSchema.parse({
|
|
986
|
+
type: AST_TYPES.FRACTION,
|
|
987
|
+
name,
|
|
988
|
+
...options
|
|
989
|
+
});
|
|
990
|
+
}
|
|
947
991
|
function isFreeText(value) {
|
|
948
992
|
return FreeTextInlineSchema.safeParse(value).success;
|
|
949
993
|
}
|
|
@@ -953,6 +997,9 @@ function isSpace(value) {
|
|
|
953
997
|
function isInlineInput(value) {
|
|
954
998
|
return InlineInputWidgetSchema.safeParse(value).success;
|
|
955
999
|
}
|
|
1000
|
+
function isFraction(value) {
|
|
1001
|
+
return FractionInlineSchema.safeParse(value).success;
|
|
1002
|
+
}
|
|
956
1003
|
function isInlineNode(value) {
|
|
957
1004
|
return InlineNodeSchema.safeParse(value).success;
|
|
958
1005
|
}
|
|
@@ -1445,6 +1492,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1445
1492
|
if (ctx.boldText) {
|
|
1446
1493
|
return this.visit(ctx.boldText);
|
|
1447
1494
|
}
|
|
1495
|
+
if (ctx.fraction) {
|
|
1496
|
+
return this.visit(ctx.fraction);
|
|
1497
|
+
}
|
|
1448
1498
|
if (ctx.inlineInput) {
|
|
1449
1499
|
return this.visit(ctx.inlineInput);
|
|
1450
1500
|
}
|
|
@@ -1478,6 +1528,15 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1478
1528
|
}
|
|
1479
1529
|
return createBoldText(value, highlight);
|
|
1480
1530
|
}
|
|
1531
|
+
fraction(ctx) {
|
|
1532
|
+
const params = this.visit(ctx.parameterList);
|
|
1533
|
+
const name = params.name;
|
|
1534
|
+
const numerator = params.numerator;
|
|
1535
|
+
const denominator = params.denominator;
|
|
1536
|
+
const answer = params.answer;
|
|
1537
|
+
const simplified = params.simplified !== void 0 ? params.simplified : void 0;
|
|
1538
|
+
return createFraction(name, { numerator, denominator, answer, simplified });
|
|
1539
|
+
}
|
|
1481
1540
|
inlineInput(ctx) {
|
|
1482
1541
|
const params = this.visit(ctx.parameterList);
|
|
1483
1542
|
const name = params.name;
|
|
@@ -1750,11 +1809,18 @@ const NumericKeypadJSONSchema = z.object({
|
|
|
1750
1809
|
includeOperators: z.boolean().optional(),
|
|
1751
1810
|
includeDecimal: z.boolean().optional()
|
|
1752
1811
|
});
|
|
1812
|
+
const FractionJSONSchema = z.object({
|
|
1813
|
+
type: z.literal(JSON_TYPES.FRACTION),
|
|
1814
|
+
numerator: z.number().nullable(),
|
|
1815
|
+
denominator: z.number().nullable(),
|
|
1816
|
+
simplified: z.boolean()
|
|
1817
|
+
});
|
|
1753
1818
|
const WidgetJSONSchema = z.union([
|
|
1754
1819
|
InlineInputJSONSchema,
|
|
1755
1820
|
DragOptionsJSONSchema,
|
|
1756
1821
|
RadioOptionsJSONSchema,
|
|
1757
|
-
NumericKeypadJSONSchema
|
|
1822
|
+
NumericKeypadJSONSchema,
|
|
1823
|
+
FractionJSONSchema
|
|
1758
1824
|
]);
|
|
1759
1825
|
const ValidationJSONSchema = z.object({
|
|
1760
1826
|
mode: z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
|
|
@@ -1839,6 +1905,9 @@ function isRadioOptionsJSON(widget) {
|
|
|
1839
1905
|
function isNumericKeypadJSON(widget) {
|
|
1840
1906
|
return widget.type === JSON_TYPES.NUMERIC_KEYPAD;
|
|
1841
1907
|
}
|
|
1908
|
+
function isFractionJSON(widget) {
|
|
1909
|
+
return widget.type === JSON_TYPES.FRACTION;
|
|
1910
|
+
}
|
|
1842
1911
|
class LayoutIdGenerator {
|
|
1843
1912
|
constructor() {
|
|
1844
1913
|
this.counter = 0;
|
|
@@ -1903,6 +1972,17 @@ function toJSON(ast) {
|
|
|
1903
1972
|
answers[inline.name] = inline.answer;
|
|
1904
1973
|
}
|
|
1905
1974
|
}
|
|
1975
|
+
if (inline.type === AST_TYPES.FRACTION) {
|
|
1976
|
+
widgets[inline.name] = {
|
|
1977
|
+
type: JSON_TYPES.FRACTION,
|
|
1978
|
+
numerator: inline.numerator ?? null,
|
|
1979
|
+
denominator: inline.denominator ?? null,
|
|
1980
|
+
simplified: inline.simplified ?? true
|
|
1981
|
+
};
|
|
1982
|
+
if (inline.answer !== void 0) {
|
|
1983
|
+
answers[inline.name] = inline.answer;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1906
1986
|
}
|
|
1907
1987
|
for (const block of ast.lines) {
|
|
1908
1988
|
if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
@@ -2093,6 +2173,12 @@ function transformInline(inline, idGenerator) {
|
|
|
2093
2173
|
type: JSON_TYPES.WIDGET_REF,
|
|
2094
2174
|
widgetId: inline.name
|
|
2095
2175
|
};
|
|
2176
|
+
case AST_TYPES.FRACTION:
|
|
2177
|
+
return {
|
|
2178
|
+
id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
|
|
2179
|
+
type: JSON_TYPES.WIDGET_REF,
|
|
2180
|
+
widgetId: inline.name
|
|
2181
|
+
};
|
|
2096
2182
|
default:
|
|
2097
2183
|
throw new Error(`Unknown inline type: ${inline.type}`);
|
|
2098
2184
|
}
|
|
@@ -2146,6 +2232,7 @@ function isNLDragCorrect(encoded, expectedLabel, expectedPosition) {
|
|
|
2146
2232
|
const position = normalize(payload.slice(atIdx + 1));
|
|
2147
2233
|
return label === normalize(expectedLabel) && position === normalize(expectedPosition);
|
|
2148
2234
|
}
|
|
2235
|
+
const math$1 = create(all);
|
|
2149
2236
|
function collectNumberLines(items) {
|
|
2150
2237
|
const result = [];
|
|
2151
2238
|
for (const item of items) {
|
|
@@ -2185,7 +2272,9 @@ function validateSimple(exercise, userInputs) {
|
|
|
2185
2272
|
for (const [widgetId, expected] of Object.entries(answers)) {
|
|
2186
2273
|
totalCount++;
|
|
2187
2274
|
const userValue = userInputs[widgetId];
|
|
2188
|
-
const
|
|
2275
|
+
const widget = exercise.widgets[widgetId];
|
|
2276
|
+
const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
|
|
2277
|
+
const isCorrect = isFractionLenient ? areFractionsEquivalent(userValue, expected) : areValuesEqual(userValue, expected, positionMap[widgetId]);
|
|
2189
2278
|
details[widgetId] = {
|
|
2190
2279
|
widgetId,
|
|
2191
2280
|
userValue,
|
|
@@ -2221,6 +2310,16 @@ function areValuesEqual(userValue, expected, expectedPosition) {
|
|
|
2221
2310
|
}
|
|
2222
2311
|
return normalize(userValue) === normalize(expected);
|
|
2223
2312
|
}
|
|
2313
|
+
function areFractionsEquivalent(user, expected) {
|
|
2314
|
+
if (user === void 0 || user === null) return false;
|
|
2315
|
+
try {
|
|
2316
|
+
const userFrac = math$1.fraction(String(user));
|
|
2317
|
+
const expectedFrac = math$1.fraction(String(expected));
|
|
2318
|
+
return math$1.equal(userFrac, expectedFrac);
|
|
2319
|
+
} catch {
|
|
2320
|
+
return areValuesEqual(user, expected);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2224
2323
|
const math = create(all);
|
|
2225
2324
|
const BLOCKED_FUNCTIONS = [
|
|
2226
2325
|
"import",
|
|
@@ -2374,7 +2473,7 @@ function validateExpression(exercise, userInputs) {
|
|
|
2374
2473
|
hasInvalidInput = true;
|
|
2375
2474
|
continue;
|
|
2376
2475
|
}
|
|
2377
|
-
const numValue = typeof rawValue === "number" ? rawValue :
|
|
2476
|
+
const numValue = typeof rawValue === "number" ? rawValue : parseFractionOrNumber(String(rawValue).trim());
|
|
2378
2477
|
if (!Number.isFinite(numValue)) {
|
|
2379
2478
|
details[varName] = {
|
|
2380
2479
|
widgetId: varName,
|
|
@@ -2469,6 +2568,39 @@ function findValidCombination(expression, ranges) {
|
|
|
2469
2568
|
}
|
|
2470
2569
|
return null;
|
|
2471
2570
|
}
|
|
2571
|
+
function parseFractionOrNumber(s) {
|
|
2572
|
+
const parts = s.split("/");
|
|
2573
|
+
if (parts.length === 2) {
|
|
2574
|
+
const num = parseFloat(parts[0]);
|
|
2575
|
+
const den = parseFloat(parts[1]);
|
|
2576
|
+
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
|
|
2577
|
+
return num / den;
|
|
2578
|
+
}
|
|
2579
|
+
return NaN;
|
|
2580
|
+
}
|
|
2581
|
+
return parseFloat(s);
|
|
2582
|
+
}
|
|
2583
|
+
function preprocessFractionValues(exercise, userInputs) {
|
|
2584
|
+
const result = { ...userInputs };
|
|
2585
|
+
for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
|
|
2586
|
+
if (widget.type !== JSON_TYPES.FRACTION) continue;
|
|
2587
|
+
const numKey = getFractionKey(widgetId, "num");
|
|
2588
|
+
const denKey = getFractionKey(widgetId, "den");
|
|
2589
|
+
const numEffective = widget.numerator !== null ? String(widget.numerator) : result[numKey] !== void 0 ? String(result[numKey]) : "";
|
|
2590
|
+
const denEffective = widget.denominator !== null ? String(widget.denominator) : result[denKey] !== void 0 ? String(result[denKey]) : "";
|
|
2591
|
+
if (numEffective !== "" && denEffective !== "") {
|
|
2592
|
+
result[widgetId] = `${numEffective}/${denEffective}`;
|
|
2593
|
+
}
|
|
2594
|
+
delete result[numKey];
|
|
2595
|
+
delete result[denKey];
|
|
2596
|
+
}
|
|
2597
|
+
for (const key2 of Object.keys(result)) {
|
|
2598
|
+
if (isFractionKey(key2)) {
|
|
2599
|
+
delete result[key2];
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return result;
|
|
2603
|
+
}
|
|
2472
2604
|
function validate(exercise, userInputs) {
|
|
2473
2605
|
if (!exercise.validation) {
|
|
2474
2606
|
return {
|
|
@@ -2476,12 +2608,13 @@ function validate(exercise, userInputs) {
|
|
|
2476
2608
|
details: {}
|
|
2477
2609
|
};
|
|
2478
2610
|
}
|
|
2611
|
+
const processedInputs = preprocessFractionValues(exercise, userInputs);
|
|
2479
2612
|
const validatedExercise = exercise;
|
|
2480
2613
|
switch (validatedExercise.validation.mode) {
|
|
2481
2614
|
case VALIDATION_MODES.SIMPLE:
|
|
2482
|
-
return validateSimple(validatedExercise,
|
|
2615
|
+
return validateSimple(validatedExercise, processedInputs);
|
|
2483
2616
|
case VALIDATION_MODES.EXPRESSION:
|
|
2484
|
-
return validateExpression(validatedExercise,
|
|
2617
|
+
return validateExpression(validatedExercise, processedInputs);
|
|
2485
2618
|
default:
|
|
2486
2619
|
return {
|
|
2487
2620
|
isCorrect: false,
|
|
@@ -6551,6 +6684,7 @@ const InlineInput = ({
|
|
|
6551
6684
|
widgetId,
|
|
6552
6685
|
value,
|
|
6553
6686
|
isDropped = false,
|
|
6687
|
+
validationStatus = null,
|
|
6554
6688
|
inputType,
|
|
6555
6689
|
length,
|
|
6556
6690
|
digits,
|
|
@@ -6568,6 +6702,7 @@ const InlineInput = ({
|
|
|
6568
6702
|
}, [handleChange]);
|
|
6569
6703
|
const expectedLength = inputType === "numeric" ? digits : length;
|
|
6570
6704
|
const dynamicWidth = `${(expectedLength || 5) * 0.8 + 1}em`;
|
|
6705
|
+
const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
|
|
6571
6706
|
return /* @__PURE__ */ jsx(
|
|
6572
6707
|
Droppable,
|
|
6573
6708
|
{
|
|
@@ -6580,7 +6715,7 @@ const InlineInput = ({
|
|
|
6580
6715
|
/* @__PURE__ */ jsx(
|
|
6581
6716
|
"button",
|
|
6582
6717
|
{
|
|
6583
|
-
className:
|
|
6718
|
+
className: `inline-input-chip${statusClass}`,
|
|
6584
6719
|
onClick: () => handleChange("", false),
|
|
6585
6720
|
title: "Click to remove",
|
|
6586
6721
|
children: value
|
|
@@ -6593,7 +6728,7 @@ const InlineInput = ({
|
|
|
6593
6728
|
{
|
|
6594
6729
|
id: widgetId + "-input",
|
|
6595
6730
|
type: "text",
|
|
6596
|
-
className:
|
|
6731
|
+
className: `inline-input${statusClass}`,
|
|
6597
6732
|
value,
|
|
6598
6733
|
onChange: (e) => handleChange(e.target.value, false),
|
|
6599
6734
|
onFocus: () => onFocus?.(widgetId),
|
|
@@ -6670,18 +6805,83 @@ const NumericKeypad = ({
|
|
|
6670
6805
|
includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
|
|
6671
6806
|
] });
|
|
6672
6807
|
};
|
|
6808
|
+
const FractionInput = ({
|
|
6809
|
+
widgetId,
|
|
6810
|
+
numerator,
|
|
6811
|
+
denominator,
|
|
6812
|
+
values,
|
|
6813
|
+
droppedInputs,
|
|
6814
|
+
validationStatus = null,
|
|
6815
|
+
onChange,
|
|
6816
|
+
onFocus
|
|
6817
|
+
}) => {
|
|
6818
|
+
const numKey = getFractionKey(widgetId, "num");
|
|
6819
|
+
const denKey = getFractionKey(widgetId, "den");
|
|
6820
|
+
const numValue = values[numKey] ?? "";
|
|
6821
|
+
const denValue = values[denKey] ?? "";
|
|
6822
|
+
const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
|
|
6823
|
+
const handleDrop = useCallback((subKey) => (item) => {
|
|
6824
|
+
if (item.type === "drag-option") {
|
|
6825
|
+
const data = item.data;
|
|
6826
|
+
onChange(subKey, data.value, true, String(item.id));
|
|
6827
|
+
}
|
|
6828
|
+
}, [onChange]);
|
|
6829
|
+
const renderSlot = (subKey, fixedValue, currentValue, label) => {
|
|
6830
|
+
if (fixedValue !== null) {
|
|
6831
|
+
return /* @__PURE__ */ jsx("span", { className: "fraction-fixed", children: fixedValue });
|
|
6832
|
+
}
|
|
6833
|
+
const isDroppedHere = droppedInputs.has(subKey);
|
|
6834
|
+
return /* @__PURE__ */ jsx(
|
|
6835
|
+
Droppable,
|
|
6836
|
+
{
|
|
6837
|
+
id: subKey,
|
|
6838
|
+
accepts: ["drag-option"],
|
|
6839
|
+
onDrop: handleDrop(subKey),
|
|
6840
|
+
className: "fraction-slot-droppable",
|
|
6841
|
+
children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsx(
|
|
6842
|
+
"button",
|
|
6843
|
+
{
|
|
6844
|
+
className: `inline-input-chip fraction-chip${statusClass}`,
|
|
6845
|
+
onClick: () => onChange(subKey, "", false),
|
|
6846
|
+
title: "Click to remove",
|
|
6847
|
+
children: currentValue
|
|
6848
|
+
}
|
|
6849
|
+
) : /* @__PURE__ */ jsx(
|
|
6850
|
+
"input",
|
|
6851
|
+
{
|
|
6852
|
+
id: `${subKey}-input`,
|
|
6853
|
+
type: "text",
|
|
6854
|
+
className: `inline-input fraction-input${statusClass}`,
|
|
6855
|
+
value: currentValue,
|
|
6856
|
+
onChange: (e) => onChange(subKey, e.target.value, false),
|
|
6857
|
+
onFocus: () => onFocus?.(subKey),
|
|
6858
|
+
maxLength: 4,
|
|
6859
|
+
"aria-label": label
|
|
6860
|
+
}
|
|
6861
|
+
)
|
|
6862
|
+
}
|
|
6863
|
+
);
|
|
6864
|
+
};
|
|
6865
|
+
return /* @__PURE__ */ jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
|
|
6866
|
+
/* @__PURE__ */ jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator") }),
|
|
6867
|
+
/* @__PURE__ */ jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
|
|
6868
|
+
/* @__PURE__ */ jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
|
|
6869
|
+
] });
|
|
6870
|
+
};
|
|
6673
6871
|
const RadioOptions = ({
|
|
6674
6872
|
name,
|
|
6675
6873
|
options,
|
|
6676
6874
|
value,
|
|
6875
|
+
validationStatus = null,
|
|
6677
6876
|
onChange
|
|
6678
6877
|
}) => {
|
|
6679
6878
|
return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
|
|
6680
6879
|
const isSelected = value === option;
|
|
6880
|
+
const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
|
|
6681
6881
|
return /* @__PURE__ */ jsx(
|
|
6682
6882
|
"button",
|
|
6683
6883
|
{
|
|
6684
|
-
className: `radio-option${isSelected ? " selected" : ""}`,
|
|
6884
|
+
className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
|
|
6685
6885
|
onClick: () => onChange(name, option),
|
|
6686
6886
|
type: "button",
|
|
6687
6887
|
children: option
|
|
@@ -6702,11 +6902,17 @@ function isRadioOptionsWidget(widget) {
|
|
|
6702
6902
|
function isNumericKeypadWidget(widget) {
|
|
6703
6903
|
return widget.type === "numeric-keypad";
|
|
6704
6904
|
}
|
|
6905
|
+
function isFractionWidget(widget) {
|
|
6906
|
+
return widget.type === "fraction";
|
|
6907
|
+
}
|
|
6705
6908
|
const WidgetRenderer = ({
|
|
6706
6909
|
widget,
|
|
6707
6910
|
widgetId,
|
|
6708
6911
|
value,
|
|
6912
|
+
values = {},
|
|
6709
6913
|
isDropped = false,
|
|
6914
|
+
validationStatus = null,
|
|
6915
|
+
droppedInputs = /* @__PURE__ */ new Set(),
|
|
6710
6916
|
onChange,
|
|
6711
6917
|
onInputFocus,
|
|
6712
6918
|
onKeypadChange
|
|
@@ -6718,6 +6924,7 @@ const WidgetRenderer = ({
|
|
|
6718
6924
|
widgetId,
|
|
6719
6925
|
value,
|
|
6720
6926
|
isDropped,
|
|
6927
|
+
validationStatus,
|
|
6721
6928
|
inputType: widget.inputType,
|
|
6722
6929
|
length: widget.inputType === "string" ? widget.length : void 0,
|
|
6723
6930
|
digits: widget.inputType === "numeric" ? widget.digits : void 0,
|
|
@@ -6743,6 +6950,7 @@ const WidgetRenderer = ({
|
|
|
6743
6950
|
name: widgetId,
|
|
6744
6951
|
options: widget.options,
|
|
6745
6952
|
value,
|
|
6953
|
+
validationStatus,
|
|
6746
6954
|
onChange
|
|
6747
6955
|
}
|
|
6748
6956
|
);
|
|
@@ -6759,6 +6967,21 @@ const WidgetRenderer = ({
|
|
|
6759
6967
|
}
|
|
6760
6968
|
);
|
|
6761
6969
|
}
|
|
6970
|
+
if (isFractionWidget(widget)) {
|
|
6971
|
+
return /* @__PURE__ */ jsx(
|
|
6972
|
+
FractionInput,
|
|
6973
|
+
{
|
|
6974
|
+
widgetId,
|
|
6975
|
+
numerator: widget.numerator,
|
|
6976
|
+
denominator: widget.denominator,
|
|
6977
|
+
values,
|
|
6978
|
+
droppedInputs,
|
|
6979
|
+
validationStatus,
|
|
6980
|
+
onChange,
|
|
6981
|
+
onFocus: onInputFocus
|
|
6982
|
+
}
|
|
6983
|
+
);
|
|
6984
|
+
}
|
|
6762
6985
|
return null;
|
|
6763
6986
|
};
|
|
6764
6987
|
const EPSILON = 10 ** -INTERNAL_PRECISION;
|
|
@@ -7266,6 +7489,7 @@ const NumberLineRenderer = ({
|
|
|
7266
7489
|
widgets,
|
|
7267
7490
|
values,
|
|
7268
7491
|
droppedInputs,
|
|
7492
|
+
validationDetails,
|
|
7269
7493
|
onChange,
|
|
7270
7494
|
onInputFocus
|
|
7271
7495
|
}) => {
|
|
@@ -7333,6 +7557,7 @@ const NumberLineRenderer = ({
|
|
|
7333
7557
|
widgets,
|
|
7334
7558
|
values,
|
|
7335
7559
|
droppedInputs,
|
|
7560
|
+
validationDetails,
|
|
7336
7561
|
onChange,
|
|
7337
7562
|
onInputFocus
|
|
7338
7563
|
},
|
|
@@ -7457,6 +7682,7 @@ const LayoutRenderer = ({
|
|
|
7457
7682
|
widgets,
|
|
7458
7683
|
values,
|
|
7459
7684
|
droppedInputs,
|
|
7685
|
+
validationDetails,
|
|
7460
7686
|
onChange,
|
|
7461
7687
|
onInputFocus
|
|
7462
7688
|
}) => {
|
|
@@ -7509,13 +7735,18 @@ const LayoutRenderer = ({
|
|
|
7509
7735
|
case JSON_TYPES.WIDGET_REF: {
|
|
7510
7736
|
const widget = widgets[item.widgetId];
|
|
7511
7737
|
if (!widget) return null;
|
|
7738
|
+
const isCorrect = validationDetails?.[item.widgetId];
|
|
7739
|
+
const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
|
|
7512
7740
|
return /* @__PURE__ */ jsx(
|
|
7513
7741
|
WidgetRenderer,
|
|
7514
7742
|
{
|
|
7515
7743
|
widget,
|
|
7516
7744
|
widgetId: item.widgetId,
|
|
7517
7745
|
value: values[item.widgetId] || "",
|
|
7746
|
+
values,
|
|
7518
7747
|
isDropped: droppedInputs.has(item.widgetId),
|
|
7748
|
+
validationStatus,
|
|
7749
|
+
droppedInputs,
|
|
7519
7750
|
onChange,
|
|
7520
7751
|
onInputFocus
|
|
7521
7752
|
}
|
|
@@ -7535,6 +7766,7 @@ const LayoutRenderer = ({
|
|
|
7535
7766
|
widgets,
|
|
7536
7767
|
values,
|
|
7537
7768
|
droppedInputs,
|
|
7769
|
+
validationDetails,
|
|
7538
7770
|
onChange,
|
|
7539
7771
|
onInputFocus
|
|
7540
7772
|
},
|
|
@@ -7553,6 +7785,7 @@ const LayoutRenderer = ({
|
|
|
7553
7785
|
widgets,
|
|
7554
7786
|
values,
|
|
7555
7787
|
droppedInputs,
|
|
7788
|
+
validationDetails,
|
|
7556
7789
|
onChange,
|
|
7557
7790
|
onInputFocus
|
|
7558
7791
|
},
|
|
@@ -7569,6 +7802,7 @@ const LayoutRenderer = ({
|
|
|
7569
7802
|
widgets,
|
|
7570
7803
|
values,
|
|
7571
7804
|
droppedInputs,
|
|
7805
|
+
validationDetails,
|
|
7572
7806
|
onChange,
|
|
7573
7807
|
onInputFocus
|
|
7574
7808
|
}
|
|
@@ -7596,9 +7830,15 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7596
7830
|
const [values, setValues] = useState({});
|
|
7597
7831
|
const [droppedSources, setDroppedSources] = useState(/* @__PURE__ */ new Map());
|
|
7598
7832
|
const [focusedInputId, setFocusedInputId] = useState(null);
|
|
7833
|
+
const [validationDetails, setValidationDetails] = useState(null);
|
|
7599
7834
|
useImperativeHandle(ref, () => ({
|
|
7600
7835
|
submit: () => {
|
|
7601
7836
|
const result = validate(json, values);
|
|
7837
|
+
const details = {};
|
|
7838
|
+
for (const [id, v] of Object.entries(result.details)) {
|
|
7839
|
+
details[id] = v.isCorrect;
|
|
7840
|
+
}
|
|
7841
|
+
setValidationDetails(details);
|
|
7602
7842
|
onSubmit?.(values);
|
|
7603
7843
|
return result;
|
|
7604
7844
|
},
|
|
@@ -7607,6 +7847,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7607
7847
|
setValues({});
|
|
7608
7848
|
setDroppedSources(/* @__PURE__ */ new Map());
|
|
7609
7849
|
setFocusedInputId(null);
|
|
7850
|
+
setValidationDetails(null);
|
|
7610
7851
|
},
|
|
7611
7852
|
focusFirstInput: () => {
|
|
7612
7853
|
const container = document.querySelector(".math-exercise-renderer");
|
|
@@ -7720,6 +7961,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7720
7961
|
widgets: json.widgets,
|
|
7721
7962
|
values,
|
|
7722
7963
|
droppedInputs,
|
|
7964
|
+
validationDetails,
|
|
7723
7965
|
onChange: handleChange,
|
|
7724
7966
|
onInputFocus: setFocusedInputId
|
|
7725
7967
|
},
|
|
@@ -7734,6 +7976,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7734
7976
|
widgets: json.widgets,
|
|
7735
7977
|
values,
|
|
7736
7978
|
droppedInputs,
|
|
7979
|
+
validationDetails,
|
|
7737
7980
|
onChange: handleChange,
|
|
7738
7981
|
onInputFocus: setFocusedInputId
|
|
7739
7982
|
},
|
|
@@ -7749,6 +7992,7 @@ const ExerciseRenderer = forwardRef(({
|
|
|
7749
7992
|
widgets: json.widgets,
|
|
7750
7993
|
values,
|
|
7751
7994
|
droppedInputs,
|
|
7995
|
+
validationDetails,
|
|
7752
7996
|
onChange: handleChange,
|
|
7753
7997
|
onInputFocus: setFocusedInputId
|
|
7754
7998
|
},
|
|
@@ -7838,6 +8082,8 @@ export {
|
|
|
7838
8082
|
ExerciseMetaSchema,
|
|
7839
8083
|
ExerciseRenderer,
|
|
7840
8084
|
ExpressionValidationSchema,
|
|
8085
|
+
FractionInlineSchema,
|
|
8086
|
+
FractionJSONSchema,
|
|
7841
8087
|
FreeTextInlineSchema,
|
|
7842
8088
|
GroupItemSchema,
|
|
7843
8089
|
HighlightedTextInlineSchema,
|
|
@@ -7885,6 +8131,7 @@ export {
|
|
|
7885
8131
|
createDragOptions,
|
|
7886
8132
|
createExerciseDocument,
|
|
7887
8133
|
createExpressionValidation,
|
|
8134
|
+
createFraction,
|
|
7888
8135
|
createFreeText,
|
|
7889
8136
|
createHighlightedText,
|
|
7890
8137
|
createLineBlock,
|
|
@@ -7897,6 +8144,7 @@ export {
|
|
|
7897
8144
|
evaluate,
|
|
7898
8145
|
extractVariables as extractExpressionVariables,
|
|
7899
8146
|
findValidCombination,
|
|
8147
|
+
getFractionKey,
|
|
7900
8148
|
getMissingInputs,
|
|
7901
8149
|
isBoldText,
|
|
7902
8150
|
isColorText,
|
|
@@ -7904,6 +8152,9 @@ export {
|
|
|
7904
8152
|
isDragOptionsJSON,
|
|
7905
8153
|
isExerciseDocument,
|
|
7906
8154
|
isExpressionValidation,
|
|
8155
|
+
isFraction,
|
|
8156
|
+
isFractionJSON,
|
|
8157
|
+
isFractionKey,
|
|
7907
8158
|
isFreeText,
|
|
7908
8159
|
isGroupItem,
|
|
7909
8160
|
isHighlightedText,
|
|
@@ -7930,6 +8181,7 @@ export {
|
|
|
7930
8181
|
isValidationNode,
|
|
7931
8182
|
isWidgetRef,
|
|
7932
8183
|
parse,
|
|
8184
|
+
parseFractionKey,
|
|
7933
8185
|
parseSafe,
|
|
7934
8186
|
renderExercise,
|
|
7935
8187
|
toAST,
|