@mathscan/math-exercise-engine 1.2.14 → 1.2.15

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.
@@ -25,6 +25,8 @@ const AST_TYPES = {
25
25
  BOLD_TEXT: "BoldText",
26
26
  /** Inline input widget (string or numeric) */
27
27
  INLINE_INPUT: "InlineInput",
28
+ /** Drop-only inline input; stores the selected option name */
29
+ DROP_INPUT: "DropInput",
28
30
  /** Single inline draggable option (appears in-flow with text) */
29
31
  INLINE_DRAG_OPTION: "InlineDragOption",
30
32
  /** Inline fraction widget (numerator/denominator with optional inputs) */
@@ -112,6 +114,8 @@ const AST_TYPES = {
112
114
  // ═══════════════════════════════════════════════════════════════════════════
113
115
  /** Drag-and-drop options tool */
114
116
  DRAG_OPTIONS: "DragOptions",
117
+ /** Rich draggable options tool */
118
+ DRAGGABLE_OPTIONS: "DraggableOptions",
115
119
  /** Radio button options tool */
116
120
  RADIO_OPTIONS: "RadioOptions",
117
121
  /** Numeric keypad input tool */
@@ -217,8 +221,12 @@ const JSON_TYPES = {
217
221
  // ═══════════════════════════════════════════════════════════════════════════
218
222
  /** Inline input widget (string or numeric) */
219
223
  INLINE_INPUT: "inline-input",
224
+ /** Drop-only inline input; stores the selected option name */
225
+ DROP_INPUT: "drop-input",
220
226
  /** Drag-and-drop options widget */
221
227
  DRAG_OPTIONS: "drag-options",
228
+ /** Rich draggable options widget */
229
+ DRAGGABLE_OPTIONS: "draggable-options",
222
230
  /** Single inline draggable option (appears in-flow with text) */
223
231
  INLINE_DRAG_OPTION: "inline-drag-option",
224
232
  /** Radio button options widget */
@@ -241,6 +249,7 @@ const JSON_TYPES = {
241
249
  GEOMETRY: "geometry"
242
250
  };
243
251
  const DRAG_OPTIONS_WIDGET_ID = "_drag_options";
252
+ const DRAGGABLE_OPTIONS_WIDGET_ID = "_draggable_options";
244
253
  const NUMERIC_KEYPAD_WIDGET_ID = "_numeric_keypad";
245
254
  const RULER_WIDGET_ID = "_ruler";
246
255
  const PROTRACTOR_WIDGET_ID = "_protractor";
@@ -256,6 +265,7 @@ const GRID_SYMMETRY_WIDGET_PREFIX = "_grid_symmetry_";
256
265
  const GEOMETRY_WIDGET_PREFIX = "_geometry_";
257
266
  const RTL_INLINE_WIDGET_TYPES = /* @__PURE__ */ new Set([
258
267
  JSON_TYPES.INLINE_INPUT,
268
+ JSON_TYPES.DROP_INPUT,
259
269
  JSON_TYPES.INLINE_DRAG_OPTION,
260
270
  JSON_TYPES.FRACTION,
261
271
  JSON_TYPES.VISUAL_FRACTION
@@ -355,6 +365,10 @@ const STRING_INPUT = chevrotain.createToken({
355
365
  name: "STRING_INPUT",
356
366
  pattern: /STRING_INPUT/
357
367
  });
368
+ const DROP_INPUT = chevrotain.createToken({
369
+ name: "DROP_INPUT",
370
+ pattern: /DROP_INPUT/
371
+ });
358
372
  const INLINE_DRAG_OPTION = chevrotain.createToken({
359
373
  name: "INLINE_DRAG_OPTION",
360
374
  pattern: /INLINE_DRAG_OPTION/
@@ -363,6 +377,18 @@ const DRAGABLE_BUTTONS_OPTIONS = chevrotain.createToken({
363
377
  name: "DRAGABLE_BUTTONS_OPTIONS",
364
378
  pattern: /DRAGABLE_BUTTONS_OPTIONS/
365
379
  });
380
+ const DRAGGABLE_OPTIONS = chevrotain.createToken({
381
+ name: "DRAGGABLE_OPTIONS",
382
+ pattern: /DRAGGABLE_OPTIONS/
383
+ });
384
+ const OPTION = chevrotain.createToken({
385
+ name: "OPTION",
386
+ // Only a keyword when immediately followed by `[` (which the grammar always
387
+ // requires: `OPTION[name=...]`). Without the lookahead, the bare common word
388
+ // "OPTION"/"OPTIONS" in exercise prose would tokenize as a keyword and break
389
+ // parsing.
390
+ pattern: /OPTION(?=\[)/
391
+ });
366
392
  const RADIO_BUTTONS_OPTIONS = chevrotain.createToken({
367
393
  name: "RADIO_BUTTONS_OPTIONS",
368
394
  pattern: /RADIO_BUTTONS_OPTIONS/
@@ -673,8 +699,11 @@ const allTokens = [
673
699
  HIGHLIGHT_TEXT,
674
700
  W_S_I,
675
701
  STRING_INPUT,
702
+ DROP_INPUT,
676
703
  INLINE_DRAG_OPTION,
704
+ DRAGGABLE_OPTIONS,
677
705
  DRAGABLE_BUTTONS_OPTIONS,
706
+ OPTION,
678
707
  RADIO_BUTTONS_OPTIONS,
679
708
  NUMERIC_KEYPAD,
680
709
  WIDGET_ANSWER,
@@ -1446,6 +1475,8 @@ class MathExerciseParser extends chevrotain.CstParser {
1446
1475
  { ALT: () => this.SUBRULE(this.visualFraction) },
1447
1476
  // W_S_I (inline input widget) — excluded in display-only contexts (e.g. MATCHER ITEM)
1448
1477
  { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineInput) },
1478
+ // DROP_INPUT (drop-only inline answer widget) — excluded in display-only contexts
1479
+ { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.dropInput) },
1449
1480
  // INLINE_DRAG_OPTION (single draggable option inline with text) — excluded in display-only
1450
1481
  { GATE: () => !displayOnly, ALT: () => this.SUBRULE(this.inlineDragOption) },
1451
1482
  // CLOCK (inline clock widget)
@@ -1537,6 +1568,12 @@ class MathExerciseParser extends chevrotain.CstParser {
1537
1568
  this.SUBRULE(this.parameterList);
1538
1569
  this.CONSUME(RBracket);
1539
1570
  });
1571
+ this.dropInput = this.RULE("dropInput", () => {
1572
+ this.CONSUME(DROP_INPUT);
1573
+ this.CONSUME(LBracket);
1574
+ this.SUBRULE(this.parameterList);
1575
+ this.CONSUME(RBracket);
1576
+ });
1540
1577
  this.inlineDragOption = this.RULE("inlineDragOption", () => {
1541
1578
  this.CONSUME(INLINE_DRAG_OPTION);
1542
1579
  this.CONSUME(LBracket);
@@ -1545,6 +1582,7 @@ class MathExerciseParser extends chevrotain.CstParser {
1545
1582
  });
1546
1583
  this.tool = this.RULE("tool", () => {
1547
1584
  this.OR([
1585
+ { ALT: () => this.SUBRULE(this.draggableOptions) },
1548
1586
  { ALT: () => this.SUBRULE(this.dragableButtons) },
1549
1587
  { ALT: () => this.SUBRULE(this.numericKeypad) },
1550
1588
  { ALT: () => this.SUBRULE(this.rulerTool) },
@@ -1552,6 +1590,44 @@ class MathExerciseParser extends chevrotain.CstParser {
1552
1590
  { ALT: () => this.SUBRULE(this.setSquareTool) }
1553
1591
  ]);
1554
1592
  });
1593
+ this.draggableOptions = this.RULE("draggableOptions", () => {
1594
+ this.CONSUME(DRAGGABLE_OPTIONS);
1595
+ this.CONSUME(LBracket);
1596
+ this.OPTION(() => this.SUBRULE(this.parameterList));
1597
+ this.CONSUME(RBracket);
1598
+ this.MANY(() => {
1599
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1600
+ });
1601
+ this.CONSUME(LBrace);
1602
+ this.AT_LEAST_ONE(() => this.SUBRULE(this.draggableOption));
1603
+ this.CONSUME(RBrace);
1604
+ this.MANY2(() => {
1605
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1606
+ });
1607
+ });
1608
+ this.draggableOption = this.RULE("draggableOption", () => {
1609
+ this.MANY(() => {
1610
+ this.OR([{ ALT: () => this.CONSUME(Space) }, { ALT: () => this.CONSUME(Newline) }]);
1611
+ });
1612
+ this.CONSUME(OPTION);
1613
+ this.CONSUME(LBracket);
1614
+ this.SUBRULE(this.parameterList);
1615
+ this.CONSUME(RBracket);
1616
+ this.MANY2(() => {
1617
+ this.OR2([{ ALT: () => this.CONSUME2(Space) }, { ALT: () => this.CONSUME2(Newline) }]);
1618
+ });
1619
+ this.CONSUME(LBrace);
1620
+ this.MANY3(() => {
1621
+ this.OR3([
1622
+ { ALT: () => this.SUBRULE(this.inlineElement, { ARGS: [true] }) },
1623
+ { ALT: () => this.CONSUME3(Newline) }
1624
+ ]);
1625
+ });
1626
+ this.CONSUME(RBrace);
1627
+ this.MANY4(() => {
1628
+ this.OR4([{ ALT: () => this.CONSUME4(Space) }, { ALT: () => this.CONSUME4(Newline) }]);
1629
+ });
1630
+ });
1555
1631
  this.dragableButtons = this.RULE("dragableButtons", () => {
1556
1632
  this.CONSUME(DRAGABLE_BUTTONS_OPTIONS);
1557
1633
  this.CONSUME(LBracket);
@@ -2109,6 +2185,14 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
2109
2185
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
2110
2186
  })
2111
2187
  ]);
2188
+ const DropInputSchema = zod.z.object({
2189
+ type: zod.z.literal(AST_TYPES.DROP_INPUT),
2190
+ name: zod.z.string().min(1),
2191
+ answer: zod.z.string().min(1),
2192
+ /** Box width in characters: the answer is an option name, so nothing else implies a size. */
2193
+ width: InlineInputWidthSchema,
2194
+ placeholder: zod.z.string().max(32).optional()
2195
+ });
2112
2196
  const FractionInlineSchema = zod.z.object({
2113
2197
  type: zod.z.literal(AST_TYPES.FRACTION),
2114
2198
  name: zod.z.string(),
@@ -2222,6 +2306,7 @@ const InlineNodeSchema = zod.z.union([
2222
2306
  ColorTextInlineSchema,
2223
2307
  BoldTextInlineSchema,
2224
2308
  InlineInputWidgetSchema,
2309
+ DropInputSchema,
2225
2310
  FractionInlineSchema,
2226
2311
  ImageInlineSchema,
2227
2312
  VisualFractionInlineSchema,
@@ -2297,6 +2382,19 @@ function createNumericInput(name, digits, answer, placeholder, disabled, width,
2297
2382
  "NUMERIC_INPUT"
2298
2383
  );
2299
2384
  }
2385
+ function createDropInput(name, answer, placeholder, width) {
2386
+ return parseOrThrow(
2387
+ DropInputSchema,
2388
+ {
2389
+ type: AST_TYPES.DROP_INPUT,
2390
+ name,
2391
+ answer,
2392
+ placeholder,
2393
+ width
2394
+ },
2395
+ "DROP_INPUT"
2396
+ );
2397
+ }
2300
2398
  function createImage(name, src, alt, options = {}) {
2301
2399
  return ImageInlineSchema.parse({
2302
2400
  type: AST_TYPES.IMAGE,
@@ -2351,6 +2449,9 @@ function isSpace(value) {
2351
2449
  function isInlineInput(value) {
2352
2450
  return InlineInputWidgetSchema.safeParse(value).success;
2353
2451
  }
2452
+ function isDropInput(value) {
2453
+ return DropInputSchema.safeParse(value).success;
2454
+ }
2354
2455
  function isFraction(value) {
2355
2456
  return FractionInlineSchema.safeParse(value).success;
2356
2457
  }
@@ -2405,7 +2506,7 @@ function createNumberLinePosition(value, inlines) {
2405
2506
  if (!hasRealContent) {
2406
2507
  throw new Error(`POSITION[value=${value}]: content cannot be empty or whitespace only`);
2407
2508
  }
2408
- const inputs = inlines.filter((i) => i.type === AST_TYPES.INLINE_INPUT);
2509
+ const inputs = inlines.filter((i) => i.type === AST_TYPES.INLINE_INPUT || i.type === AST_TYPES.DROP_INPUT);
2409
2510
  if (inputs.length > 1) {
2410
2511
  throw new Error(
2411
2512
  `POSITION[value=${value}]: only one input widget is allowed, got ${inputs.length}`
@@ -3118,6 +3219,45 @@ const DragOptionsToolSchema = zod.z.object({
3118
3219
  type: zod.z.literal(AST_TYPES.DRAG_OPTIONS),
3119
3220
  options: zod.z.array(zod.z.string()).min(1)
3120
3221
  });
3222
+ function hasOptionContent(content, spaceType = AST_TYPES.SPACE) {
3223
+ return content.some((node) => node.type !== spaceType && !(typeof node.value === "string" && node.value.trim() === ""));
3224
+ }
3225
+ function isAnsweredFraction(node) {
3226
+ return node.type === AST_TYPES.FRACTION && node.answer !== void 0;
3227
+ }
3228
+ function isOptionContentNode(node) {
3229
+ const parsed = DisplayInlineNodeSchema.safeParse(node);
3230
+ return parsed.success && !isAnsweredFraction(parsed.data);
3231
+ }
3232
+ const DraggableOptionSchema = zod.z.object({
3233
+ name: zod.z.string().min(1),
3234
+ content: zod.z.array(DisplayInlineNodeSchema).min(1).refine((content) => hasOptionContent(content), { message: "OPTION content cannot be empty or whitespace only" }).refine(
3235
+ (content) => !content.some(isAnsweredFraction),
3236
+ { message: "FRACTION inside OPTION must be display-only (no answer)" }
3237
+ )
3238
+ });
3239
+ function addDuplicateOptionNameIssues(options, ctx, pathPrefix = []) {
3240
+ const seen = /* @__PURE__ */ new Set();
3241
+ options.forEach((option, index) => {
3242
+ const key2 = normalize(option.name);
3243
+ if (seen.has(key2)) {
3244
+ ctx.addIssue({
3245
+ code: zod.z.ZodIssueCode.custom,
3246
+ message: `Duplicate option name "${option.name}" (names are compared ignoring case and number format)`,
3247
+ path: [...pathPrefix, "options", index, "name"]
3248
+ });
3249
+ }
3250
+ seen.add(key2);
3251
+ });
3252
+ }
3253
+ const DraggableOptionsToolObjectSchema = zod.z.object({
3254
+ type: zod.z.literal(AST_TYPES.DRAGGABLE_OPTIONS),
3255
+ reusable: zod.z.boolean().default(false),
3256
+ options: zod.z.array(DraggableOptionSchema).min(1)
3257
+ });
3258
+ const DraggableOptionsToolSchema = DraggableOptionsToolObjectSchema.superRefine((tool, ctx) => {
3259
+ addDuplicateOptionNameIssues(tool.options, ctx);
3260
+ });
3121
3261
  const NumericKeypadToolSchema = zod.z.object({
3122
3262
  type: zod.z.literal(AST_TYPES.NUMERIC_KEYPAD),
3123
3263
  includeOperators: zod.z.boolean().optional(),
@@ -3138,6 +3278,7 @@ const SetSquareToolSchema = zod.z.object({
3138
3278
  });
3139
3279
  const ToolNodeSchema = zod.z.discriminatedUnion("type", [
3140
3280
  DragOptionsToolSchema,
3281
+ DraggableOptionsToolObjectSchema,
3141
3282
  NumericKeypadToolSchema,
3142
3283
  RulerToolSchema,
3143
3284
  ProtractorToolSchema,
@@ -3149,6 +3290,19 @@ function createDragOptions(options) {
3149
3290
  options
3150
3291
  });
3151
3292
  }
3293
+ function createDraggableOptions(options, reusable = false) {
3294
+ const result = DraggableOptionsToolSchema.safeParse({
3295
+ type: AST_TYPES.DRAGGABLE_OPTIONS,
3296
+ reusable,
3297
+ options
3298
+ });
3299
+ if (result.success) return result.data;
3300
+ const issue = result.error.issues[0];
3301
+ throw new Error(`DRAGGABLE_OPTIONS: ${issue ? issue.message : "validation failed"}`);
3302
+ }
3303
+ function createDraggableOption(name, content) {
3304
+ return DraggableOptionSchema.parse({ name, content });
3305
+ }
3152
3306
  function createNumericKeypad(includeOperators, includeDecimal, style) {
3153
3307
  return NumericKeypadToolSchema.parse({
3154
3308
  type: AST_TYPES.NUMERIC_KEYPAD,
@@ -3169,6 +3323,9 @@ function createSetSquareTool(size) {
3169
3323
  function isDragOptions(value) {
3170
3324
  return DragOptionsToolSchema.safeParse(value).success;
3171
3325
  }
3326
+ function isDraggableOptions(value) {
3327
+ return DraggableOptionsToolSchema.safeParse(value).success;
3328
+ }
3172
3329
  function isNumericKeypad(value) {
3173
3330
  return NumericKeypadToolSchema.safeParse(value).success;
3174
3331
  }
@@ -3727,9 +3884,13 @@ const ExerciseDocumentSchema = zod.z.object({
3727
3884
  seen.add(name);
3728
3885
  }
3729
3886
  };
3887
+ const dropInputs = [];
3730
3888
  const visitInlines = (inlines, basePath) => {
3731
3889
  inlines.forEach((inline, i) => {
3732
- if (inline.type === AST_TYPES.INLINE_INPUT || inline.type === AST_TYPES.FRACTION || inline.type === AST_TYPES.IMAGE || inline.type === AST_TYPES.VISUAL_FRACTION) {
3890
+ if (inline.type === AST_TYPES.DROP_INPUT) {
3891
+ dropInputs.push({ name: inline.name, answer: inline.answer, path: [...basePath, i] });
3892
+ }
3893
+ if (inline.type === AST_TYPES.INLINE_INPUT || inline.type === AST_TYPES.DROP_INPUT || inline.type === AST_TYPES.FRACTION || inline.type === AST_TYPES.IMAGE || inline.type === AST_TYPES.VISUAL_FRACTION) {
3733
3894
  record(inline.name, [...basePath, i, "name"]);
3734
3895
  } else if (inline.type === AST_TYPES.CLOCK && inline.mode === "interactive") {
3735
3896
  record(inline.name, [...basePath, i, "name"]);
@@ -3746,9 +3907,13 @@ const ExerciseDocumentSchema = zod.z.object({
3746
3907
  });
3747
3908
  });
3748
3909
  } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
3749
- block.positions.forEach((pos, pi) => {
3910
+ const numberLine = block;
3911
+ numberLine.positions.forEach((pos, pi) => {
3750
3912
  visitInlines(pos.inlines, ["lines", bi, "positions", pi, "inlines"]);
3751
3913
  });
3914
+ numberLine.arrows?.forEach((arrow, ai) => {
3915
+ visitInlines(arrow.inlines, ["lines", bi, "arrows", ai, "inlines"]);
3916
+ });
3752
3917
  } else if (block.type === AST_TYPES.MATCHER_BLOCK) {
3753
3918
  const m = block;
3754
3919
  record(m.name, ["lines", bi, "name"]);
@@ -3791,15 +3956,78 @@ const ExerciseDocumentSchema = zod.z.object({
3791
3956
  record(block.name, ["lines", bi, "name"]);
3792
3957
  }
3793
3958
  });
3959
+ const richBanks = (doc.tools ?? []).map((tool, toolIndex) => ({ tool, toolIndex })).filter((entry) => entry.tool.type === AST_TYPES.DRAGGABLE_OPTIONS);
3960
+ richBanks.forEach(({ tool: bank, toolIndex }) => {
3961
+ if (bank.type !== AST_TYPES.DRAGGABLE_OPTIONS) return;
3962
+ bank.options.forEach((option, optionIndex) => {
3963
+ visitInlines(option.content, ["tools", toolIndex, "options", optionIndex, "content"]);
3964
+ });
3965
+ addDuplicateOptionNameIssues(bank.options, ctx, ["tools", toolIndex]);
3966
+ if (dropInputs.length === 0) {
3967
+ ctx.addIssue({
3968
+ code: zod.z.ZodIssueCode.custom,
3969
+ message: "DRAGGABLE_OPTIONS needs at least one DROP_INPUT: its cards can only be dropped there",
3970
+ path: ["tools", toolIndex]
3971
+ });
3972
+ }
3973
+ });
3974
+ if (richBanks.length > 1) {
3975
+ ctx.addIssue({
3976
+ code: zod.z.ZodIssueCode.custom,
3977
+ message: "Only one DRAGGABLE_OPTIONS tool is allowed",
3978
+ path: ["tools"]
3979
+ });
3980
+ }
3981
+ const optionNames = new Set(
3982
+ richBanks.flatMap(({ tool }) => (tool.type === AST_TYPES.DRAGGABLE_OPTIONS ? tool.options : []).map((o) => o.name))
3983
+ );
3984
+ dropInputs.forEach(({ name, answer, path }) => {
3985
+ if (richBanks.length === 0) {
3986
+ ctx.addIssue({
3987
+ code: zod.z.ZodIssueCode.custom,
3988
+ message: `DROP_INPUT "${name}" needs a DRAGGABLE_OPTIONS bank to draw from`,
3989
+ path
3990
+ });
3991
+ } else if (!optionNames.has(answer)) {
3992
+ ctx.addIssue({
3993
+ code: zod.z.ZodIssueCode.custom,
3994
+ message: `DROP_INPUT "${name}": answer "${answer}" does not match any OPTION name`,
3995
+ path: [...path, "answer"]
3996
+ });
3997
+ }
3998
+ });
3999
+ const isSingleUseBank = richBanks.some(({ tool }) => tool.type === AST_TYPES.DRAGGABLE_OPTIONS && !tool.reusable);
4000
+ if (isSingleUseBank) {
4001
+ const firstInputByAnswer = /* @__PURE__ */ new Map();
4002
+ dropInputs.forEach(({ name, answer, path }) => {
4003
+ if (!optionNames.has(answer)) return;
4004
+ const firstInput = firstInputByAnswer.get(answer);
4005
+ if (firstInput === void 0) {
4006
+ firstInputByAnswer.set(answer, name);
4007
+ return;
4008
+ }
4009
+ ctx.addIssue({
4010
+ code: zod.z.ZodIssueCode.custom,
4011
+ message: `DROP_INPUT "${name}": answer "${answer}" is also the answer of "${firstInput}", but its card can only be placed once. Add a second option or use DRAGGABLE_OPTIONS[reusable=true]`,
4012
+ path: [...path, "answer"]
4013
+ });
4014
+ });
4015
+ }
3794
4016
  });
3795
4017
  function createExerciseDocument(question, lines, validation2, tools) {
3796
- return ExerciseDocumentSchema.parse({
4018
+ const result = ExerciseDocumentSchema.safeParse({
3797
4019
  type: AST_TYPES.EXERCISE_DOCUMENT,
3798
4020
  question,
3799
4021
  lines,
3800
4022
  tools,
3801
4023
  validation: validation2
3802
4024
  });
4025
+ if (result.success) return result.data;
4026
+ const { issues } = result.error;
4027
+ if (issues.every((issue) => issue.code === zod.z.ZodIssueCode.custom)) {
4028
+ throw new Error(issues.map((issue) => issue.message).join("\n"));
4029
+ }
4030
+ throw result.error;
3803
4031
  }
3804
4032
  function isExerciseDocument(value) {
3805
4033
  return ExerciseDocumentSchema.safeParse(value).success;
@@ -4199,6 +4427,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4199
4427
  if (ctx.inlineInput) {
4200
4428
  return this.visit(ctx.inlineInput);
4201
4429
  }
4430
+ if (ctx.dropInput) {
4431
+ return this.visit(ctx.dropInput);
4432
+ }
4202
4433
  if (ctx.inlineDragOption) {
4203
4434
  return this.visit(ctx.inlineDragOption);
4204
4435
  }
@@ -4353,6 +4584,18 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4353
4584
  const digits = params.digits;
4354
4585
  return createNumericInput(name, digits, answer, placeholder, disabled, width, prefill);
4355
4586
  }
4587
+ dropInput(ctx) {
4588
+ const params = this.visit(ctx.parameterList);
4589
+ if (typeof params.name === "string" && params.name.startsWith("_")) {
4590
+ throw new Error(`DROP_INPUT: name "${params.name}" is reserved (names cannot start with "_")`);
4591
+ }
4592
+ return createDropInput(
4593
+ params.name,
4594
+ params.answer,
4595
+ params.placeholder,
4596
+ params.width
4597
+ );
4598
+ }
4356
4599
  inlineDragOption(ctx) {
4357
4600
  const valueToken = ctx.StringLiteral[0];
4358
4601
  const value = this.extractString(valueToken.image);
@@ -4491,6 +4734,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4491
4734
  // Tools
4492
4735
  // ═══════════════════════════════════════════════════════════════════════════
4493
4736
  tool(ctx) {
4737
+ if (ctx.draggableOptions) {
4738
+ return this.visit(ctx.draggableOptions);
4739
+ }
4494
4740
  if (ctx.dragableButtons) {
4495
4741
  return this.visit(ctx.dragableButtons);
4496
4742
  }
@@ -4508,6 +4754,33 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4508
4754
  }
4509
4755
  throw new Error("Unknown tool");
4510
4756
  }
4757
+ draggableOptions(ctx) {
4758
+ const params = ctx.parameterList ? this.visit(ctx.parameterList) : {};
4759
+ if (params.reusable !== void 0 && !["true", "false"].includes(String(params.reusable))) {
4760
+ throw new Error("DRAGGABLE_OPTIONS: reusable must be true or false");
4761
+ }
4762
+ const reusable = params.reusable === true || params.reusable === "true";
4763
+ const options = ctx.draggableOption.map((option) => this.visit(option));
4764
+ return createDraggableOptions(options, reusable);
4765
+ }
4766
+ draggableOption(ctx) {
4767
+ const params = this.visit(ctx.parameterList);
4768
+ const rawContent = ctx.inlineElement ? ctx.inlineElement.map((element) => this.visit(element)) : [];
4769
+ const name = params.name;
4770
+ if (typeof name !== "string" || name === "") {
4771
+ throw new Error("DRAGGABLE_OPTIONS: OPTION requires a non-empty string name");
4772
+ }
4773
+ if (!hasOptionContent(rawContent)) {
4774
+ throw new Error(`DRAGGABLE_OPTIONS: OPTION "${name}" content cannot be empty or whitespace only`);
4775
+ }
4776
+ const invalid = rawContent.find((node) => !isOptionContentNode(node));
4777
+ if (invalid) {
4778
+ throw new Error(
4779
+ `DRAGGABLE_OPTIONS: OPTION "${name}" cannot contain ${invalid.type} here. Content must be display-only: text, styled text, images, and fractions or visual fractions without an answer`
4780
+ );
4781
+ }
4782
+ return createDraggableOption(name, rawContent);
4783
+ }
4511
4784
  dragableButtons(ctx) {
4512
4785
  const strings = this.visit(ctx.stringList);
4513
4786
  return createDragOptions(strings);
@@ -5115,10 +5388,30 @@ const InlineInputJSONSchema = zod.z.discriminatedUnion("inputType", [
5115
5388
  StringInputJSONSchema,
5116
5389
  NumericInputJSONSchema
5117
5390
  ]);
5391
+ const DropInputJSONSchema = zod.z.object({
5392
+ type: zod.z.literal(JSON_TYPES.DROP_INPUT),
5393
+ /** Box width in characters; absent means the default input size. */
5394
+ width: InlineInputWidthSchema,
5395
+ placeholder: zod.z.string().max(32).optional()
5396
+ });
5118
5397
  const DragOptionsJSONSchema = zod.z.object({
5119
5398
  type: zod.z.literal(JSON_TYPES.DRAG_OPTIONS),
5120
5399
  options: zod.z.array(zod.z.string())
5121
5400
  });
5401
+ const DraggableOptionJSONSchema = zod.z.object({
5402
+ name: zod.z.string().min(1),
5403
+ content: zod.z.array(LayoutItemSchema).min(1).refine(
5404
+ (content) => hasOptionContent(content, JSON_TYPES.SPACE),
5405
+ { message: "Option content cannot be empty or whitespace only" }
5406
+ )
5407
+ });
5408
+ const DraggableOptionsJSONSchema = zod.z.object({
5409
+ type: zod.z.literal(JSON_TYPES.DRAGGABLE_OPTIONS),
5410
+ reusable: zod.z.boolean(),
5411
+ options: zod.z.array(DraggableOptionJSONSchema).min(1)
5412
+ }).superRefine((widget, ctx) => {
5413
+ addDuplicateOptionNameIssues(widget.options, ctx);
5414
+ });
5122
5415
  const InlineDragOptionJSONSchema = zod.z.object({
5123
5416
  type: zod.z.literal(JSON_TYPES.INLINE_DRAG_OPTION),
5124
5417
  value: zod.z.string().min(1)
@@ -5228,7 +5521,9 @@ const GeometryJSONSchema = zod.z.object({
5228
5521
  });
5229
5522
  const WidgetJSONSchema = zod.z.union([
5230
5523
  InlineInputJSONSchema,
5524
+ DropInputJSONSchema,
5231
5525
  DragOptionsJSONSchema,
5526
+ DraggableOptionsJSONSchema,
5232
5527
  InlineDragOptionJSONSchema,
5233
5528
  RadioOptionsJSONSchema,
5234
5529
  NumericKeypadJSONSchema,
@@ -5345,9 +5640,15 @@ function isInlineInputJSON(widget) {
5345
5640
  function isDisabledInputJSON(widget) {
5346
5641
  return isInlineInputJSON(widget) && widget.disabled === true;
5347
5642
  }
5643
+ function isDropInputJSON(widget) {
5644
+ return widget.type === JSON_TYPES.DROP_INPUT;
5645
+ }
5348
5646
  function isDragOptionsJSON(widget) {
5349
5647
  return widget.type === JSON_TYPES.DRAG_OPTIONS;
5350
5648
  }
5649
+ function isDraggableOptionsJSON(widget) {
5650
+ return widget.type === JSON_TYPES.DRAGGABLE_OPTIONS;
5651
+ }
5351
5652
  function isInlineDragOptionJSON(widget) {
5352
5653
  return widget.type === JSON_TYPES.INLINE_DRAG_OPTION;
5353
5654
  }
@@ -10881,7 +11182,16 @@ function toJSON(ast) {
10881
11182
  const answers = {};
10882
11183
  let gridSymmetryCounter = 0;
10883
11184
  let geometryCounter = 0;
10884
- const allInlines = collectAllInlines(ast.lines);
11185
+ const richOptionsTool = ast.tools?.find(
11186
+ (tool) => tool.type === AST_TYPES.DRAGGABLE_OPTIONS
11187
+ );
11188
+ const allInlines = [
11189
+ ...collectAllInlines(ast.lines),
11190
+ ...richOptionsTool?.options.flatMap((option) => option.content) ?? []
11191
+ ];
11192
+ const matcherCardInlines = new Set(collectAllInlines(ast.lines.filter(
11193
+ (block) => block.type === AST_TYPES.MATCHER_BLOCK || block.type === AST_TYPES.PAIR_MATCHER_BLOCK
11194
+ )));
10885
11195
  const inlineDragIds = /* @__PURE__ */ new Map();
10886
11196
  {
10887
11197
  let counter = 0;
@@ -11007,7 +11317,7 @@ function toJSON(ast) {
11007
11317
  const hasDragBank = ast.tools?.some((tool) => tool.type === AST_TYPES.DRAG_OPTIONS) ?? false;
11008
11318
  for (const inline of allInlines) {
11009
11319
  if (inline.type === AST_TYPES.INLINE_INPUT) {
11010
- const disabled = inline.disabled || hasDragBank && inline.prefill !== void 0;
11320
+ const disabled = inline.disabled || hasDragBank && !!inline.prefill;
11011
11321
  if (inline.inputType === INPUT_TYPES.STRING) {
11012
11322
  widgets[inline.name] = {
11013
11323
  type: JSON_TYPES.INLINE_INPUT,
@@ -11032,6 +11342,13 @@ function toJSON(ast) {
11032
11342
  if (inline.answer !== void 0 && !disabled) {
11033
11343
  answers[inline.name] = inline.answer;
11034
11344
  }
11345
+ } else if (inline.type === AST_TYPES.DROP_INPUT) {
11346
+ widgets[inline.name] = {
11347
+ type: JSON_TYPES.DROP_INPUT,
11348
+ ...inline.width !== void 0 && { width: inline.width },
11349
+ placeholder: inline.placeholder
11350
+ };
11351
+ answers[inline.name] = inline.answer;
11035
11352
  } else if (inline.type === AST_TYPES.CLOCK && inline.mode === "interactive") {
11036
11353
  answers[inline.name] = inline.answer;
11037
11354
  }
@@ -11043,7 +11360,7 @@ function toJSON(ast) {
11043
11360
  simplified: inline.simplified ?? true,
11044
11361
  placeholder: inline.placeholder
11045
11362
  };
11046
- if (inline.answer !== void 0) {
11363
+ if (inline.answer !== void 0 && !matcherCardInlines.has(inline)) {
11047
11364
  answers[inline.name] = inline.answer;
11048
11365
  }
11049
11366
  }
@@ -11119,6 +11436,15 @@ function toJSON(ast) {
11119
11436
  // Randomise chip order per compile so the answer position isn't fixed.
11120
11437
  options: lodashExports.shuffle(tool.options)
11121
11438
  };
11439
+ } else if (tool.type === AST_TYPES.DRAGGABLE_OPTIONS) {
11440
+ widgets[DRAGGABLE_OPTIONS_WIDGET_ID] = {
11441
+ type: JSON_TYPES.DRAGGABLE_OPTIONS,
11442
+ reusable: tool.reusable,
11443
+ options: tool.options.map((option) => ({
11444
+ name: option.name,
11445
+ content: option.content.map((inline) => transformInline(inline, idGenerator, inlineDragIds))
11446
+ }))
11447
+ };
11122
11448
  } else if (tool.type === AST_TYPES.NUMERIC_KEYPAD) {
11123
11449
  widgets[NUMERIC_KEYPAD_WIDGET_ID] = {
11124
11450
  type: JSON_TYPES.NUMERIC_KEYPAD,
@@ -11584,6 +11910,12 @@ function transformInline(inline, idGenerator, inlineDragIds) {
11584
11910
  type: JSON_TYPES.WIDGET_REF,
11585
11911
  widgetId: inline.name
11586
11912
  };
11913
+ case AST_TYPES.DROP_INPUT:
11914
+ return {
11915
+ id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
11916
+ type: JSON_TYPES.WIDGET_REF,
11917
+ widgetId: inline.name
11918
+ };
11587
11919
  case AST_TYPES.FRACTION:
11588
11920
  return {
11589
11921
  id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
@@ -11640,6 +11972,7 @@ function transformValidation(validation2, answers, widgets) {
11640
11972
  };
11641
11973
  } else if (validation2.type === AST_TYPES.EXPRESSION_VALIDATION) {
11642
11974
  assertNoDisabledInExpression(validation2.expression, widgets);
11975
+ assertNoDropInputs(Object.keys(widgets), widgets);
11643
11976
  const validVariableNames = getEnabledWidgetNames(widgets);
11644
11977
  const variables = extractVariables$1(validation2.expression, validVariableNames);
11645
11978
  return {
@@ -11649,6 +11982,7 @@ function transformValidation(validation2, answers, widgets) {
11649
11982
  };
11650
11983
  } else if (validation2.type === AST_TYPES.MIXED_VALIDATION) {
11651
11984
  assertNoDisabledInExpression(validation2.expression, widgets);
11985
+ assertNoDropInputs(extractVariables$1(validation2.expression, Object.keys(widgets)), widgets);
11652
11986
  const validVariableNames = getEnabledWidgetNames(widgets);
11653
11987
  const variables = extractVariables$1(validation2.expression, validVariableNames);
11654
11988
  const inExpression = new Set(variables);
@@ -11687,6 +12021,14 @@ function assertNoDisabledInExpression(expression, widgets) {
11687
12021
  );
11688
12022
  }
11689
12023
  }
12024
+ function assertNoDropInputs(names, widgets) {
12025
+ const dropInputs = names.filter((name) => widgets[name]?.type === JSON_TYPES.DROP_INPUT);
12026
+ if (dropInputs.length > 0) {
12027
+ throw new Error(
12028
+ `WIDGET_ANSWER: DROP_INPUT ${dropInputs.map((n) => `"${n}"`).join(", ")} cannot be used in expression validation — its value is an option name, not a number. Use type="simple"`
12029
+ );
12030
+ }
12031
+ }
11690
12032
  function getEnabledWidgetNames(widgets) {
11691
12033
  return Object.entries(widgets).filter(([, widget]) => !isDisabledInputJSON(widget)).map(([name]) => name);
11692
12034
  }
@@ -12260,7 +12602,7 @@ function withDisabledPrefilledInputs(exercise) {
12260
12602
  const widgets = { ...exercise.widgets };
12261
12603
  let changed = false;
12262
12604
  for (const [id, widget] of Object.entries(widgets)) {
12263
- if (widget.type === JSON_TYPES.INLINE_INPUT && widget.prefill !== void 0 && !widget.disabled) {
12605
+ if (widget.type === JSON_TYPES.INLINE_INPUT && widget.prefill && !widget.disabled) {
12264
12606
  widgets[id] = { ...widget, disabled: true };
12265
12607
  changed = true;
12266
12608
  }
@@ -12433,11 +12775,13 @@ function getMissingInputs(exercise, userInputs) {
12433
12775
  }
12434
12776
  function validateInputFormats(exercise, userInputs) {
12435
12777
  const errors = {};
12436
- for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
12778
+ const { widgets } = withDisabledPrefilledInputs(exercise);
12779
+ for (const [widgetId, widget] of Object.entries(widgets)) {
12437
12780
  const value = userInputs[widgetId];
12438
12781
  if (value === void 0 || value === "") {
12439
12782
  continue;
12440
12783
  }
12784
+ if (isDisabledInputJSON(widget)) continue;
12441
12785
  if (widget.type === JSON_TYPES.INLINE_INPUT && widget.inputType === INPUT_TYPES.NUMERIC) {
12442
12786
  const numValue = typeof value === "number" ? value : parseFloat(String(value));
12443
12787
  if (!Number.isFinite(numValue)) {
@@ -12455,6 +12799,8 @@ function validateInputFormats(exercise, userInputs) {
12455
12799
  const DragItemTypeSchema = zod.z.enum([
12456
12800
  "drag-option",
12457
12801
  // Draggable answer options
12802
+ "rich-drag-option",
12803
+ // Rich option from DRAGGABLE_OPTIONS or DROP_INPUT
12458
12804
  "placed-chip",
12459
12805
  // Chip already placed on a number-line axis (re-drag)
12460
12806
  "ruler-handle",
@@ -12468,6 +12814,16 @@ const DragOptionDataSchema = zod.z.object({
12468
12814
  value: zod.z.string(),
12469
12815
  label: zod.z.string()
12470
12816
  });
12817
+ const RichDragOptionDataSchema = zod.z.object({
12818
+ optionName: zod.z.string().min(1),
12819
+ origin: zod.z.discriminatedUnion("kind", [
12820
+ zod.z.object({ kind: zod.z.literal("bank") }),
12821
+ zod.z.object({
12822
+ kind: zod.z.literal("drop-input"),
12823
+ inputName: zod.z.string().min(1)
12824
+ })
12825
+ ])
12826
+ });
12471
12827
  const DragItemSchema = (dataSchema) => zod.z.object({
12472
12828
  id: zod.z.union([zod.z.string(), zod.z.number()]),
12473
12829
  // UniqueIdentifier from dnd-kit
@@ -12475,6 +12831,7 @@ const DragItemSchema = (dataSchema) => zod.z.object({
12475
12831
  data: dataSchema
12476
12832
  });
12477
12833
  DragItemSchema(DragOptionDataSchema);
12834
+ DragItemSchema(RichDragOptionDataSchema);
12478
12835
  zod.z.object({
12479
12836
  targetId: zod.z.string(),
12480
12837
  item: zod.z.discriminatedUnion("type", [
@@ -16255,12 +16612,16 @@ function useExerciseDnd() {
16255
16612
  }
16256
16613
  return ctx;
16257
16614
  }
16615
+ function useExerciseDndOptional() {
16616
+ return React.useContext(ExerciseDndContext);
16617
+ }
16258
16618
  const NO_EXERCISE = Symbol("no-exercise");
16259
16619
  const ExerciseDndProvider = ({
16260
16620
  children,
16261
16621
  droppedSources,
16262
16622
  onDragEnd,
16263
- exerciseKey
16623
+ exerciseKey,
16624
+ renderOverlay
16264
16625
  }) => {
16265
16626
  const [activeItem, setActiveItem] = React.useState(null);
16266
16627
  const [draggedExerciseKey, setDraggedExerciseKey] = React.useState(NO_EXERCISE);
@@ -16296,7 +16657,9 @@ const ExerciseDndProvider = ({
16296
16657
  const raw = active.data.current;
16297
16658
  const item = raw?.type === "placed-chip" ? { type: "placed-chip", widgetId: raw.widgetId, label: raw.label, sourceOptionId: raw.sourceOptionId } : raw;
16298
16659
  setActiveItem(item);
16299
- if (item.type === "drag-option") setDraggedExerciseKey(exerciseKey);
16660
+ if (item.type === "drag-option" || item.type === "rich-drag-option") {
16661
+ setDraggedExerciseKey(exerciseKey);
16662
+ }
16300
16663
  playSound("drag-start");
16301
16664
  };
16302
16665
  const handleDragEnd = (event) => {
@@ -16339,14 +16702,16 @@ const ExerciseDndProvider = ({
16339
16702
  onDragCancel: handleDragCancel,
16340
16703
  children: [
16341
16704
  children,
16342
- /* @__PURE__ */ jsxRuntime.jsx(DragOverlay, { dropAnimation: null, children: activeItem && /* @__PURE__ */ jsxRuntime.jsx(DragOverlayContent, { item: activeItem }) })
16705
+ /* @__PURE__ */ jsxRuntime.jsx(DragOverlay, { dropAnimation: null, children: activeItem && /* @__PURE__ */ jsxRuntime.jsx(DragOverlayContent, { item: activeItem, renderOverlay }) })
16343
16706
  ]
16344
16707
  }
16345
16708
  )
16346
16709
  }
16347
16710
  );
16348
16711
  };
16349
- const DragOverlayContent = ({ item }) => {
16712
+ const DragOverlayContent = ({ item, renderOverlay }) => {
16713
+ const customOverlay = renderOverlay?.(item);
16714
+ if (customOverlay != null) return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: customOverlay });
16350
16715
  switch (item.type) {
16351
16716
  case "drag-option": {
16352
16717
  const data = item.data;
@@ -16365,7 +16730,8 @@ const Draggable = ({
16365
16730
  data,
16366
16731
  disabled = false,
16367
16732
  children,
16368
- className = ""
16733
+ className = "",
16734
+ behavior = "move"
16369
16735
  }) => {
16370
16736
  const dragItem = { id, type, data };
16371
16737
  const {
@@ -16378,6 +16744,7 @@ const Draggable = ({
16378
16744
  data: dragItem,
16379
16745
  disabled
16380
16746
  });
16747
+ const isCopy = behavior === "copy";
16381
16748
  const style = {
16382
16749
  cursor: disabled ? "default" : "grab",
16383
16750
  touchAction: "none"
@@ -16387,7 +16754,7 @@ const Draggable = ({
16387
16754
  {
16388
16755
  ref: setNodeRef,
16389
16756
  style,
16390
- className: `draggable ${isDragging ? "dragging" : ""} ${className}`,
16757
+ className: `draggable ${isDragging && !isCopy ? "dragging" : ""} ${className}`,
16391
16758
  ...listeners,
16392
16759
  ...attributes,
16393
16760
  children
@@ -17179,6 +17546,9 @@ const PlacedInputChip = ({
17179
17546
  }
17180
17547
  );
17181
17548
  };
17549
+ function getInputBoxWidth(chars) {
17550
+ return `${chars * 0.8 + 1}em`;
17551
+ }
17182
17552
  function getMaxInputLength(exercise, inputKey) {
17183
17553
  const widget = exercise.widgets[inputKey];
17184
17554
  if (widget?.type !== JSON_TYPES.INLINE_INPUT) return void 0;
@@ -17235,7 +17605,7 @@ const InlineInput = ({
17235
17605
  return applyDropToInput(widgetId, item, onChange);
17236
17606
  }, [onChange, widgetId]);
17237
17607
  const expectedLength = inputType === INPUT_TYPES.NUMERIC ? digits : length;
17238
- const dynamicWidth = `${(width ?? expectedLength ?? 5) * 0.8 + 1}em`;
17608
+ const dynamicWidth = getInputBoxWidth(width ?? expectedLength ?? 5);
17239
17609
  const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
17240
17610
  const disabledClass = disabled ? " inline-input--disabled" : "";
17241
17611
  const isFixedChip = hasDragBank && disabled && value !== "";
@@ -17439,42 +17809,418 @@ const InlineDragOption = ({
17439
17809
  }
17440
17810
  ) });
17441
17811
  };
17442
- const NumericKeypad = ({
17443
- value,
17444
- includeOperators = false,
17445
- includeDecimal = false,
17446
- onChange
17447
- }) => {
17448
- const playSound = useSound();
17449
- const locked = useExerciseLocked();
17450
- const handleDigit = (digit) => {
17451
- if (locked) return;
17452
- playSound("keypad-press");
17453
- onChange(value + digit);
17454
- };
17455
- const handleBackspace = () => {
17456
- if (locked) return;
17457
- playSound("keypad-press");
17458
- onChange(value.slice(0, -1));
17459
- };
17460
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `numeric-keypad ${includeOperators ? "numeric-keypad-with-operators" : ""}`, "data-tutorial": "keypad", children: [
17461
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "1", onClick: () => handleDigit("1"), children: "1" }),
17462
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "2", onClick: () => handleDigit("2"), children: "2" }),
17463
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "3", onClick: () => handleDigit("3"), children: "3" }),
17464
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "+", onClick: () => handleDigit("+"), children: "+" }),
17465
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "4", onClick: () => handleDigit("4"), children: "4" }),
17466
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "5", onClick: () => handleDigit("5"), children: "5" }),
17467
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "6", onClick: () => handleDigit("6"), children: "6" }),
17468
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "-", onClick: () => handleDigit("-"), children: "-" }),
17469
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "7", onClick: () => handleDigit("7"), children: "7" }),
17470
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "8", onClick: () => handleDigit("8"), children: "8" }),
17471
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "9", onClick: () => handleDigit("9"), children: "9" }),
17472
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "×", onClick: () => handleDigit("×"), children: "×" }),
17473
- includeDecimal ? /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-decimal", "data-key": ",", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsxRuntime.jsx("div", {}),
17474
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "0", onClick: () => handleDigit("0"), children: "0" }),
17475
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-backspace", "data-key": "backspace", onClick: handleBackspace, children: "⌫" }),
17476
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "÷", onClick: () => handleDigit("÷"), children: "÷" })
17477
- ] });
17812
+ function isArabic(text) {
17813
+ return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
17814
+ }
17815
+ function hasLatinChars(text) {
17816
+ return /[A-Za-z]/.test(text);
17817
+ }
17818
+ function getTextValue(item) {
17819
+ if ((item.type === JSON_TYPES.TEXT || item.type === JSON_TYPES.HIGHLIGHTED_TEXT || item.type === JSON_TYPES.BOLD_TEXT || item.type === JSON_TYPES.COLOR_TEXT) && "value" in item) {
17820
+ return item.value;
17821
+ }
17822
+ return void 0;
17823
+ }
17824
+ function itemContainsArabic(item) {
17825
+ const text = getTextValue(item);
17826
+ if (text !== void 0) {
17827
+ return isArabic(text);
17828
+ }
17829
+ if (item.type === JSON_TYPES.GROUP && "items" in item) {
17830
+ return item.items.some((subItem) => itemContainsArabic(subItem));
17831
+ }
17832
+ return false;
17833
+ }
17834
+ function itemContainsLatin(item) {
17835
+ const text = getTextValue(item);
17836
+ if (text !== void 0) {
17837
+ return hasLatinChars(text);
17838
+ }
17839
+ if (item.type === JSON_TYPES.GROUP && "items" in item) {
17840
+ return item.items.some((subItem) => itemContainsLatin(subItem));
17841
+ }
17842
+ return false;
17843
+ }
17844
+ function shouldItemsUseRTL(items) {
17845
+ const hasArabic = items.some(itemContainsArabic);
17846
+ const hasLatin = items.some(itemContainsLatin);
17847
+ return hasArabic && !hasLatin;
17848
+ }
17849
+ function isTextItem(item) {
17850
+ return item.type === JSON_TYPES.TEXT || item.type === JSON_TYPES.HIGHLIGHTED_TEXT || item.type === JSON_TYPES.BOLD_TEXT || item.type === JSON_TYPES.COLOR_TEXT || item.type === JSON_TYPES.OPERATOR || item.type === JSON_TYPES.SPACE || item.type === JSON_TYPES.GROUP;
17851
+ }
17852
+ function groupLineItems(items) {
17853
+ const groups = [];
17854
+ let currentTextGroup = [];
17855
+ for (const item of items) {
17856
+ if (isTextItem(item)) {
17857
+ currentTextGroup.push(item);
17858
+ } else {
17859
+ if (currentTextGroup.length > 0) {
17860
+ const hasArabic = currentTextGroup.some(itemContainsArabic);
17861
+ const hasLatin = currentTextGroup.some(itemContainsLatin);
17862
+ const shouldApplyRTL = hasArabic && !hasLatin;
17863
+ groups.push({ items: currentTextGroup, isTextGroup: true, hasArabic, hasLatin, shouldApplyRTL });
17864
+ currentTextGroup = [];
17865
+ }
17866
+ groups.push({ items: [item], isTextGroup: false, hasArabic: false, hasLatin: false, shouldApplyRTL: false });
17867
+ }
17868
+ }
17869
+ if (currentTextGroup.length > 0) {
17870
+ const hasArabic = currentTextGroup.some(itemContainsArabic);
17871
+ const hasLatin = currentTextGroup.some(itemContainsLatin);
17872
+ const shouldApplyRTL = hasArabic && !hasLatin;
17873
+ groups.push({ items: currentTextGroup, isTextGroup: true, hasArabic, hasLatin, shouldApplyRTL });
17874
+ }
17875
+ return groups;
17876
+ }
17877
+ const EPSILON = 10 ** -INTERNAL_PRECISION;
17878
+ const cleanFloat = (n) => parseFloat(n.toFixed(INTERNAL_PRECISION));
17879
+ const roundTick = (n) => parseFloat(n.toFixed(DISPLAY_PRECISION));
17880
+ const almostEqual = (a, b) => Math.abs(a - b) <= EPSILON;
17881
+ const NUMBER_LINE_AXIS_ENDS = {
17882
+ "arrow-right": { left: "tick", right: "arrow" },
17883
+ "arrow-left": { left: "arrow", right: "tick" },
17884
+ "arrow-both": { left: "arrow", right: "arrow" },
17885
+ "line": { left: "line", right: "line" },
17886
+ "line-left": { left: "line", right: "arrow" }
17887
+ };
17888
+ const toPercent = (value, min, max) => {
17889
+ const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
17890
+ return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
17891
+ };
17892
+ const pixelToValue = (chipCenterX, axisLeft, axisWidth, min, max) => {
17893
+ const relX = (chipCenterX - axisLeft) / axisWidth;
17894
+ const fraction = (relX - SIDE_PAD / 100) / (1 - 2 * SIDE_PAD / 100);
17895
+ const clamped = Math.max(0, Math.min(1, fraction));
17896
+ return min + clamped * (max - min);
17897
+ };
17898
+ const getMajorTicks = (min, max, step) => {
17899
+ if (step <= 0 || max <= min) return [min, max];
17900
+ const count = Math.floor((max - min) / step);
17901
+ const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
17902
+ const lastTick = ticks[ticks.length - 1];
17903
+ if (roundTick(lastTick) !== roundTick(max)) {
17904
+ ticks.push(max);
17905
+ } else {
17906
+ ticks[ticks.length - 1] = max;
17907
+ }
17908
+ return ticks;
17909
+ };
17910
+ const getMinorTickValues = (majorTicks, minorTicks) => {
17911
+ if (minorTicks <= 0 || majorTicks.length < 2) return [];
17912
+ const result = [];
17913
+ for (let i = 0; i < majorTicks.length - 1; i++) {
17914
+ const from = majorTicks[i];
17915
+ const to = majorTicks[i + 1];
17916
+ const interval = to - from;
17917
+ for (let j = 1; j <= minorTicks; j++) {
17918
+ result.push(cleanFloat(from + j / (minorTicks + 1) * interval));
17919
+ }
17920
+ }
17921
+ return result;
17922
+ };
17923
+ const getSnapTicks = (majorTicks, minorTicks) => [
17924
+ ...majorTicks,
17925
+ ...getMinorTickValues(majorTicks, minorTicks)
17926
+ ];
17927
+ const snapToNearest = (raw, ticks) => {
17928
+ if (ticks.length === 0) return cleanFloat(raw);
17929
+ return ticks.reduce((best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best);
17930
+ };
17931
+ const getPositionWidgetId = (content) => {
17932
+ for (const item of content) {
17933
+ if (isWidgetRef(item)) return item.widgetId;
17934
+ }
17935
+ return null;
17936
+ };
17937
+ const toHighlightColor = (color) => color.startsWith("#") ? color : `var(--nl-highlight-${color})`;
17938
+ function placeTableCells(rows, cols) {
17939
+ const placements = /* @__PURE__ */ new Map();
17940
+ const occupied = rows.map(() => []);
17941
+ const totalRows = rows.length;
17942
+ rows.forEach((row, rowIndex) => {
17943
+ const taken = occupied[rowIndex] ?? [];
17944
+ let col = 0;
17945
+ for (const cell of row.cells) {
17946
+ while (taken[col]) col++;
17947
+ const colspan = cell.colspan || 1;
17948
+ const rowspan = Math.min(cell.rowspan || 1, totalRows - rowIndex);
17949
+ for (let r = rowIndex; r < rowIndex + rowspan; r++) {
17950
+ const line = occupied[r];
17951
+ if (!line) continue;
17952
+ for (let c = col; c < col + colspan; c++) line[c] = true;
17953
+ }
17954
+ placements.set(cell.id, {
17955
+ column: col + 1,
17956
+ row: rowIndex + 1,
17957
+ colspan,
17958
+ rowspan,
17959
+ isLastColumn: col + colspan >= cols,
17960
+ isLastRow: rowIndex + rowspan >= totalRows
17961
+ });
17962
+ col += colspan;
17963
+ }
17964
+ });
17965
+ return placements;
17966
+ }
17967
+ const EMPTY_VALUES$2 = {};
17968
+ const EMPTY_DROPPED_INPUTS = /* @__PURE__ */ new Set();
17969
+ const ignoreChange = () => {
17970
+ };
17971
+ const PLAIN_TEXT_TYPES = /* @__PURE__ */ new Set([JSON_TYPES.TEXT, JSON_TYPES.SPACE, JSON_TYPES.BOLD_TEXT]);
17972
+ function isPlainTextContent(content) {
17973
+ return content.every((item) => PLAIN_TEXT_TYPES.has(item.type));
17974
+ }
17975
+ const RichOptionContent = ({ content, widgets }) => {
17976
+ const renderItem = (item) => /* @__PURE__ */ jsxRuntime.jsx(
17977
+ LayoutRenderer,
17978
+ {
17979
+ item: item.type === JSON_TYPES.IMAGE ? { ...item, fullscreen: false } : item,
17980
+ widgets,
17981
+ values: EMPTY_VALUES$2,
17982
+ droppedInputs: EMPTY_DROPPED_INPUTS,
17983
+ onChange: ignoreChange
17984
+ },
17985
+ item.id
17986
+ );
17987
+ const hasRTLInlineWidget = content.some((item) => {
17988
+ if (item.type !== JSON_TYPES.WIDGET_REF) return false;
17989
+ const widget = widgets[item.widgetId];
17990
+ return !!widget && RTL_INLINE_WIDGET_TYPES.has(widget.type);
17991
+ });
17992
+ const isRTL = hasRTLInlineWidget && content.some(itemContainsArabic);
17993
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "rich-option-content", dir: isRTL ? "rtl" : "ltr", children: groupLineItems(content).map((group, index) => group.isTextGroup ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-group", dir: group.shouldApplyRTL ? "rtl" : void 0, children: group.items.map(renderItem) }, index) : group.items.map(renderItem)) });
17994
+ };
17995
+ const DraggableOptions = ({
17996
+ widget,
17997
+ widgets,
17998
+ usedOptionNames
17999
+ }) => {
18000
+ const { exerciseKey } = useExerciseDnd();
18001
+ return /* @__PURE__ */ jsxRuntime.jsx(PillBackground, { children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options draggable-options", children: widget.options.map((option) => /* @__PURE__ */ jsxRuntime.jsx(
18002
+ RichDragOption,
18003
+ {
18004
+ option,
18005
+ widgets,
18006
+ reusable: widget.reusable,
18007
+ used: !widget.reusable && usedOptionNames.has(option.name)
18008
+ },
18009
+ `${exerciseKey ?? ""}:${option.name}`
18010
+ )) }) });
18011
+ };
18012
+ const RichDragOption = ({ option, widgets, reusable, used }) => {
18013
+ const { activeItem, hasDraggedOption, playHintSound } = useExerciseDnd();
18014
+ const locked = useExerciseLocked();
18015
+ const { jiggling: hinting, onAnimationEnd: onHintEnd } = useJiggle(hasDraggedOption, {
18016
+ ignoreBubbledAnimationEnd: true
18017
+ });
18018
+ const optionId = `rich-drag-option:${option.name}`;
18019
+ const data = { optionName: option.name, origin: { kind: "bank" } };
18020
+ const isPickedUp = !reusable && activeItem?.type === "rich-drag-option" && activeItem.id === optionId;
18021
+ const surfaceClass = isPlainTextContent(option.content) ? "drag-option" : "drag-option drag-option--rich";
18022
+ const emptySlot = /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-empty", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: surfaceClass, disabled: true, children: /* @__PURE__ */ jsxRuntime.jsx(RichOptionContent, { content: option.content, widgets }) }) });
18023
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-slot rich-drag-option-slot", children: used ? emptySlot : /* @__PURE__ */ jsxRuntime.jsx(
18024
+ Draggable,
18025
+ {
18026
+ id: optionId,
18027
+ type: "rich-drag-option",
18028
+ data,
18029
+ disabled: locked,
18030
+ behavior: reusable ? "copy" : "move",
18031
+ className: "drag-option-wrapper rich-drag-option-wrapper",
18032
+ children: isPickedUp ? emptySlot : /* @__PURE__ */ jsxRuntime.jsx(
18033
+ "button",
18034
+ {
18035
+ type: "button",
18036
+ className: `${surfaceClass} rich-drag-option${hinting && !locked ? " drag-option--hint" : ""}`,
18037
+ onAnimationStart: (event) => {
18038
+ if (hinting && event.target === event.currentTarget) playHintSound();
18039
+ },
18040
+ onAnimationEnd: onHintEnd,
18041
+ "aria-disabled": locked ? "true" : void 0,
18042
+ children: /* @__PURE__ */ jsxRuntime.jsx(RichOptionContent, { content: option.content, widgets })
18043
+ }
18044
+ )
18045
+ }
18046
+ ) });
18047
+ };
18048
+ const PlacedRichOption = ({
18049
+ inputName,
18050
+ option,
18051
+ widgets,
18052
+ onRemove,
18053
+ className = ""
18054
+ }) => {
18055
+ const locked = useExerciseLocked();
18056
+ const dragId = `${inputName}__rich-option`;
18057
+ const data = {
18058
+ optionName: option.name,
18059
+ origin: { kind: "drop-input", inputName }
18060
+ };
18061
+ const dragItem = { id: dragId, type: "rich-drag-option", data };
18062
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
18063
+ id: dragId,
18064
+ data: dragItem,
18065
+ disabled: locked
18066
+ });
18067
+ useDndMonitor({
18068
+ onDragEnd(event) {
18069
+ if (isUndoDrop(event, dragId)) onRemove();
18070
+ }
18071
+ });
18072
+ return /* @__PURE__ */ jsxRuntime.jsx(
18073
+ "button",
18074
+ {
18075
+ ref: setNodeRef,
18076
+ type: "button",
18077
+ className: `inline-input-chip placed-rich-option ${className}${isDragging ? " dragging" : ""}`.trim(),
18078
+ onClick: (event) => {
18079
+ if (!locked && event.detail !== 0) onRemove();
18080
+ },
18081
+ title: locked ? void 0 : "Drag to move, or click to remove",
18082
+ style: {
18083
+ width: "100%",
18084
+ height: "100%",
18085
+ minWidth: 0,
18086
+ maxWidth: "none",
18087
+ margin: 0,
18088
+ boxSizing: "border-box",
18089
+ touchAction: "none",
18090
+ cursor: locked ? "default" : "grab"
18091
+ },
18092
+ ...listeners,
18093
+ ...attributes,
18094
+ children: /* @__PURE__ */ jsxRuntime.jsx(RichOptionContent, { content: option.content, widgets })
18095
+ }
18096
+ );
18097
+ };
18098
+ const dropSourceId = (optionName) => `rich-drag-option:${optionName}`;
18099
+ function applyRichDropToInput(targetInputName, item, onChange) {
18100
+ if (item.type !== "rich-drag-option") return false;
18101
+ const data = item.data;
18102
+ if (data.origin.kind === "drop-input") {
18103
+ if (data.origin.inputName === targetInputName) return false;
18104
+ onChange(data.origin.inputName, "", false);
18105
+ }
18106
+ onChange(targetInputName, data.optionName, true, dropSourceId(data.optionName));
18107
+ return true;
18108
+ }
18109
+ const ACCEPTED_DRAG_TYPES = ["rich-drag-option"];
18110
+ const DropInput = ({
18111
+ widgetId,
18112
+ value,
18113
+ width,
18114
+ placeholder,
18115
+ validationStatus = null,
18116
+ option,
18117
+ widgets = {},
18118
+ onChange = () => {
18119
+ }
18120
+ }) => {
18121
+ const locked = useExerciseLocked();
18122
+ const dnd = useExerciseDndOptional();
18123
+ const isEmpty = value === "";
18124
+ const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
18125
+ const hasChip = !isEmpty && Boolean(option) && Boolean(dnd);
18126
+ const boxStyle = width !== void 0 ? { width: getInputBoxWidth(width) } : void 0;
18127
+ const handleDrop = React.useCallback(
18128
+ (item) => applyRichDropToInput(widgetId, item, onChange),
18129
+ [widgetId, onChange]
18130
+ );
18131
+ const handleRemove = React.useCallback(
18132
+ () => onChange(widgetId, "", false),
18133
+ [widgetId, onChange]
18134
+ );
18135
+ const placeholderText = placeholder ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "drop-input-placeholder", children: placeholder }) : null;
18136
+ const input = /* @__PURE__ */ jsxRuntime.jsx(
18137
+ "div",
18138
+ {
18139
+ className: `inline-widget drop-input${hasChip ? " drop-input--filled" : ` inline-input${statusClass}`}${isEmpty ? " drop-input--empty" : ""}${locked ? " drop-input--locked" : ""}`,
18140
+ style: hasChip ? void 0 : boxStyle,
18141
+ role: "group",
18142
+ "aria-label": placeholder ?? widgetId,
18143
+ "aria-disabled": locked ? "true" : void 0,
18144
+ "data-widget-id": widgetId,
18145
+ children: isEmpty ? placeholderText : option ? dnd ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
18146
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "inline-input drop-input-sizer", style: boxStyle, "aria-hidden": "true", children: placeholderText }),
18147
+ /* @__PURE__ */ jsxRuntime.jsx(
18148
+ PlacedRichOption,
18149
+ {
18150
+ inputName: widgetId,
18151
+ option,
18152
+ widgets,
18153
+ onRemove: handleRemove,
18154
+ className: statusClass.trim()
18155
+ }
18156
+ )
18157
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(RichOptionContent, { content: option.content, widgets }) : dnd && !locked ? (
18158
+ // Value that resolves to no bank option (author answer/option-name
18159
+ // mismatch, legacy JSON). Keep it clearable so the field is not stuck.
18160
+ /* @__PURE__ */ jsxRuntime.jsx(
18161
+ "button",
18162
+ {
18163
+ type: "button",
18164
+ className: "drop-input-unresolved",
18165
+ onClick: (event) => {
18166
+ if (event.detail !== 0) handleRemove();
18167
+ },
18168
+ title: "Click to clear",
18169
+ children: value
18170
+ }
18171
+ )
18172
+ ) : value
18173
+ }
18174
+ );
18175
+ if (!dnd) return input;
18176
+ return /* @__PURE__ */ jsxRuntime.jsx(
18177
+ Droppable,
18178
+ {
18179
+ id: widgetId,
18180
+ accepts: ACCEPTED_DRAG_TYPES,
18181
+ onDrop: handleDrop,
18182
+ disabled: locked,
18183
+ className: "drop-input-droppable",
18184
+ children: input
18185
+ }
18186
+ );
18187
+ };
18188
+ const NumericKeypad = ({
18189
+ value,
18190
+ includeOperators = false,
18191
+ includeDecimal = false,
18192
+ onChange
18193
+ }) => {
18194
+ const playSound = useSound();
18195
+ const locked = useExerciseLocked();
18196
+ const handleDigit = (digit) => {
18197
+ if (locked) return;
18198
+ playSound("keypad-press");
18199
+ onChange(value + digit);
18200
+ };
18201
+ const handleBackspace = () => {
18202
+ if (locked) return;
18203
+ playSound("keypad-press");
18204
+ onChange(value.slice(0, -1));
18205
+ };
18206
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `numeric-keypad ${includeOperators ? "numeric-keypad-with-operators" : ""}`, "data-tutorial": "keypad", children: [
18207
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "1", onClick: () => handleDigit("1"), children: "1" }),
18208
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "2", onClick: () => handleDigit("2"), children: "2" }),
18209
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "3", onClick: () => handleDigit("3"), children: "3" }),
18210
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "+", onClick: () => handleDigit("+"), children: "+" }),
18211
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "4", onClick: () => handleDigit("4"), children: "4" }),
18212
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "5", onClick: () => handleDigit("5"), children: "5" }),
18213
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "6", onClick: () => handleDigit("6"), children: "6" }),
18214
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "-", onClick: () => handleDigit("-"), children: "-" }),
18215
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "7", onClick: () => handleDigit("7"), children: "7" }),
18216
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "8", onClick: () => handleDigit("8"), children: "8" }),
18217
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "9", onClick: () => handleDigit("9"), children: "9" }),
18218
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "×", onClick: () => handleDigit("×"), children: "×" }),
18219
+ includeDecimal ? /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-decimal", "data-key": ",", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsxRuntime.jsx("div", {}),
18220
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "0", onClick: () => handleDigit("0"), children: "0" }),
18221
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-backspace", "data-key": "backspace", onClick: handleBackspace, children: "⌫" }),
18222
+ includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "÷", onClick: () => handleDigit("÷"), children: "÷" })
18223
+ ] });
17478
18224
  };
17479
18225
  const FractionInput = ({
17480
18226
  widgetId,
@@ -19277,41 +20023,6 @@ const MatcherWidget = ({
19277
20023
  );
19278
20024
  };
19279
20025
  MatcherWidget.displayName = "MatcherWidget";
19280
- function isArabic(text) {
19281
- return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
19282
- }
19283
- function hasLatinChars(text) {
19284
- return /[A-Za-z]/.test(text);
19285
- }
19286
- function itemContainsArabic(item) {
19287
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
19288
- return isArabic(item.value);
19289
- }
19290
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
19291
- return isArabic(item.value);
19292
- }
19293
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
19294
- return item.items.some((subItem) => itemContainsArabic(subItem));
19295
- }
19296
- return false;
19297
- }
19298
- function itemContainsLatin(item) {
19299
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
19300
- return hasLatinChars(item.value);
19301
- }
19302
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
19303
- return hasLatinChars(item.value);
19304
- }
19305
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
19306
- return item.items.some((subItem) => itemContainsLatin(subItem));
19307
- }
19308
- return false;
19309
- }
19310
- function shouldItemsUseRTL(items) {
19311
- const hasArabic = items.some(itemContainsArabic);
19312
- const hasLatin = items.some(itemContainsLatin);
19313
- return hasArabic && !hasLatin;
19314
- }
19315
20026
  function usePairLines({
19316
20027
  containerRef,
19317
20028
  pairs,
@@ -19725,9 +20436,15 @@ PairMatcherWidget.displayName = "PairMatcherWidget";
19725
20436
  function isInlineInputWidget(widget) {
19726
20437
  return widget.type === "inline-input";
19727
20438
  }
20439
+ function isDropInputWidget(widget) {
20440
+ return widget.type === "drop-input";
20441
+ }
19728
20442
  function isDragOptionsWidget(widget) {
19729
20443
  return widget.type === "drag-options";
19730
20444
  }
20445
+ function isDraggableOptionsWidget(widget) {
20446
+ return widget.type === "draggable-options";
20447
+ }
19731
20448
  function isInlineDragOptionWidget(widget) {
19732
20449
  return widget.type === "inline-drag-option";
19733
20450
  }
@@ -19756,7 +20473,7 @@ function isGeometryWidget(widget) {
19756
20473
  return widget.type === JSON_TYPES.GEOMETRY;
19757
20474
  }
19758
20475
  const GeometryWidget = React.lazy(async () => ({
19759
- default: (await Promise.resolve().then(() => require("./GeometryWidget-D6_DlxhZ.js"))).GeometryWidget
20476
+ default: (await Promise.resolve().then(() => require("./GeometryWidget-CTTLw871.js"))).GeometryWidget
19760
20477
  }));
19761
20478
  const WidgetRenderer = ({
19762
20479
  widget,
@@ -19794,6 +20511,23 @@ const WidgetRenderer = ({
19794
20511
  }
19795
20512
  );
19796
20513
  }
20514
+ if (isDropInputWidget(widget)) {
20515
+ const optionsWidget = widgets?.[DRAGGABLE_OPTIONS_WIDGET_ID];
20516
+ const option = optionsWidget && isDraggableOptionsWidget(optionsWidget) ? optionsWidget.options.find((candidate) => candidate.name === strValue) : void 0;
20517
+ return /* @__PURE__ */ jsxRuntime.jsx(
20518
+ DropInput,
20519
+ {
20520
+ widgetId,
20521
+ value: strValue,
20522
+ width: widget.width,
20523
+ placeholder: widget.placeholder,
20524
+ validationStatus,
20525
+ option,
20526
+ widgets,
20527
+ onChange
20528
+ }
20529
+ );
20530
+ }
19797
20531
  if (isDragOptionsWidget(widget)) {
19798
20532
  return /* @__PURE__ */ jsxRuntime.jsx(
19799
20533
  DragOptions,
@@ -19804,6 +20538,25 @@ const WidgetRenderer = ({
19804
20538
  }
19805
20539
  );
19806
20540
  }
20541
+ if (isDraggableOptionsWidget(widget)) {
20542
+ const usedOptionNames = /* @__PURE__ */ new Set();
20543
+ if (!widget.reusable) {
20544
+ for (const [id, candidate] of Object.entries(widgets ?? {})) {
20545
+ const candidateValue = values[id];
20546
+ if (isDropInputWidget(candidate) && typeof candidateValue === "string" && candidateValue !== "") {
20547
+ usedOptionNames.add(candidateValue);
20548
+ }
20549
+ }
20550
+ }
20551
+ return /* @__PURE__ */ jsxRuntime.jsx(
20552
+ DraggableOptions,
20553
+ {
20554
+ widget,
20555
+ widgets: widgets ?? { [widgetId]: widget },
20556
+ usedOptionNames
20557
+ }
20558
+ );
20559
+ }
19807
20560
  if (isInlineDragOptionWidget(widget)) {
19808
20561
  return /* @__PURE__ */ jsxRuntime.jsx(
19809
20562
  InlineDragOption,
@@ -20145,75 +20898,14 @@ const ImageItem = ({
20145
20898
  ref: triggerRef,
20146
20899
  type: "button",
20147
20900
  className: "image-zoom-btn",
20148
- "aria-label": alt ? `View "${alt}" fullscreen` : "View image fullscreen",
20149
- onClick: open,
20150
- children: /* @__PURE__ */ jsxRuntime.jsx(ExpandDiamondIcon, {})
20151
- }
20152
- ),
20153
- overlay
20154
- ] });
20155
- };
20156
- const EPSILON = 10 ** -INTERNAL_PRECISION;
20157
- const cleanFloat = (n) => parseFloat(n.toFixed(INTERNAL_PRECISION));
20158
- const roundTick = (n) => parseFloat(n.toFixed(DISPLAY_PRECISION));
20159
- const almostEqual = (a, b) => Math.abs(a - b) <= EPSILON;
20160
- const NUMBER_LINE_AXIS_ENDS = {
20161
- "arrow-right": { left: "tick", right: "arrow" },
20162
- "arrow-left": { left: "arrow", right: "tick" },
20163
- "arrow-both": { left: "arrow", right: "arrow" },
20164
- "line": { left: "line", right: "line" },
20165
- "line-left": { left: "line", right: "arrow" }
20166
- };
20167
- const toPercent = (value, min, max) => {
20168
- const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
20169
- return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
20170
- };
20171
- const pixelToValue = (chipCenterX, axisLeft, axisWidth, min, max) => {
20172
- const relX = (chipCenterX - axisLeft) / axisWidth;
20173
- const fraction = (relX - SIDE_PAD / 100) / (1 - 2 * SIDE_PAD / 100);
20174
- const clamped = Math.max(0, Math.min(1, fraction));
20175
- return min + clamped * (max - min);
20176
- };
20177
- const getMajorTicks = (min, max, step) => {
20178
- if (step <= 0 || max <= min) return [min, max];
20179
- const count = Math.floor((max - min) / step);
20180
- const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
20181
- const lastTick = ticks[ticks.length - 1];
20182
- if (roundTick(lastTick) !== roundTick(max)) {
20183
- ticks.push(max);
20184
- } else {
20185
- ticks[ticks.length - 1] = max;
20186
- }
20187
- return ticks;
20188
- };
20189
- const getMinorTickValues = (majorTicks, minorTicks) => {
20190
- if (minorTicks <= 0 || majorTicks.length < 2) return [];
20191
- const result = [];
20192
- for (let i = 0; i < majorTicks.length - 1; i++) {
20193
- const from = majorTicks[i];
20194
- const to = majorTicks[i + 1];
20195
- const interval = to - from;
20196
- for (let j = 1; j <= minorTicks; j++) {
20197
- result.push(cleanFloat(from + j / (minorTicks + 1) * interval));
20198
- }
20199
- }
20200
- return result;
20201
- };
20202
- const getSnapTicks = (majorTicks, minorTicks) => [
20203
- ...majorTicks,
20204
- ...getMinorTickValues(majorTicks, minorTicks)
20205
- ];
20206
- const snapToNearest = (raw, ticks) => {
20207
- if (ticks.length === 0) return cleanFloat(raw);
20208
- return ticks.reduce((best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best);
20209
- };
20210
- const getPositionWidgetId = (content) => {
20211
- for (const item of content) {
20212
- if (isWidgetRef(item)) return item.widgetId;
20213
- }
20214
- return null;
20901
+ "aria-label": alt ? `View "${alt}" fullscreen` : "View image fullscreen",
20902
+ onClick: open,
20903
+ children: /* @__PURE__ */ jsxRuntime.jsx(ExpandDiamondIcon, {})
20904
+ }
20905
+ ),
20906
+ overlay
20907
+ ] });
20215
20908
  };
20216
- const toHighlightColor = (color) => color.startsWith("#") ? color : `var(--nl-highlight-${color})`;
20217
20909
  const getBandGeometry = (h, min, max) => {
20218
20910
  const leftPct = toPercent(Math.min(h.from, h.to), min, max);
20219
20911
  const rightPct = toPercent(Math.max(h.from, h.to), min, max);
@@ -21670,63 +22362,6 @@ const ClockRenderer = ({
21670
22362
  format === "12h" && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "clock-period-badge", children: hours >= 12 ? "PM" : "AM" })
21671
22363
  ] });
21672
22364
  };
21673
- function isTextItem(item) {
21674
- return item.type === JSON_TYPES.TEXT || item.type === JSON_TYPES.HIGHLIGHTED_TEXT || item.type === JSON_TYPES.BOLD_TEXT || item.type === JSON_TYPES.COLOR_TEXT || item.type === JSON_TYPES.OPERATOR || item.type === JSON_TYPES.SPACE || item.type === JSON_TYPES.GROUP;
21675
- }
21676
- function groupLineItems(items) {
21677
- const groups = [];
21678
- let currentTextGroup = [];
21679
- for (const item of items) {
21680
- if (isTextItem(item)) {
21681
- currentTextGroup.push(item);
21682
- } else {
21683
- if (currentTextGroup.length > 0) {
21684
- const hasArabic = currentTextGroup.some(itemContainsArabic);
21685
- const hasLatin = currentTextGroup.some(itemContainsLatin);
21686
- const shouldApplyRTL = hasArabic && !hasLatin;
21687
- groups.push({ items: currentTextGroup, isTextGroup: true, hasArabic, hasLatin, shouldApplyRTL });
21688
- currentTextGroup = [];
21689
- }
21690
- groups.push({ items: [item], isTextGroup: false, hasArabic: false, hasLatin: false, shouldApplyRTL: false });
21691
- }
21692
- }
21693
- if (currentTextGroup.length > 0) {
21694
- const hasArabic = currentTextGroup.some(itemContainsArabic);
21695
- const hasLatin = currentTextGroup.some(itemContainsLatin);
21696
- const shouldApplyRTL = hasArabic && !hasLatin;
21697
- groups.push({ items: currentTextGroup, isTextGroup: true, hasArabic, hasLatin, shouldApplyRTL });
21698
- }
21699
- return groups;
21700
- }
21701
- function placeTableCells(rows, cols) {
21702
- const placements = /* @__PURE__ */ new Map();
21703
- const occupied = rows.map(() => []);
21704
- const totalRows = rows.length;
21705
- rows.forEach((row, rowIndex) => {
21706
- const taken = occupied[rowIndex] ?? [];
21707
- let col = 0;
21708
- for (const cell of row.cells) {
21709
- while (taken[col]) col++;
21710
- const colspan = cell.colspan || 1;
21711
- const rowspan = Math.min(cell.rowspan || 1, totalRows - rowIndex);
21712
- for (let r = rowIndex; r < rowIndex + rowspan; r++) {
21713
- const line = occupied[r];
21714
- if (!line) continue;
21715
- for (let c = col; c < col + colspan; c++) line[c] = true;
21716
- }
21717
- placements.set(cell.id, {
21718
- column: col + 1,
21719
- row: rowIndex + 1,
21720
- colspan,
21721
- rowspan,
21722
- isLastColumn: col + colspan >= cols,
21723
- isLastRow: rowIndex + rowspan >= totalRows
21724
- });
21725
- col += colspan;
21726
- }
21727
- });
21728
- return placements;
21729
- }
21730
22365
  const toValidationStatus = (v) => v === true ? "correct" : v === false ? "incorrect" : null;
21731
22366
  const isNumericText = (value) => /^[\d.,\s]+$/.test(value);
21732
22367
  const numberClass = (value) => isNumericText(value) ? "text-item-number" : "";
@@ -22174,9 +22809,11 @@ function ChameleonHand({ className }) {
22174
22809
  );
22175
22810
  }
22176
22811
  const TUTORIAL_HAND_TIP = { x: 56 / 75, y: 22 / 78 };
22812
+ const centerOf = (r) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
22177
22813
  const CYCLE_MS = 1400;
22178
22814
  const PRESS_AT_MS = 650;
22179
22815
  const PRESS_HOLD_MS = 220;
22816
+ const MAX_SPEECH_MS = 3e4;
22180
22817
  const HOLE_PAD = 10;
22181
22818
  const HOLE_RADIUS = 18;
22182
22819
  const HAND_W = 72;
@@ -22194,7 +22831,6 @@ function toRect(t) {
22194
22831
  if (r.width <= 0 || r.height <= 0) return null;
22195
22832
  return { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) };
22196
22833
  }
22197
- const centerOf = (r) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
22198
22834
  const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22199
22835
  const { steps } = tutorial;
22200
22836
  const [stepIndex, setStepIndex] = React.useState(
@@ -22270,7 +22906,9 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22270
22906
  const [completedStep, setCompletedStep] = React.useState(null);
22271
22907
  React.useEffect(() => {
22272
22908
  optionsRef.current.onStep?.(stepIndex, step.id);
22273
- const speech = Promise.resolve(optionsRef.current.onSpeak?.(step.id, textRef.current)).catch(() => {
22909
+ const speech = new Promise((resolve2) => {
22910
+ resolve2(optionsRef.current.onSpeak?.(step.id, textRef.current));
22911
+ }).catch(() => {
22274
22912
  });
22275
22913
  if (!autoAdvance) return;
22276
22914
  const duration = Math.max(2, step.taps(ctxRef.current).length) * CYCLE_MS;
@@ -22279,7 +22917,11 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22279
22917
  const animation = new Promise((resolve2) => {
22280
22918
  timer = setTimeout(resolve2, duration);
22281
22919
  });
22282
- void Promise.all([speech, animation]).then(() => {
22920
+ let speechTimer;
22921
+ const speechLimit = new Promise((resolve2) => {
22922
+ speechTimer = setTimeout(resolve2, MAX_SPEECH_MS);
22923
+ });
22924
+ void Promise.all([Promise.race([speech, speechLimit]), animation]).then(() => {
22283
22925
  if (cancelled) return;
22284
22926
  if (isLast) {
22285
22927
  if (showBubble) setCompletedStep(stepIndex);
@@ -22289,6 +22931,7 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22289
22931
  return () => {
22290
22932
  cancelled = true;
22291
22933
  clearTimeout(timer);
22934
+ clearTimeout(speechTimer);
22292
22935
  };
22293
22936
  }, [stepIndex, step, autoAdvance, isLast, showBubble]);
22294
22937
  const showNext = !autoAdvance || isLast && completedStep === stepIndex;
@@ -22444,7 +23087,7 @@ function key(ctx, k) {
22444
23087
  }
22445
23088
  function answerBox(ctx) {
22446
23089
  const inputs = getTextInputs(ctx.root);
22447
- return inputs.find(canTakeInput) ?? inputs[0] ?? null;
23090
+ return inputs.find(canTakeInput) ?? inputs.find(canAdvanceTo) ?? null;
22448
23091
  }
22449
23092
  function answerDigits(ctx) {
22450
23093
  const id = answerBox(ctx)?.id.replace(/-input$/, "");
@@ -22481,7 +23124,6 @@ const StandInFill = ({ box, slots, filled, digits }) => /* @__PURE__ */ jsxRunti
22481
23124
  children: Array.from({ length: slots }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: i < filled ? "met-tutorial-fill-dot" : "met-tutorial-fill-slot", children: i < filled ? digits?.[i] ?? "●" : "_" }, i))
22482
23125
  }
22483
23126
  );
22484
- const center = (r) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
22485
23127
  const HopScene = ({ ctx, spotlight, taps, tapCount }) => {
22486
23128
  const box = spotlight[1];
22487
23129
  if (!box) return null;
@@ -22491,8 +23133,8 @@ const HopScene = ({ ctx, spotlight, taps, tapCount }) => {
22491
23133
  const from = tapCount > 0 ? taps[(tapCount - 1) % taps.length] : null;
22492
23134
  let arc = null;
22493
23135
  if (from) {
22494
- const a = center(from);
22495
- const b = center(box);
23136
+ const a = centerOf(from);
23137
+ const b = centerOf(box);
22496
23138
  const lift = Math.max(40, Math.hypot(b.x - a.x, b.y - a.y) * 0.35);
22497
23139
  arc = `M ${a.x} ${a.y} Q ${(a.x + b.x) / 2} ${Math.min(a.y, b.y) - lift} ${b.x} ${b.y}`;
22498
23140
  }
@@ -22555,29 +23197,72 @@ const WIDGET_TUTORIALS = [keypadTutorial];
22555
23197
  function findWidgetTutorial(json) {
22556
23198
  return WIDGET_TUTORIALS.find((t) => t.appliesTo(json)) ?? null;
22557
23199
  }
23200
+ function hasMatchingLength(value, expected) {
23201
+ return (typeof expected === "string" || typeof expected === "number") && (typeof value === "string" || typeof value === "number") && String(value).length > 0 && String(value).length === String(expected).length;
23202
+ }
23203
+ function isSelectionGroups(value) {
23204
+ return Array.isArray(value) && value.every(
23205
+ (group) => Array.isArray(group) && group.length > 0 && group.every((id) => typeof id === "string")
23206
+ );
23207
+ }
23208
+ function hasMatchingSelectionCount(value, expected, itemIds, pairsOnly) {
23209
+ if (!isSelectionGroups(value) || !isSelectionGroups(expected)) return false;
23210
+ if (pairsOnly && (value.some((group) => group.length !== 2) || expected.some((group) => group.length !== 2)))
23211
+ return false;
23212
+ const selected = value.flat();
23213
+ const requiredCount = expected.flat().length;
23214
+ const declared = new Set(itemIds);
23215
+ return requiredCount > 0 && selected.length === requiredCount && new Set(selected).size === selected.length && selected.every((id) => declared.has(id));
23216
+ }
22558
23217
  function getAnswerLengthCompletion(json, answerable, values, touched) {
22559
23218
  const fields = [];
23219
+ const addTypedField = (id, widgetId, expected) => {
23220
+ const value = values[id];
23221
+ const hasKnownLength = typeof expected === "string" || typeof expected === "number";
23222
+ const hasScalarValue = typeof value === "string" || typeof value === "number";
23223
+ fields.push({
23224
+ id,
23225
+ widgetId,
23226
+ matches: hasMatchingLength(value, expected),
23227
+ // Unknown answer lengths use normal completion; known ones use grading's normalization.
23228
+ guideMatches: !hasKnownLength || touched.has(widgetId) && hasScalarValue && hasMatchingLength(normalize(value), normalize(expected))
23229
+ });
23230
+ };
22560
23231
  for (const widgetId of answerable) {
22561
23232
  const widget = json.widgets[widgetId];
22562
23233
  const expected = json.validation?.answers?.[widgetId];
22563
23234
  if (widget?.type === JSON_TYPES.INLINE_INPUT && !widget.disabled) {
22564
- fields.push({ id: widgetId, widgetId, expected });
23235
+ addTypedField(widgetId, widgetId, expected);
22565
23236
  } else if (widget?.type === JSON_TYPES.FRACTION) {
22566
23237
  const parts = typeof expected === "string" ? expected.split("/") : [];
22567
23238
  const validParts = parts.length === 2;
22568
23239
  if (widget.numerator === null) {
22569
- fields.push({ id: getFractionKey(widgetId, "num"), widgetId, expected: validParts ? parts[0] : void 0 });
23240
+ const id = getFractionKey(widgetId, "num");
23241
+ addTypedField(id, widgetId, validParts ? parts[0] : void 0);
22570
23242
  }
22571
23243
  if (widget.denominator === null) {
22572
- fields.push({ id: getFractionKey(widgetId, "den"), widgetId, expected: validParts ? parts[1] : void 0 });
23244
+ const id = getFractionKey(widgetId, "den");
23245
+ addTypedField(id, widgetId, validParts ? parts[1] : void 0);
22573
23246
  }
23247
+ } else if (widget?.type === JSON_TYPES.MATCHER || widget?.type === JSON_TYPES.PAIR_MATCHER) {
23248
+ const itemIds = widget.type === JSON_TYPES.MATCHER ? widget.rows.flatMap((row) => row.items.map((item) => item.itemId)) : widget.groups.flatMap((group) => group.map((item) => item.itemId));
23249
+ const matches = hasMatchingSelectionCount(
23250
+ values[widgetId],
23251
+ expected,
23252
+ itemIds,
23253
+ widget.type === JSON_TYPES.PAIR_MATCHER
23254
+ );
23255
+ fields.push({
23256
+ id: widgetId,
23257
+ widgetId,
23258
+ matches,
23259
+ guideMatches: expected == null || touched.has(widgetId) && matches
23260
+ });
22574
23261
  }
22575
23262
  }
22576
- const answerLengthMatchedIds = fields.filter(({ id, widgetId, expected }) => {
22577
- const value = values[id];
22578
- return touched.has(widgetId) && (typeof expected === "string" || typeof expected === "number") && (typeof value === "string" || typeof value === "number") && String(value).length > 0 && String(value).length === String(expected).length;
22579
- }).map(({ id }) => id);
23263
+ const answerLengthMatchedIds = fields.filter(({ widgetId, matches }) => touched.has(widgetId) && matches).map(({ id }) => id);
22580
23264
  return {
23265
+ areSubmitGuideCountsMatched: fields.every(({ guideMatches }) => guideMatches),
22581
23266
  isAnswerLengthMatched: fields.length > 0 && answerLengthMatchedIds.length === fields.length,
22582
23267
  answerLengthMatchedIds
22583
23268
  };
@@ -22603,12 +23288,9 @@ function buildInitialValues(json) {
22603
23288
  if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) {
22604
23289
  initial[id] = [];
22605
23290
  } else if (widget.type === JSON_TYPES.GRID_SYMMETRY) {
22606
- initial[id] = widget.cells.filter((cell) => isCellInInteractiveArea(
22607
- cell,
22608
- widget.rows,
22609
- widget.cols,
22610
- widget.interactiveArea
22611
- ));
23291
+ initial[id] = widget.cells.filter(
23292
+ (cell) => isCellInInteractiveArea(cell, widget.rows, widget.cols, widget.interactiveArea)
23293
+ );
22612
23294
  } else if (widget.type === JSON_TYPES.CHART_BAR || widget.type === JSON_TYPES.CHART_PICTO) {
22613
23295
  initial[id] = String(widget.value);
22614
23296
  } else if (widget.type === JSON_TYPES.GEOMETRY) {
@@ -22647,8 +23329,10 @@ function buildRevealedValues(json) {
22647
23329
  const widget = json.widgets[widgetId];
22648
23330
  if (widget?.type === JSON_TYPES.FRACTION && typeof expected === "string") {
22649
23331
  const [num, den] = expected.split("/", 2);
22650
- if (widget.numerator === null && num !== void 0) revealed[getFractionKey(widgetId, "num")] = num;
22651
- if (widget.denominator === null && den !== void 0) revealed[getFractionKey(widgetId, "den")] = den;
23332
+ if (widget.numerator === null && num !== void 0)
23333
+ revealed[getFractionKey(widgetId, "num")] = num;
23334
+ if (widget.denominator === null && den !== void 0)
23335
+ revealed[getFractionKey(widgetId, "den")] = den;
22652
23336
  continue;
22653
23337
  }
22654
23338
  revealed[widgetId] = typeof expected === "number" ? String(expected) : expected;
@@ -22680,449 +23364,571 @@ function buildRevealedDropSources(json, revealedValues) {
22680
23364
  const pool = [];
22681
23365
  const bank = json.widgets[DRAG_OPTIONS_WIDGET_ID];
22682
23366
  if (bank?.type === JSON_TYPES.DRAG_OPTIONS) {
22683
- bank.options.forEach((value, idx) => pool.push({ optionId: `drag-option-${idx}`, value: String(value) }));
23367
+ bank.options.forEach(
23368
+ (value, idx) => pool.push({ optionId: `drag-option-${idx}`, value: String(value) })
23369
+ );
22684
23370
  }
22685
23371
  for (const [widgetId, widget] of Object.entries(json.widgets)) {
22686
- if (widget.type === JSON_TYPES.INLINE_DRAG_OPTION) pool.push({ optionId: widgetId, value: widget.value });
23372
+ if (widget.type === JSON_TYPES.INLINE_DRAG_OPTION)
23373
+ pool.push({ optionId: widgetId, value: widget.value });
22687
23374
  }
22688
23375
  const sources = /* @__PURE__ */ new Map();
22689
23376
  if (pool.length === 0) return sources;
22690
23377
  const used = /* @__PURE__ */ new Set();
22691
23378
  for (const [inputId, value] of Object.entries(revealedValues)) {
22692
23379
  if (typeof value !== "string" || value === "") continue;
22693
- const match = pool.find((o) => !used.has(o.optionId) && normalize(o.value) === normalize(value));
23380
+ if (json.widgets[inputId]?.type !== JSON_TYPES.INLINE_INPUT && !isFractionKey(inputId))
23381
+ continue;
23382
+ const match = pool.find(
23383
+ (o) => !used.has(o.optionId) && normalize(o.value) === normalize(value)
23384
+ );
22694
23385
  if (!match) continue;
22695
23386
  used.add(match.optionId);
22696
23387
  sources.set(inputId, match.optionId);
22697
23388
  }
22698
23389
  return sources;
22699
23390
  }
22700
- const ExerciseRenderer = React.forwardRef(({
22701
- json: suppliedJson,
22702
- onSubmit,
22703
- onChange,
22704
- showSubmitButton = false,
22705
- className = "",
22706
- soundEnabled = true,
22707
- sounds,
22708
- theme
22709
- }, ref) => {
22710
- const json = React.useMemo(() => withDisabledPrefilledInputs(suppliedJson), [suppliedJson]);
22711
- const initialValuesSignature = React.useMemo(() => buildInitialValuesSignature(json), [json]);
22712
- const [values, setValues] = React.useState(() => buildInitialValues(json));
22713
- const valuesRef = React.useRef(values);
22714
- valuesRef.current = values;
22715
- const touchedRef = React.useRef(/* @__PURE__ */ new Set());
22716
- const [loadedValuesSignature, setLoadedValuesSignature] = React.useState(initialValuesSignature);
22717
- const [droppedSources, setDroppedSources] = React.useState(/* @__PURE__ */ new Map());
22718
- const [focusedInputId, setFocusedInputId] = React.useState(null);
22719
- const [validationDetails, setValidationDetails] = React.useState(null);
22720
- const rootRef = React.useRef(null);
22721
- const [resetKey, setResetKey] = React.useState(0);
22722
- const [revealed, setRevealed] = React.useState(false);
22723
- const tutorial = React.useMemo(() => findWidgetTutorial(json), [json]);
22724
- const [tutorialRun, setTutorialRun] = React.useState(null);
22725
- const themeStyle = React.useMemo(() => {
22726
- const style = {};
22727
- if (theme?.correct) style["--exercise-correct"] = theme.correct;
22728
- if (theme?.incorrect) style["--exercise-incorrect"] = theme.incorrect;
22729
- return Object.keys(style).length > 0 ? style : void 0;
22730
- }, [theme?.correct, theme?.incorrect]);
22731
- React.useEffect(() => {
22732
- warnOnInvalidThemeColors({ correct: theme?.correct, incorrect: theme?.incorrect });
22733
- }, [theme?.correct, theme?.incorrect]);
22734
- const playSound = useSoundPlayer(soundEnabled, sounds);
22735
- const externallyLocked = useExerciseLocked();
22736
- React.useLayoutEffect(() => {
22737
- if (loadedValuesSignature === initialValuesSignature) return;
22738
- setValues(buildInitialValues(json));
22739
- setDroppedSources(/* @__PURE__ */ new Map());
22740
- setFocusedInputId(null);
22741
- setValidationDetails(null);
22742
- setResetKey((k) => k + 1);
22743
- setLoadedValuesSignature(initialValuesSignature);
22744
- touchedRef.current = /* @__PURE__ */ new Set();
22745
- setTutorialRun(null);
22746
- }, [json, initialValuesSignature, loadedValuesSignature]);
22747
- const locked = validationDetails !== null || externallyLocked;
22748
- const lastResultRef = React.useRef(null);
22749
- const runSubmit = () => {
22750
- if (locked) return lastResultRef.current ?? validate(json, values);
22751
- const result = validate(json, values);
22752
- playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
22753
- const details = { ...result.details };
22754
- const slotValidation = (id, raw, expected) => ({
22755
- widgetId: id,
22756
- userValue: typeof raw === "string" ? raw : void 0,
22757
- isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
22758
- });
22759
- for (const [widgetId, widget] of Object.entries(json.widgets)) {
22760
- if (widget.type !== JSON_TYPES.FRACTION) continue;
22761
- if (!(widgetId in details)) continue;
22762
- const answer = json.validation?.answers?.[widgetId];
22763
- if (!answer) continue;
22764
- const [expectedNum, expectedDen] = answer.split("/", 2);
22765
- if (!expectedNum || !expectedDen) continue;
22766
- if (details[widgetId]?.isCorrect) {
22767
- if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
22768
- if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
22769
- } else {
22770
- if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
22771
- if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
22772
- }
22773
- }
22774
- setValidationDetails(details);
22775
- onSubmit?.(values);
22776
- lastResultRef.current = result;
22777
- return result;
22778
- };
22779
- React.useImperativeHandle(ref, () => ({
22780
- submit: runSubmit,
22781
- getValues: () => values,
22782
- getCompletion: () => {
22783
- const answerable = getAnswerableInputs(json);
22784
- const missingSet = new Set(getMissingInputs(json, valuesRef.current));
22785
- for (const id of answerable) {
22786
- if (!touchedRef.current.has(id)) missingSet.add(id);
22787
- }
22788
- const missing = [...missingSet];
22789
- const filledIds = answerable.filter((id) => !missingSet.has(id));
22790
- return {
22791
- total: answerable.length,
22792
- filled: filledIds.length,
22793
- missing,
22794
- filledIds,
22795
- isComplete: answerable.length > 0 && missing.length === 0,
22796
- isAnyFilled: filledIds.length > 0,
22797
- ...getAnswerLengthCompletion(json, answerable, valuesRef.current, touchedRef.current)
22798
- };
22799
- },
22800
- reset: () => {
22801
- const initial = buildInitialValues(json);
22802
- valuesRef.current = initial;
22803
- setValues(initial);
23391
+ const ExerciseRenderer = React.forwardRef(
23392
+ ({
23393
+ json: suppliedJson,
23394
+ onSubmit,
23395
+ onChange,
23396
+ showSubmitButton = false,
23397
+ className = "",
23398
+ soundEnabled = true,
23399
+ sounds,
23400
+ theme
23401
+ }, ref) => {
23402
+ const json = React.useMemo(() => withDisabledPrefilledInputs(suppliedJson), [suppliedJson]);
23403
+ const initialValuesSignature = React.useMemo(() => buildInitialValuesSignature(json), [json]);
23404
+ const [values, setValues] = React.useState(
23405
+ () => buildInitialValues(json)
23406
+ );
23407
+ const valuesRef = React.useRef(values);
23408
+ valuesRef.current = values;
23409
+ const touchedRef = React.useRef(/* @__PURE__ */ new Set());
23410
+ const [loadedValuesSignature, setLoadedValuesSignature] = React.useState(initialValuesSignature);
23411
+ const [droppedSources, setDroppedSources] = React.useState(/* @__PURE__ */ new Map());
23412
+ const [focusedInputId, setFocusedInputId] = React.useState(null);
23413
+ const [validationDetails, setValidationDetails] = React.useState(null);
23414
+ const rootRef = React.useRef(null);
23415
+ const [resetKey, setResetKey] = React.useState(0);
23416
+ const [revealed, setRevealed] = React.useState(false);
23417
+ const tutorial = React.useMemo(() => findWidgetTutorial(json), [json]);
23418
+ const [tutorialRun, setTutorialRun] = React.useState(null);
23419
+ const themeStyle = React.useMemo(() => {
23420
+ const style = {};
23421
+ if (theme?.correct) style["--exercise-correct"] = theme.correct;
23422
+ if (theme?.incorrect) style["--exercise-incorrect"] = theme.incorrect;
23423
+ return Object.keys(style).length > 0 ? style : void 0;
23424
+ }, [theme?.correct, theme?.incorrect]);
23425
+ React.useEffect(() => {
23426
+ warnOnInvalidThemeColors({ correct: theme?.correct, incorrect: theme?.incorrect });
23427
+ }, [theme?.correct, theme?.incorrect]);
23428
+ const playSound = useSoundPlayer(soundEnabled, sounds);
23429
+ const externallyLocked = useExerciseLocked();
23430
+ React.useLayoutEffect(() => {
23431
+ if (loadedValuesSignature === initialValuesSignature) return;
23432
+ setValues(buildInitialValues(json));
22804
23433
  setDroppedSources(/* @__PURE__ */ new Map());
22805
23434
  setFocusedInputId(null);
22806
23435
  setValidationDetails(null);
22807
23436
  setResetKey((k) => k + 1);
22808
- setRevealed(false);
22809
23437
  setLoadedValuesSignature(initialValuesSignature);
22810
23438
  touchedRef.current = /* @__PURE__ */ new Set();
22811
- },
22812
- revealAnswers: () => {
22813
- const revealedValues = buildRevealedValues(json);
22814
- if (Object.keys(revealedValues).length === 0) return;
22815
- playSound("answer-reveal");
22816
- const details = {};
22817
- for (const [widgetId, userValue] of Object.entries(revealedValues)) {
22818
- const widget = json.widgets[widgetId];
22819
- if (widget?.type === JSON_TYPES.GEOMETRY) {
22820
- const { isCorrect, result } = checkGeometryWidget(widget.json, userValue, widgetId);
22821
- details[widgetId] = { widgetId, userValue, isCorrect, geometryFeedback: result };
23439
+ setTutorialRun(null);
23440
+ }, [json, initialValuesSignature, loadedValuesSignature]);
23441
+ const locked = validationDetails !== null || externallyLocked;
23442
+ const lastResultRef = React.useRef(null);
23443
+ const runSubmit = () => {
23444
+ if (locked) return lastResultRef.current ?? validate(json, values);
23445
+ const result = validate(json, values);
23446
+ playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
23447
+ const details = { ...result.details };
23448
+ const slotValidation = (id, raw, expected) => ({
23449
+ widgetId: id,
23450
+ userValue: typeof raw === "string" ? raw : void 0,
23451
+ isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
23452
+ });
23453
+ for (const [widgetId, widget] of Object.entries(json.widgets)) {
23454
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
23455
+ if (!(widgetId in details)) continue;
23456
+ const answer = json.validation?.answers?.[widgetId];
23457
+ if (!answer) continue;
23458
+ const [expectedNum, expectedDen] = answer.split("/", 2);
23459
+ if (!expectedNum || !expectedDen) continue;
23460
+ if (details[widgetId]?.isCorrect) {
23461
+ if (widget.numerator === null)
23462
+ details[`${widgetId}__num`] = {
23463
+ widgetId: `${widgetId}__num`,
23464
+ userValue: void 0,
23465
+ isCorrect: true
23466
+ };
23467
+ if (widget.denominator === null)
23468
+ details[`${widgetId}__den`] = {
23469
+ widgetId: `${widgetId}__den`,
23470
+ userValue: void 0,
23471
+ isCorrect: true
23472
+ };
22822
23473
  } else {
22823
- details[widgetId] = { widgetId, userValue, isCorrect: true };
23474
+ if (widget.numerator === null)
23475
+ details[`${widgetId}__num`] = slotValidation(
23476
+ `${widgetId}__num`,
23477
+ values[`${widgetId}__num`],
23478
+ expectedNum
23479
+ );
23480
+ if (widget.denominator === null)
23481
+ details[`${widgetId}__den`] = slotValidation(
23482
+ `${widgetId}__den`,
23483
+ values[`${widgetId}__den`],
23484
+ expectedDen
23485
+ );
22824
23486
  }
23487
+ }
23488
+ setValidationDetails(details);
23489
+ onSubmit?.(values);
23490
+ lastResultRef.current = result;
23491
+ return result;
23492
+ };
23493
+ React.useImperativeHandle(
23494
+ ref,
23495
+ () => ({
23496
+ submit: runSubmit,
23497
+ getValues: () => values,
23498
+ getCompletion: () => {
23499
+ const answerable = getAnswerableInputs(json);
23500
+ const missingSet = new Set(getMissingInputs(json, valuesRef.current));
23501
+ for (const id of answerable) {
23502
+ if (!touchedRef.current.has(id)) missingSet.add(id);
23503
+ }
23504
+ const missing = [...missingSet];
23505
+ const filledIds = answerable.filter((id) => !missingSet.has(id));
23506
+ const isComplete = answerable.length > 0 && missing.length === 0;
23507
+ const { areSubmitGuideCountsMatched, ...answerLengthCompletion } = getAnswerLengthCompletion(json, answerable, valuesRef.current, touchedRef.current);
23508
+ return {
23509
+ total: answerable.length,
23510
+ filled: filledIds.length,
23511
+ missing,
23512
+ filledIds,
23513
+ isComplete,
23514
+ isSubmitGuideReady: isComplete && areSubmitGuideCountsMatched,
23515
+ isAnyFilled: filledIds.length > 0,
23516
+ ...answerLengthCompletion
23517
+ };
23518
+ },
23519
+ reset: () => {
23520
+ const initial = buildInitialValues(json);
23521
+ valuesRef.current = initial;
23522
+ setValues(initial);
23523
+ setDroppedSources(/* @__PURE__ */ new Map());
23524
+ setFocusedInputId(null);
23525
+ setValidationDetails(null);
23526
+ setResetKey((k) => k + 1);
23527
+ setRevealed(false);
23528
+ setLoadedValuesSignature(initialValuesSignature);
23529
+ touchedRef.current = /* @__PURE__ */ new Set();
23530
+ },
23531
+ revealAnswers: () => {
23532
+ const revealedValues = buildRevealedValues(json);
23533
+ if (Object.keys(revealedValues).length === 0) return;
23534
+ playSound("answer-reveal");
23535
+ const details = {};
23536
+ for (const [widgetId, userValue] of Object.entries(revealedValues)) {
23537
+ const widget = json.widgets[widgetId];
23538
+ if (widget?.type === JSON_TYPES.GEOMETRY) {
23539
+ const { isCorrect, result } = checkGeometryWidget(widget.json, userValue, widgetId);
23540
+ details[widgetId] = { widgetId, userValue, isCorrect, geometryFeedback: result };
23541
+ } else {
23542
+ details[widgetId] = { widgetId, userValue, isCorrect: true };
23543
+ }
23544
+ touchedRef.current.add(toBaseId(widgetId));
23545
+ }
23546
+ const next = { ...valuesRef.current, ...revealedValues };
23547
+ valuesRef.current = next;
23548
+ setValues(next);
23549
+ setDroppedSources(buildRevealedDropSources(json, revealedValues));
23550
+ setValidationDetails(details);
23551
+ setRevealed(true);
23552
+ setResetKey((k) => k + 1);
23553
+ },
23554
+ focusFirstInput: () => {
23555
+ const container = rootRef.current ?? document.querySelector(".math-exercise-renderer");
23556
+ if (!container) return;
23557
+ const candidates = getTextInputs(container);
23558
+ const target = candidates.find(canTakeInput) ?? // A locked exercise is read-only on purpose — nothing to focus until
23559
+ // reset(). Unlocked, a read-only field is not the lock, so refusing to
23560
+ // focus it would strand the student with no caret; take the first one
23561
+ // that isn't disabled or a chip's invisible SlotSizer.
23562
+ (locked ? void 0 : candidates.find(canAdvanceTo));
23563
+ target?.focus();
23564
+ },
23565
+ hasTutorial: () => tutorial !== null,
23566
+ tutorialId: () => tutorial?.id ?? null,
23567
+ startTutorial: (options = {}) => {
23568
+ if (!tutorial || !rootRef.current) return false;
23569
+ setTutorialRun((prev) => ({ options, run: (prev?.run ?? 0) + 1 }));
23570
+ return true;
23571
+ },
23572
+ stopTutorial: () => setTutorialRun(null)
23573
+ }),
23574
+ [json, values, onSubmit, playSound, locked, initialValuesSignature, tutorial]
23575
+ );
23576
+ const focusNextInput = (currentKey) => {
23577
+ if (!rootRef.current) return;
23578
+ findNextInput(rootRef.current, currentKey)?.focus();
23579
+ };
23580
+ const handleChange = (widgetId, value, fromDrop = false, sourceOptionId, silent = false) => {
23581
+ if (locked) return;
23582
+ if (!silent) {
23583
+ setRevealed(false);
22825
23584
  touchedRef.current.add(toBaseId(widgetId));
22826
23585
  }
22827
- const next = { ...valuesRef.current, ...revealedValues };
23586
+ const previous = valuesRef.current[widgetId];
23587
+ const next = { ...valuesRef.current, [widgetId]: value };
22828
23588
  valuesRef.current = next;
22829
23589
  setValues(next);
22830
- setDroppedSources(buildRevealedDropSources(json, revealedValues));
22831
- setValidationDetails(details);
22832
- setRevealed(true);
22833
- setResetKey((k) => k + 1);
22834
- },
22835
- focusFirstInput: () => {
22836
- const container = rootRef.current ?? document.querySelector(".math-exercise-renderer");
22837
- if (!container) return;
22838
- const candidates = getTextInputs(container);
22839
- const target = candidates.find(canTakeInput) ?? (locked ? void 0 : candidates.find(canAdvanceTo));
22840
- target?.focus();
22841
- },
22842
- hasTutorial: () => tutorial !== null,
22843
- tutorialId: () => tutorial?.id ?? null,
22844
- startTutorial: (options = {}) => {
22845
- if (!tutorial || !rootRef.current) return false;
22846
- setTutorialRun((prev) => ({ options, run: (prev?.run ?? 0) + 1 }));
22847
- return true;
22848
- },
22849
- stopTutorial: () => setTutorialRun(null)
22850
- }), [json, values, onSubmit, playSound, locked, initialValuesSignature, tutorial]);
22851
- const focusNextInput = (currentKey) => {
22852
- if (!rootRef.current) return;
22853
- findNextInput(rootRef.current, currentKey)?.focus();
22854
- };
22855
- const handleChange = (widgetId, value, fromDrop = false, sourceOptionId, silent = false) => {
22856
- if (locked) return;
22857
- if (!silent) {
22858
- setRevealed(false);
22859
- touchedRef.current.add(toBaseId(widgetId));
22860
- }
22861
- const previous = valuesRef.current[widgetId];
22862
- const next = { ...valuesRef.current, [widgetId]: value };
22863
- valuesRef.current = next;
22864
- setValues(next);
22865
- setDroppedSources((prev) => {
22866
- const next2 = new Map(prev);
22867
- if (fromDrop && typeof value === "string" && value !== "" && sourceOptionId) {
22868
- next2.set(widgetId, sourceOptionId);
22869
- } else {
22870
- next2.delete(widgetId);
22871
- }
22872
- return next2;
22873
- });
22874
- onChange?.(next);
22875
- if (!fromDrop && !silent && hasJustFilledBox(json, widgetId, previous, value)) {
22876
- focusNextInput(widgetId);
22877
- }
22878
- };
22879
- const handleSubmit = () => {
22880
- runSubmit();
22881
- };
22882
- const lineGroups = React.useMemo(() => {
22883
- const isRTLInlineWidget = (item) => {
22884
- if (item.type === JSON_TYPES.WIDGET_REF) {
22885
- const widget = json.widgets[item.widgetId];
22886
- return !!widget && RTL_INLINE_WIDGET_TYPES.has(widget.type);
23590
+ setDroppedSources((prev) => {
23591
+ const next2 = new Map(prev);
23592
+ if (fromDrop && typeof value === "string" && value !== "" && sourceOptionId) {
23593
+ next2.set(widgetId, sourceOptionId);
23594
+ } else {
23595
+ next2.delete(widgetId);
23596
+ }
23597
+ return next2;
23598
+ });
23599
+ onChange?.(next);
23600
+ if (!fromDrop && !silent && hasJustFilledBox(json, widgetId, previous, value)) {
23601
+ focusNextInput(widgetId);
22887
23602
  }
22888
- return RTL_INLINE_WIDGET_TYPES.has(item.type);
22889
23603
  };
22890
- const groups = [];
22891
- let currentGroup = [];
22892
- for (const item of json.layout) {
22893
- if (item.type === JSON_TYPES.QUESTION_TEXT) {
22894
- groups.push({
22895
- items: [item],
22896
- hasArabic: itemContainsArabic(item),
22897
- hasRTLInlineWidget: false,
22898
- subGroups: []
22899
- // Question text doesn't need sub-grouping
22900
- });
22901
- continue;
22902
- }
22903
- if (item.type === JSON_TYPES.TABLE) {
22904
- if (currentGroup.length > 0) {
22905
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22906
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22907
- const subGroups = groupLineItems(currentGroup);
22908
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22909
- currentGroup = [];
22910
- }
22911
- groups.push({
22912
- items: [item],
22913
- hasArabic: false,
22914
- hasRTLInlineWidget: false,
22915
- subGroups: []
22916
- });
22917
- continue;
22918
- }
22919
- if (item.type === JSON_TYPES.NUMBER_LINE) {
22920
- if (currentGroup.length > 0) {
22921
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22922
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22923
- const subGroups = groupLineItems(currentGroup);
22924
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22925
- currentGroup = [];
22926
- }
22927
- groups.push({
22928
- items: [item],
22929
- hasArabic: false,
22930
- hasRTLInlineWidget: false,
22931
- subGroups: []
22932
- });
22933
- continue;
22934
- }
22935
- if (item.type === JSON_TYPES.VERTICAL_OPERATION) {
22936
- if (currentGroup.length > 0) {
22937
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22938
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22939
- const subGroups = groupLineItems(currentGroup);
22940
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22941
- currentGroup = [];
22942
- }
22943
- groups.push({
22944
- items: [item],
22945
- hasArabic: false,
22946
- hasRTLInlineWidget: false,
22947
- subGroups: []
22948
- });
22949
- continue;
22950
- }
22951
- if (item.type === JSON_TYPES.CHART) {
22952
- if (currentGroup.length > 0) {
22953
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22954
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22955
- const subGroups = groupLineItems(currentGroup);
22956
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22957
- currentGroup = [];
22958
- }
22959
- groups.push({
22960
- items: [item],
22961
- hasArabic: false,
22962
- hasRTLInlineWidget: false,
22963
- subGroups: []
22964
- });
22965
- continue;
22966
- }
22967
- if (item.type === JSON_TYPES.LINEBREAK) {
22968
- if (currentGroup.length > 0) {
22969
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22970
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22971
- const subGroups = groupLineItems(currentGroup);
22972
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22973
- currentGroup = [];
22974
- }
22975
- } else {
22976
- currentGroup.push(item);
22977
- }
22978
- }
22979
- if (currentGroup.length > 0) {
22980
- const hasArabic = currentGroup.some((item) => itemContainsArabic(item));
22981
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22982
- const subGroups = groupLineItems(currentGroup);
22983
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22984
- }
22985
- return groups;
22986
- }, [json.layout, json.widgets]);
22987
- const droppedInputs = React.useMemo(() => new Set(droppedSources.keys()), [droppedSources]);
22988
- const dragOptionsWidget = json.widgets["_drag_options"];
22989
- const rulerWidget = json.widgets[RULER_WIDGET_ID];
22990
- const protractorWidget = json.widgets[PROTRACTOR_WIDGET_ID];
22991
- const setSquareWidget = json.widgets[SET_SQUARE_WIDGET_ID];
22992
- const numericKeypadWidget = json.widgets[NUMERIC_KEYPAD_WIDGET_ID];
22993
- const { tools, toolsMinHeight } = React.useMemo(() => {
22994
- const list = [];
22995
- const PAD = 12;
22996
- const STAGGER = 24;
22997
- let maxH = 0;
22998
- const getSize = (w) => typeof w === "object" && w !== null && "size" in w && typeof w.size === "number" ? w.size : 1;
22999
- const add2 = (id, type, width) => {
23000
- const offset = list.length * STAGGER;
23001
- list.push({ id, type, width, initialPosition: { x: PAD + offset, y: PAD + offset } });
23002
- const extra = type === "set-square" ? FLIP_BTN_EXTRA : 0;
23003
- const h = PAD + Math.ceil(width * (TOOL_ASPECT_RATIO[type] ?? 0)) + extra + PAD;
23004
- if (h > maxH) maxH = h;
23604
+ const handleSubmit = () => {
23605
+ runSubmit();
23005
23606
  };
23006
- if (rulerWidget) add2("ruler-1", "ruler", Math.round(getSize(rulerWidget) * 240));
23007
- if (protractorWidget) add2("protractor-1", "protractor", Math.round(getSize(protractorWidget) * 180));
23008
- if (setSquareWidget) add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
23009
- return { tools: list, toolsMinHeight: maxH };
23010
- }, [rulerWidget, protractorWidget, setSquareWidget]);
23011
- return /* @__PURE__ */ jsxRuntime.jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsxRuntime.jsx(ExerciseLockProvider, { locked, children: /* @__PURE__ */ jsxRuntime.jsx(ExerciseDndProvider, { droppedSources, exerciseKey: loadedValuesSignature, children: /* @__PURE__ */ jsxRuntime.jsx(KeypadContext.Provider, { value: Boolean(numericKeypadWidget), children: /* @__PURE__ */ jsxRuntime.jsx(DragBankContext.Provider, { value: json.widgets[DRAG_OPTIONS_WIDGET_ID] !== void 0, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: rootRef, className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`, style: themeStyle, children: [
23012
- /* @__PURE__ */ jsxRuntime.jsxs(
23013
- "div",
23607
+ const lineGroups = React.useMemo(() => {
23608
+ const isRTLInlineWidget = (item) => {
23609
+ if (item.type === JSON_TYPES.WIDGET_REF) {
23610
+ const widget = json.widgets[item.widgetId];
23611
+ return !!widget && RTL_INLINE_WIDGET_TYPES.has(widget.type);
23612
+ }
23613
+ return RTL_INLINE_WIDGET_TYPES.has(item.type);
23614
+ };
23615
+ const groups = [];
23616
+ let currentGroup = [];
23617
+ for (const item of json.layout) {
23618
+ if (item.type === JSON_TYPES.QUESTION_TEXT) {
23619
+ groups.push({
23620
+ items: [item],
23621
+ hasArabic: itemContainsArabic(item),
23622
+ hasRTLInlineWidget: false,
23623
+ subGroups: []
23624
+ // Question text doesn't need sub-grouping
23625
+ });
23626
+ continue;
23627
+ }
23628
+ if (item.type === JSON_TYPES.TABLE) {
23629
+ if (currentGroup.length > 0) {
23630
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
23631
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
23632
+ const subGroups = groupLineItems(currentGroup);
23633
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
23634
+ currentGroup = [];
23635
+ }
23636
+ groups.push({
23637
+ items: [item],
23638
+ hasArabic: false,
23639
+ hasRTLInlineWidget: false,
23640
+ subGroups: []
23641
+ });
23642
+ continue;
23643
+ }
23644
+ if (item.type === JSON_TYPES.NUMBER_LINE) {
23645
+ if (currentGroup.length > 0) {
23646
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
23647
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
23648
+ const subGroups = groupLineItems(currentGroup);
23649
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
23650
+ currentGroup = [];
23651
+ }
23652
+ groups.push({
23653
+ items: [item],
23654
+ hasArabic: false,
23655
+ hasRTLInlineWidget: false,
23656
+ subGroups: []
23657
+ });
23658
+ continue;
23659
+ }
23660
+ if (item.type === JSON_TYPES.VERTICAL_OPERATION) {
23661
+ if (currentGroup.length > 0) {
23662
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
23663
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
23664
+ const subGroups = groupLineItems(currentGroup);
23665
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
23666
+ currentGroup = [];
23667
+ }
23668
+ groups.push({
23669
+ items: [item],
23670
+ hasArabic: false,
23671
+ hasRTLInlineWidget: false,
23672
+ subGroups: []
23673
+ });
23674
+ continue;
23675
+ }
23676
+ if (item.type === JSON_TYPES.CHART) {
23677
+ if (currentGroup.length > 0) {
23678
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
23679
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
23680
+ const subGroups = groupLineItems(currentGroup);
23681
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
23682
+ currentGroup = [];
23683
+ }
23684
+ groups.push({
23685
+ items: [item],
23686
+ hasArabic: false,
23687
+ hasRTLInlineWidget: false,
23688
+ subGroups: []
23689
+ });
23690
+ continue;
23691
+ }
23692
+ if (item.type === JSON_TYPES.LINEBREAK) {
23693
+ if (currentGroup.length > 0) {
23694
+ const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
23695
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
23696
+ const subGroups = groupLineItems(currentGroup);
23697
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
23698
+ currentGroup = [];
23699
+ }
23700
+ } else {
23701
+ currentGroup.push(item);
23702
+ }
23703
+ }
23704
+ if (currentGroup.length > 0) {
23705
+ const hasArabic = currentGroup.some((item) => itemContainsArabic(item));
23706
+ const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
23707
+ const subGroups = groupLineItems(currentGroup);
23708
+ groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
23709
+ }
23710
+ return groups;
23711
+ }, [json.layout, json.widgets]);
23712
+ const droppedInputs = React.useMemo(() => new Set(droppedSources.keys()), [droppedSources]);
23713
+ const dragOptionsWidget = json.widgets["_drag_options"];
23714
+ const draggableOptionsWidget = json.widgets[DRAGGABLE_OPTIONS_WIDGET_ID];
23715
+ const rulerWidget = json.widgets[RULER_WIDGET_ID];
23716
+ const protractorWidget = json.widgets[PROTRACTOR_WIDGET_ID];
23717
+ const setSquareWidget = json.widgets[SET_SQUARE_WIDGET_ID];
23718
+ const numericKeypadWidget = json.widgets[NUMERIC_KEYPAD_WIDGET_ID];
23719
+ const { tools, toolsMinHeight } = React.useMemo(() => {
23720
+ const list = [];
23721
+ const PAD = 12;
23722
+ const STAGGER = 24;
23723
+ let maxH = 0;
23724
+ const getSize = (w) => typeof w === "object" && w !== null && "size" in w && typeof w.size === "number" ? w.size : 1;
23725
+ const add2 = (id, type, width) => {
23726
+ const offset = list.length * STAGGER;
23727
+ list.push({ id, type, width, initialPosition: { x: PAD + offset, y: PAD + offset } });
23728
+ const extra = type === "set-square" ? FLIP_BTN_EXTRA : 0;
23729
+ const h = PAD + Math.ceil(width * (TOOL_ASPECT_RATIO[type] ?? 0)) + extra + PAD;
23730
+ if (h > maxH) maxH = h;
23731
+ };
23732
+ if (rulerWidget) add2("ruler-1", "ruler", Math.round(getSize(rulerWidget) * 240));
23733
+ if (protractorWidget)
23734
+ add2("protractor-1", "protractor", Math.round(getSize(protractorWidget) * 180));
23735
+ if (setSquareWidget)
23736
+ add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
23737
+ return { tools: list, toolsMinHeight: maxH };
23738
+ }, [rulerWidget, protractorWidget, setSquareWidget]);
23739
+ return /* @__PURE__ */ jsxRuntime.jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsxRuntime.jsx(ExerciseLockProvider, { locked, children: /* @__PURE__ */ jsxRuntime.jsx(
23740
+ ExerciseDndProvider,
23014
23741
  {
23015
- className: `exercise-layout-container ${numericKeypadWidget ? "has-keypad" : ""}`,
23016
- style: toolsMinHeight > 0 ? { minHeight: toolsMinHeight } : void 0,
23017
- children: [
23018
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "exercise-layout", children: lineGroups.map((group, groupIdx) => /* @__PURE__ */ jsxRuntime.jsx(
23019
- "div",
23020
- {
23021
- className: "exercise-line",
23022
- dir: group.hasArabic && group.hasRTLInlineWidget ? "rtl" : void 0,
23023
- children: group.subGroups.length > 0 ? (
23024
- // Render sub-groups for regular lines
23025
- group.subGroups.map((subGroup, subIdx) => subGroup.isTextGroup ? (
23026
- // Text group - only apply RTL for pure Arabic (no mixed languages)
23027
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-group", dir: subGroup.shouldApplyRTL ? "rtl" : void 0, children: subGroup.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
23028
- LayoutRenderer,
23029
- {
23030
- item,
23031
- widgets: json.widgets,
23032
- values,
23033
- droppedInputs,
23034
- validationDetails,
23035
- onChange: handleChange,
23036
- onInputFocus: setFocusedInputId,
23037
- resetKey
23038
- },
23039
- item.id
23040
- )) }, subIdx)
23041
- ) : (
23042
- // Widget group - no dir wrapper, render directly
23043
- subGroup.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
23044
- LayoutRenderer,
23045
- {
23046
- item,
23047
- widgets: json.widgets,
23048
- values,
23049
- droppedInputs,
23050
- validationDetails,
23051
- onChange: handleChange,
23052
- onInputFocus: setFocusedInputId,
23053
- resetKey
23054
- },
23055
- item.id
23056
- ))
23057
- ))
23058
- ) : (
23059
- // For QUESTION_TEXT items (no sub-groups)
23060
- group.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
23061
- LayoutRenderer,
23742
+ droppedSources,
23743
+ exerciseKey: loadedValuesSignature,
23744
+ renderOverlay: (item) => {
23745
+ if (item.type !== "rich-drag-option" || draggableOptionsWidget?.type !== JSON_TYPES.DRAGGABLE_OPTIONS) {
23746
+ return null;
23747
+ }
23748
+ const data = item.data;
23749
+ const option = draggableOptionsWidget.options.find(
23750
+ (candidate) => candidate.name === data.optionName
23751
+ );
23752
+ return option ? (
23753
+ // The overlay renders outside the root below, so it needs the root's class
23754
+ // and theme style for nested widgets to resolve the --exercise-* variables.
23755
+ /* @__PURE__ */ jsxRuntime.jsx(
23756
+ "div",
23757
+ {
23758
+ className: `math-exercise-renderer rich-drag-overlay-root ${className}`,
23759
+ style: themeStyle,
23760
+ "aria-hidden": "true",
23761
+ children: /* @__PURE__ */ jsxRuntime.jsx(
23762
+ "div",
23062
23763
  {
23063
- item,
23064
- widgets: json.widgets,
23065
- values,
23066
- droppedInputs,
23067
- validationDetails,
23068
- onChange: handleChange,
23069
- onInputFocus: setFocusedInputId,
23070
- resetKey
23071
- },
23072
- item.id
23073
- ))
23074
- )
23075
- },
23076
- groupIdx
23077
- )) }),
23078
- numericKeypadWidget && numericKeypadWidget.type === "numeric-keypad" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "numeric-keypad-container", children: /* @__PURE__ */ jsxRuntime.jsx(
23079
- WidgetRenderer,
23080
- {
23081
- widget: numericKeypadWidget,
23082
- widgetId: "_numeric_keypad",
23083
- value: focusedInputId ? values[focusedInputId] || "" : "",
23084
- onChange: () => {
23085
- },
23086
- onKeypadChange: (newValue) => {
23087
- if (!focusedInputId) return;
23088
- const maxLength = getMaxInputLength(json, focusedInputId);
23089
- if (maxLength !== void 0 && newValue.length > maxLength) return;
23090
- handleChange(focusedInputId, newValue, false);
23764
+ className: `drag-overlay-option rich-drag-overlay-option${isPlainTextContent(option.content) ? "" : " drag-overlay-option--rich"}`,
23765
+ children: /* @__PURE__ */ jsxRuntime.jsx(RichOptionContent, { content: option.content, widgets: json.widgets })
23766
+ }
23767
+ )
23091
23768
  }
23092
- }
23093
- ) }),
23094
- tools.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(ToolsOverlay, { tools, resetKey })
23095
- ]
23096
- }
23097
- ),
23098
- dragOptionsWidget && dragOptionsWidget.type === "drag-options" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options-container", children: /* @__PURE__ */ jsxRuntime.jsx(
23099
- WidgetRenderer,
23100
- {
23101
- widget: dragOptionsWidget,
23102
- widgetId: "_drag_options",
23103
- value: "",
23104
- onChange: () => {
23105
- }
23769
+ )
23770
+ ) : null;
23771
+ },
23772
+ children: /* @__PURE__ */ jsxRuntime.jsx(KeypadContext.Provider, { value: Boolean(numericKeypadWidget), children: /* @__PURE__ */ jsxRuntime.jsx(DragBankContext.Provider, { value: json.widgets[DRAG_OPTIONS_WIDGET_ID] !== void 0, children: /* @__PURE__ */ jsxRuntime.jsxs(
23773
+ "div",
23774
+ {
23775
+ ref: rootRef,
23776
+ className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`,
23777
+ style: themeStyle,
23778
+ children: [
23779
+ /* @__PURE__ */ jsxRuntime.jsxs(
23780
+ "div",
23781
+ {
23782
+ className: `exercise-layout-container ${numericKeypadWidget ? "has-keypad" : ""}`,
23783
+ style: toolsMinHeight > 0 ? { minHeight: toolsMinHeight } : void 0,
23784
+ children: [
23785
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "exercise-layout", children: lineGroups.map((group, groupIdx) => /* @__PURE__ */ jsxRuntime.jsx(
23786
+ "div",
23787
+ {
23788
+ className: "exercise-line",
23789
+ dir: group.hasArabic && group.hasRTLInlineWidget ? "rtl" : void 0,
23790
+ children: group.subGroups.length > 0 ? (
23791
+ // Render sub-groups for regular lines
23792
+ group.subGroups.map(
23793
+ (subGroup, subIdx) => subGroup.isTextGroup ? (
23794
+ // Text group - only apply RTL for pure Arabic (no mixed languages)
23795
+ /* @__PURE__ */ jsxRuntime.jsx(
23796
+ "span",
23797
+ {
23798
+ className: "text-group",
23799
+ dir: subGroup.shouldApplyRTL ? "rtl" : void 0,
23800
+ children: subGroup.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
23801
+ LayoutRenderer,
23802
+ {
23803
+ item,
23804
+ widgets: json.widgets,
23805
+ values,
23806
+ droppedInputs,
23807
+ validationDetails,
23808
+ onChange: handleChange,
23809
+ onInputFocus: setFocusedInputId,
23810
+ resetKey
23811
+ },
23812
+ item.id
23813
+ ))
23814
+ },
23815
+ subIdx
23816
+ )
23817
+ ) : (
23818
+ // Widget group - no dir wrapper, render directly
23819
+ subGroup.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
23820
+ LayoutRenderer,
23821
+ {
23822
+ item,
23823
+ widgets: json.widgets,
23824
+ values,
23825
+ droppedInputs,
23826
+ validationDetails,
23827
+ onChange: handleChange,
23828
+ onInputFocus: setFocusedInputId,
23829
+ resetKey
23830
+ },
23831
+ item.id
23832
+ ))
23833
+ )
23834
+ )
23835
+ ) : (
23836
+ // For QUESTION_TEXT items (no sub-groups)
23837
+ group.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
23838
+ LayoutRenderer,
23839
+ {
23840
+ item,
23841
+ widgets: json.widgets,
23842
+ values,
23843
+ droppedInputs,
23844
+ validationDetails,
23845
+ onChange: handleChange,
23846
+ onInputFocus: setFocusedInputId,
23847
+ resetKey
23848
+ },
23849
+ item.id
23850
+ ))
23851
+ )
23852
+ },
23853
+ groupIdx
23854
+ )) }),
23855
+ numericKeypadWidget && numericKeypadWidget.type === "numeric-keypad" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "numeric-keypad-container", children: /* @__PURE__ */ jsxRuntime.jsx(
23856
+ WidgetRenderer,
23857
+ {
23858
+ widget: numericKeypadWidget,
23859
+ widgetId: "_numeric_keypad",
23860
+ value: focusedInputId ? values[focusedInputId] || "" : "",
23861
+ onChange: () => {
23862
+ },
23863
+ onKeypadChange: (newValue) => {
23864
+ if (!focusedInputId) return;
23865
+ const maxLength = getMaxInputLength(json, focusedInputId);
23866
+ const currentLength = String(values[focusedInputId] ?? "").length;
23867
+ if (maxLength !== void 0 && newValue.length > maxLength && newValue.length > currentLength)
23868
+ return;
23869
+ handleChange(focusedInputId, newValue, false);
23870
+ }
23871
+ }
23872
+ ) }),
23873
+ tools.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(ToolsOverlay, { tools, resetKey })
23874
+ ]
23875
+ }
23876
+ ),
23877
+ dragOptionsWidget && dragOptionsWidget.type === "drag-options" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options-container", children: /* @__PURE__ */ jsxRuntime.jsx(
23878
+ WidgetRenderer,
23879
+ {
23880
+ widget: dragOptionsWidget,
23881
+ widgetId: "_drag_options",
23882
+ value: "",
23883
+ onChange: () => {
23884
+ }
23885
+ }
23886
+ ) }),
23887
+ draggableOptionsWidget && draggableOptionsWidget.type === JSON_TYPES.DRAGGABLE_OPTIONS && // Keeps the legacy class too: host CSS uses :has(.drag-options-container) to
23888
+ // tell drag exercises from typed ones.
23889
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options-container draggable-options-container", children: /* @__PURE__ */ jsxRuntime.jsx(
23890
+ WidgetRenderer,
23891
+ {
23892
+ widget: draggableOptionsWidget,
23893
+ widgetId: DRAGGABLE_OPTIONS_WIDGET_ID,
23894
+ value: "",
23895
+ values,
23896
+ widgets: json.widgets,
23897
+ onChange: () => {
23898
+ }
23899
+ }
23900
+ ) }),
23901
+ showSubmitButton && /* @__PURE__ */ jsxRuntime.jsx(
23902
+ "button",
23903
+ {
23904
+ className: "exercise-submit-btn",
23905
+ disabled: locked,
23906
+ onClick: handleSubmit,
23907
+ children: "Check Answer"
23908
+ }
23909
+ ),
23910
+ tutorial && tutorialRun && rootRef.current && /* @__PURE__ */ jsxRuntime.jsx(
23911
+ TutorialOverlay,
23912
+ {
23913
+ tutorial,
23914
+ root: rootRef.current,
23915
+ json,
23916
+ options: tutorialRun.options,
23917
+ onClose: (finished) => {
23918
+ setTutorialRun(null);
23919
+ tutorialRun.options.onClose?.();
23920
+ if (finished) tutorialRun.options.onDone?.();
23921
+ }
23922
+ },
23923
+ tutorialRun.run
23924
+ )
23925
+ ]
23926
+ }
23927
+ ) }) })
23106
23928
  }
23107
- ) }),
23108
- showSubmitButton && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "exercise-submit-btn", disabled: locked, onClick: handleSubmit, children: "Check Answer" }),
23109
- tutorial && tutorialRun && rootRef.current && /* @__PURE__ */ jsxRuntime.jsx(
23110
- TutorialOverlay,
23111
- {
23112
- tutorial,
23113
- root: rootRef.current,
23114
- json,
23115
- options: tutorialRun.options,
23116
- onClose: (finished) => {
23117
- setTutorialRun(null);
23118
- tutorialRun.options.onClose?.();
23119
- if (finished) tutorialRun.options.onDone?.();
23120
- }
23121
- },
23122
- tutorialRun.run
23123
- )
23124
- ] }) }) }) }) }) });
23125
- });
23929
+ ) }) });
23930
+ }
23931
+ );
23126
23932
  ExerciseRenderer.displayName = "ExerciseRenderer";
23127
23933
  function renderExercise(options) {
23128
23934
  const container = typeof options.container === "string" ? document.querySelector(options.container) : options.container;
@@ -23195,6 +24001,12 @@ exports.DEFAULT_TABLE_BORDER_COLOR = DEFAULT_TABLE_BORDER_COLOR;
23195
24001
  exports.DisplayInlineNodeSchema = DisplayInlineNodeSchema;
23196
24002
  exports.DragOptionsJSONSchema = DragOptionsJSONSchema;
23197
24003
  exports.DragOptionsToolSchema = DragOptionsToolSchema;
24004
+ exports.DraggableOptionJSONSchema = DraggableOptionJSONSchema;
24005
+ exports.DraggableOptionSchema = DraggableOptionSchema;
24006
+ exports.DraggableOptionsJSONSchema = DraggableOptionsJSONSchema;
24007
+ exports.DraggableOptionsToolSchema = DraggableOptionsToolSchema;
24008
+ exports.DropInputJSONSchema = DropInputJSONSchema;
24009
+ exports.DropInputSchema = DropInputSchema;
23198
24010
  exports.EngineError = EngineError;
23199
24011
  exports.ExerciseDocumentSchema = ExerciseDocumentSchema;
23200
24012
  exports.ExerciseJSONSchema = ExerciseJSONSchema;
@@ -23303,6 +24115,7 @@ exports.WIDGET_TUTORIALS = WIDGET_TUTORIALS;
23303
24115
  exports.WidgetJSONSchema = WidgetJSONSchema;
23304
24116
  exports.WidgetRefSchema = WidgetRefSchema;
23305
24117
  exports.WidgetRenderer = WidgetRenderer;
24118
+ exports.addDuplicateOptionNameIssues = addDuplicateOptionNameIssues;
23306
24119
  exports.alignGroupBarsToSeries = alignGroupBarsToSeries;
23307
24120
  exports.compile = compile;
23308
24121
  exports.compileWithAST = compileWithAST;
@@ -23319,6 +24132,9 @@ exports.createChartSeriesBar = createChartSeriesBar;
23319
24132
  exports.createColorText = createColorText;
23320
24133
  exports.createDisplayClock = createDisplayClock;
23321
24134
  exports.createDragOptions = createDragOptions;
24135
+ exports.createDraggableOption = createDraggableOption;
24136
+ exports.createDraggableOptions = createDraggableOptions;
24137
+ exports.createDropInput = createDropInput;
23322
24138
  exports.createExerciseDocument = createExerciseDocument;
23323
24139
  exports.createExpressionValidation = createExpressionValidation;
23324
24140
  exports.createFraction = createFraction;
@@ -23363,6 +24179,7 @@ exports.findWidgetTutorial = findWidgetTutorial;
23363
24179
  exports.getAnswerableInputs = getAnswerableInputs;
23364
24180
  exports.getFractionKey = getFractionKey;
23365
24181
  exports.getMissingInputs = getMissingInputs;
24182
+ exports.hasOptionContent = hasOptionContent;
23366
24183
  exports.isBoldText = isBoldText;
23367
24184
  exports.isCellInInteractiveArea = isCellInInteractiveArea;
23368
24185
  exports.isChartAxis = isChartAxis;
@@ -23380,6 +24197,10 @@ exports.isClockItem = isClockItem;
23380
24197
  exports.isColorText = isColorText;
23381
24198
  exports.isDragOptions = isDragOptions;
23382
24199
  exports.isDragOptionsJSON = isDragOptionsJSON;
24200
+ exports.isDraggableOptions = isDraggableOptions;
24201
+ exports.isDraggableOptionsJSON = isDraggableOptionsJSON;
24202
+ exports.isDropInput = isDropInput;
24203
+ exports.isDropInputJSON = isDropInputJSON;
23383
24204
  exports.isExerciseDocument = isExerciseDocument;
23384
24205
  exports.isExpressionValidation = isExpressionValidation;
23385
24206
  exports.isFraction = isFraction;
@@ -23409,6 +24230,7 @@ exports.isMixedValidation = isMixedValidation;
23409
24230
  exports.isNumericKeypad = isNumericKeypad;
23410
24231
  exports.isNumericKeypadJSON = isNumericKeypadJSON;
23411
24232
  exports.isOperatorItem = isOperatorItem;
24233
+ exports.isOptionContentNode = isOptionContentNode;
23412
24234
  exports.isPairMatcherBlock = isPairMatcherBlock;
23413
24235
  exports.isPairMatcherWidget = isPairMatcherWidget;
23414
24236
  exports.isProtractorTool = isProtractorTool;
@@ -23465,4 +24287,4 @@ exports.validateUniqueIds = validateUniqueIds;
23465
24287
  exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
23466
24288
  exports.validateWidgetJSON = validateWidgetJSON;
23467
24289
  exports.validateWidgetJSONSafe = validateWidgetJSONSafe;
23468
- //# sourceMappingURL=index-gCvC2wRR.js.map
24290
+ //# sourceMappingURL=index-C9OHpy2h.js.map