@mathscan/math-exercise-engine 1.0.15 → 1.0.17
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 +3031 -81
- package/dist/index.js +1047 -104
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1047 -104
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +240 -64
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -54,6 +54,8 @@ const AST_TYPES = {
|
|
|
54
54
|
MATCHER_ROW: "MatcherRow",
|
|
55
55
|
/** Item within a matcher row */
|
|
56
56
|
MATCHER_ITEM: "MatcherItem",
|
|
57
|
+
/** Visual fraction inline widget */
|
|
58
|
+
VISUAL_FRACTION: "VisualFraction",
|
|
57
59
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
58
60
|
// Tools
|
|
59
61
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -134,7 +136,9 @@ const JSON_TYPES = {
|
|
|
134
136
|
/** Numeric keypad widget */
|
|
135
137
|
NUMERIC_KEYPAD: "numeric-keypad",
|
|
136
138
|
/** Inline fraction widget */
|
|
137
|
-
FRACTION: "fraction"
|
|
139
|
+
FRACTION: "fraction",
|
|
140
|
+
/** Visual fraction widget */
|
|
141
|
+
VISUAL_FRACTION: "visual-fraction"
|
|
138
142
|
};
|
|
139
143
|
const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
|
|
140
144
|
const VALIDATION_MODES = {
|
|
@@ -156,6 +160,7 @@ const SIDE_PAD = 7;
|
|
|
156
160
|
const CSS_VAR_SIDE_PAD = "--nl-side-pad";
|
|
157
161
|
const NUMBER_LINE_LABELS = ["all", "extremes", "min-only", "max-only", "none"];
|
|
158
162
|
const NUMBER_LINE_HIGHLIGHT_COLORS = ["blue", "red", "green", "orange", "purple", "yellow"];
|
|
163
|
+
const NUMBER_LINE_DRAG_MODES = ["axis", "inputs"];
|
|
159
164
|
function getFractionKey(widgetId, part) {
|
|
160
165
|
return `${widgetId}__${part}`;
|
|
161
166
|
}
|
|
@@ -268,6 +273,10 @@ const HIGHLIGHT = chevrotain.createToken({
|
|
|
268
273
|
name: "HIGHLIGHT",
|
|
269
274
|
pattern: /HIGHLIGHT(?!_TEXT)/
|
|
270
275
|
});
|
|
276
|
+
const VISUAL_FRACTION = chevrotain.createToken({
|
|
277
|
+
name: "VISUAL_FRACTION",
|
|
278
|
+
pattern: /VISUAL_FRACTION/
|
|
279
|
+
});
|
|
271
280
|
const FRACTION = chevrotain.createToken({
|
|
272
281
|
name: "FRACTION",
|
|
273
282
|
pattern: /FRACTION/
|
|
@@ -397,6 +406,7 @@ const allTokens = [
|
|
|
397
406
|
CELL,
|
|
398
407
|
COUNTER,
|
|
399
408
|
HIGHLIGHT,
|
|
409
|
+
VISUAL_FRACTION,
|
|
400
410
|
FRACTION,
|
|
401
411
|
MATCHER,
|
|
402
412
|
ITEM,
|
|
@@ -862,6 +872,8 @@ class MathExerciseParser extends chevrotain.CstParser {
|
|
|
862
872
|
{ ALT: () => this.SUBRULE(this.fraction) },
|
|
863
873
|
// IMAGE (inline image element)
|
|
864
874
|
{ ALT: () => this.SUBRULE(this.image) },
|
|
875
|
+
// VISUAL_FRACTION — always allowed; display-only when answer=[] or absence of answer parameter, interactive when answer=[...] with values.
|
|
876
|
+
{ ALT: () => this.SUBRULE(this.visualFraction) },
|
|
865
877
|
// W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
|
|
866
878
|
{ GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) }
|
|
867
879
|
]);
|
|
@@ -899,6 +911,41 @@ class MathExerciseParser extends chevrotain.CstParser {
|
|
|
899
911
|
this.SUBRULE(this.parameterList);
|
|
900
912
|
this.CONSUME(RBracket);
|
|
901
913
|
});
|
|
914
|
+
this.visualFraction = this.RULE("visualFraction", () => {
|
|
915
|
+
this.CONSUME(VISUAL_FRACTION);
|
|
916
|
+
this.CONSUME(LBracket);
|
|
917
|
+
this.SUBRULE(this.parameterList);
|
|
918
|
+
this.CONSUME(RBracket);
|
|
919
|
+
});
|
|
920
|
+
this.numberArray = this.RULE("numberArray", () => {
|
|
921
|
+
this.CONSUME(LBracket);
|
|
922
|
+
this.MANY(() => {
|
|
923
|
+
this.CONSUME(Space);
|
|
924
|
+
});
|
|
925
|
+
this.OPTION(() => {
|
|
926
|
+
this.CONSUME(NumberLiteral, { LABEL: "items" });
|
|
927
|
+
this.MANY2(() => {
|
|
928
|
+
this.MANY3(() => {
|
|
929
|
+
this.CONSUME2(Space);
|
|
930
|
+
});
|
|
931
|
+
this.CONSUME(Comma);
|
|
932
|
+
this.MANY4(() => {
|
|
933
|
+
this.CONSUME3(Space);
|
|
934
|
+
});
|
|
935
|
+
this.CONSUME2(NumberLiteral, { LABEL: "items" });
|
|
936
|
+
});
|
|
937
|
+
this.OPTION2(() => {
|
|
938
|
+
this.MANY5(() => {
|
|
939
|
+
this.CONSUME4(Space);
|
|
940
|
+
});
|
|
941
|
+
this.CONSUME2(Comma);
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
this.MANY6(() => {
|
|
945
|
+
this.CONSUME5(Space);
|
|
946
|
+
});
|
|
947
|
+
this.CONSUME(RBracket);
|
|
948
|
+
});
|
|
902
949
|
this.inlineInput = this.RULE("inlineInput", () => {
|
|
903
950
|
this.OR([
|
|
904
951
|
{ ALT: () => this.CONSUME(W_S_I, { LABEL: "stringInput" }) },
|
|
@@ -986,7 +1033,9 @@ class MathExerciseParser extends chevrotain.CstParser {
|
|
|
986
1033
|
// Tuple value: (start, end)
|
|
987
1034
|
{ ALT: () => this.SUBRULE(this.tuple, { LABEL: "value" }) },
|
|
988
1035
|
// Curly-brace string list: {"a", "b", "c"}
|
|
989
|
-
{ ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) }
|
|
1036
|
+
{ ALT: () => this.SUBRULE(this.curlyStringList, { LABEL: "value" }) },
|
|
1037
|
+
// Square-bracket number array: [1, 2, 3] — used by VISUAL_FRACTION filled/answer
|
|
1038
|
+
{ ALT: () => this.SUBRULE(this.numberArray, { LABEL: "value" }) }
|
|
990
1039
|
]);
|
|
991
1040
|
});
|
|
992
1041
|
this.tuple = this.RULE("tuple", () => {
|
|
@@ -1042,6 +1091,25 @@ class MathExerciseParser extends chevrotain.CstParser {
|
|
|
1042
1091
|
}
|
|
1043
1092
|
}
|
|
1044
1093
|
const parser = new MathExerciseParser();
|
|
1094
|
+
function parseOrThrow(schema, data, label) {
|
|
1095
|
+
const result = schema.safeParse(data);
|
|
1096
|
+
if (!result.success) {
|
|
1097
|
+
const issue = result.error.issues[0];
|
|
1098
|
+
if (!issue) throw new Error(`${label}: validation failed`);
|
|
1099
|
+
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
|
|
1100
|
+
throw new Error(`${label}: ${path} ${issue.message}`);
|
|
1101
|
+
}
|
|
1102
|
+
return result.data;
|
|
1103
|
+
}
|
|
1104
|
+
const VISUAL_FRACTION_SHAPES = ["bar", "circle", "triangle", "grid"];
|
|
1105
|
+
const VISUAL_FRACTION_SHAPE = {
|
|
1106
|
+
BAR: "bar",
|
|
1107
|
+
CIRCLE: "circle",
|
|
1108
|
+
TRIANGLE: "triangle",
|
|
1109
|
+
GRID: "grid"
|
|
1110
|
+
};
|
|
1111
|
+
const VISUAL_FRACTION_DEFAULT_SIZE = 1.35;
|
|
1112
|
+
const VISUAL_FRACTION_MAX_DIVISIONS = 100;
|
|
1045
1113
|
const FreeTextInlineSchema = zod.z.object({
|
|
1046
1114
|
type: zod.z.literal(AST_TYPES.FREE_TEXT),
|
|
1047
1115
|
value: zod.z.string()
|
|
@@ -1071,6 +1139,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
|
|
|
1071
1139
|
name: zod.z.string(),
|
|
1072
1140
|
inputType: zod.z.literal(INPUT_TYPES.STRING),
|
|
1073
1141
|
length: zod.z.number().int().positive(),
|
|
1142
|
+
placeholder: zod.z.string().max(32).optional(),
|
|
1074
1143
|
answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
|
|
1075
1144
|
}),
|
|
1076
1145
|
zod.z.object({
|
|
@@ -1078,6 +1147,7 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
|
|
|
1078
1147
|
name: zod.z.string(),
|
|
1079
1148
|
inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
|
|
1080
1149
|
digits: zod.z.number().int().positive(),
|
|
1150
|
+
placeholder: zod.z.string().max(32).optional(),
|
|
1081
1151
|
answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
|
|
1082
1152
|
})
|
|
1083
1153
|
]);
|
|
@@ -1087,7 +1157,8 @@ const FractionInlineSchema = zod.z.object({
|
|
|
1087
1157
|
numerator: zod.z.number().optional(),
|
|
1088
1158
|
denominator: zod.z.number().optional(),
|
|
1089
1159
|
answer: zod.z.string().optional(),
|
|
1090
|
-
simplified: zod.z.boolean().default(true)
|
|
1160
|
+
simplified: zod.z.boolean().default(true),
|
|
1161
|
+
placeholder: zod.z.string().max(32).optional()
|
|
1091
1162
|
});
|
|
1092
1163
|
const IMAGE_SRC_SCHEME_RE$1 = /^(https?:\/\/|data:image\/)/i;
|
|
1093
1164
|
const ImageInlineSchema = zod.z.object({
|
|
@@ -1100,6 +1171,60 @@ const ImageInlineSchema = zod.z.object({
|
|
|
1100
1171
|
width: zod.z.number().positive().optional(),
|
|
1101
1172
|
height: zod.z.number().positive().optional()
|
|
1102
1173
|
});
|
|
1174
|
+
const VisualFractionInlineSchema = zod.z.object({
|
|
1175
|
+
type: zod.z.literal(AST_TYPES.VISUAL_FRACTION),
|
|
1176
|
+
name: zod.z.string(),
|
|
1177
|
+
shape: zod.z.enum(VISUAL_FRACTION_SHAPES).default("bar"),
|
|
1178
|
+
divisions: zod.z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
|
|
1179
|
+
message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
|
|
1180
|
+
}),
|
|
1181
|
+
filled: zod.z.array(zod.z.number().int().nonnegative()).default([]),
|
|
1182
|
+
answer: zod.z.array(zod.z.number().int().nonnegative()).default([]),
|
|
1183
|
+
orientation: zod.z.enum(["horizontal", "vertical"]).optional(),
|
|
1184
|
+
label: zod.z.string().optional(),
|
|
1185
|
+
/** Unified author size.
|
|
1186
|
+
* - bar / grid: overrides `--visual-fraction-part-size` (cell size).
|
|
1187
|
+
* - circle / triangle: maps to `--visual-fraction-size` with the renderer scale factor.
|
|
1188
|
+
* Omit to use CSS defaults. */
|
|
1189
|
+
size: zod.z.number().positive().optional()
|
|
1190
|
+
}).superRefine(validateVisualFractionCapacity);
|
|
1191
|
+
function validateVisualFractionCapacity(data, ctx) {
|
|
1192
|
+
const filledTotal = data.filled.reduce((sum, count) => sum + count, 0);
|
|
1193
|
+
const answerTotal = data.answer.reduce((sum, count) => sum + count, 0);
|
|
1194
|
+
if (filledTotal > data.divisions) {
|
|
1195
|
+
ctx.addIssue({
|
|
1196
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1197
|
+
message: `filled total (${filledTotal}) cannot exceed divisions (${data.divisions})`,
|
|
1198
|
+
path: ["filled"]
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
if (filledTotal + answerTotal > data.divisions) {
|
|
1202
|
+
ctx.addIssue({
|
|
1203
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1204
|
+
message: `filled + answer total (${filledTotal + answerTotal}) cannot exceed divisions (${data.divisions})`,
|
|
1205
|
+
path: ["answer"]
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
const shape = data.shape;
|
|
1209
|
+
if (shape === "triangle" && !isPerfectSquare(data.divisions)) {
|
|
1210
|
+
ctx.addIssue({
|
|
1211
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1212
|
+
message: `triangle divisions (${data.divisions}) must be a perfect square to render equal triangle parts`,
|
|
1213
|
+
path: ["divisions"]
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
if (shape === "grid" && !isPerfectSquare(data.divisions)) {
|
|
1217
|
+
ctx.addIssue({
|
|
1218
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1219
|
+
message: `grid divisions (${data.divisions}) must be a perfect square to render equal rows and columns`,
|
|
1220
|
+
path: ["divisions"]
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
function isPerfectSquare(value) {
|
|
1225
|
+
const root = Math.sqrt(value);
|
|
1226
|
+
return Number.isInteger(root);
|
|
1227
|
+
}
|
|
1103
1228
|
const InlineNodeSchema = zod.z.union([
|
|
1104
1229
|
FreeTextInlineSchema,
|
|
1105
1230
|
SpaceInlineSchema,
|
|
@@ -1108,7 +1233,8 @@ const InlineNodeSchema = zod.z.union([
|
|
|
1108
1233
|
BoldTextInlineSchema,
|
|
1109
1234
|
InlineInputWidgetSchema,
|
|
1110
1235
|
FractionInlineSchema,
|
|
1111
|
-
ImageInlineSchema
|
|
1236
|
+
ImageInlineSchema,
|
|
1237
|
+
VisualFractionInlineSchema
|
|
1112
1238
|
]);
|
|
1113
1239
|
function createFreeText(value) {
|
|
1114
1240
|
return FreeTextInlineSchema.parse({
|
|
@@ -1143,23 +1269,25 @@ function createBoldText(value, highlight) {
|
|
|
1143
1269
|
highlight
|
|
1144
1270
|
});
|
|
1145
1271
|
}
|
|
1146
|
-
function createStringInput(name, length, answer) {
|
|
1147
|
-
return InlineInputWidgetSchema
|
|
1272
|
+
function createStringInput(name, length, answer, placeholder) {
|
|
1273
|
+
return parseOrThrow(InlineInputWidgetSchema, {
|
|
1148
1274
|
type: AST_TYPES.INLINE_INPUT,
|
|
1149
1275
|
name,
|
|
1150
1276
|
inputType: INPUT_TYPES.STRING,
|
|
1151
1277
|
length,
|
|
1278
|
+
placeholder,
|
|
1152
1279
|
answer
|
|
1153
|
-
});
|
|
1280
|
+
}, "STRING_INPUT");
|
|
1154
1281
|
}
|
|
1155
|
-
function createNumericInput(name, digits, answer) {
|
|
1156
|
-
return InlineInputWidgetSchema
|
|
1282
|
+
function createNumericInput(name, digits, answer, placeholder) {
|
|
1283
|
+
return parseOrThrow(InlineInputWidgetSchema, {
|
|
1157
1284
|
type: AST_TYPES.INLINE_INPUT,
|
|
1158
1285
|
name,
|
|
1159
1286
|
inputType: INPUT_TYPES.NUMERIC,
|
|
1160
1287
|
digits,
|
|
1288
|
+
placeholder,
|
|
1161
1289
|
answer
|
|
1162
|
-
});
|
|
1290
|
+
}, "NUMERIC_INPUT");
|
|
1163
1291
|
}
|
|
1164
1292
|
function createImage(name, src, alt, options = {}) {
|
|
1165
1293
|
return ImageInlineSchema.parse({
|
|
@@ -1177,6 +1305,14 @@ function createFraction(name, options = {}) {
|
|
|
1177
1305
|
...options
|
|
1178
1306
|
});
|
|
1179
1307
|
}
|
|
1308
|
+
function createVisualFraction(name, divisions, options = {}) {
|
|
1309
|
+
return VisualFractionInlineSchema.parse({
|
|
1310
|
+
type: AST_TYPES.VISUAL_FRACTION,
|
|
1311
|
+
name,
|
|
1312
|
+
divisions,
|
|
1313
|
+
...options
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1180
1316
|
function isFreeText(value) {
|
|
1181
1317
|
return FreeTextInlineSchema.safeParse(value).success;
|
|
1182
1318
|
}
|
|
@@ -1195,6 +1331,9 @@ function isImage(value) {
|
|
|
1195
1331
|
function isInlineNode(value) {
|
|
1196
1332
|
return InlineNodeSchema.safeParse(value).success;
|
|
1197
1333
|
}
|
|
1334
|
+
function isVisualFractionInline(value) {
|
|
1335
|
+
return VisualFractionInlineSchema.safeParse(value).success;
|
|
1336
|
+
}
|
|
1198
1337
|
const TextBlockSchema = zod.z.object({
|
|
1199
1338
|
type: zod.z.literal(AST_TYPES.TEXT_BLOCK),
|
|
1200
1339
|
value: zod.z.string()
|
|
@@ -1301,6 +1440,11 @@ const NumberLineBlockSchema = zod.z.object({
|
|
|
1301
1440
|
}).optional(),
|
|
1302
1441
|
counter: NumberLineCounterSchema.optional(),
|
|
1303
1442
|
highlights: zod.z.array(NumberLineHighlightSchema).optional(),
|
|
1443
|
+
dragMode: zod.z.enum(NUMBER_LINE_DRAG_MODES, {
|
|
1444
|
+
errorMap: () => ({
|
|
1445
|
+
message: `NUMBER_LINE: dragMode must be one of ${NUMBER_LINE_DRAG_MODES.join(", ")}`
|
|
1446
|
+
})
|
|
1447
|
+
}).optional(),
|
|
1304
1448
|
positions: zod.z.array(NumberLinePositionSchema)
|
|
1305
1449
|
}).refine((data) => data.min < data.max, {
|
|
1306
1450
|
message: "NUMBER_LINE: min must be less than max"
|
|
@@ -1338,7 +1482,7 @@ const NumberLineBlockSchema = zod.z.object({
|
|
|
1338
1482
|
}
|
|
1339
1483
|
}
|
|
1340
1484
|
});
|
|
1341
|
-
function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights) {
|
|
1485
|
+
function createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights, dragMode) {
|
|
1342
1486
|
return NumberLineBlockSchema.parse({
|
|
1343
1487
|
type: AST_TYPES.NUMBER_LINE_BLOCK,
|
|
1344
1488
|
min,
|
|
@@ -1349,6 +1493,7 @@ function createNumberLineBlock(min, max, positions, step, minorTicks, style, lab
|
|
|
1349
1493
|
labelVisibility,
|
|
1350
1494
|
counter,
|
|
1351
1495
|
highlights,
|
|
1496
|
+
dragMode,
|
|
1352
1497
|
positions
|
|
1353
1498
|
});
|
|
1354
1499
|
}
|
|
@@ -1390,6 +1535,10 @@ const DisplayFractionInlineSchema = FractionInlineSchema.refine(
|
|
|
1390
1535
|
(f) => f.numerator !== void 0 && f.denominator !== void 0,
|
|
1391
1536
|
{ message: "FRACTION inside MATCHER ITEM must be in display mode (both numerator and denominator required)" }
|
|
1392
1537
|
);
|
|
1538
|
+
const DisplayVisualFractionInlineSchema = VisualFractionInlineSchema.refine(
|
|
1539
|
+
(vf) => vf.answer.length === 0,
|
|
1540
|
+
{ message: "VISUAL_FRACTION inside MATCHER ITEM must be display-only (answer must be empty: answer=[])" }
|
|
1541
|
+
);
|
|
1393
1542
|
const DisplayInlineNodeSchema = zod.z.union([
|
|
1394
1543
|
FreeTextInlineSchema,
|
|
1395
1544
|
SpaceInlineSchema,
|
|
@@ -1397,7 +1546,8 @@ const DisplayInlineNodeSchema = zod.z.union([
|
|
|
1397
1546
|
ColorTextInlineSchema,
|
|
1398
1547
|
BoldTextInlineSchema,
|
|
1399
1548
|
DisplayFractionInlineSchema,
|
|
1400
|
-
ImageInlineSchema
|
|
1549
|
+
ImageInlineSchema,
|
|
1550
|
+
DisplayVisualFractionInlineSchema
|
|
1401
1551
|
]);
|
|
1402
1552
|
const MatcherItemSchema = zod.z.object({
|
|
1403
1553
|
type: zod.z.literal(AST_TYPES.MATCHER_ITEM),
|
|
@@ -1493,7 +1643,8 @@ const DragOptionsToolSchema = zod.z.object({
|
|
|
1493
1643
|
const NumericKeypadToolSchema = zod.z.object({
|
|
1494
1644
|
type: zod.z.literal(AST_TYPES.NUMERIC_KEYPAD),
|
|
1495
1645
|
includeOperators: zod.z.boolean().optional(),
|
|
1496
|
-
includeDecimal: zod.z.boolean().optional()
|
|
1646
|
+
includeDecimal: zod.z.boolean().optional(),
|
|
1647
|
+
style: zod.z.enum(["diamond", "classic"]).optional()
|
|
1497
1648
|
});
|
|
1498
1649
|
const ToolNodeSchema = zod.z.discriminatedUnion("type", [
|
|
1499
1650
|
DragOptionsToolSchema,
|
|
@@ -1505,11 +1656,12 @@ function createDragOptions(options) {
|
|
|
1505
1656
|
options
|
|
1506
1657
|
});
|
|
1507
1658
|
}
|
|
1508
|
-
function createNumericKeypad(includeOperators, includeDecimal) {
|
|
1659
|
+
function createNumericKeypad(includeOperators, includeDecimal, style) {
|
|
1509
1660
|
return NumericKeypadToolSchema.parse({
|
|
1510
1661
|
type: AST_TYPES.NUMERIC_KEYPAD,
|
|
1511
1662
|
includeOperators,
|
|
1512
|
-
includeDecimal
|
|
1663
|
+
includeDecimal,
|
|
1664
|
+
style
|
|
1513
1665
|
});
|
|
1514
1666
|
}
|
|
1515
1667
|
function isDragOptions(value) {
|
|
@@ -1706,6 +1858,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1706
1858
|
const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
|
|
1707
1859
|
const style = params.style;
|
|
1708
1860
|
const labelVisibility = params.labelVisibility;
|
|
1861
|
+
const dragMode = params.dragMode;
|
|
1709
1862
|
const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
|
|
1710
1863
|
const rawCounters = ctx.counter;
|
|
1711
1864
|
if (rawCounters && rawCounters.length > 1) {
|
|
@@ -1713,7 +1866,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1713
1866
|
}
|
|
1714
1867
|
const counter = rawCounters ? this.visit(rawCounters) : void 0;
|
|
1715
1868
|
const highlights = ctx.highlight ? ctx.highlight.map((h) => this.visit(h)) : [];
|
|
1716
|
-
return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights);
|
|
1869
|
+
return createNumberLineBlock(min, max, positions, step, minorTicks, style, labelVisibility, counter, highlights, dragMode);
|
|
1717
1870
|
}
|
|
1718
1871
|
position(ctx) {
|
|
1719
1872
|
const params = this.visit(ctx.parameterList);
|
|
@@ -1863,6 +2016,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1863
2016
|
if (ctx.image) {
|
|
1864
2017
|
return this.visit(ctx.image);
|
|
1865
2018
|
}
|
|
2019
|
+
if (ctx.visualFraction) {
|
|
2020
|
+
return this.visit(ctx.visualFraction);
|
|
2021
|
+
}
|
|
1866
2022
|
if (ctx.inlineInput) {
|
|
1867
2023
|
return this.visit(ctx.inlineInput);
|
|
1868
2024
|
}
|
|
@@ -1903,7 +2059,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1903
2059
|
const denominator = params.denominator;
|
|
1904
2060
|
const answer = params.answer;
|
|
1905
2061
|
const simplified = params.simplified !== void 0 ? params.simplified : void 0;
|
|
1906
|
-
|
|
2062
|
+
const placeholder = params.placeholder;
|
|
2063
|
+
return createFraction(name, { numerator, denominator, answer, simplified, placeholder });
|
|
1907
2064
|
}
|
|
1908
2065
|
image(ctx) {
|
|
1909
2066
|
const params = this.visit(ctx.parameterList);
|
|
@@ -1914,16 +2071,32 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1914
2071
|
const height = params.height;
|
|
1915
2072
|
return createImage(name, src, alt, { width, height });
|
|
1916
2073
|
}
|
|
2074
|
+
visualFraction(ctx) {
|
|
2075
|
+
const params = this.visit(ctx.parameterList);
|
|
2076
|
+
const name = params.name;
|
|
2077
|
+
const divisions = params.divisions;
|
|
2078
|
+
const shape = params.shape;
|
|
2079
|
+
const filled = params.filled ?? [];
|
|
2080
|
+
const answer = params.answer ?? [];
|
|
2081
|
+
const orientation = params.orientation;
|
|
2082
|
+
const label = params.label !== void 0 ? params.label : void 0;
|
|
2083
|
+
const size = params.size !== void 0 ? params.size : void 0;
|
|
2084
|
+
return createVisualFraction(name, divisions, { shape, filled, answer, orientation, label, size });
|
|
2085
|
+
}
|
|
2086
|
+
numberArray(ctx) {
|
|
2087
|
+
return ctx.items ? ctx.items.map((t) => parseFloat(t.image)) : [];
|
|
2088
|
+
}
|
|
1917
2089
|
inlineInput(ctx) {
|
|
1918
2090
|
const params = this.visit(ctx.parameterList);
|
|
1919
2091
|
const name = params.name;
|
|
1920
2092
|
const answer = params.answer;
|
|
2093
|
+
const placeholder = params.placeholder;
|
|
1921
2094
|
if (ctx.stringInput) {
|
|
1922
2095
|
const length = params.length;
|
|
1923
|
-
return createStringInput(name, length, answer);
|
|
2096
|
+
return createStringInput(name, length, answer, placeholder);
|
|
1924
2097
|
}
|
|
1925
2098
|
const digits = params.digits;
|
|
1926
|
-
return createNumericInput(name, digits, answer);
|
|
2099
|
+
return createNumericInput(name, digits, answer, placeholder);
|
|
1927
2100
|
}
|
|
1928
2101
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1929
2102
|
// Tools
|
|
@@ -1945,7 +2118,8 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1945
2118
|
const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
|
|
1946
2119
|
const includeOperators = params.includeOperators === true || params.includeOperators === "true";
|
|
1947
2120
|
const includeDecimal = params.includeDecimal === true || params.includeDecimal === "true";
|
|
1948
|
-
|
|
2121
|
+
const style = params.style;
|
|
2122
|
+
return createNumericKeypad(includeOperators || void 0, includeDecimal || void 0, style);
|
|
1949
2123
|
}
|
|
1950
2124
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1951
2125
|
// Validation
|
|
@@ -2170,6 +2344,7 @@ const NumberLineItemSchema = zod.z.object({
|
|
|
2170
2344
|
labelVisibility: zod.z.enum(NUMBER_LINE_LABELS).optional(),
|
|
2171
2345
|
counter: NumberLineCounterItemSchema.optional(),
|
|
2172
2346
|
highlights: zod.z.array(NumberLineHighlightItemSchema).optional(),
|
|
2347
|
+
dragMode: zod.z.enum(NUMBER_LINE_DRAG_MODES).optional(),
|
|
2173
2348
|
positions: zod.z.array(NumberLinePositionItemSchema)
|
|
2174
2349
|
});
|
|
2175
2350
|
const MatcherItemContentSchema = zod.z.object({
|
|
@@ -2188,12 +2363,14 @@ const MatcherRowItemSchema = zod.z.object({
|
|
|
2188
2363
|
const StringInputJSONSchema = zod.z.object({
|
|
2189
2364
|
type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
|
|
2190
2365
|
inputType: zod.z.literal(INPUT_TYPES.STRING),
|
|
2191
|
-
length: zod.z.number().int().positive()
|
|
2366
|
+
length: zod.z.number().int().positive(),
|
|
2367
|
+
placeholder: zod.z.string().max(32).optional()
|
|
2192
2368
|
});
|
|
2193
2369
|
const NumericInputJSONSchema = zod.z.object({
|
|
2194
2370
|
type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
|
|
2195
2371
|
inputType: zod.z.literal(INPUT_TYPES.NUMERIC),
|
|
2196
|
-
digits: zod.z.number().int().positive()
|
|
2372
|
+
digits: zod.z.number().int().positive(),
|
|
2373
|
+
placeholder: zod.z.string().max(32).optional()
|
|
2197
2374
|
});
|
|
2198
2375
|
const InlineInputJSONSchema = zod.z.discriminatedUnion("inputType", [
|
|
2199
2376
|
StringInputJSONSchema,
|
|
@@ -2210,13 +2387,15 @@ const RadioOptionsJSONSchema = zod.z.object({
|
|
|
2210
2387
|
const NumericKeypadJSONSchema = zod.z.object({
|
|
2211
2388
|
type: zod.z.literal(JSON_TYPES.NUMERIC_KEYPAD),
|
|
2212
2389
|
includeOperators: zod.z.boolean().optional(),
|
|
2213
|
-
includeDecimal: zod.z.boolean().optional()
|
|
2390
|
+
includeDecimal: zod.z.boolean().optional(),
|
|
2391
|
+
style: zod.z.enum(["diamond", "classic"]).optional()
|
|
2214
2392
|
});
|
|
2215
2393
|
const FractionJSONSchema = zod.z.object({
|
|
2216
2394
|
type: zod.z.literal(JSON_TYPES.FRACTION),
|
|
2217
2395
|
numerator: zod.z.number().nullable(),
|
|
2218
2396
|
denominator: zod.z.number().nullable(),
|
|
2219
|
-
simplified: zod.z.boolean()
|
|
2397
|
+
simplified: zod.z.boolean(),
|
|
2398
|
+
placeholder: zod.z.string().max(32).optional()
|
|
2220
2399
|
});
|
|
2221
2400
|
const MatcherWidgetJSONSchema = zod.z.object({
|
|
2222
2401
|
type: zod.z.literal(JSON_TYPES.MATCHER),
|
|
@@ -2226,24 +2405,44 @@ const MatcherWidgetJSONSchema = zod.z.object({
|
|
|
2226
2405
|
/** Number of answer groups — equals answers.length, stored here to avoid prop-drilling. */
|
|
2227
2406
|
groupCount: zod.z.number().int().min(1)
|
|
2228
2407
|
});
|
|
2408
|
+
const VisualFractionJSONSchema = zod.z.object({
|
|
2409
|
+
type: zod.z.literal(JSON_TYPES.VISUAL_FRACTION),
|
|
2410
|
+
shape: zod.z.enum(["bar", "circle", "triangle", "grid"]).default("bar"),
|
|
2411
|
+
divisions: zod.z.number().int().positive().max(VISUAL_FRACTION_MAX_DIVISIONS, {
|
|
2412
|
+
message: `divisions cannot exceed ${VISUAL_FRACTION_MAX_DIVISIONS}`
|
|
2413
|
+
}),
|
|
2414
|
+
filled: zod.z.array(zod.z.number().int().nonnegative()),
|
|
2415
|
+
answer: zod.z.array(zod.z.number().int().nonnegative()),
|
|
2416
|
+
orientation: zod.z.enum(["horizontal", "vertical"]).optional(),
|
|
2417
|
+
/** Optional label text rendered near the shape — replaces the old showLabel boolean */
|
|
2418
|
+
label: zod.z.string().optional(),
|
|
2419
|
+
/** Overall shape size in unified author units.
|
|
2420
|
+
* - bar / grid: overrides `--visual-fraction-part-size` CSS custom property (cell size in rem).
|
|
2421
|
+
* - circle / triangle: overrides `--visual-fraction-size` through the renderer scale factor.
|
|
2422
|
+
*/
|
|
2423
|
+
size: zod.z.number().positive().optional()
|
|
2424
|
+
}).superRefine(validateVisualFractionCapacity);
|
|
2229
2425
|
const WidgetJSONSchema = zod.z.union([
|
|
2230
2426
|
InlineInputJSONSchema,
|
|
2231
2427
|
DragOptionsJSONSchema,
|
|
2232
2428
|
RadioOptionsJSONSchema,
|
|
2233
2429
|
NumericKeypadJSONSchema,
|
|
2234
2430
|
FractionJSONSchema,
|
|
2235
|
-
MatcherWidgetJSONSchema
|
|
2431
|
+
MatcherWidgetJSONSchema,
|
|
2432
|
+
VisualFractionJSONSchema
|
|
2236
2433
|
]);
|
|
2237
2434
|
const ValidationJSONSchema = zod.z.object({
|
|
2238
2435
|
mode: zod.z.enum([VALIDATION_MODES.SIMPLE, VALIDATION_MODES.EXPRESSION]),
|
|
2239
2436
|
// Simple answers: string|number per widget id.
|
|
2240
2437
|
// Matcher answers: string[][] keyed by matcher name. Outer/inner min(1)
|
|
2241
2438
|
// mirrors MatcherBlockSchema invariants in src/ast/matcher.ts.
|
|
2439
|
+
// VisualFraction answers: number[] — one count per color group.
|
|
2242
2440
|
answers: zod.z.record(
|
|
2243
2441
|
zod.z.union([
|
|
2244
2442
|
zod.z.string(),
|
|
2245
2443
|
zod.z.number(),
|
|
2246
|
-
zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1)
|
|
2444
|
+
zod.z.array(zod.z.array(zod.z.string().min(1)).min(1)).min(1),
|
|
2445
|
+
zod.z.array(zod.z.number().int().nonnegative())
|
|
2247
2446
|
])
|
|
2248
2447
|
).optional(),
|
|
2249
2448
|
expression: zod.z.string().optional(),
|
|
@@ -2394,13 +2593,15 @@ function toJSON(ast) {
|
|
|
2394
2593
|
widgets[inline.name] = {
|
|
2395
2594
|
type: JSON_TYPES.INLINE_INPUT,
|
|
2396
2595
|
inputType: INPUT_TYPES.STRING,
|
|
2397
|
-
length: inline.length
|
|
2596
|
+
length: inline.length,
|
|
2597
|
+
placeholder: inline.placeholder
|
|
2398
2598
|
};
|
|
2399
2599
|
} else {
|
|
2400
2600
|
widgets[inline.name] = {
|
|
2401
2601
|
type: JSON_TYPES.INLINE_INPUT,
|
|
2402
2602
|
inputType: INPUT_TYPES.NUMERIC,
|
|
2403
|
-
digits: inline.digits
|
|
2603
|
+
digits: inline.digits,
|
|
2604
|
+
placeholder: inline.placeholder
|
|
2404
2605
|
};
|
|
2405
2606
|
}
|
|
2406
2607
|
if (inline.answer !== void 0) {
|
|
@@ -2412,12 +2613,29 @@ function toJSON(ast) {
|
|
|
2412
2613
|
type: JSON_TYPES.FRACTION,
|
|
2413
2614
|
numerator: inline.numerator ?? null,
|
|
2414
2615
|
denominator: inline.denominator ?? null,
|
|
2415
|
-
simplified: inline.simplified ?? true
|
|
2616
|
+
simplified: inline.simplified ?? true,
|
|
2617
|
+
placeholder: inline.placeholder
|
|
2416
2618
|
};
|
|
2417
2619
|
if (inline.answer !== void 0) {
|
|
2418
2620
|
answers[inline.name] = inline.answer;
|
|
2419
2621
|
}
|
|
2420
2622
|
}
|
|
2623
|
+
if (inline.type === AST_TYPES.VISUAL_FRACTION) {
|
|
2624
|
+
const answerArr = inline.answer ?? [];
|
|
2625
|
+
widgets[inline.name] = {
|
|
2626
|
+
type: JSON_TYPES.VISUAL_FRACTION,
|
|
2627
|
+
shape: inline.shape,
|
|
2628
|
+
divisions: inline.divisions,
|
|
2629
|
+
filled: inline.filled ?? [],
|
|
2630
|
+
answer: answerArr,
|
|
2631
|
+
...inline.orientation !== void 0 && { orientation: inline.orientation },
|
|
2632
|
+
...inline.label !== void 0 && { label: inline.label },
|
|
2633
|
+
...inline.size !== void 0 && { size: inline.size }
|
|
2634
|
+
};
|
|
2635
|
+
if (answerArr.length > 0) {
|
|
2636
|
+
answers[inline.name] = answerArr;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2421
2639
|
}
|
|
2422
2640
|
for (const block of ast.lines) {
|
|
2423
2641
|
if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
@@ -2444,7 +2662,8 @@ function toJSON(ast) {
|
|
|
2444
2662
|
widgets["_numeric_keypad"] = {
|
|
2445
2663
|
type: JSON_TYPES.NUMERIC_KEYPAD,
|
|
2446
2664
|
includeOperators: tool.includeOperators,
|
|
2447
|
-
includeDecimal: tool.includeDecimal
|
|
2665
|
+
includeDecimal: tool.includeDecimal,
|
|
2666
|
+
style: tool.style
|
|
2448
2667
|
};
|
|
2449
2668
|
}
|
|
2450
2669
|
}
|
|
@@ -2526,6 +2745,7 @@ function transformNumberLine(numberLine, idGenerator) {
|
|
|
2526
2745
|
...numberLine.labelVisibility !== void 0 && { labelVisibility: numberLine.labelVisibility },
|
|
2527
2746
|
...counterItem !== void 0 && { counter: counterItem },
|
|
2528
2747
|
...highlights !== void 0 && { highlights },
|
|
2748
|
+
...numberLine.dragMode !== void 0 && { dragMode: numberLine.dragMode },
|
|
2529
2749
|
positions
|
|
2530
2750
|
};
|
|
2531
2751
|
return item;
|
|
@@ -2658,6 +2878,12 @@ function transformInline(inline, idGenerator) {
|
|
|
2658
2878
|
...inline.width !== void 0 && { width: inline.width },
|
|
2659
2879
|
...inline.height !== void 0 && { height: inline.height }
|
|
2660
2880
|
};
|
|
2881
|
+
case AST_TYPES.VISUAL_FRACTION:
|
|
2882
|
+
return {
|
|
2883
|
+
id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
|
|
2884
|
+
type: JSON_TYPES.WIDGET_REF,
|
|
2885
|
+
widgetId: inline.name
|
|
2886
|
+
};
|
|
2661
2887
|
default:
|
|
2662
2888
|
throw new Error(`Unknown inline type: ${inline.type}`);
|
|
2663
2889
|
}
|
|
@@ -8200,6 +8426,11 @@ function areMatcherEqual(expected, result) {
|
|
|
8200
8426
|
const normalize2 = (groups) => lodashExports.sortBy(groups.map((g) => lodashExports.sortBy(g)), (g) => g.join("|"));
|
|
8201
8427
|
return lodashExports.isEqual(normalize2(expected), normalize2(result));
|
|
8202
8428
|
}
|
|
8429
|
+
function areVisualFractionCountsEqual(expected, result) {
|
|
8430
|
+
if (!Array.isArray(result)) return false;
|
|
8431
|
+
if (expected.length !== result.length) return false;
|
|
8432
|
+
return lodashExports.isEqual(lodashExports.sortBy(expected), lodashExports.sortBy(result));
|
|
8433
|
+
}
|
|
8203
8434
|
const math$1 = mathjs.create(mathjs.all);
|
|
8204
8435
|
function collectNumberLines(items) {
|
|
8205
8436
|
const result = [];
|
|
@@ -8248,6 +8479,15 @@ function validateSimple(exercise, userInputs) {
|
|
|
8248
8479
|
else allCorrect = false;
|
|
8249
8480
|
continue;
|
|
8250
8481
|
}
|
|
8482
|
+
if (widget?.type === JSON_TYPES.VISUAL_FRACTION) {
|
|
8483
|
+
const userValue2 = userInputs[widgetId];
|
|
8484
|
+
const isCorrect2 = areVisualFractionCountsEqual(expected, userValue2);
|
|
8485
|
+
details[widgetId] = { widgetId, userValue: userValue2, isCorrect: isCorrect2 };
|
|
8486
|
+
totalCount++;
|
|
8487
|
+
if (isCorrect2) correctCount++;
|
|
8488
|
+
else allCorrect = false;
|
|
8489
|
+
continue;
|
|
8490
|
+
}
|
|
8251
8491
|
totalCount++;
|
|
8252
8492
|
const userValue = userInputs[widgetId];
|
|
8253
8493
|
const isFractionLenient = widget?.type === JSON_TYPES.FRACTION && widget.simplified === false;
|
|
@@ -8754,10 +8994,10 @@ function getOwnerDocument(target) {
|
|
|
8754
8994
|
}
|
|
8755
8995
|
return document;
|
|
8756
8996
|
}
|
|
8757
|
-
const useIsomorphicLayoutEffect = canUseDOM ? React.useLayoutEffect : React.useEffect;
|
|
8997
|
+
const useIsomorphicLayoutEffect$1 = canUseDOM ? React.useLayoutEffect : React.useEffect;
|
|
8758
8998
|
function useEvent(handler) {
|
|
8759
8999
|
const handlerRef = React.useRef(handler);
|
|
8760
|
-
useIsomorphicLayoutEffect(() => {
|
|
9000
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
8761
9001
|
handlerRef.current = handler;
|
|
8762
9002
|
});
|
|
8763
9003
|
return React.useCallback(function() {
|
|
@@ -8785,7 +9025,7 @@ function useLatestValue(value, dependencies) {
|
|
|
8785
9025
|
dependencies = [value];
|
|
8786
9026
|
}
|
|
8787
9027
|
const valueRef = React.useRef(value);
|
|
8788
|
-
useIsomorphicLayoutEffect(() => {
|
|
9028
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
8789
9029
|
if (valueRef.current !== value) {
|
|
8790
9030
|
valueRef.current = value;
|
|
8791
9031
|
}
|
|
@@ -10653,9 +10893,9 @@ function useResizeObserver(_ref) {
|
|
|
10653
10893
|
return void 0;
|
|
10654
10894
|
}
|
|
10655
10895
|
const {
|
|
10656
|
-
ResizeObserver
|
|
10896
|
+
ResizeObserver: ResizeObserver2
|
|
10657
10897
|
} = window;
|
|
10658
|
-
return new
|
|
10898
|
+
return new ResizeObserver2(handleResize);
|
|
10659
10899
|
},
|
|
10660
10900
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
10661
10901
|
[disabled]
|
|
@@ -10709,7 +10949,7 @@ function useRect(element, measure, fallbackRect) {
|
|
|
10709
10949
|
const resizeObserver = useResizeObserver({
|
|
10710
10950
|
callback: measureRect
|
|
10711
10951
|
});
|
|
10712
|
-
useIsomorphicLayoutEffect(() => {
|
|
10952
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
10713
10953
|
measureRect();
|
|
10714
10954
|
if (element) {
|
|
10715
10955
|
resizeObserver == null ? void 0 : resizeObserver.observe(element);
|
|
@@ -10883,7 +11123,7 @@ function useRects(elements, measure) {
|
|
|
10883
11123
|
const resizeObserver = useResizeObserver({
|
|
10884
11124
|
callback: measureRects
|
|
10885
11125
|
});
|
|
10886
|
-
useIsomorphicLayoutEffect(() => {
|
|
11126
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
10887
11127
|
resizeObserver == null ? void 0 : resizeObserver.disconnect();
|
|
10888
11128
|
measureRects();
|
|
10889
11129
|
elements.forEach((element) => resizeObserver == null ? void 0 : resizeObserver.observe(element));
|
|
@@ -11245,7 +11485,7 @@ function useLayoutShiftScrollCompensation(_ref) {
|
|
|
11245
11485
|
x: config,
|
|
11246
11486
|
y: config
|
|
11247
11487
|
} : config;
|
|
11248
|
-
useIsomorphicLayoutEffect(() => {
|
|
11488
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11249
11489
|
const disabled = !x && !y;
|
|
11250
11490
|
if (disabled || !activeNode) {
|
|
11251
11491
|
initialized.current = false;
|
|
@@ -11606,7 +11846,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
|
|
|
11606
11846
|
}, [draggableNodes, instantiateSensor]);
|
|
11607
11847
|
const activators = useCombineActivators(sensors, bindActivatorToSensorInstantiator);
|
|
11608
11848
|
useSensorSetup(sensors);
|
|
11609
|
-
useIsomorphicLayoutEffect(() => {
|
|
11849
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11610
11850
|
if (activeNodeRect && status === Status.Initializing) {
|
|
11611
11851
|
setStatus(Status.Initialized);
|
|
11612
11852
|
}
|
|
@@ -11690,7 +11930,7 @@ const DndContext = /* @__PURE__ */ React.memo(function DndContext2(_ref) {
|
|
|
11690
11930
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
11691
11931
|
[overId]
|
|
11692
11932
|
);
|
|
11693
|
-
useIsomorphicLayoutEffect(() => {
|
|
11933
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11694
11934
|
sensorContext.current = {
|
|
11695
11935
|
activatorEvent,
|
|
11696
11936
|
active,
|
|
@@ -11817,7 +12057,7 @@ function useDraggable(_ref) {
|
|
|
11817
12057
|
const [activatorNode, setActivatorNodeRef] = useNodeRef();
|
|
11818
12058
|
const listeners = useSyntheticListeners(activators, id);
|
|
11819
12059
|
const dataRef = useLatestValue(data);
|
|
11820
|
-
useIsomorphicLayoutEffect(
|
|
12060
|
+
useIsomorphicLayoutEffect$1(
|
|
11821
12061
|
() => {
|
|
11822
12062
|
draggableNodes.set(id, {
|
|
11823
12063
|
id,
|
|
@@ -11990,7 +12230,7 @@ function AnimationManager(_ref) {
|
|
|
11990
12230
|
if (!children && !clonedChildren && previousChildren) {
|
|
11991
12231
|
setClonedChildren(previousChildren);
|
|
11992
12232
|
}
|
|
11993
|
-
useIsomorphicLayoutEffect(() => {
|
|
12233
|
+
useIsomorphicLayoutEffect$1(() => {
|
|
11994
12234
|
if (!element) {
|
|
11995
12235
|
return;
|
|
11996
12236
|
}
|
|
@@ -12672,6 +12912,7 @@ const InlineInput = ({
|
|
|
12672
12912
|
inputType,
|
|
12673
12913
|
length,
|
|
12674
12914
|
digits,
|
|
12915
|
+
placeholder,
|
|
12675
12916
|
onChange,
|
|
12676
12917
|
onFocus
|
|
12677
12918
|
}) => {
|
|
@@ -12717,19 +12958,88 @@ const InlineInput = ({
|
|
|
12717
12958
|
onChange: (e) => handleChange(e.target.value, false),
|
|
12718
12959
|
onFocus: () => onFocus?.(widgetId),
|
|
12719
12960
|
maxLength: expectedLength,
|
|
12720
|
-
style: { width: dynamicWidth }
|
|
12961
|
+
style: { width: dynamicWidth },
|
|
12962
|
+
placeholder
|
|
12721
12963
|
}
|
|
12722
12964
|
)
|
|
12723
12965
|
)
|
|
12724
12966
|
}
|
|
12725
12967
|
);
|
|
12726
12968
|
};
|
|
12969
|
+
const PillBackground = ({
|
|
12970
|
+
width = "100%",
|
|
12971
|
+
minHeight = "72px",
|
|
12972
|
+
fill = "#FFE7B4",
|
|
12973
|
+
children,
|
|
12974
|
+
className = "",
|
|
12975
|
+
style,
|
|
12976
|
+
...rest
|
|
12977
|
+
}) => {
|
|
12978
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
12979
|
+
"div",
|
|
12980
|
+
{
|
|
12981
|
+
className: `pill-bg-wrapper ${className}`.trim(),
|
|
12982
|
+
style: {
|
|
12983
|
+
position: "relative",
|
|
12984
|
+
display: "inline-flex",
|
|
12985
|
+
alignItems: "center",
|
|
12986
|
+
justifyContent: "center",
|
|
12987
|
+
width,
|
|
12988
|
+
minHeight,
|
|
12989
|
+
...style
|
|
12990
|
+
},
|
|
12991
|
+
...rest,
|
|
12992
|
+
children: [
|
|
12993
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
12994
|
+
"svg",
|
|
12995
|
+
{
|
|
12996
|
+
viewBox: "0 0 510 108",
|
|
12997
|
+
preserveAspectRatio: "none",
|
|
12998
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
12999
|
+
"aria-hidden": "true",
|
|
13000
|
+
style: {
|
|
13001
|
+
position: "absolute",
|
|
13002
|
+
inset: 0,
|
|
13003
|
+
width: "100%",
|
|
13004
|
+
height: "100%",
|
|
13005
|
+
display: "block"
|
|
13006
|
+
},
|
|
13007
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
13008
|
+
"path",
|
|
13009
|
+
{
|
|
13010
|
+
d: "M439.707 0C457.071 0 472.52 8.196 482.398 20.9297C498.608 26.3308 510 39.1028 510 54C510 68.8966 498.608 81.667 482.399 87.0684C472.521 99.803 457.072 108 439.707 108H70.8975C53.653 108 38.2956 99.9172 28.4092 87.333C11.7719 82.0918 0.00025108 69.1432 0 54C0 38.8562 11.7721 25.906 28.4102 20.665C38.2966 8.08186 53.6537 0 70.8975 0H439.707Z",
|
|
13011
|
+
fill
|
|
13012
|
+
}
|
|
13013
|
+
)
|
|
13014
|
+
}
|
|
13015
|
+
),
|
|
13016
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
13017
|
+
"div",
|
|
13018
|
+
{
|
|
13019
|
+
style: {
|
|
13020
|
+
position: "relative",
|
|
13021
|
+
zIndex: 1,
|
|
13022
|
+
display: "flex",
|
|
13023
|
+
alignItems: "center",
|
|
13024
|
+
justifyContent: "center",
|
|
13025
|
+
width: "100%",
|
|
13026
|
+
height: "100%",
|
|
13027
|
+
padding: "0 2rem",
|
|
13028
|
+
boxSizing: "border-box"
|
|
13029
|
+
},
|
|
13030
|
+
children
|
|
13031
|
+
}
|
|
13032
|
+
)
|
|
13033
|
+
]
|
|
13034
|
+
}
|
|
13035
|
+
);
|
|
13036
|
+
};
|
|
12727
13037
|
const DragOptions = ({
|
|
12728
13038
|
options,
|
|
12729
13039
|
onOptionClick
|
|
12730
13040
|
}) => {
|
|
12731
13041
|
const { usedOptionIds } = useExerciseDnd();
|
|
12732
|
-
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options", children: options.map((option, idx) => {
|
|
13042
|
+
return /* @__PURE__ */ jsxRuntime.jsx(PillBackground, { children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options", children: options.map((option, idx) => {
|
|
12733
13043
|
const optionId = `drag-option-${idx}`;
|
|
12734
13044
|
const optionValue = String(option);
|
|
12735
13045
|
const isUsed = usedOptionIds.has(optionId);
|
|
@@ -12756,7 +13066,7 @@ const DragOptions = ({
|
|
|
12756
13066
|
}
|
|
12757
13067
|
)
|
|
12758
13068
|
) }, optionId);
|
|
12759
|
-
}) });
|
|
13069
|
+
}) }) });
|
|
12760
13070
|
};
|
|
12761
13071
|
const NumericKeypad = ({
|
|
12762
13072
|
value,
|
|
@@ -12796,8 +13106,11 @@ const FractionInput = ({
|
|
|
12796
13106
|
values,
|
|
12797
13107
|
droppedInputs,
|
|
12798
13108
|
validationStatus = null,
|
|
13109
|
+
numValidationStatus = null,
|
|
13110
|
+
denValidationStatus = null,
|
|
12799
13111
|
onChange,
|
|
12800
|
-
onFocus
|
|
13112
|
+
onFocus,
|
|
13113
|
+
placeholder
|
|
12801
13114
|
}) => {
|
|
12802
13115
|
const numKey = getFractionKey(widgetId, "num");
|
|
12803
13116
|
const denKey = getFractionKey(widgetId, "den");
|
|
@@ -12810,11 +13123,12 @@ const FractionInput = ({
|
|
|
12810
13123
|
onChange(subKey, data.value, true, String(item.id));
|
|
12811
13124
|
}
|
|
12812
13125
|
}, [onChange]);
|
|
12813
|
-
const renderSlot = (subKey, fixedValue, currentValue, label) => {
|
|
13126
|
+
const renderSlot = (subKey, fixedValue, currentValue, label, slotStatus) => {
|
|
12814
13127
|
if (fixedValue !== null) {
|
|
12815
13128
|
return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-fixed", children: fixedValue });
|
|
12816
13129
|
}
|
|
12817
13130
|
const isDroppedHere = droppedInputs.has(subKey);
|
|
13131
|
+
const slotClass = slotStatus ? ` widget--${slotStatus}` : statusClass;
|
|
12818
13132
|
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
12819
13133
|
Droppable,
|
|
12820
13134
|
{
|
|
@@ -12825,7 +13139,7 @@ const FractionInput = ({
|
|
|
12825
13139
|
children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
12826
13140
|
"button",
|
|
12827
13141
|
{
|
|
12828
|
-
className: `inline-input-chip fraction-chip${
|
|
13142
|
+
className: `inline-input-chip fraction-chip${slotClass}`,
|
|
12829
13143
|
onClick: () => onChange(subKey, "", false),
|
|
12830
13144
|
title: "Click to remove",
|
|
12831
13145
|
children: currentValue
|
|
@@ -12835,45 +13149,25 @@ const FractionInput = ({
|
|
|
12835
13149
|
{
|
|
12836
13150
|
id: `${subKey}-input`,
|
|
12837
13151
|
type: "text",
|
|
12838
|
-
className: `inline-input fraction-input${
|
|
13152
|
+
className: `inline-input fraction-input${slotClass}`,
|
|
12839
13153
|
value: currentValue,
|
|
12840
13154
|
onChange: (e) => onChange(subKey, e.target.value, false),
|
|
12841
13155
|
onFocus: () => onFocus?.(subKey),
|
|
12842
|
-
maxLength: 4,
|
|
12843
|
-
|
|
13156
|
+
maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
|
|
13157
|
+
size: placeholder ? placeholder.length : void 0,
|
|
13158
|
+
"aria-label": label,
|
|
13159
|
+
placeholder
|
|
12844
13160
|
}
|
|
12845
13161
|
)
|
|
12846
13162
|
}
|
|
12847
13163
|
);
|
|
12848
13164
|
};
|
|
12849
13165
|
return /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "fraction", role: "math", "aria-label": `fraction ${widgetId}`, children: [
|
|
12850
|
-
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator") }),
|
|
13166
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-numerator", children: renderSlot(numKey, numerator, numValue, "numerator", numValidationStatus) }),
|
|
12851
13167
|
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-bar", "aria-hidden": "true" }),
|
|
12852
|
-
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator") })
|
|
13168
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-denominator", children: renderSlot(denKey, denominator, denValue, "denominator", denValidationStatus) })
|
|
12853
13169
|
] });
|
|
12854
13170
|
};
|
|
12855
|
-
const RadioOptions = ({
|
|
12856
|
-
name,
|
|
12857
|
-
options,
|
|
12858
|
-
value,
|
|
12859
|
-
validationStatus = null,
|
|
12860
|
-
onChange
|
|
12861
|
-
}) => {
|
|
12862
|
-
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
|
|
12863
|
-
const isSelected = value === option;
|
|
12864
|
-
const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
|
|
12865
|
-
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
12866
|
-
"button",
|
|
12867
|
-
{
|
|
12868
|
-
className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
|
|
12869
|
-
onClick: () => onChange(name, option),
|
|
12870
|
-
type: "button",
|
|
12871
|
-
children: option
|
|
12872
|
-
},
|
|
12873
|
-
`${name}-radio-${idx}`
|
|
12874
|
-
);
|
|
12875
|
-
}) });
|
|
12876
|
-
};
|
|
12877
13171
|
function isLightColor(hex) {
|
|
12878
13172
|
const clean = hex.replace("#", "");
|
|
12879
13173
|
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
@@ -12888,7 +13182,8 @@ function isLightColor(hex) {
|
|
|
12888
13182
|
return 1.05 / (L + 0.05) < 4.5;
|
|
12889
13183
|
}
|
|
12890
13184
|
function getTextColorForBackground(color) {
|
|
12891
|
-
|
|
13185
|
+
const hslLightness = parseHslLightness(color);
|
|
13186
|
+
if (hslLightness !== null) return hslLightness >= 60 ? "#1a1a1a" : "#ffffff";
|
|
12892
13187
|
return isLightColor(color) ? "#1a1a1a" : "#ffffff";
|
|
12893
13188
|
}
|
|
12894
13189
|
function generateHslPalette(n) {
|
|
@@ -12897,7 +13192,7 @@ function generateHslPalette(n) {
|
|
|
12897
13192
|
return `hsl(${hue}, 60%, 72%)`;
|
|
12898
13193
|
});
|
|
12899
13194
|
}
|
|
12900
|
-
function
|
|
13195
|
+
function resolveGroupPalette(userColors, groupCount) {
|
|
12901
13196
|
if (groupCount <= 0) return [];
|
|
12902
13197
|
if (!userColors || userColors.length === 0) {
|
|
12903
13198
|
return generateHslPalette(groupCount);
|
|
@@ -12909,6 +13204,475 @@ function resolveMatcherPalette(userColors, groupCount) {
|
|
|
12909
13204
|
const extras = generateHslPalette(needed);
|
|
12910
13205
|
return [...userColors, ...extras];
|
|
12911
13206
|
}
|
|
13207
|
+
function resolveDistinctGroupPalette(groupCount, options = {}) {
|
|
13208
|
+
if (groupCount <= 0) return [];
|
|
13209
|
+
const used = new Set((options.excludeColors ?? []).map(normalizeColorToken));
|
|
13210
|
+
const palette = [];
|
|
13211
|
+
for (const color of options.seedColors ?? []) {
|
|
13212
|
+
const token = normalizeColorToken(color);
|
|
13213
|
+
if (used.has(token)) continue;
|
|
13214
|
+
palette.push(color);
|
|
13215
|
+
used.add(token);
|
|
13216
|
+
if (palette.length === groupCount) return palette;
|
|
13217
|
+
}
|
|
13218
|
+
const addCandidates = (candidates) => {
|
|
13219
|
+
for (const color of candidates) {
|
|
13220
|
+
const token = normalizeColorToken(color);
|
|
13221
|
+
if (used.has(token)) continue;
|
|
13222
|
+
palette.push(color);
|
|
13223
|
+
used.add(token);
|
|
13224
|
+
if (palette.length === groupCount) return;
|
|
13225
|
+
}
|
|
13226
|
+
};
|
|
13227
|
+
addCandidates(generateHslPalette(groupCount));
|
|
13228
|
+
let candidateCount = groupCount + 1;
|
|
13229
|
+
while (palette.length < groupCount) {
|
|
13230
|
+
addCandidates(generateHslPalette(candidateCount++));
|
|
13231
|
+
}
|
|
13232
|
+
return palette;
|
|
13233
|
+
}
|
|
13234
|
+
function getGroupClassName(groupIndex) {
|
|
13235
|
+
return `widget-group-${groupIndex + 1}`;
|
|
13236
|
+
}
|
|
13237
|
+
function normalizeColorToken(color) {
|
|
13238
|
+
return color.replace(/\s+/g, "").toLowerCase();
|
|
13239
|
+
}
|
|
13240
|
+
function parseHslLightness(color) {
|
|
13241
|
+
const match = color.match(/^hsl\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*([\d.]+)%\s*\)$/i);
|
|
13242
|
+
return match ? Number(match[1]) : null;
|
|
13243
|
+
}
|
|
13244
|
+
function sumCounts(counts) {
|
|
13245
|
+
return counts.reduce((sum, count) => sum + count, 0);
|
|
13246
|
+
}
|
|
13247
|
+
function countsToPartGroups(counts) {
|
|
13248
|
+
return counts.flatMap(
|
|
13249
|
+
(count, groupIndex) => Array.from({ length: count }, () => groupIndex)
|
|
13250
|
+
);
|
|
13251
|
+
}
|
|
13252
|
+
function normalizeCounts(value, expected) {
|
|
13253
|
+
if (!Array.isArray(value)) return expected.map(() => 0);
|
|
13254
|
+
return expected.map((max, i) => Math.max(0, Math.min(max, value[i] ?? 0)));
|
|
13255
|
+
}
|
|
13256
|
+
function serializeCounts(value) {
|
|
13257
|
+
return Array.isArray(value) ? value.join("|") : null;
|
|
13258
|
+
}
|
|
13259
|
+
function countsToAssignments(counts, divisions, filledCount) {
|
|
13260
|
+
const assignments = Array.from({ length: divisions }, () => null);
|
|
13261
|
+
let index = filledCount;
|
|
13262
|
+
counts.forEach((count, groupIndex) => {
|
|
13263
|
+
for (let i = 0; i < count && index < divisions; i++) {
|
|
13264
|
+
assignments[index] = groupIndex;
|
|
13265
|
+
index++;
|
|
13266
|
+
}
|
|
13267
|
+
});
|
|
13268
|
+
return assignments;
|
|
13269
|
+
}
|
|
13270
|
+
function getNextAssignments(assignments, partIndex, groupCount) {
|
|
13271
|
+
if (groupCount === 0) return [...assignments];
|
|
13272
|
+
const current = assignments[partIndex] ?? null;
|
|
13273
|
+
const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
|
|
13274
|
+
const updated = [...assignments];
|
|
13275
|
+
updated[partIndex] = next;
|
|
13276
|
+
return updated;
|
|
13277
|
+
}
|
|
13278
|
+
function assignmentsToCounts(assignments, groupCount) {
|
|
13279
|
+
const counts = Array.from({ length: groupCount }, () => 0);
|
|
13280
|
+
for (const groupIndex of assignments) {
|
|
13281
|
+
if (groupIndex !== null && groupIndex >= 0 && groupIndex < groupCount) {
|
|
13282
|
+
counts[groupIndex] = (counts[groupIndex] ?? 0) + 1;
|
|
13283
|
+
}
|
|
13284
|
+
}
|
|
13285
|
+
return counts;
|
|
13286
|
+
}
|
|
13287
|
+
function useVisualFractionState({
|
|
13288
|
+
widgetId,
|
|
13289
|
+
divisions,
|
|
13290
|
+
filledCount,
|
|
13291
|
+
answer,
|
|
13292
|
+
value,
|
|
13293
|
+
onChange
|
|
13294
|
+
}) {
|
|
13295
|
+
const [partAssignments, setPartAssignments] = React.useState(
|
|
13296
|
+
() => countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
|
|
13297
|
+
);
|
|
13298
|
+
const lastInternalValueRef = React.useRef(null);
|
|
13299
|
+
const valueKey = serializeCounts(value);
|
|
13300
|
+
React.useEffect(() => {
|
|
13301
|
+
if (valueKey !== null && valueKey === lastInternalValueRef.current) return;
|
|
13302
|
+
lastInternalValueRef.current = null;
|
|
13303
|
+
setPartAssignments(
|
|
13304
|
+
countsToAssignments(normalizeCounts(value, answer), divisions, filledCount)
|
|
13305
|
+
);
|
|
13306
|
+
}, [answer, divisions, filledCount, valueKey]);
|
|
13307
|
+
const handlePartClick = (partIndex) => {
|
|
13308
|
+
if (partIndex < filledCount) return;
|
|
13309
|
+
const updated = getNextAssignments(partAssignments, partIndex, answer.length);
|
|
13310
|
+
const nextCounts = assignmentsToCounts(updated, answer.length);
|
|
13311
|
+
lastInternalValueRef.current = serializeCounts(nextCounts);
|
|
13312
|
+
setPartAssignments(updated);
|
|
13313
|
+
onChange(widgetId, nextCounts);
|
|
13314
|
+
};
|
|
13315
|
+
return { partAssignments, handlePartClick };
|
|
13316
|
+
}
|
|
13317
|
+
const VISUAL_FRACTION_DISPLAY_COLOR = "#AE9CFF";
|
|
13318
|
+
const VISUAL_FRACTION_BORDER_COLOR = "#705DC0";
|
|
13319
|
+
const VISUAL_FRACTION_SVG_SIZE_SCALE = 8;
|
|
13320
|
+
function getCircleSectorProps(index, divisions) {
|
|
13321
|
+
if (divisions <= 1) return { kind: "circle" };
|
|
13322
|
+
const angle = 360 / divisions;
|
|
13323
|
+
const startAngle = -90 + index * angle;
|
|
13324
|
+
const endAngle = startAngle + angle;
|
|
13325
|
+
const start = polarToCartesian(50, 50, 48, endAngle);
|
|
13326
|
+
const end = polarToCartesian(50, 50, 48, startAngle);
|
|
13327
|
+
const largeArcFlag = angle > 180 ? 1 : 0;
|
|
13328
|
+
return {
|
|
13329
|
+
kind: "path",
|
|
13330
|
+
d: [
|
|
13331
|
+
"M",
|
|
13332
|
+
50,
|
|
13333
|
+
50,
|
|
13334
|
+
"L",
|
|
13335
|
+
start.x,
|
|
13336
|
+
start.y,
|
|
13337
|
+
"A",
|
|
13338
|
+
48,
|
|
13339
|
+
48,
|
|
13340
|
+
0,
|
|
13341
|
+
largeArcFlag,
|
|
13342
|
+
0,
|
|
13343
|
+
end.x,
|
|
13344
|
+
end.y,
|
|
13345
|
+
"Z"
|
|
13346
|
+
].join(" ")
|
|
13347
|
+
};
|
|
13348
|
+
}
|
|
13349
|
+
function polarToCartesian(cx, cy, r, angleInDegrees) {
|
|
13350
|
+
const rad = angleInDegrees * Math.PI / 180;
|
|
13351
|
+
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
|
13352
|
+
}
|
|
13353
|
+
function getEqualTrianglePartPath(index, divisions) {
|
|
13354
|
+
const side = Math.sqrt(divisions);
|
|
13355
|
+
if (!Number.isInteger(side)) {
|
|
13356
|
+
throw new Error(`getEqualTrianglePartPath: divisions (${divisions}) must be a perfect square.`);
|
|
13357
|
+
}
|
|
13358
|
+
const smallSide = 96 / side;
|
|
13359
|
+
const smallHeight = smallSide * Math.sqrt(3) / 2;
|
|
13360
|
+
const totalHeight = side * smallHeight;
|
|
13361
|
+
const offsetY = (100 - totalHeight) / 2;
|
|
13362
|
+
let cursor = 0;
|
|
13363
|
+
for (let row = 0; row < side; row++) {
|
|
13364
|
+
const y = offsetY + row * smallHeight;
|
|
13365
|
+
const rowLeft = 50 - (row + 1) * smallSide / 2;
|
|
13366
|
+
for (let col = 0; col <= row; col++) {
|
|
13367
|
+
if (cursor === index) {
|
|
13368
|
+
const x = rowLeft + col * smallSide;
|
|
13369
|
+
return ["M", x + smallSide / 2, y, "L", x + smallSide, y + smallHeight, "L", x, y + smallHeight, "Z"].join(" ");
|
|
13370
|
+
}
|
|
13371
|
+
cursor++;
|
|
13372
|
+
}
|
|
13373
|
+
for (let col = 0; col < row; col++) {
|
|
13374
|
+
if (cursor === index) {
|
|
13375
|
+
const x = rowLeft + col * smallSide;
|
|
13376
|
+
return ["M", x + smallSide, y + smallHeight, "L", x + smallSide / 2, y, "L", x + smallSide * 1.5, y, "Z"].join(" ");
|
|
13377
|
+
}
|
|
13378
|
+
cursor++;
|
|
13379
|
+
}
|
|
13380
|
+
}
|
|
13381
|
+
return "";
|
|
13382
|
+
}
|
|
13383
|
+
function getGridColumnCount(divisions) {
|
|
13384
|
+
return Math.ceil(Math.sqrt(divisions));
|
|
13385
|
+
}
|
|
13386
|
+
function getInteractiveProps(part, onPartClick) {
|
|
13387
|
+
if (!part.isInteractiveSlot) return {};
|
|
13388
|
+
return {
|
|
13389
|
+
onClick: () => onPartClick(part.index),
|
|
13390
|
+
role: "button",
|
|
13391
|
+
"aria-pressed": part.isSelected,
|
|
13392
|
+
"aria-label": getPartAriaLabel(part),
|
|
13393
|
+
tabIndex: 0,
|
|
13394
|
+
onKeyDown: (event) => {
|
|
13395
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
13396
|
+
event.preventDefault();
|
|
13397
|
+
onPartClick(part.index);
|
|
13398
|
+
}
|
|
13399
|
+
}
|
|
13400
|
+
};
|
|
13401
|
+
}
|
|
13402
|
+
function getPartAriaLabel(part) {
|
|
13403
|
+
return part.groupIndex === null ? `Part ${part.index + 1}, empty` : `Part ${part.index + 1}, group ${part.groupIndex + 1}`;
|
|
13404
|
+
}
|
|
13405
|
+
const BarShape = ({
|
|
13406
|
+
parts,
|
|
13407
|
+
divisions,
|
|
13408
|
+
orientation,
|
|
13409
|
+
onPartClick
|
|
13410
|
+
}) => {
|
|
13411
|
+
const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
|
|
13412
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
13413
|
+
"div",
|
|
13414
|
+
{
|
|
13415
|
+
className: `visual-fraction-widget__parts${orientation === "vertical" ? " visual-fraction-widget__parts--vertical" : " visual-fraction-widget__parts--horizontal"}`,
|
|
13416
|
+
style: { "--visual-fraction-divisions": divisions },
|
|
13417
|
+
children: renderedParts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
13418
|
+
"div",
|
|
13419
|
+
{
|
|
13420
|
+
className: part.className,
|
|
13421
|
+
style: part.style,
|
|
13422
|
+
...getInteractiveProps(part, onPartClick)
|
|
13423
|
+
},
|
|
13424
|
+
part.index
|
|
13425
|
+
))
|
|
13426
|
+
}
|
|
13427
|
+
);
|
|
13428
|
+
};
|
|
13429
|
+
BarShape.displayName = "BarShape";
|
|
13430
|
+
const GridShape = ({
|
|
13431
|
+
parts,
|
|
13432
|
+
divisions,
|
|
13433
|
+
onPartClick
|
|
13434
|
+
}) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
13435
|
+
"div",
|
|
13436
|
+
{
|
|
13437
|
+
className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
|
|
13438
|
+
style: {
|
|
13439
|
+
"--visual-fraction-divisions": divisions,
|
|
13440
|
+
"--visual-fraction-grid-cols": getGridColumnCount(divisions),
|
|
13441
|
+
// Safe integer division: zod schema enforces divisions = perfect square.
|
|
13442
|
+
"--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
|
|
13443
|
+
},
|
|
13444
|
+
children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
13445
|
+
"div",
|
|
13446
|
+
{
|
|
13447
|
+
className: part.className,
|
|
13448
|
+
style: part.style,
|
|
13449
|
+
...getInteractiveProps(part, onPartClick)
|
|
13450
|
+
},
|
|
13451
|
+
part.index
|
|
13452
|
+
))
|
|
13453
|
+
}
|
|
13454
|
+
);
|
|
13455
|
+
GridShape.displayName = "GridShape";
|
|
13456
|
+
const CircleShape = ({
|
|
13457
|
+
parts,
|
|
13458
|
+
divisions,
|
|
13459
|
+
onPartClick
|
|
13460
|
+
}) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
13461
|
+
"svg",
|
|
13462
|
+
{
|
|
13463
|
+
className: "visual-fraction-widget__shape visual-fraction-widget__circle",
|
|
13464
|
+
viewBox: "0 0 100 100",
|
|
13465
|
+
children: parts.map((part) => {
|
|
13466
|
+
const sectorProps = getCircleSectorProps(part.index, divisions);
|
|
13467
|
+
const className = `${part.className} visual-fraction-widget__sector`;
|
|
13468
|
+
const interactiveProps = getInteractiveProps(part, onPartClick);
|
|
13469
|
+
return sectorProps.kind === "circle" ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
13470
|
+
"circle",
|
|
13471
|
+
{
|
|
13472
|
+
className,
|
|
13473
|
+
style: part.style,
|
|
13474
|
+
cx: "50",
|
|
13475
|
+
cy: "50",
|
|
13476
|
+
r: "48",
|
|
13477
|
+
...interactiveProps
|
|
13478
|
+
},
|
|
13479
|
+
part.index
|
|
13480
|
+
) : /* @__PURE__ */ jsxRuntime.jsx(
|
|
13481
|
+
"path",
|
|
13482
|
+
{
|
|
13483
|
+
className,
|
|
13484
|
+
style: part.style,
|
|
13485
|
+
d: sectorProps.d,
|
|
13486
|
+
...interactiveProps
|
|
13487
|
+
},
|
|
13488
|
+
part.index
|
|
13489
|
+
);
|
|
13490
|
+
})
|
|
13491
|
+
}
|
|
13492
|
+
);
|
|
13493
|
+
CircleShape.displayName = "CircleShape";
|
|
13494
|
+
const TriangleShape = ({
|
|
13495
|
+
parts,
|
|
13496
|
+
divisions,
|
|
13497
|
+
onPartClick
|
|
13498
|
+
}) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
13499
|
+
"svg",
|
|
13500
|
+
{
|
|
13501
|
+
className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
|
|
13502
|
+
viewBox: "0 0 100 100",
|
|
13503
|
+
children: parts.map((part) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
13504
|
+
"path",
|
|
13505
|
+
{
|
|
13506
|
+
className: `${part.className} visual-fraction-widget__sector`,
|
|
13507
|
+
style: part.style,
|
|
13508
|
+
d: getEqualTrianglePartPath(part.index, divisions),
|
|
13509
|
+
...getInteractiveProps(part, onPartClick)
|
|
13510
|
+
},
|
|
13511
|
+
part.index
|
|
13512
|
+
))
|
|
13513
|
+
}
|
|
13514
|
+
);
|
|
13515
|
+
TriangleShape.displayName = "TriangleShape";
|
|
13516
|
+
const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(BarShape, { parts, divisions, orientation, onPartClick });
|
|
13517
|
+
const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(CircleShape, { parts, divisions, onPartClick });
|
|
13518
|
+
const ShapeTriangle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(TriangleShape, { parts, divisions, onPartClick });
|
|
13519
|
+
const ShapeGrid = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsxRuntime.jsx(GridShape, { parts, divisions, onPartClick });
|
|
13520
|
+
const SHAPE_COMPONENTS = {
|
|
13521
|
+
[VISUAL_FRACTION_SHAPE.BAR]: ShapeBar,
|
|
13522
|
+
[VISUAL_FRACTION_SHAPE.CIRCLE]: ShapeCircle,
|
|
13523
|
+
[VISUAL_FRACTION_SHAPE.TRIANGLE]: ShapeTriangle,
|
|
13524
|
+
[VISUAL_FRACTION_SHAPE.GRID]: ShapeGrid
|
|
13525
|
+
};
|
|
13526
|
+
const VisualFraction = ({
|
|
13527
|
+
widgetId,
|
|
13528
|
+
widget,
|
|
13529
|
+
value,
|
|
13530
|
+
validationStatus = null,
|
|
13531
|
+
onChange
|
|
13532
|
+
}) => {
|
|
13533
|
+
const { shape, divisions, filled, answer, orientation, label } = widget;
|
|
13534
|
+
const filledCount = sumCounts(filled);
|
|
13535
|
+
const filledPartGroups = React.useMemo(() => countsToPartGroups(filled), [filled]);
|
|
13536
|
+
const filledPalette = React.useMemo(() => {
|
|
13537
|
+
if (filled.length === 0) return [];
|
|
13538
|
+
return resolveDistinctGroupPalette(filled.length, {
|
|
13539
|
+
seedColors: [VISUAL_FRACTION_DISPLAY_COLOR],
|
|
13540
|
+
excludeColors: [VISUAL_FRACTION_BORDER_COLOR]
|
|
13541
|
+
});
|
|
13542
|
+
}, [filled]);
|
|
13543
|
+
const answerPalette = React.useMemo(() => {
|
|
13544
|
+
if (answer.length === 0) return [];
|
|
13545
|
+
return resolveDistinctGroupPalette(answer.length, {
|
|
13546
|
+
excludeColors: [VISUAL_FRACTION_BORDER_COLOR, ...filledPalette]
|
|
13547
|
+
});
|
|
13548
|
+
}, [answer, filledPalette]);
|
|
13549
|
+
const { partAssignments, handlePartClick } = useVisualFractionState({
|
|
13550
|
+
widgetId,
|
|
13551
|
+
divisions,
|
|
13552
|
+
filledCount,
|
|
13553
|
+
answer,
|
|
13554
|
+
value,
|
|
13555
|
+
onChange
|
|
13556
|
+
});
|
|
13557
|
+
const parts = React.useMemo(
|
|
13558
|
+
() => Array.from({ length: divisions }, (_, i) => {
|
|
13559
|
+
let partClass = "visual-fraction-widget__part visual-fraction-widget__part--empty";
|
|
13560
|
+
let groupIdx = null;
|
|
13561
|
+
if (i < filledCount) {
|
|
13562
|
+
groupIdx = filledPartGroups[i] ?? 0;
|
|
13563
|
+
partClass = "visual-fraction-widget__part visual-fraction-widget__part--filled";
|
|
13564
|
+
} else if (partAssignments[i] != null) {
|
|
13565
|
+
groupIdx = partAssignments[i];
|
|
13566
|
+
partClass = "visual-fraction-widget__part visual-fraction-widget__part--answer";
|
|
13567
|
+
}
|
|
13568
|
+
if (groupIdx !== null) {
|
|
13569
|
+
partClass += ` visual-fraction-widget__part--group-${groupIdx + 1} ${getGroupClassName(groupIdx)}`;
|
|
13570
|
+
}
|
|
13571
|
+
const colorKind = i < filledCount ? "filled" : "answer";
|
|
13572
|
+
const palette = colorKind === "filled" ? filledPalette : answerPalette;
|
|
13573
|
+
const fallbackColor = palette[groupIdx ?? 0];
|
|
13574
|
+
const style = groupIdx !== null ? {
|
|
13575
|
+
"--visual-fraction-part-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-color, ${fallbackColor})`,
|
|
13576
|
+
"--visual-fraction-part-text-color": `var(--visual-fraction-${colorKind}-group-${groupIdx + 1}-text-color, ${getTextColorForBackground(fallbackColor)})`
|
|
13577
|
+
} : void 0;
|
|
13578
|
+
return {
|
|
13579
|
+
index: i,
|
|
13580
|
+
className: partClass,
|
|
13581
|
+
style,
|
|
13582
|
+
// A slot is interactive when there are answer groups AND it falls in the non-filled range.
|
|
13583
|
+
// answer.length counts groups (not total slots); the state hook handles per-slot cycling.
|
|
13584
|
+
isInteractiveSlot: answer.length > 0 && i >= filledCount,
|
|
13585
|
+
isSelected: groupIdx !== null,
|
|
13586
|
+
groupIndex: groupIdx
|
|
13587
|
+
};
|
|
13588
|
+
}),
|
|
13589
|
+
[divisions, filledCount, filledPartGroups, partAssignments, answer, filledPalette, answerPalette]
|
|
13590
|
+
);
|
|
13591
|
+
const statusClass = validationStatus ? ` visual-fraction-widget--${validationStatus} widget--${validationStatus}` : "";
|
|
13592
|
+
const rootStyle = React.useMemo(() => getSizeStyle(widget.size), [widget.size, shape]);
|
|
13593
|
+
const ShapeComponent = SHAPE_COMPONENTS[shape];
|
|
13594
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
13595
|
+
"div",
|
|
13596
|
+
{
|
|
13597
|
+
className: `visual-fraction-widget visual-fraction-widget--${shape}${statusClass}`,
|
|
13598
|
+
style: rootStyle,
|
|
13599
|
+
role: "group",
|
|
13600
|
+
"aria-label": `${divisions} part ${shape} fraction`,
|
|
13601
|
+
children: [
|
|
13602
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
13603
|
+
ShapeComponent,
|
|
13604
|
+
{
|
|
13605
|
+
parts,
|
|
13606
|
+
divisions,
|
|
13607
|
+
orientation: orientation ?? "horizontal",
|
|
13608
|
+
onPartClick: handlePartClick
|
|
13609
|
+
}
|
|
13610
|
+
),
|
|
13611
|
+
label && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "visual-fraction-widget__label", children: label })
|
|
13612
|
+
]
|
|
13613
|
+
}
|
|
13614
|
+
);
|
|
13615
|
+
};
|
|
13616
|
+
VisualFraction.displayName = "VisualFraction";
|
|
13617
|
+
function getSizeStyle(size, _shape) {
|
|
13618
|
+
if (size === void 0) return void 0;
|
|
13619
|
+
return {
|
|
13620
|
+
"--visual-fraction-size": `${size * VISUAL_FRACTION_SVG_SIZE_SCALE}rem`
|
|
13621
|
+
};
|
|
13622
|
+
}
|
|
13623
|
+
const RadioOptions = ({
|
|
13624
|
+
name,
|
|
13625
|
+
options,
|
|
13626
|
+
value,
|
|
13627
|
+
validationStatus = null,
|
|
13628
|
+
onChange
|
|
13629
|
+
}) => {
|
|
13630
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "radio-options", children: options.map((option, idx) => {
|
|
13631
|
+
const isSelected = value === option;
|
|
13632
|
+
const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
|
|
13633
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
13634
|
+
"button",
|
|
13635
|
+
{
|
|
13636
|
+
className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
|
|
13637
|
+
onClick: () => onChange(name, option),
|
|
13638
|
+
type: "button",
|
|
13639
|
+
children: option
|
|
13640
|
+
},
|
|
13641
|
+
`${name}-radio-${idx}`
|
|
13642
|
+
);
|
|
13643
|
+
}) });
|
|
13644
|
+
};
|
|
13645
|
+
const Btn = ({
|
|
13646
|
+
label,
|
|
13647
|
+
extraClass = "keypad-btn-digit",
|
|
13648
|
+
onClick
|
|
13649
|
+
}) => /* @__PURE__ */ jsxRuntime.jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsxRuntime.jsx("span", { children: label }) });
|
|
13650
|
+
const DiamondKeypad = ({ value, onChange }) => {
|
|
13651
|
+
const append = (ch) => onChange(value + ch);
|
|
13652
|
+
const backspace = () => onChange(value.slice(0, -1));
|
|
13653
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad", children: [
|
|
13654
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13655
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "1", onClick: () => append("1") }),
|
|
13656
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "2", onClick: () => append("2") }),
|
|
13657
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "3", onClick: () => append("3") })
|
|
13658
|
+
] }),
|
|
13659
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13660
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "4", onClick: () => append("4") }),
|
|
13661
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "5", onClick: () => append("5") }),
|
|
13662
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "6", onClick: () => append("6") }),
|
|
13663
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "7", onClick: () => append("7") })
|
|
13664
|
+
] }),
|
|
13665
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13666
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "8", onClick: () => append("8") }),
|
|
13667
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "9", onClick: () => append("9") }),
|
|
13668
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "0", onClick: () => append("0") })
|
|
13669
|
+
] }),
|
|
13670
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "diamond-keypad-row", children: [
|
|
13671
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: ".", extraClass: "keypad-btn-decimal", onClick: () => append(".") }),
|
|
13672
|
+
/* @__PURE__ */ jsxRuntime.jsx(Btn, { label: "⌫", extraClass: "keypad-btn-backspace", onClick: backspace })
|
|
13673
|
+
] })
|
|
13674
|
+
] });
|
|
13675
|
+
};
|
|
12912
13676
|
function collectItemIds(rows) {
|
|
12913
13677
|
return rows.flatMap((row) => row.items.map((item) => item.itemId));
|
|
12914
13678
|
}
|
|
@@ -12950,7 +13714,7 @@ const MatcherWidget = ({
|
|
|
12950
13714
|
const { rows, colors: userColors, groupCount } = widget;
|
|
12951
13715
|
const widgetsMap = widgets ?? EMPTY_WIDGETS;
|
|
12952
13716
|
const palette = React.useMemo(
|
|
12953
|
-
() =>
|
|
13717
|
+
() => resolveGroupPalette(userColors, groupCount),
|
|
12954
13718
|
[userColors, groupCount]
|
|
12955
13719
|
);
|
|
12956
13720
|
const textColors = React.useMemo(
|
|
@@ -12966,13 +13730,21 @@ const MatcherWidget = ({
|
|
|
12966
13730
|
setAssignments((prev) => {
|
|
12967
13731
|
const current = prev[itemId] ?? null;
|
|
12968
13732
|
const next = current === null ? 0 : current + 1 >= groupCount ? null : current + 1;
|
|
12969
|
-
|
|
12970
|
-
onChange(widgetId, buildGroups(updated));
|
|
12971
|
-
return updated;
|
|
13733
|
+
return { ...prev, [itemId]: next };
|
|
12972
13734
|
});
|
|
12973
13735
|
},
|
|
12974
|
-
[groupCount
|
|
13736
|
+
[groupCount]
|
|
12975
13737
|
);
|
|
13738
|
+
const onChangeRef = React.useRef(onChange);
|
|
13739
|
+
onChangeRef.current = onChange;
|
|
13740
|
+
const isMounted = React.useRef(false);
|
|
13741
|
+
React.useEffect(() => {
|
|
13742
|
+
if (!isMounted.current) {
|
|
13743
|
+
isMounted.current = true;
|
|
13744
|
+
return;
|
|
13745
|
+
}
|
|
13746
|
+
onChangeRef.current(widgetId, buildGroups(assignments));
|
|
13747
|
+
}, [assignments, widgetId]);
|
|
12976
13748
|
const maxCols = React.useMemo(
|
|
12977
13749
|
() => rows.length === 0 ? 1 : Math.max(...rows.map((r) => r.items.length)),
|
|
12978
13750
|
[rows]
|
|
@@ -12993,9 +13765,9 @@ const MatcherWidget = ({
|
|
|
12993
13765
|
{
|
|
12994
13766
|
type: "button",
|
|
12995
13767
|
className: `matcher-card${isSelected ? " matcher-card--selected" : ""}`,
|
|
12996
|
-
style: color ? {
|
|
12997
|
-
"--matcher-card-color": color,
|
|
12998
|
-
"--matcher-card-text-color": textColors[colorIdx]
|
|
13768
|
+
style: colorIdx !== null && color ? {
|
|
13769
|
+
"--matcher-card-color": `var(--matcher-group-${colorIdx + 1}-color, ${color})`,
|
|
13770
|
+
"--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
|
|
12999
13771
|
} : void 0,
|
|
13000
13772
|
onClick: () => handleCardClick(item.itemId),
|
|
13001
13773
|
"aria-pressed": isSelected,
|
|
@@ -13037,6 +13809,9 @@ function isFractionWidget(widget) {
|
|
|
13037
13809
|
function isMatcherWidget(widget) {
|
|
13038
13810
|
return widget.type === "matcher";
|
|
13039
13811
|
}
|
|
13812
|
+
function isVisualFractionWidget(widget) {
|
|
13813
|
+
return widget.type === "visual-fraction";
|
|
13814
|
+
}
|
|
13040
13815
|
const WidgetRenderer = ({
|
|
13041
13816
|
widget,
|
|
13042
13817
|
widgetId,
|
|
@@ -13044,6 +13819,8 @@ const WidgetRenderer = ({
|
|
|
13044
13819
|
values = {},
|
|
13045
13820
|
isDropped = false,
|
|
13046
13821
|
validationStatus = null,
|
|
13822
|
+
numValidationStatus = null,
|
|
13823
|
+
denValidationStatus = null,
|
|
13047
13824
|
droppedInputs = /* @__PURE__ */ new Set(),
|
|
13048
13825
|
widgets,
|
|
13049
13826
|
onChange,
|
|
@@ -13062,6 +13839,7 @@ const WidgetRenderer = ({
|
|
|
13062
13839
|
inputType: widget.inputType,
|
|
13063
13840
|
length: widget.inputType === "string" ? widget.length : void 0,
|
|
13064
13841
|
digits: widget.inputType === "numeric" ? widget.digits : void 0,
|
|
13842
|
+
placeholder: widget.placeholder,
|
|
13065
13843
|
onChange,
|
|
13066
13844
|
onFocus: onInputFocus
|
|
13067
13845
|
}
|
|
@@ -13090,14 +13868,24 @@ const WidgetRenderer = ({
|
|
|
13090
13868
|
);
|
|
13091
13869
|
}
|
|
13092
13870
|
if (isNumericKeypadWidget(widget)) {
|
|
13871
|
+
const keypadOnChange = onKeypadChange || (() => {
|
|
13872
|
+
});
|
|
13873
|
+
if (widget.style === "classic") {
|
|
13874
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
13875
|
+
NumericKeypad,
|
|
13876
|
+
{
|
|
13877
|
+
value: strValue,
|
|
13878
|
+
includeOperators: widget.includeOperators,
|
|
13879
|
+
includeDecimal: widget.includeDecimal,
|
|
13880
|
+
onChange: keypadOnChange
|
|
13881
|
+
}
|
|
13882
|
+
);
|
|
13883
|
+
}
|
|
13093
13884
|
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
13094
|
-
|
|
13885
|
+
DiamondKeypad,
|
|
13095
13886
|
{
|
|
13096
13887
|
value: strValue,
|
|
13097
|
-
|
|
13098
|
-
includeDecimal: widget.includeDecimal,
|
|
13099
|
-
onChange: onKeypadChange || (() => {
|
|
13100
|
-
})
|
|
13888
|
+
onChange: keypadOnChange
|
|
13101
13889
|
}
|
|
13102
13890
|
);
|
|
13103
13891
|
}
|
|
@@ -13111,8 +13899,11 @@ const WidgetRenderer = ({
|
|
|
13111
13899
|
values,
|
|
13112
13900
|
droppedInputs,
|
|
13113
13901
|
validationStatus,
|
|
13902
|
+
numValidationStatus,
|
|
13903
|
+
denValidationStatus,
|
|
13114
13904
|
onChange,
|
|
13115
|
-
onFocus: onInputFocus
|
|
13905
|
+
onFocus: onInputFocus,
|
|
13906
|
+
placeholder: widget.placeholder
|
|
13116
13907
|
}
|
|
13117
13908
|
);
|
|
13118
13909
|
}
|
|
@@ -13129,6 +13920,18 @@ const WidgetRenderer = ({
|
|
|
13129
13920
|
}
|
|
13130
13921
|
);
|
|
13131
13922
|
}
|
|
13923
|
+
if (isVisualFractionWidget(widget)) {
|
|
13924
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
13925
|
+
VisualFraction,
|
|
13926
|
+
{
|
|
13927
|
+
widgetId,
|
|
13928
|
+
widget,
|
|
13929
|
+
value: Array.isArray(value) && value.every((v) => typeof v === "number") ? value : void 0,
|
|
13930
|
+
validationStatus,
|
|
13931
|
+
onChange: (id, nextValue) => onChange(id, nextValue)
|
|
13932
|
+
}
|
|
13933
|
+
);
|
|
13934
|
+
}
|
|
13132
13935
|
return null;
|
|
13133
13936
|
};
|
|
13134
13937
|
const EPSILON = 10 ** -INTERNAL_PRECISION;
|
|
@@ -13150,7 +13953,7 @@ const getMajorTicks = (min, max, step) => {
|
|
|
13150
13953
|
const count = Math.floor((max - min) / step);
|
|
13151
13954
|
const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
|
|
13152
13955
|
const lastTick = ticks[ticks.length - 1];
|
|
13153
|
-
if (
|
|
13956
|
+
if (roundTick(lastTick) !== roundTick(max)) {
|
|
13154
13957
|
ticks.push(max);
|
|
13155
13958
|
} else {
|
|
13156
13959
|
ticks[ticks.length - 1] = max;
|
|
@@ -13631,6 +14434,102 @@ const CounterControls = ({
|
|
|
13631
14434
|
] }),
|
|
13632
14435
|
counter.name && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "number-line-counter-label", children: counter.name })
|
|
13633
14436
|
] });
|
|
14437
|
+
const CONTENT_ROW_EXTRA_SPACE = 8;
|
|
14438
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
|
|
14439
|
+
function useMeasuredPositionContentHeight(positionIds) {
|
|
14440
|
+
const [heightPx, setHeightPx] = React.useState(null);
|
|
14441
|
+
const elementsRef = React.useRef(/* @__PURE__ */ new Map());
|
|
14442
|
+
const refCallbacksRef = React.useRef(/* @__PURE__ */ new Map());
|
|
14443
|
+
const observerRef = React.useRef(null);
|
|
14444
|
+
const measureFrameRef = React.useRef(null);
|
|
14445
|
+
const idsKey = positionIds.join("|");
|
|
14446
|
+
const runMeasure = React.useCallback(() => {
|
|
14447
|
+
let maxHeight = 0;
|
|
14448
|
+
for (const element of elementsRef.current.values()) {
|
|
14449
|
+
const rectHeight = element.getBoundingClientRect().height;
|
|
14450
|
+
const measuredHeight = Math.max(rectHeight, element.offsetHeight);
|
|
14451
|
+
maxHeight = Math.max(maxHeight, measuredHeight);
|
|
14452
|
+
}
|
|
14453
|
+
const nextHeight = maxHeight > 0 ? Math.ceil(maxHeight + CONTENT_ROW_EXTRA_SPACE) : null;
|
|
14454
|
+
setHeightPx((current) => current === nextHeight ? current : nextHeight);
|
|
14455
|
+
}, []);
|
|
14456
|
+
const measure = React.useCallback(() => {
|
|
14457
|
+
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
|
|
14458
|
+
runMeasure();
|
|
14459
|
+
return;
|
|
14460
|
+
}
|
|
14461
|
+
if (measureFrameRef.current !== null) return;
|
|
14462
|
+
measureFrameRef.current = window.requestAnimationFrame(() => {
|
|
14463
|
+
measureFrameRef.current = null;
|
|
14464
|
+
runMeasure();
|
|
14465
|
+
});
|
|
14466
|
+
}, [runMeasure]);
|
|
14467
|
+
React.useEffect(
|
|
14468
|
+
() => () => {
|
|
14469
|
+
if (measureFrameRef.current !== null && typeof window !== "undefined") {
|
|
14470
|
+
window.cancelAnimationFrame(measureFrameRef.current);
|
|
14471
|
+
measureFrameRef.current = null;
|
|
14472
|
+
}
|
|
14473
|
+
},
|
|
14474
|
+
[]
|
|
14475
|
+
);
|
|
14476
|
+
const getPositionContentRef = React.useCallback(
|
|
14477
|
+
(id) => {
|
|
14478
|
+
const cachedRef = refCallbacksRef.current.get(id);
|
|
14479
|
+
if (cachedRef) return cachedRef;
|
|
14480
|
+
const refCallback = (node) => {
|
|
14481
|
+
const current = elementsRef.current.get(id);
|
|
14482
|
+
if (current && observerRef.current) {
|
|
14483
|
+
observerRef.current.unobserve(current);
|
|
14484
|
+
}
|
|
14485
|
+
if (node) {
|
|
14486
|
+
elementsRef.current.set(id, node);
|
|
14487
|
+
observerRef.current?.observe(node);
|
|
14488
|
+
} else {
|
|
14489
|
+
elementsRef.current.delete(id);
|
|
14490
|
+
}
|
|
14491
|
+
measure();
|
|
14492
|
+
};
|
|
14493
|
+
refCallbacksRef.current.set(id, refCallback);
|
|
14494
|
+
return refCallback;
|
|
14495
|
+
},
|
|
14496
|
+
[measure]
|
|
14497
|
+
);
|
|
14498
|
+
React.useEffect(() => {
|
|
14499
|
+
const liveIds = new Set(positionIds);
|
|
14500
|
+
for (const id of elementsRef.current.keys()) {
|
|
14501
|
+
if (!liveIds.has(id)) {
|
|
14502
|
+
elementsRef.current.delete(id);
|
|
14503
|
+
}
|
|
14504
|
+
}
|
|
14505
|
+
for (const id of refCallbacksRef.current.keys()) {
|
|
14506
|
+
if (!liveIds.has(id)) {
|
|
14507
|
+
refCallbacksRef.current.delete(id);
|
|
14508
|
+
}
|
|
14509
|
+
}
|
|
14510
|
+
measure();
|
|
14511
|
+
}, [idsKey, measure]);
|
|
14512
|
+
useIsomorphicLayoutEffect(() => {
|
|
14513
|
+
if (typeof ResizeObserver === "undefined") {
|
|
14514
|
+
measure();
|
|
14515
|
+
return void 0;
|
|
14516
|
+
}
|
|
14517
|
+
const observer = new ResizeObserver(measure);
|
|
14518
|
+
observerRef.current = observer;
|
|
14519
|
+
for (const element of elementsRef.current.values()) {
|
|
14520
|
+
observer.observe(element);
|
|
14521
|
+
}
|
|
14522
|
+
measure();
|
|
14523
|
+
return () => {
|
|
14524
|
+
observer.disconnect();
|
|
14525
|
+
observerRef.current = null;
|
|
14526
|
+
};
|
|
14527
|
+
}, [idsKey, measure]);
|
|
14528
|
+
return {
|
|
14529
|
+
positionContentHeight: heightPx === null ? void 0 : `${heightPx}px`,
|
|
14530
|
+
getPositionContentRef
|
|
14531
|
+
};
|
|
14532
|
+
}
|
|
13634
14533
|
const NumberLineRenderer = ({
|
|
13635
14534
|
item,
|
|
13636
14535
|
widgets,
|
|
@@ -13665,7 +14564,7 @@ const NumberLineRenderer = ({
|
|
|
13665
14564
|
return step;
|
|
13666
14565
|
}, [counter, counterCount, min, max, step]);
|
|
13667
14566
|
const majorTicks = React.useMemo(
|
|
13668
|
-
() => getMajorTicks(min, max, effectiveStep).map(roundTick),
|
|
14567
|
+
() => [...new Set(getMajorTicks(min, max, effectiveStep).map(roundTick))],
|
|
13669
14568
|
[min, max, effectiveStep]
|
|
13670
14569
|
);
|
|
13671
14570
|
const minorValues = React.useMemo(
|
|
@@ -13674,28 +14573,38 @@ const NumberLineRenderer = ({
|
|
|
13674
14573
|
);
|
|
13675
14574
|
const showArrowRight = lineStyle === "arrow-right" || lineStyle === "arrow-both";
|
|
13676
14575
|
const showArrowLeft = lineStyle === "arrow-left" || lineStyle === "arrow-both";
|
|
13677
|
-
const
|
|
14576
|
+
const hasDragOptions = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
|
|
14577
|
+
const isAxisDragMode = hasDragOptions && item.dragMode === "axis";
|
|
13678
14578
|
const inputTickSet = React.useMemo(() => {
|
|
13679
|
-
if (
|
|
14579
|
+
if (isAxisDragMode) return /* @__PURE__ */ new Set();
|
|
13680
14580
|
return new Set(
|
|
13681
14581
|
positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => roundTick(pos.value))
|
|
13682
14582
|
);
|
|
13683
|
-
}, [
|
|
14583
|
+
}, [isAxisDragMode, positions]);
|
|
13684
14584
|
const visiblePositions = React.useMemo(
|
|
13685
|
-
() => positions.filter((pos) => !
|
|
13686
|
-
[
|
|
14585
|
+
() => positions.filter((pos) => !isAxisDragMode || getPositionWidgetId(pos.content) === null),
|
|
14586
|
+
[isAxisDragMode, positions]
|
|
13687
14587
|
);
|
|
14588
|
+
const visiblePositionIds = React.useMemo(
|
|
14589
|
+
() => visiblePositions.map((pos) => pos.id),
|
|
14590
|
+
[visiblePositions]
|
|
14591
|
+
);
|
|
14592
|
+
const { positionContentHeight, getPositionContentRef } = useMeasuredPositionContentHeight(visiblePositionIds);
|
|
13688
14593
|
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
13689
14594
|
"div",
|
|
13690
14595
|
{
|
|
13691
|
-
className: `number-line${
|
|
13692
|
-
style: {
|
|
14596
|
+
className: `number-line${isAxisDragMode ? " number-line--drag-mode" : ""}${lineStyle === "line" ? " number-line--style-line" : ""}`,
|
|
14597
|
+
style: {
|
|
14598
|
+
[CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%`,
|
|
14599
|
+
...positionContentHeight && { "--nl-position-content-height": positionContentHeight }
|
|
14600
|
+
},
|
|
13693
14601
|
role: "group",
|
|
13694
14602
|
"aria-label": `Number line from ${min} to ${max}`,
|
|
13695
14603
|
children: [
|
|
13696
14604
|
visiblePositions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: visiblePositions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
13697
14605
|
"div",
|
|
13698
14606
|
{
|
|
14607
|
+
ref: getPositionContentRef(pos.id),
|
|
13699
14608
|
className: "number-line-position-content",
|
|
13700
14609
|
style: { left: `${toPercent(pos.value, min, max)}%` },
|
|
13701
14610
|
children: pos.content.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
@@ -13714,7 +14623,7 @@ const NumberLineRenderer = ({
|
|
|
13714
14623
|
},
|
|
13715
14624
|
pos.id
|
|
13716
14625
|
)) }),
|
|
13717
|
-
|
|
14626
|
+
isAxisDragMode ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
13718
14627
|
FreeDragAxis,
|
|
13719
14628
|
{
|
|
13720
14629
|
axisDropId: `${item.id}--nl-axis`,
|
|
@@ -13825,6 +14734,7 @@ function groupLineItems(items) {
|
|
|
13825
14734
|
}
|
|
13826
14735
|
return groups;
|
|
13827
14736
|
}
|
|
14737
|
+
const toValidationStatus = (v) => v === true ? "correct" : v === false ? "incorrect" : null;
|
|
13828
14738
|
const LayoutRenderer = ({
|
|
13829
14739
|
item,
|
|
13830
14740
|
widgets,
|
|
@@ -13840,8 +14750,10 @@ const LayoutRenderer = ({
|
|
|
13840
14750
|
const hasArabic = isArabic(item.value);
|
|
13841
14751
|
return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `question-text ${hasArabic ? "question-text-arabic" : ""}`, children: item.value });
|
|
13842
14752
|
}
|
|
13843
|
-
case JSON_TYPES.TEXT:
|
|
13844
|
-
|
|
14753
|
+
case JSON_TYPES.TEXT: {
|
|
14754
|
+
const isNum = /^[\d.,\s]+$/.test(item.value);
|
|
14755
|
+
return /* @__PURE__ */ jsxRuntime.jsx("span", { className: `text-item ${isNum ? "text-item-number" : ""} ${isArabic(item.value) ? "text-item-arabic" : "text-item-not-arabic"}`, children: item.value });
|
|
14756
|
+
}
|
|
13845
14757
|
case JSON_TYPES.HIGHLIGHTED_TEXT: {
|
|
13846
14758
|
const hasArabic = isArabic(item.value);
|
|
13847
14759
|
const dirAttr = !hasArabic ? "ltr" : void 0;
|
|
@@ -13898,7 +14810,7 @@ const LayoutRenderer = ({
|
|
|
13898
14810
|
if (!widget) return null;
|
|
13899
14811
|
const isCorrect = validationDetails?.[item.widgetId];
|
|
13900
14812
|
const validationStatus = isCorrect === true ? "correct" : isCorrect === false ? "incorrect" : null;
|
|
13901
|
-
const widgetKey = widget.type === JSON_TYPES.MATCHER ? `${item.widgetId}-${resetKey}` : item.widgetId;
|
|
14813
|
+
const widgetKey = widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.VISUAL_FRACTION ? `${item.widgetId}-${resetKey}` : item.widgetId;
|
|
13902
14814
|
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
13903
14815
|
WidgetRenderer,
|
|
13904
14816
|
{
|
|
@@ -13908,6 +14820,8 @@ const LayoutRenderer = ({
|
|
|
13908
14820
|
values,
|
|
13909
14821
|
isDropped: droppedInputs.has(item.widgetId),
|
|
13910
14822
|
validationStatus,
|
|
14823
|
+
numValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__num`]),
|
|
14824
|
+
denValidationStatus: toValidationStatus(validationDetails?.[`${item.widgetId}__den`]),
|
|
13911
14825
|
droppedInputs,
|
|
13912
14826
|
widgets,
|
|
13913
14827
|
onChange,
|
|
@@ -14013,6 +14927,27 @@ const ExerciseRenderer = React.forwardRef(({
|
|
|
14013
14927
|
for (const [id, v] of Object.entries(result.details)) {
|
|
14014
14928
|
details[id] = v.isCorrect;
|
|
14015
14929
|
}
|
|
14930
|
+
for (const [widgetId, widget] of Object.entries(json.widgets)) {
|
|
14931
|
+
if (widget.type !== JSON_TYPES.FRACTION) continue;
|
|
14932
|
+
if (!(widgetId in details)) continue;
|
|
14933
|
+
const answer = json.validation?.answers?.[widgetId];
|
|
14934
|
+
if (!answer) continue;
|
|
14935
|
+
const [expectedNum, expectedDen] = answer.split("/", 2);
|
|
14936
|
+
if (!expectedNum || !expectedDen) continue;
|
|
14937
|
+
if (details[widgetId]) {
|
|
14938
|
+
if (widget.numerator === null) details[`${widgetId}__num`] = true;
|
|
14939
|
+
if (widget.denominator === null) details[`${widgetId}__den`] = true;
|
|
14940
|
+
} else {
|
|
14941
|
+
if (widget.numerator === null) {
|
|
14942
|
+
const u = values[`${widgetId}__num`];
|
|
14943
|
+
details[`${widgetId}__num`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedNum);
|
|
14944
|
+
}
|
|
14945
|
+
if (widget.denominator === null) {
|
|
14946
|
+
const u = values[`${widgetId}__den`];
|
|
14947
|
+
details[`${widgetId}__den`] = typeof u === "string" && u !== "" && normalize(u) === normalize(expectedDen);
|
|
14948
|
+
}
|
|
14949
|
+
}
|
|
14950
|
+
}
|
|
14016
14951
|
setValidationDetails(details);
|
|
14017
14952
|
onSubmit?.(values);
|
|
14018
14953
|
return result;
|
|
@@ -14302,9 +15237,14 @@ exports.TextBlockSchema = TextBlockSchema;
|
|
|
14302
15237
|
exports.TextItemSchema = TextItemSchema;
|
|
14303
15238
|
exports.ToolNodeSchema = ToolNodeSchema;
|
|
14304
15239
|
exports.TransformError = TransformError;
|
|
15240
|
+
exports.VISUAL_FRACTION_DEFAULT_SIZE = VISUAL_FRACTION_DEFAULT_SIZE;
|
|
15241
|
+
exports.VISUAL_FRACTION_MAX_DIVISIONS = VISUAL_FRACTION_MAX_DIVISIONS;
|
|
15242
|
+
exports.VISUAL_FRACTION_SHAPE = VISUAL_FRACTION_SHAPE;
|
|
15243
|
+
exports.VISUAL_FRACTION_SHAPES = VISUAL_FRACTION_SHAPES;
|
|
14305
15244
|
exports.ValidationError = ValidationError;
|
|
14306
15245
|
exports.ValidationJSONSchema = ValidationJSONSchema;
|
|
14307
15246
|
exports.ValidationNodeSchema = ValidationNodeSchema;
|
|
15247
|
+
exports.VisualFractionInlineSchema = VisualFractionInlineSchema;
|
|
14308
15248
|
exports.WidgetJSONSchema = WidgetJSONSchema;
|
|
14309
15249
|
exports.WidgetRefSchema = WidgetRefSchema;
|
|
14310
15250
|
exports.WidgetRenderer = WidgetRenderer;
|
|
@@ -14329,6 +15269,7 @@ exports.createSimpleValidation = createSimpleValidation;
|
|
|
14329
15269
|
exports.createSpace = createSpace;
|
|
14330
15270
|
exports.createStringInput = createStringInput;
|
|
14331
15271
|
exports.createTextBlock = createTextBlock;
|
|
15272
|
+
exports.createVisualFraction = createVisualFraction;
|
|
14332
15273
|
exports.evaluate = evaluate;
|
|
14333
15274
|
exports.extractExpressionVariables = extractVariables;
|
|
14334
15275
|
exports.findValidCombination = findValidCombination;
|
|
@@ -14372,6 +15313,7 @@ exports.isTextItem = isTextItem$1;
|
|
|
14372
15313
|
exports.isToolNode = isToolNode;
|
|
14373
15314
|
exports.isValidExpression = isValidExpression;
|
|
14374
15315
|
exports.isValidationNode = isValidationNode;
|
|
15316
|
+
exports.isVisualFractionInline = isVisualFractionInline;
|
|
14375
15317
|
exports.isWidgetRef = isWidgetRef;
|
|
14376
15318
|
exports.parse = parse;
|
|
14377
15319
|
exports.parseFractionKey = parseFractionKey;
|
|
@@ -14388,6 +15330,7 @@ exports.validateExerciseJSONSafe = validateExerciseJSONSafe;
|
|
|
14388
15330
|
exports.validateExpression = validateExpression;
|
|
14389
15331
|
exports.validateInputFormats = validateInputFormats;
|
|
14390
15332
|
exports.validateSimple = validateSimple;
|
|
15333
|
+
exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
|
|
14391
15334
|
exports.validateWidgetJSON = validateWidgetJSON;
|
|
14392
15335
|
exports.validateWidgetJSONSafe = validateWidgetJSONSafe;
|
|
14393
15336
|
//# sourceMappingURL=index.js.map
|