@mathscan/math-exercise-engine 1.0.8 → 1.0.9
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 +1269 -286
- package/dist/index.js +603 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +603 -7
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +238 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -36,6 +36,10 @@ const AST_TYPES = {
|
|
|
36
36
|
TABLE_ROW: "TableRow",
|
|
37
37
|
/** Cell within a table row */
|
|
38
38
|
TABLE_CELL: "TableCell",
|
|
39
|
+
/** Number line layout block */
|
|
40
|
+
NUMBER_LINE_BLOCK: "NumberLineBlock",
|
|
41
|
+
/** A position marker on the number line */
|
|
42
|
+
NUMBER_LINE_POSITION: "NumberLinePosition",
|
|
39
43
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
40
44
|
// Tools
|
|
41
45
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -88,6 +92,10 @@ const JSON_TYPES = {
|
|
|
88
92
|
TABLE_ROW: "table-row",
|
|
89
93
|
/** Cell within a table */
|
|
90
94
|
TABLE_CELL: "table-cell",
|
|
95
|
+
/** Number line layout block */
|
|
96
|
+
NUMBER_LINE: "number-line",
|
|
97
|
+
/** Position marker within a number line */
|
|
98
|
+
NUMBER_LINE_POSITION: "number-line-position",
|
|
91
99
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
100
|
// Widget Types
|
|
93
101
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -100,6 +108,7 @@ const JSON_TYPES = {
|
|
|
100
108
|
/** Numeric keypad widget */
|
|
101
109
|
NUMERIC_KEYPAD: "numeric-keypad"
|
|
102
110
|
};
|
|
111
|
+
const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
|
|
103
112
|
const VALIDATION_MODES = {
|
|
104
113
|
/** Simple answer matching validation */
|
|
105
114
|
SIMPLE: "simple",
|
|
@@ -112,6 +121,9 @@ const INPUT_TYPES = {
|
|
|
112
121
|
/** Numeric input (digits only) */
|
|
113
122
|
NUMERIC: "numeric"
|
|
114
123
|
};
|
|
124
|
+
const NUMBER_LINE_STYLES = ["arrow-right", "arrow-left", "arrow-both", "line"];
|
|
125
|
+
const SIDE_PAD = 7;
|
|
126
|
+
const CSS_VAR_SIDE_PAD = "--nl-side-pad";
|
|
115
127
|
const Newline = chevrotain.createToken({
|
|
116
128
|
name: "Newline",
|
|
117
129
|
pattern: /\r?\n/,
|
|
@@ -185,6 +197,14 @@ const NUMERIC_INPUT = chevrotain.createToken({
|
|
|
185
197
|
name: "NUMERIC_INPUT",
|
|
186
198
|
pattern: /NUMERIC_INPUT/
|
|
187
199
|
});
|
|
200
|
+
const NUMBER_LINE = chevrotain.createToken({
|
|
201
|
+
name: "NUMBER_LINE",
|
|
202
|
+
pattern: /NUMBER_LINE/
|
|
203
|
+
});
|
|
204
|
+
const POSITION = chevrotain.createToken({
|
|
205
|
+
name: "POSITION",
|
|
206
|
+
pattern: /POSITION/
|
|
207
|
+
});
|
|
188
208
|
const TABLE = chevrotain.createToken({
|
|
189
209
|
name: "TABLE",
|
|
190
210
|
pattern: /TABLE/
|
|
@@ -299,6 +319,8 @@ const allTokens = [
|
|
|
299
319
|
WIDGET_ANSWER,
|
|
300
320
|
W_N_I,
|
|
301
321
|
NUMERIC_INPUT,
|
|
322
|
+
NUMBER_LINE,
|
|
323
|
+
POSITION,
|
|
302
324
|
TABLE,
|
|
303
325
|
ROW,
|
|
304
326
|
CELL,
|
|
@@ -388,14 +410,14 @@ const MathExerciseLexer = new chevrotain.Lexer(allTokens, {
|
|
|
388
410
|
positionTracking: "full"
|
|
389
411
|
});
|
|
390
412
|
function tokenize(input) {
|
|
391
|
-
const result = MathExerciseLexer.tokenize(input);
|
|
413
|
+
const result = MathExerciseLexer.tokenize(input.replace(/\u00A0/g, " "));
|
|
392
414
|
if (result.errors.length > 0) {
|
|
393
415
|
throw new LexerError(result.errors);
|
|
394
416
|
}
|
|
395
417
|
return result.tokens;
|
|
396
418
|
}
|
|
397
419
|
function tokenizeSafe(input) {
|
|
398
|
-
const result = MathExerciseLexer.tokenize(input);
|
|
420
|
+
const result = MathExerciseLexer.tokenize(input.replace(/\u00A0/g, " "));
|
|
399
421
|
return {
|
|
400
422
|
tokens: result.tokens,
|
|
401
423
|
errors: result.errors
|
|
@@ -453,6 +475,10 @@ class MathExerciseParser extends chevrotain.CstParser {
|
|
|
453
475
|
GATE: this.BACKTRACK(this.radioButtons),
|
|
454
476
|
ALT: () => this.SUBRULE(this.radioButtons)
|
|
455
477
|
},
|
|
478
|
+
{
|
|
479
|
+
GATE: this.BACKTRACK(this.numberLine),
|
|
480
|
+
ALT: () => this.SUBRULE(this.numberLine)
|
|
481
|
+
},
|
|
456
482
|
{ ALT: () => this.SUBRULE(this.line) }
|
|
457
483
|
]);
|
|
458
484
|
});
|
|
@@ -557,6 +583,79 @@ class MathExerciseParser extends chevrotain.CstParser {
|
|
|
557
583
|
]);
|
|
558
584
|
});
|
|
559
585
|
});
|
|
586
|
+
this.numberLine = this.RULE("numberLine", () => {
|
|
587
|
+
this.MANY(() => {
|
|
588
|
+
this.OR2([
|
|
589
|
+
{ ALT: () => this.CONSUME(Space) },
|
|
590
|
+
{ ALT: () => this.CONSUME(Newline) }
|
|
591
|
+
]);
|
|
592
|
+
});
|
|
593
|
+
this.CONSUME(NUMBER_LINE);
|
|
594
|
+
this.CONSUME(LBracket);
|
|
595
|
+
this.SUBRULE(this.parameterList);
|
|
596
|
+
this.CONSUME(RBracket);
|
|
597
|
+
this.MANY2(() => {
|
|
598
|
+
this.OR3([
|
|
599
|
+
{ ALT: () => this.CONSUME2(Space) },
|
|
600
|
+
{ ALT: () => this.CONSUME2(Newline) }
|
|
601
|
+
]);
|
|
602
|
+
});
|
|
603
|
+
this.CONSUME(LBrace);
|
|
604
|
+
this.MANY3(() => {
|
|
605
|
+
this.OR4([
|
|
606
|
+
{ ALT: () => this.CONSUME3(Space) },
|
|
607
|
+
{ ALT: () => this.CONSUME3(Newline) }
|
|
608
|
+
]);
|
|
609
|
+
});
|
|
610
|
+
this.MANY4(() => {
|
|
611
|
+
this.SUBRULE(this.position);
|
|
612
|
+
});
|
|
613
|
+
this.CONSUME(RBrace);
|
|
614
|
+
this.MANY5(() => {
|
|
615
|
+
this.OR5([
|
|
616
|
+
{ ALT: () => this.CONSUME4(Newline) },
|
|
617
|
+
{ ALT: () => this.CONSUME4(Space) }
|
|
618
|
+
]);
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
this.position = this.RULE("position", () => {
|
|
622
|
+
this.MANY(() => {
|
|
623
|
+
this.OR([
|
|
624
|
+
{ ALT: () => this.CONSUME(Space) },
|
|
625
|
+
{ ALT: () => this.CONSUME(Newline) }
|
|
626
|
+
]);
|
|
627
|
+
});
|
|
628
|
+
this.CONSUME(POSITION);
|
|
629
|
+
this.CONSUME(LBracket);
|
|
630
|
+
this.SUBRULE(this.parameterList);
|
|
631
|
+
this.CONSUME(RBracket);
|
|
632
|
+
this.MANY2(() => {
|
|
633
|
+
this.OR2([
|
|
634
|
+
{ ALT: () => this.CONSUME2(Space) },
|
|
635
|
+
{ ALT: () => this.CONSUME2(Newline) }
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
this.CONSUME(LBrace);
|
|
639
|
+
this.MANY3(() => {
|
|
640
|
+
this.OR3([
|
|
641
|
+
{ ALT: () => this.CONSUME3(Space) },
|
|
642
|
+
{ ALT: () => this.CONSUME3(Newline) }
|
|
643
|
+
]);
|
|
644
|
+
});
|
|
645
|
+
this.MANY4(() => {
|
|
646
|
+
this.OR4([
|
|
647
|
+
{ ALT: () => this.SUBRULE(this.inlineElement) },
|
|
648
|
+
{ ALT: () => this.CONSUME4(Newline) }
|
|
649
|
+
]);
|
|
650
|
+
});
|
|
651
|
+
this.CONSUME(RBrace);
|
|
652
|
+
this.MANY5(() => {
|
|
653
|
+
this.OR5([
|
|
654
|
+
{ ALT: () => this.CONSUME5(Space) },
|
|
655
|
+
{ ALT: () => this.CONSUME5(Newline) }
|
|
656
|
+
]);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
560
659
|
this.line = this.RULE("line", () => {
|
|
561
660
|
this.MANY(() => {
|
|
562
661
|
this.CONSUME(Space);
|
|
@@ -919,6 +1018,71 @@ function isTextBlock(value) {
|
|
|
919
1018
|
function isLineBlock(value) {
|
|
920
1019
|
return LineBlockSchema.safeParse(value).success;
|
|
921
1020
|
}
|
|
1021
|
+
const NumberLinePositionSchema = zod.z.object({
|
|
1022
|
+
type: zod.z.literal(AST_TYPES.NUMBER_LINE_POSITION),
|
|
1023
|
+
value: zod.z.number(),
|
|
1024
|
+
inlines: zod.z.array(InlineNodeSchema).min(1)
|
|
1025
|
+
});
|
|
1026
|
+
function createNumberLinePosition(value, inlines) {
|
|
1027
|
+
const hasRealContent = inlines.some((n) => n.type !== AST_TYPES.SPACE);
|
|
1028
|
+
if (!hasRealContent) {
|
|
1029
|
+
throw new Error(`POSITION[value=${value}]: content cannot be empty or whitespace only`);
|
|
1030
|
+
}
|
|
1031
|
+
const inputs = inlines.filter((i) => i.type === AST_TYPES.INLINE_INPUT);
|
|
1032
|
+
if (inputs.length > 1) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
`POSITION[value=${value}]: only one input widget is allowed, got ${inputs.length}`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
return NumberLinePositionSchema.parse({
|
|
1038
|
+
type: AST_TYPES.NUMBER_LINE_POSITION,
|
|
1039
|
+
value,
|
|
1040
|
+
inlines
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
const NumberLineBlockSchema = zod.z.object({
|
|
1044
|
+
type: zod.z.literal(AST_TYPES.NUMBER_LINE_BLOCK),
|
|
1045
|
+
min: zod.z.number(),
|
|
1046
|
+
max: zod.z.number(),
|
|
1047
|
+
step: zod.z.number().positive({ message: "NUMBER_LINE: step must be greater than 0" }).default(1),
|
|
1048
|
+
minorTicks: zod.z.number().int().nonnegative({ message: "NUMBER_LINE: minorTicks must be >= 0" }).optional(),
|
|
1049
|
+
style: zod.z.enum(NUMBER_LINE_STYLES, {
|
|
1050
|
+
errorMap: () => ({
|
|
1051
|
+
message: `NUMBER_LINE: style must be one of ${NUMBER_LINE_STYLES.join(", ")}`
|
|
1052
|
+
})
|
|
1053
|
+
}).optional(),
|
|
1054
|
+
positions: zod.z.array(NumberLinePositionSchema)
|
|
1055
|
+
}).refine((data) => data.min < data.max, {
|
|
1056
|
+
message: "NUMBER_LINE: min must be less than max"
|
|
1057
|
+
}).superRefine((data, ctx) => {
|
|
1058
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1059
|
+
for (const pos of data.positions) {
|
|
1060
|
+
if (pos.value < data.min || pos.value > data.max) {
|
|
1061
|
+
ctx.addIssue({
|
|
1062
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1063
|
+
message: `NUMBER_LINE: POSITION value (${pos.value}) must be within [${data.min}, ${data.max}]`
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
if (seen.has(pos.value)) {
|
|
1067
|
+
ctx.addIssue({
|
|
1068
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1069
|
+
message: `NUMBER_LINE: duplicate POSITION value (${pos.value})`
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
seen.add(pos.value);
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
function createNumberLineBlock(min, max, positions, step, minorTicks, style) {
|
|
1076
|
+
return NumberLineBlockSchema.parse({
|
|
1077
|
+
type: AST_TYPES.NUMBER_LINE_BLOCK,
|
|
1078
|
+
min,
|
|
1079
|
+
max,
|
|
1080
|
+
step,
|
|
1081
|
+
minorTicks,
|
|
1082
|
+
style,
|
|
1083
|
+
positions
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
922
1086
|
const TableCellSchema = zod.z.object({
|
|
923
1087
|
type: zod.z.literal(AST_TYPES.TABLE_CELL),
|
|
924
1088
|
colspan: zod.z.number().int().positive().optional(),
|
|
@@ -1033,7 +1197,7 @@ function isExpressionValidation(value) {
|
|
|
1033
1197
|
function isValidationNode(value) {
|
|
1034
1198
|
return ValidationNodeSchema.safeParse(value).success;
|
|
1035
1199
|
}
|
|
1036
|
-
const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema]);
|
|
1200
|
+
const ContentBlockSchema = zod.z.union([LineBlockSchema, TableBlockSchema, RadioOptionsBlockSchema, NumberLineBlockSchema]);
|
|
1037
1201
|
const ExerciseDocumentSchema = zod.z.object({
|
|
1038
1202
|
type: zod.z.literal(AST_TYPES.EXERCISE_DOCUMENT),
|
|
1039
1203
|
question: TextBlockSchema.optional(),
|
|
@@ -1083,6 +1247,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1083
1247
|
contentBlock(ctx) {
|
|
1084
1248
|
if (ctx.table) return this.visit(ctx.table);
|
|
1085
1249
|
if (ctx.radioButtons) return this.visit(ctx.radioButtons);
|
|
1250
|
+
if (ctx.numberLine) return this.visit(ctx.numberLine);
|
|
1086
1251
|
if (ctx.line) return this.visit(ctx.line);
|
|
1087
1252
|
throw new Error("Unknown content block");
|
|
1088
1253
|
}
|
|
@@ -1113,6 +1278,31 @@ class NaturalDSLVisitor extends BaseCstVisitor {
|
|
|
1113
1278
|
return createTableCell(inlines, colspan);
|
|
1114
1279
|
}
|
|
1115
1280
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1281
|
+
// Number Line Block
|
|
1282
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1283
|
+
numberLine(ctx) {
|
|
1284
|
+
const params = this.visit(ctx.parameterList);
|
|
1285
|
+
if (params.min === void 0 || params.max === void 0) {
|
|
1286
|
+
throw new Error("NUMBER_LINE requires parameters: min and max");
|
|
1287
|
+
}
|
|
1288
|
+
const min = Number(params.min);
|
|
1289
|
+
const max = Number(params.max);
|
|
1290
|
+
const step = params.step !== void 0 ? Number(params.step) : void 0;
|
|
1291
|
+
const minorTicks = params.minorTicks !== void 0 ? Number(params.minorTicks) : void 0;
|
|
1292
|
+
const style = params.style;
|
|
1293
|
+
const positions = ctx.position ? ctx.position.map((p) => this.visit(p)) : [];
|
|
1294
|
+
return createNumberLineBlock(min, max, positions, step, minorTicks, style);
|
|
1295
|
+
}
|
|
1296
|
+
position(ctx) {
|
|
1297
|
+
const params = this.visit(ctx.parameterList);
|
|
1298
|
+
if (params.value === void 0) {
|
|
1299
|
+
throw new Error("POSITION requires parameter: value");
|
|
1300
|
+
}
|
|
1301
|
+
const value = Number(params.value);
|
|
1302
|
+
const inlines = ctx.inlineElement ? ctx.inlineElement.map((elem) => this.visit(elem)) : [];
|
|
1303
|
+
return createNumberLinePosition(value, inlines);
|
|
1304
|
+
}
|
|
1305
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1116
1306
|
// Line Block
|
|
1117
1307
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
1118
1308
|
line(ctx) {
|
|
@@ -1413,7 +1603,8 @@ const LayoutItemSchema = zod.z.lazy(
|
|
|
1413
1603
|
LinebreakSchema,
|
|
1414
1604
|
GroupItemSchema,
|
|
1415
1605
|
OperatorItemSchema,
|
|
1416
|
-
TableItemSchema
|
|
1606
|
+
TableItemSchema,
|
|
1607
|
+
NumberLineItemSchema
|
|
1417
1608
|
])
|
|
1418
1609
|
);
|
|
1419
1610
|
const GroupItemSchema = zod.z.object({
|
|
@@ -1438,6 +1629,22 @@ const TableItemSchema = zod.z.object({
|
|
|
1438
1629
|
cols: zod.z.number().int().positive(),
|
|
1439
1630
|
rows: zod.z.array(TableRowItemSchema)
|
|
1440
1631
|
});
|
|
1632
|
+
const NumberLinePositionItemSchema = zod.z.object({
|
|
1633
|
+
id: zod.z.string(),
|
|
1634
|
+
type: zod.z.literal(JSON_TYPES.NUMBER_LINE_POSITION),
|
|
1635
|
+
value: zod.z.number(),
|
|
1636
|
+
content: zod.z.array(zod.z.lazy(() => LayoutItemSchema))
|
|
1637
|
+
});
|
|
1638
|
+
const NumberLineItemSchema = zod.z.object({
|
|
1639
|
+
id: zod.z.string(),
|
|
1640
|
+
type: zod.z.literal(JSON_TYPES.NUMBER_LINE),
|
|
1641
|
+
min: zod.z.number(),
|
|
1642
|
+
max: zod.z.number(),
|
|
1643
|
+
step: zod.z.number().positive(),
|
|
1644
|
+
minorTicks: zod.z.number().int().nonnegative().optional(),
|
|
1645
|
+
style: zod.z.enum(NUMBER_LINE_STYLES).optional(),
|
|
1646
|
+
positions: zod.z.array(NumberLinePositionItemSchema)
|
|
1647
|
+
});
|
|
1441
1648
|
const StringInputJSONSchema = zod.z.object({
|
|
1442
1649
|
type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
|
|
1443
1650
|
inputType: zod.z.literal(INPUT_TYPES.STRING),
|
|
@@ -1509,7 +1716,7 @@ function isQuestionText(item) {
|
|
|
1509
1716
|
function isTextItem$1(item) {
|
|
1510
1717
|
return item.type === JSON_TYPES.TEXT;
|
|
1511
1718
|
}
|
|
1512
|
-
function isWidgetRef(item) {
|
|
1719
|
+
function isWidgetRef$1(item) {
|
|
1513
1720
|
return item.type === JSON_TYPES.WIDGET_REF;
|
|
1514
1721
|
}
|
|
1515
1722
|
function isHighlightedText(item) {
|
|
@@ -1578,6 +1785,8 @@ function toJSON(ast) {
|
|
|
1578
1785
|
for (const block of ast.lines) {
|
|
1579
1786
|
if (block.type === AST_TYPES.TABLE_BLOCK) {
|
|
1580
1787
|
layout.push(transformTable(block, idGenerator));
|
|
1788
|
+
} else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
|
|
1789
|
+
layout.push(transformNumberLine(block, idGenerator));
|
|
1581
1790
|
} else if (block.type === AST_TYPES.RADIO_OPTIONS) {
|
|
1582
1791
|
const radioBlock = block;
|
|
1583
1792
|
layout.push({
|
|
@@ -1663,10 +1872,36 @@ function collectAllInlines(blocks) {
|
|
|
1663
1872
|
result.push(...cell.inlines);
|
|
1664
1873
|
}
|
|
1665
1874
|
}
|
|
1875
|
+
} else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
|
|
1876
|
+
for (const position of block.positions) {
|
|
1877
|
+
result.push(...position.inlines);
|
|
1878
|
+
}
|
|
1666
1879
|
}
|
|
1667
1880
|
}
|
|
1668
1881
|
return result;
|
|
1669
1882
|
}
|
|
1883
|
+
function transformNumberLine(numberLine, idGenerator) {
|
|
1884
|
+
const positions = numberLine.positions.map((pos) => {
|
|
1885
|
+
const content = pos.inlines.map((inline) => transformInline(inline, idGenerator));
|
|
1886
|
+
return {
|
|
1887
|
+
id: idGenerator.generate(JSON_TYPES.NUMBER_LINE_POSITION),
|
|
1888
|
+
type: JSON_TYPES.NUMBER_LINE_POSITION,
|
|
1889
|
+
value: pos.value,
|
|
1890
|
+
content
|
|
1891
|
+
};
|
|
1892
|
+
});
|
|
1893
|
+
const item = {
|
|
1894
|
+
id: idGenerator.generate(JSON_TYPES.NUMBER_LINE),
|
|
1895
|
+
type: JSON_TYPES.NUMBER_LINE,
|
|
1896
|
+
min: numberLine.min,
|
|
1897
|
+
max: numberLine.max,
|
|
1898
|
+
step: numberLine.step,
|
|
1899
|
+
positions
|
|
1900
|
+
};
|
|
1901
|
+
if (numberLine.minorTicks !== void 0) item.minorTicks = numberLine.minorTicks;
|
|
1902
|
+
if (numberLine.style !== void 0) item.style = numberLine.style;
|
|
1903
|
+
return item;
|
|
1904
|
+
}
|
|
1670
1905
|
function transformTable(table, idGenerator) {
|
|
1671
1906
|
const rows = table.rows.map((row) => {
|
|
1672
1907
|
const cells = row.cells.map((cell) => {
|
|
@@ -2148,6 +2383,8 @@ function validateInputFormats(exercise, userInputs) {
|
|
|
2148
2383
|
const DragItemTypeSchema = zod.z.enum([
|
|
2149
2384
|
"drag-option",
|
|
2150
2385
|
// Draggable answer options
|
|
2386
|
+
"placed-chip",
|
|
2387
|
+
// Chip already placed on a number-line axis (re-drag)
|
|
2151
2388
|
"ruler-handle",
|
|
2152
2389
|
// Future: ruler endpoints
|
|
2153
2390
|
"shape",
|
|
@@ -5859,7 +6096,8 @@ const ExerciseDndProvider = ({
|
|
|
5859
6096
|
}, [dropHandlers]);
|
|
5860
6097
|
const handleDragStart = (event) => {
|
|
5861
6098
|
const { active } = event;
|
|
5862
|
-
const
|
|
6099
|
+
const raw = active.data.current;
|
|
6100
|
+
const item = raw?.type === "placed-chip" ? { id: String(active.id), type: "placed-chip", data: { label: raw.label } } : raw;
|
|
5863
6101
|
setActiveItem(item);
|
|
5864
6102
|
};
|
|
5865
6103
|
const handleDragEnd = (event) => {
|
|
@@ -5910,6 +6148,10 @@ const DragOverlayContent = ({ item }) => {
|
|
|
5910
6148
|
const data = item.data;
|
|
5911
6149
|
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-overlay-option", children: data.label });
|
|
5912
6150
|
}
|
|
6151
|
+
case "placed-chip": {
|
|
6152
|
+
const label = item.data?.label ?? "";
|
|
6153
|
+
return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-overlay-option", children: label });
|
|
6154
|
+
}
|
|
5913
6155
|
default:
|
|
5914
6156
|
return null;
|
|
5915
6157
|
}
|
|
@@ -6368,6 +6610,334 @@ const WidgetRenderer = ({
|
|
|
6368
6610
|
}
|
|
6369
6611
|
return null;
|
|
6370
6612
|
};
|
|
6613
|
+
const cleanFloat = (n) => parseFloat(n.toFixed(10));
|
|
6614
|
+
const toPercent = (value, min, max) => {
|
|
6615
|
+
const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
|
|
6616
|
+
return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
|
|
6617
|
+
};
|
|
6618
|
+
const pixelToValue = (chipCenterX, axisLeft, axisWidth, min, max) => {
|
|
6619
|
+
const relX = (chipCenterX - axisLeft) / axisWidth;
|
|
6620
|
+
const fraction = (relX - SIDE_PAD / 100) / (1 - 2 * SIDE_PAD / 100);
|
|
6621
|
+
const clamped = Math.max(0, Math.min(1, fraction));
|
|
6622
|
+
return min + clamped * (max - min);
|
|
6623
|
+
};
|
|
6624
|
+
const getMajorTicks = (min, max, step) => {
|
|
6625
|
+
if (step <= 0 || max <= min) return [min, max];
|
|
6626
|
+
const count = Math.floor((max - min) / step);
|
|
6627
|
+
const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
|
|
6628
|
+
const lastTick = ticks[ticks.length - 1];
|
|
6629
|
+
if (Math.abs(lastTick - max) > 1e-9) ticks.push(max);
|
|
6630
|
+
return ticks;
|
|
6631
|
+
};
|
|
6632
|
+
const getMinorTickValues = (majorTicks, minorTicks) => {
|
|
6633
|
+
if (minorTicks <= 0) return [];
|
|
6634
|
+
const result = [];
|
|
6635
|
+
majorTicks.slice(0, -1).forEach((tick, idx) => {
|
|
6636
|
+
const next = majorTicks[idx + 1];
|
|
6637
|
+
const interval = next - tick;
|
|
6638
|
+
for (let j = 1; j <= minorTicks; j++) {
|
|
6639
|
+
result.push(cleanFloat(tick + j / (minorTicks + 1) * interval));
|
|
6640
|
+
}
|
|
6641
|
+
});
|
|
6642
|
+
return result;
|
|
6643
|
+
};
|
|
6644
|
+
const getSnapTicks = (majorTicks, minorTicks) => [
|
|
6645
|
+
...majorTicks,
|
|
6646
|
+
...getMinorTickValues(majorTicks, minorTicks)
|
|
6647
|
+
];
|
|
6648
|
+
const snapToNearest = (raw, ticks) => {
|
|
6649
|
+
if (ticks.length === 0) return cleanFloat(raw);
|
|
6650
|
+
const snapped = ticks.reduce(
|
|
6651
|
+
(best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best
|
|
6652
|
+
);
|
|
6653
|
+
return cleanFloat(snapped);
|
|
6654
|
+
};
|
|
6655
|
+
const isWidgetRef = (item) => item.type === JSON_TYPES.WIDGET_REF;
|
|
6656
|
+
const getPositionWidgetId = (content) => {
|
|
6657
|
+
for (const item of content) {
|
|
6658
|
+
if (isWidgetRef(item)) return item.widgetId;
|
|
6659
|
+
}
|
|
6660
|
+
return null;
|
|
6661
|
+
};
|
|
6662
|
+
const PlacedChip = ({ widgetId, label, leftPercent, onRemove }) => {
|
|
6663
|
+
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
|
6664
|
+
id: widgetId,
|
|
6665
|
+
data: { type: "placed-chip", widgetId, label }
|
|
6666
|
+
});
|
|
6667
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
6668
|
+
"div",
|
|
6669
|
+
{
|
|
6670
|
+
className: "number-line-placed-chip",
|
|
6671
|
+
style: { left: `${leftPercent}%`, opacity: isDragging ? 0.3 : 1 },
|
|
6672
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
6673
|
+
"button",
|
|
6674
|
+
{
|
|
6675
|
+
ref: setNodeRef,
|
|
6676
|
+
className: "number-line-placed-chip-btn",
|
|
6677
|
+
title: "Drag to move · Click to remove",
|
|
6678
|
+
onClick: onRemove,
|
|
6679
|
+
...listeners,
|
|
6680
|
+
...attributes,
|
|
6681
|
+
children: label
|
|
6682
|
+
}
|
|
6683
|
+
)
|
|
6684
|
+
}
|
|
6685
|
+
);
|
|
6686
|
+
};
|
|
6687
|
+
const FreeDragAxis = ({
|
|
6688
|
+
axisDropId,
|
|
6689
|
+
min,
|
|
6690
|
+
max,
|
|
6691
|
+
minorTicks,
|
|
6692
|
+
majorTicks,
|
|
6693
|
+
showArrowLeft,
|
|
6694
|
+
showArrowRight,
|
|
6695
|
+
positions,
|
|
6696
|
+
values,
|
|
6697
|
+
onChange
|
|
6698
|
+
}) => {
|
|
6699
|
+
const axisRef = React.useRef(null);
|
|
6700
|
+
const snapTicks = React.useMemo(
|
|
6701
|
+
() => getSnapTicks(majorTicks, minorTicks),
|
|
6702
|
+
[majorTicks, minorTicks]
|
|
6703
|
+
);
|
|
6704
|
+
const [dropLabels, setDropLabels] = React.useState(/* @__PURE__ */ new Map());
|
|
6705
|
+
const [dropSources, setDropSources] = React.useState(/* @__PURE__ */ new Map());
|
|
6706
|
+
React.useEffect(() => {
|
|
6707
|
+
setDropLabels((prev) => {
|
|
6708
|
+
let changed = false;
|
|
6709
|
+
const next = new Map(prev);
|
|
6710
|
+
for (const [wid] of next) {
|
|
6711
|
+
if (!values[wid]) {
|
|
6712
|
+
next.delete(wid);
|
|
6713
|
+
changed = true;
|
|
6714
|
+
}
|
|
6715
|
+
}
|
|
6716
|
+
return changed ? next : prev;
|
|
6717
|
+
});
|
|
6718
|
+
setDropSources((prev) => {
|
|
6719
|
+
let changed = false;
|
|
6720
|
+
const next = new Map(prev);
|
|
6721
|
+
for (const [wid] of next) {
|
|
6722
|
+
if (!values[wid]) {
|
|
6723
|
+
next.delete(wid);
|
|
6724
|
+
changed = true;
|
|
6725
|
+
}
|
|
6726
|
+
}
|
|
6727
|
+
return changed ? next : prev;
|
|
6728
|
+
});
|
|
6729
|
+
}, [values]);
|
|
6730
|
+
const { setNodeRef, isOver } = useDroppable({ id: axisDropId });
|
|
6731
|
+
const mergedRef = React.useCallback((node) => {
|
|
6732
|
+
axisRef.current = node;
|
|
6733
|
+
setNodeRef(node);
|
|
6734
|
+
}, [setNodeRef]);
|
|
6735
|
+
const snapFromEvent = React.useCallback((translated) => {
|
|
6736
|
+
if (!axisRef.current) return null;
|
|
6737
|
+
const axisRect = axisRef.current.getBoundingClientRect();
|
|
6738
|
+
const chipCenterX = translated.left + translated.width / 2;
|
|
6739
|
+
const raw = pixelToValue(chipCenterX, axisRect.left, axisRect.width, min, max);
|
|
6740
|
+
return snapToNearest(raw, snapTicks);
|
|
6741
|
+
}, [min, max, snapTicks]);
|
|
6742
|
+
useDndMonitor({
|
|
6743
|
+
onDragEnd(event) {
|
|
6744
|
+
if (!event.over || event.over.id !== axisDropId) return;
|
|
6745
|
+
if (!axisRef.current) return;
|
|
6746
|
+
const translated = event.active.rect.current.translated;
|
|
6747
|
+
if (!translated) return;
|
|
6748
|
+
const placed = snapFromEvent(translated);
|
|
6749
|
+
if (placed === null) return;
|
|
6750
|
+
const data = event.active.data.current;
|
|
6751
|
+
if (data?.type === "placed-chip") {
|
|
6752
|
+
const widgetId = data.widgetId;
|
|
6753
|
+
const label = data.label;
|
|
6754
|
+
const originalSourceId = dropSources.get(widgetId) ?? widgetId;
|
|
6755
|
+
onChange(widgetId, String(placed), true, originalSourceId);
|
|
6756
|
+
setDropLabels((prev) => new Map(prev).set(widgetId, label));
|
|
6757
|
+
return;
|
|
6758
|
+
}
|
|
6759
|
+
const draggedItem = event.active.data.current;
|
|
6760
|
+
if (draggedItem?.type !== "drag-option") return;
|
|
6761
|
+
const targetPos = positions.find((pos) => {
|
|
6762
|
+
const wid = getPositionWidgetId(pos.content);
|
|
6763
|
+
return wid && !values[wid];
|
|
6764
|
+
});
|
|
6765
|
+
if (!targetPos) return;
|
|
6766
|
+
const targetWidgetId = getPositionWidgetId(targetPos.content);
|
|
6767
|
+
if (!targetWidgetId) return;
|
|
6768
|
+
const chipLabel = draggedItem.data.value;
|
|
6769
|
+
const sourceOptId = String(event.active.id);
|
|
6770
|
+
onChange(targetWidgetId, String(placed), true, sourceOptId);
|
|
6771
|
+
setDropLabels((prev) => new Map(prev).set(targetWidgetId, chipLabel));
|
|
6772
|
+
setDropSources((prev) => new Map(prev).set(targetWidgetId, sourceOptId));
|
|
6773
|
+
}
|
|
6774
|
+
});
|
|
6775
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
6776
|
+
"div",
|
|
6777
|
+
{
|
|
6778
|
+
ref: mergedRef,
|
|
6779
|
+
className: `number-line-axis-row nl-axis-drag-mode${isOver ? " nl-axis-drag-over" : ""}`,
|
|
6780
|
+
children: [
|
|
6781
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-track" }),
|
|
6782
|
+
showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left" }),
|
|
6783
|
+
showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right" }),
|
|
6784
|
+
getMinorTickValues(majorTicks, minorTicks).map((v) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
6785
|
+
"div",
|
|
6786
|
+
{
|
|
6787
|
+
className: "number-line-minor-tick",
|
|
6788
|
+
style: { left: `${toPercent(v, min, max)}%` }
|
|
6789
|
+
},
|
|
6790
|
+
`minor-${v}`
|
|
6791
|
+
)),
|
|
6792
|
+
majorTicks.map((tick) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
6793
|
+
"div",
|
|
6794
|
+
{
|
|
6795
|
+
className: "number-line-major-tick",
|
|
6796
|
+
style: { left: `${toPercent(tick, min, max)}%` }
|
|
6797
|
+
},
|
|
6798
|
+
`major-${tick}`
|
|
6799
|
+
)),
|
|
6800
|
+
positions.map((pos) => {
|
|
6801
|
+
const wid = getPositionWidgetId(pos.content);
|
|
6802
|
+
const dropPos = wid ? parseFloat(values[wid] ?? "") : NaN;
|
|
6803
|
+
const label = wid ? dropLabels.get(wid) : void 0;
|
|
6804
|
+
if (!wid || isNaN(dropPos) || !label) return null;
|
|
6805
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
6806
|
+
PlacedChip,
|
|
6807
|
+
{
|
|
6808
|
+
widgetId: wid,
|
|
6809
|
+
label,
|
|
6810
|
+
leftPercent: toPercent(dropPos, min, max),
|
|
6811
|
+
onRemove: () => {
|
|
6812
|
+
const sourceId = dropSources.get(wid);
|
|
6813
|
+
onChange(wid, "", false, sourceId);
|
|
6814
|
+
setDropLabels((prev) => {
|
|
6815
|
+
const m = new Map(prev);
|
|
6816
|
+
m.delete(wid);
|
|
6817
|
+
return m;
|
|
6818
|
+
});
|
|
6819
|
+
setDropSources((prev) => {
|
|
6820
|
+
const m = new Map(prev);
|
|
6821
|
+
m.delete(wid);
|
|
6822
|
+
return m;
|
|
6823
|
+
});
|
|
6824
|
+
}
|
|
6825
|
+
},
|
|
6826
|
+
wid
|
|
6827
|
+
);
|
|
6828
|
+
})
|
|
6829
|
+
]
|
|
6830
|
+
}
|
|
6831
|
+
);
|
|
6832
|
+
};
|
|
6833
|
+
const NumberLineRenderer = ({
|
|
6834
|
+
item,
|
|
6835
|
+
widgets,
|
|
6836
|
+
values,
|
|
6837
|
+
droppedInputs,
|
|
6838
|
+
onChange,
|
|
6839
|
+
onInputFocus
|
|
6840
|
+
}) => {
|
|
6841
|
+
const { min, max, step, minorTicks = 0, style = "arrow-right", positions } = item;
|
|
6842
|
+
const majorTicks = React.useMemo(() => getMajorTicks(min, max, step), [min, max, step]);
|
|
6843
|
+
const minorValues = React.useMemo(() => getMinorTickValues(majorTicks, minorTicks), [majorTicks, minorTicks]);
|
|
6844
|
+
const showArrowRight = style === "arrow-right" || style === "arrow-both";
|
|
6845
|
+
const showArrowLeft = style === "arrow-left" || style === "arrow-both";
|
|
6846
|
+
const isDragMode = Boolean(widgets[DRAG_OPTIONS_WIDGET_ID]);
|
|
6847
|
+
const inputTickSet = React.useMemo(() => {
|
|
6848
|
+
if (isDragMode) return /* @__PURE__ */ new Set();
|
|
6849
|
+
return new Set(
|
|
6850
|
+
positions.filter((pos) => getPositionWidgetId(pos.content) !== null).map((pos) => pos.value)
|
|
6851
|
+
);
|
|
6852
|
+
}, [isDragMode, positions]);
|
|
6853
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
6854
|
+
"div",
|
|
6855
|
+
{
|
|
6856
|
+
className: `number-line${isDragMode ? " number-line--drag-mode" : ""}`,
|
|
6857
|
+
style: { [CSS_VAR_SIDE_PAD]: `${SIDE_PAD}%` },
|
|
6858
|
+
role: "img",
|
|
6859
|
+
"aria-label": `Number line from ${min} to ${max}`,
|
|
6860
|
+
children: [
|
|
6861
|
+
!isDragMode && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-content-row", children: positions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
6862
|
+
"div",
|
|
6863
|
+
{
|
|
6864
|
+
className: "number-line-position-content",
|
|
6865
|
+
style: { left: `${toPercent(pos.value, min, max)}%` },
|
|
6866
|
+
children: pos.content.map((subItem) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
6867
|
+
LayoutRenderer,
|
|
6868
|
+
{
|
|
6869
|
+
item: subItem,
|
|
6870
|
+
widgets,
|
|
6871
|
+
values,
|
|
6872
|
+
droppedInputs,
|
|
6873
|
+
onChange,
|
|
6874
|
+
onInputFocus
|
|
6875
|
+
},
|
|
6876
|
+
subItem.id
|
|
6877
|
+
))
|
|
6878
|
+
},
|
|
6879
|
+
pos.id
|
|
6880
|
+
)) }),
|
|
6881
|
+
isDragMode ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
6882
|
+
FreeDragAxis,
|
|
6883
|
+
{
|
|
6884
|
+
axisDropId: `${item.id}--nl-axis`,
|
|
6885
|
+
min,
|
|
6886
|
+
max,
|
|
6887
|
+
minorTicks,
|
|
6888
|
+
majorTicks,
|
|
6889
|
+
showArrowLeft,
|
|
6890
|
+
showArrowRight,
|
|
6891
|
+
positions,
|
|
6892
|
+
values,
|
|
6893
|
+
onChange
|
|
6894
|
+
}
|
|
6895
|
+
) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "number-line-axis-row", role: "presentation", children: [
|
|
6896
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-track", "aria-hidden": "true" }),
|
|
6897
|
+
showArrowLeft && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-left", "aria-hidden": "true" }),
|
|
6898
|
+
showArrowRight && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-arrow number-line-arrow-right", "aria-hidden": "true" }),
|
|
6899
|
+
minorValues.map((v) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
6900
|
+
"div",
|
|
6901
|
+
{
|
|
6902
|
+
className: "number-line-minor-tick",
|
|
6903
|
+
"aria-hidden": "true",
|
|
6904
|
+
style: { left: `${toPercent(v, min, max)}%` }
|
|
6905
|
+
},
|
|
6906
|
+
`minor-${v}`
|
|
6907
|
+
)),
|
|
6908
|
+
majorTicks.map((tick) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
6909
|
+
"div",
|
|
6910
|
+
{
|
|
6911
|
+
className: "number-line-major-tick",
|
|
6912
|
+
style: { left: `${toPercent(tick, min, max)}%` }
|
|
6913
|
+
},
|
|
6914
|
+
`major-${tick}`
|
|
6915
|
+
)),
|
|
6916
|
+
positions.map((pos) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
6917
|
+
"div",
|
|
6918
|
+
{
|
|
6919
|
+
className: "number-line-position-dot",
|
|
6920
|
+
style: { left: `${toPercent(pos.value, min, max)}%` }
|
|
6921
|
+
},
|
|
6922
|
+
`dot-${pos.id}`
|
|
6923
|
+
))
|
|
6924
|
+
] }),
|
|
6925
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "number-line-labels-row", "aria-hidden": "true", children: majorTicks.map((tick) => {
|
|
6926
|
+
const hasInputAtTick = inputTickSet.has(tick);
|
|
6927
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
6928
|
+
"span",
|
|
6929
|
+
{
|
|
6930
|
+
className: "number-line-tick-label",
|
|
6931
|
+
style: { left: `${toPercent(tick, min, max)}%`, visibility: hasInputAtTick ? "hidden" : "visible" },
|
|
6932
|
+
children: tick
|
|
6933
|
+
},
|
|
6934
|
+
`lbl-${tick}`
|
|
6935
|
+
);
|
|
6936
|
+
}) })
|
|
6937
|
+
]
|
|
6938
|
+
}
|
|
6939
|
+
);
|
|
6940
|
+
};
|
|
6371
6941
|
function isArabic(text) {
|
|
6372
6942
|
return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
|
|
6373
6943
|
}
|
|
@@ -6535,6 +7105,18 @@ const LayoutRenderer = ({
|
|
|
6535
7105
|
},
|
|
6536
7106
|
cell.id
|
|
6537
7107
|
)) }, row.id)) }) }) });
|
|
7108
|
+
case JSON_TYPES.NUMBER_LINE:
|
|
7109
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
7110
|
+
NumberLineRenderer,
|
|
7111
|
+
{
|
|
7112
|
+
item,
|
|
7113
|
+
widgets,
|
|
7114
|
+
values,
|
|
7115
|
+
droppedInputs,
|
|
7116
|
+
onChange,
|
|
7117
|
+
onInputFocus
|
|
7118
|
+
}
|
|
7119
|
+
);
|
|
6538
7120
|
default:
|
|
6539
7121
|
return null;
|
|
6540
7122
|
}
|
|
@@ -6622,6 +7204,20 @@ const ExerciseRenderer = React.forwardRef(({
|
|
|
6622
7204
|
});
|
|
6623
7205
|
continue;
|
|
6624
7206
|
}
|
|
7207
|
+
if (item.type === JSON_TYPES.NUMBER_LINE) {
|
|
7208
|
+
if (currentGroup.length > 0) {
|
|
7209
|
+
const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
|
|
7210
|
+
const subGroups = groupLineItems(currentGroup);
|
|
7211
|
+
groups.push({ items: currentGroup, hasArabic, subGroups });
|
|
7212
|
+
currentGroup = [];
|
|
7213
|
+
}
|
|
7214
|
+
groups.push({
|
|
7215
|
+
items: [item],
|
|
7216
|
+
hasArabic: false,
|
|
7217
|
+
subGroups: []
|
|
7218
|
+
});
|
|
7219
|
+
continue;
|
|
7220
|
+
}
|
|
6625
7221
|
if (item.type === JSON_TYPES.LINEBREAK) {
|
|
6626
7222
|
if (currentGroup.length > 0) {
|
|
6627
7223
|
const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
|
|
@@ -6872,7 +7468,7 @@ exports.isTextItem = isTextItem$1;
|
|
|
6872
7468
|
exports.isToolNode = isToolNode;
|
|
6873
7469
|
exports.isValidExpression = isValidExpression;
|
|
6874
7470
|
exports.isValidationNode = isValidationNode;
|
|
6875
|
-
exports.isWidgetRef = isWidgetRef;
|
|
7471
|
+
exports.isWidgetRef = isWidgetRef$1;
|
|
6876
7472
|
exports.parse = parse;
|
|
6877
7473
|
exports.parseSafe = parseSafe;
|
|
6878
7474
|
exports.renderExercise = renderExercise;
|