@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.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 item = active.data.current;
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;