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