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