@mathscan/math-exercise-engine 1.2.13 → 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);
@@ -2092,6 +2168,8 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
2092
2168
  width: InlineInputWidthSchema,
2093
2169
  placeholder: zod.z.string().max(32).optional(),
2094
2170
  disabled: zod.z.boolean().optional(),
2171
+ /** Starting value the student can edit (shown read-only when disabled). */
2172
+ prefill: zod.z.string().optional(),
2095
2173
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
2096
2174
  }),
2097
2175
  zod.z.object({
@@ -2102,9 +2180,19 @@ const InlineInputWidgetSchema = zod.z.discriminatedUnion("inputType", [
2102
2180
  width: InlineInputWidthSchema,
2103
2181
  placeholder: zod.z.string().max(32).optional(),
2104
2182
  disabled: zod.z.boolean().optional(),
2183
+ /** Starting value the student can edit (shown read-only when disabled). */
2184
+ prefill: zod.z.string().optional(),
2105
2185
  answer: zod.z.union([zod.z.string(), zod.z.number()]).optional()
2106
2186
  })
2107
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
+ });
2108
2196
  const FractionInlineSchema = zod.z.object({
2109
2197
  type: zod.z.literal(AST_TYPES.FRACTION),
2110
2198
  name: zod.z.string(),
@@ -2218,6 +2306,7 @@ const InlineNodeSchema = zod.z.union([
2218
2306
  ColorTextInlineSchema,
2219
2307
  BoldTextInlineSchema,
2220
2308
  InlineInputWidgetSchema,
2309
+ DropInputSchema,
2221
2310
  FractionInlineSchema,
2222
2311
  ImageInlineSchema,
2223
2312
  VisualFractionInlineSchema,
@@ -2257,7 +2346,7 @@ function createBoldText(value, highlight) {
2257
2346
  highlight
2258
2347
  });
2259
2348
  }
2260
- function createStringInput(name, length, answer, placeholder, disabled, width) {
2349
+ function createStringInput(name, length, answer, placeholder, disabled, width, prefill) {
2261
2350
  return parseOrThrow(
2262
2351
  InlineInputWidgetSchema,
2263
2352
  {
@@ -2268,13 +2357,14 @@ function createStringInput(name, length, answer, placeholder, disabled, width) {
2268
2357
  width,
2269
2358
  placeholder,
2270
2359
  disabled,
2360
+ prefill,
2271
2361
  // A disabled input is never graded, so its answer is dropped.
2272
2362
  answer: disabled ? void 0 : answer
2273
2363
  },
2274
2364
  "STRING_INPUT"
2275
2365
  );
2276
2366
  }
2277
- function createNumericInput(name, digits, answer, placeholder, disabled, width) {
2367
+ function createNumericInput(name, digits, answer, placeholder, disabled, width, prefill) {
2278
2368
  return parseOrThrow(
2279
2369
  InlineInputWidgetSchema,
2280
2370
  {
@@ -2285,12 +2375,26 @@ function createNumericInput(name, digits, answer, placeholder, disabled, width)
2285
2375
  width,
2286
2376
  placeholder,
2287
2377
  disabled,
2378
+ prefill,
2288
2379
  // A disabled input is never graded, so its answer is dropped.
2289
2380
  answer: disabled ? void 0 : answer
2290
2381
  },
2291
2382
  "NUMERIC_INPUT"
2292
2383
  );
2293
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
+ }
2294
2398
  function createImage(name, src, alt, options = {}) {
2295
2399
  return ImageInlineSchema.parse({
2296
2400
  type: AST_TYPES.IMAGE,
@@ -2345,6 +2449,9 @@ function isSpace(value) {
2345
2449
  function isInlineInput(value) {
2346
2450
  return InlineInputWidgetSchema.safeParse(value).success;
2347
2451
  }
2452
+ function isDropInput(value) {
2453
+ return DropInputSchema.safeParse(value).success;
2454
+ }
2348
2455
  function isFraction(value) {
2349
2456
  return FractionInlineSchema.safeParse(value).success;
2350
2457
  }
@@ -2399,7 +2506,7 @@ function createNumberLinePosition(value, inlines) {
2399
2506
  if (!hasRealContent) {
2400
2507
  throw new Error(`POSITION[value=${value}]: content cannot be empty or whitespace only`);
2401
2508
  }
2402
- 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);
2403
2510
  if (inputs.length > 1) {
2404
2511
  throw new Error(
2405
2512
  `POSITION[value=${value}]: only one input widget is allowed, got ${inputs.length}`
@@ -3112,6 +3219,45 @@ const DragOptionsToolSchema = zod.z.object({
3112
3219
  type: zod.z.literal(AST_TYPES.DRAG_OPTIONS),
3113
3220
  options: zod.z.array(zod.z.string()).min(1)
3114
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
+ });
3115
3261
  const NumericKeypadToolSchema = zod.z.object({
3116
3262
  type: zod.z.literal(AST_TYPES.NUMERIC_KEYPAD),
3117
3263
  includeOperators: zod.z.boolean().optional(),
@@ -3132,6 +3278,7 @@ const SetSquareToolSchema = zod.z.object({
3132
3278
  });
3133
3279
  const ToolNodeSchema = zod.z.discriminatedUnion("type", [
3134
3280
  DragOptionsToolSchema,
3281
+ DraggableOptionsToolObjectSchema,
3135
3282
  NumericKeypadToolSchema,
3136
3283
  RulerToolSchema,
3137
3284
  ProtractorToolSchema,
@@ -3143,6 +3290,19 @@ function createDragOptions(options) {
3143
3290
  options
3144
3291
  });
3145
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
+ }
3146
3306
  function createNumericKeypad(includeOperators, includeDecimal, style) {
3147
3307
  return NumericKeypadToolSchema.parse({
3148
3308
  type: AST_TYPES.NUMERIC_KEYPAD,
@@ -3163,6 +3323,9 @@ function createSetSquareTool(size) {
3163
3323
  function isDragOptions(value) {
3164
3324
  return DragOptionsToolSchema.safeParse(value).success;
3165
3325
  }
3326
+ function isDraggableOptions(value) {
3327
+ return DraggableOptionsToolSchema.safeParse(value).success;
3328
+ }
3166
3329
  function isNumericKeypad(value) {
3167
3330
  return NumericKeypadToolSchema.safeParse(value).success;
3168
3331
  }
@@ -3721,9 +3884,13 @@ const ExerciseDocumentSchema = zod.z.object({
3721
3884
  seen.add(name);
3722
3885
  }
3723
3886
  };
3887
+ const dropInputs = [];
3724
3888
  const visitInlines = (inlines, basePath) => {
3725
3889
  inlines.forEach((inline, i) => {
3726
- 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) {
3727
3894
  record(inline.name, [...basePath, i, "name"]);
3728
3895
  } else if (inline.type === AST_TYPES.CLOCK && inline.mode === "interactive") {
3729
3896
  record(inline.name, [...basePath, i, "name"]);
@@ -3740,9 +3907,13 @@ const ExerciseDocumentSchema = zod.z.object({
3740
3907
  });
3741
3908
  });
3742
3909
  } else if (block.type === AST_TYPES.NUMBER_LINE_BLOCK) {
3743
- block.positions.forEach((pos, pi) => {
3910
+ const numberLine = block;
3911
+ numberLine.positions.forEach((pos, pi) => {
3744
3912
  visitInlines(pos.inlines, ["lines", bi, "positions", pi, "inlines"]);
3745
3913
  });
3914
+ numberLine.arrows?.forEach((arrow, ai) => {
3915
+ visitInlines(arrow.inlines, ["lines", bi, "arrows", ai, "inlines"]);
3916
+ });
3746
3917
  } else if (block.type === AST_TYPES.MATCHER_BLOCK) {
3747
3918
  const m = block;
3748
3919
  record(m.name, ["lines", bi, "name"]);
@@ -3785,15 +3956,78 @@ const ExerciseDocumentSchema = zod.z.object({
3785
3956
  record(block.name, ["lines", bi, "name"]);
3786
3957
  }
3787
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
+ }
3788
4016
  });
3789
4017
  function createExerciseDocument(question, lines, validation2, tools) {
3790
- return ExerciseDocumentSchema.parse({
4018
+ const result = ExerciseDocumentSchema.safeParse({
3791
4019
  type: AST_TYPES.EXERCISE_DOCUMENT,
3792
4020
  question,
3793
4021
  lines,
3794
4022
  tools,
3795
4023
  validation: validation2
3796
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;
3797
4031
  }
3798
4032
  function isExerciseDocument(value) {
3799
4033
  return ExerciseDocumentSchema.safeParse(value).success;
@@ -4193,6 +4427,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4193
4427
  if (ctx.inlineInput) {
4194
4428
  return this.visit(ctx.inlineInput);
4195
4429
  }
4430
+ if (ctx.dropInput) {
4431
+ return this.visit(ctx.dropInput);
4432
+ }
4196
4433
  if (ctx.inlineDragOption) {
4197
4434
  return this.visit(ctx.inlineDragOption);
4198
4435
  }
@@ -4324,6 +4561,7 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4324
4561
  const name = params.name;
4325
4562
  const answer = params.answer;
4326
4563
  const placeholder = params.placeholder;
4564
+ const prefill = params.prefill === void 0 ? void 0 : String(params.prefill);
4327
4565
  let disabled;
4328
4566
  if (params.disabled === true || params.disabled === "true") disabled = true;
4329
4567
  else if (params.disabled === false || params.disabled === "false") disabled = false;
@@ -4341,10 +4579,22 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4341
4579
  }
4342
4580
  if (ctx.stringInput) {
4343
4581
  const length = params.length;
4344
- return createStringInput(name, length, answer, placeholder, disabled, width);
4582
+ return createStringInput(name, length, answer, placeholder, disabled, width, prefill);
4345
4583
  }
4346
4584
  const digits = params.digits;
4347
- return createNumericInput(name, digits, answer, placeholder, disabled, width);
4585
+ return createNumericInput(name, digits, answer, placeholder, disabled, width, prefill);
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
+ );
4348
4598
  }
4349
4599
  inlineDragOption(ctx) {
4350
4600
  const valueToken = ctx.StringLiteral[0];
@@ -4484,6 +4734,9 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4484
4734
  // Tools
4485
4735
  // ═══════════════════════════════════════════════════════════════════════════
4486
4736
  tool(ctx) {
4737
+ if (ctx.draggableOptions) {
4738
+ return this.visit(ctx.draggableOptions);
4739
+ }
4487
4740
  if (ctx.dragableButtons) {
4488
4741
  return this.visit(ctx.dragableButtons);
4489
4742
  }
@@ -4501,6 +4754,33 @@ class NaturalDSLVisitor extends BaseCstVisitor {
4501
4754
  }
4502
4755
  throw new Error("Unknown tool");
4503
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
+ }
4504
4784
  dragableButtons(ctx) {
4505
4785
  const strings = this.visit(ctx.stringList);
4506
4786
  return createDragOptions(strings);
@@ -5088,7 +5368,9 @@ const StringInputJSONSchema = zod.z.object({
5088
5368
  width: InlineInputWidthSchema,
5089
5369
  placeholder: zod.z.string().max(32).optional(),
5090
5370
  /** Gray, non-interactive, never graded (any answer for it is ignored). */
5091
- disabled: zod.z.boolean().optional()
5371
+ disabled: zod.z.boolean().optional(),
5372
+ /** Starting value the student can edit (shown read-only when disabled). */
5373
+ prefill: zod.z.string().optional()
5092
5374
  });
5093
5375
  const NumericInputJSONSchema = zod.z.object({
5094
5376
  type: zod.z.literal(JSON_TYPES.INLINE_INPUT),
@@ -5098,16 +5380,38 @@ const NumericInputJSONSchema = zod.z.object({
5098
5380
  width: InlineInputWidthSchema,
5099
5381
  placeholder: zod.z.string().max(32).optional(),
5100
5382
  /** Gray, non-interactive, never graded (any answer for it is ignored). */
5101
- disabled: zod.z.boolean().optional()
5383
+ disabled: zod.z.boolean().optional(),
5384
+ /** Starting value the student can edit (shown read-only when disabled). */
5385
+ prefill: zod.z.string().optional()
5102
5386
  });
5103
5387
  const InlineInputJSONSchema = zod.z.discriminatedUnion("inputType", [
5104
5388
  StringInputJSONSchema,
5105
5389
  NumericInputJSONSchema
5106
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
+ });
5107
5397
  const DragOptionsJSONSchema = zod.z.object({
5108
5398
  type: zod.z.literal(JSON_TYPES.DRAG_OPTIONS),
5109
5399
  options: zod.z.array(zod.z.string())
5110
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
+ });
5111
5415
  const InlineDragOptionJSONSchema = zod.z.object({
5112
5416
  type: zod.z.literal(JSON_TYPES.INLINE_DRAG_OPTION),
5113
5417
  value: zod.z.string().min(1)
@@ -5217,7 +5521,9 @@ const GeometryJSONSchema = zod.z.object({
5217
5521
  });
5218
5522
  const WidgetJSONSchema = zod.z.union([
5219
5523
  InlineInputJSONSchema,
5524
+ DropInputJSONSchema,
5220
5525
  DragOptionsJSONSchema,
5526
+ DraggableOptionsJSONSchema,
5221
5527
  InlineDragOptionJSONSchema,
5222
5528
  RadioOptionsJSONSchema,
5223
5529
  NumericKeypadJSONSchema,
@@ -5334,9 +5640,15 @@ function isInlineInputJSON(widget) {
5334
5640
  function isDisabledInputJSON(widget) {
5335
5641
  return isInlineInputJSON(widget) && widget.disabled === true;
5336
5642
  }
5643
+ function isDropInputJSON(widget) {
5644
+ return widget.type === JSON_TYPES.DROP_INPUT;
5645
+ }
5337
5646
  function isDragOptionsJSON(widget) {
5338
5647
  return widget.type === JSON_TYPES.DRAG_OPTIONS;
5339
5648
  }
5649
+ function isDraggableOptionsJSON(widget) {
5650
+ return widget.type === JSON_TYPES.DRAGGABLE_OPTIONS;
5651
+ }
5340
5652
  function isInlineDragOptionJSON(widget) {
5341
5653
  return widget.type === JSON_TYPES.INLINE_DRAG_OPTION;
5342
5654
  }
@@ -10870,7 +11182,16 @@ function toJSON(ast) {
10870
11182
  const answers = {};
10871
11183
  let gridSymmetryCounter = 0;
10872
11184
  let geometryCounter = 0;
10873
- 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
+ )));
10874
11195
  const inlineDragIds = /* @__PURE__ */ new Map();
10875
11196
  {
10876
11197
  let counter = 0;
@@ -10993,8 +11314,10 @@ function toJSON(ast) {
10993
11314
  layout.push(...lineItems);
10994
11315
  }
10995
11316
  }
11317
+ const hasDragBank = ast.tools?.some((tool) => tool.type === AST_TYPES.DRAG_OPTIONS) ?? false;
10996
11318
  for (const inline of allInlines) {
10997
11319
  if (inline.type === AST_TYPES.INLINE_INPUT) {
11320
+ const disabled = inline.disabled || hasDragBank && !!inline.prefill;
10998
11321
  if (inline.inputType === INPUT_TYPES.STRING) {
10999
11322
  widgets[inline.name] = {
11000
11323
  type: JSON_TYPES.INLINE_INPUT,
@@ -11002,7 +11325,8 @@ function toJSON(ast) {
11002
11325
  length: inline.length,
11003
11326
  ...inline.width !== void 0 && { width: inline.width },
11004
11327
  placeholder: inline.placeholder,
11005
- ...inline.disabled && { disabled: true }
11328
+ ...disabled && { disabled: true },
11329
+ ...inline.prefill !== void 0 && { prefill: inline.prefill }
11006
11330
  };
11007
11331
  } else {
11008
11332
  widgets[inline.name] = {
@@ -11011,12 +11335,20 @@ function toJSON(ast) {
11011
11335
  digits: inline.digits,
11012
11336
  ...inline.width !== void 0 && { width: inline.width },
11013
11337
  placeholder: inline.placeholder,
11014
- ...inline.disabled && { disabled: true }
11338
+ ...disabled && { disabled: true },
11339
+ ...inline.prefill !== void 0 && { prefill: inline.prefill }
11015
11340
  };
11016
11341
  }
11017
- if (inline.answer !== void 0) {
11342
+ if (inline.answer !== void 0 && !disabled) {
11018
11343
  answers[inline.name] = inline.answer;
11019
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;
11020
11352
  } else if (inline.type === AST_TYPES.CLOCK && inline.mode === "interactive") {
11021
11353
  answers[inline.name] = inline.answer;
11022
11354
  }
@@ -11028,7 +11360,7 @@ function toJSON(ast) {
11028
11360
  simplified: inline.simplified ?? true,
11029
11361
  placeholder: inline.placeholder
11030
11362
  };
11031
- if (inline.answer !== void 0) {
11363
+ if (inline.answer !== void 0 && !matcherCardInlines.has(inline)) {
11032
11364
  answers[inline.name] = inline.answer;
11033
11365
  }
11034
11366
  }
@@ -11104,6 +11436,15 @@ function toJSON(ast) {
11104
11436
  // Randomise chip order per compile so the answer position isn't fixed.
11105
11437
  options: lodashExports.shuffle(tool.options)
11106
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
+ };
11107
11448
  } else if (tool.type === AST_TYPES.NUMERIC_KEYPAD) {
11108
11449
  widgets[NUMERIC_KEYPAD_WIDGET_ID] = {
11109
11450
  type: JSON_TYPES.NUMERIC_KEYPAD,
@@ -11569,6 +11910,12 @@ function transformInline(inline, idGenerator, inlineDragIds) {
11569
11910
  type: JSON_TYPES.WIDGET_REF,
11570
11911
  widgetId: inline.name
11571
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
+ };
11572
11919
  case AST_TYPES.FRACTION:
11573
11920
  return {
11574
11921
  id: idGenerator.generate(JSON_TYPES.WIDGET_REF),
@@ -11625,6 +11972,7 @@ function transformValidation(validation2, answers, widgets) {
11625
11972
  };
11626
11973
  } else if (validation2.type === AST_TYPES.EXPRESSION_VALIDATION) {
11627
11974
  assertNoDisabledInExpression(validation2.expression, widgets);
11975
+ assertNoDropInputs(Object.keys(widgets), widgets);
11628
11976
  const validVariableNames = getEnabledWidgetNames(widgets);
11629
11977
  const variables = extractVariables$1(validation2.expression, validVariableNames);
11630
11978
  return {
@@ -11634,6 +11982,7 @@ function transformValidation(validation2, answers, widgets) {
11634
11982
  };
11635
11983
  } else if (validation2.type === AST_TYPES.MIXED_VALIDATION) {
11636
11984
  assertNoDisabledInExpression(validation2.expression, widgets);
11985
+ assertNoDropInputs(extractVariables$1(validation2.expression, Object.keys(widgets)), widgets);
11637
11986
  const validVariableNames = getEnabledWidgetNames(widgets);
11638
11987
  const variables = extractVariables$1(validation2.expression, validVariableNames);
11639
11988
  const inExpression = new Set(variables);
@@ -11672,6 +12021,14 @@ function assertNoDisabledInExpression(expression, widgets) {
11672
12021
  );
11673
12022
  }
11674
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
+ }
11675
12032
  function getEnabledWidgetNames(widgets) {
11676
12033
  return Object.entries(widgets).filter(([, widget]) => !isDisabledInputJSON(widget)).map(([name]) => name);
11677
12034
  }
@@ -12240,7 +12597,20 @@ function revealGeometryWidget(json) {
12240
12597
  if (!host.revealAnswer(store, loaded.data).success) return null;
12241
12598
  return validation.serializeGeometryState(store.getState().geometry);
12242
12599
  }
12600
+ function withDisabledPrefilledInputs(exercise) {
12601
+ if (exercise.widgets[DRAG_OPTIONS_WIDGET_ID]?.type !== JSON_TYPES.DRAG_OPTIONS) return exercise;
12602
+ const widgets = { ...exercise.widgets };
12603
+ let changed = false;
12604
+ for (const [id, widget] of Object.entries(widgets)) {
12605
+ if (widget.type === JSON_TYPES.INLINE_INPUT && widget.prefill && !widget.disabled) {
12606
+ widgets[id] = { ...widget, disabled: true };
12607
+ changed = true;
12608
+ }
12609
+ }
12610
+ return changed ? { ...exercise, widgets } : exercise;
12611
+ }
12243
12612
  function withoutDisabledWidgets(exercise) {
12613
+ exercise = withDisabledPrefilledInputs(exercise);
12244
12614
  const validation2 = exercise.validation;
12245
12615
  if (!validation2) return exercise;
12246
12616
  const disabled = new Set(
@@ -12263,6 +12633,7 @@ function withoutDisabledWidgets(exercise) {
12263
12633
  };
12264
12634
  }
12265
12635
  function findDisabledInExpression(exercise) {
12636
+ exercise = withDisabledPrefilledInputs(exercise);
12266
12637
  const expression = exercise.validation?.expression;
12267
12638
  if (!expression) return [];
12268
12639
  return extractVariables(expression, Object.keys(exercise.widgets)).filter((name) => exercise.widgets[name] && isDisabledInputJSON(exercise.widgets[name]));
@@ -12404,11 +12775,13 @@ function getMissingInputs(exercise, userInputs) {
12404
12775
  }
12405
12776
  function validateInputFormats(exercise, userInputs) {
12406
12777
  const errors = {};
12407
- for (const [widgetId, widget] of Object.entries(exercise.widgets)) {
12778
+ const { widgets } = withDisabledPrefilledInputs(exercise);
12779
+ for (const [widgetId, widget] of Object.entries(widgets)) {
12408
12780
  const value = userInputs[widgetId];
12409
12781
  if (value === void 0 || value === "") {
12410
12782
  continue;
12411
12783
  }
12784
+ if (isDisabledInputJSON(widget)) continue;
12412
12785
  if (widget.type === JSON_TYPES.INLINE_INPUT && widget.inputType === INPUT_TYPES.NUMERIC) {
12413
12786
  const numValue = typeof value === "number" ? value : parseFloat(String(value));
12414
12787
  if (!Number.isFinite(numValue)) {
@@ -12426,6 +12799,8 @@ function validateInputFormats(exercise, userInputs) {
12426
12799
  const DragItemTypeSchema = zod.z.enum([
12427
12800
  "drag-option",
12428
12801
  // Draggable answer options
12802
+ "rich-drag-option",
12803
+ // Rich option from DRAGGABLE_OPTIONS or DROP_INPUT
12429
12804
  "placed-chip",
12430
12805
  // Chip already placed on a number-line axis (re-drag)
12431
12806
  "ruler-handle",
@@ -12439,6 +12814,16 @@ const DragOptionDataSchema = zod.z.object({
12439
12814
  value: zod.z.string(),
12440
12815
  label: zod.z.string()
12441
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
+ });
12442
12827
  const DragItemSchema = (dataSchema) => zod.z.object({
12443
12828
  id: zod.z.union([zod.z.string(), zod.z.number()]),
12444
12829
  // UniqueIdentifier from dnd-kit
@@ -12446,6 +12831,7 @@ const DragItemSchema = (dataSchema) => zod.z.object({
12446
12831
  data: dataSchema
12447
12832
  });
12448
12833
  DragItemSchema(DragOptionDataSchema);
12834
+ DragItemSchema(RichDragOptionDataSchema);
12449
12835
  zod.z.object({
12450
12836
  targetId: zod.z.string(),
12451
12837
  item: zod.z.discriminatedUnion("type", [
@@ -16226,12 +16612,16 @@ function useExerciseDnd() {
16226
16612
  }
16227
16613
  return ctx;
16228
16614
  }
16615
+ function useExerciseDndOptional() {
16616
+ return React.useContext(ExerciseDndContext);
16617
+ }
16229
16618
  const NO_EXERCISE = Symbol("no-exercise");
16230
16619
  const ExerciseDndProvider = ({
16231
16620
  children,
16232
16621
  droppedSources,
16233
16622
  onDragEnd,
16234
- exerciseKey
16623
+ exerciseKey,
16624
+ renderOverlay
16235
16625
  }) => {
16236
16626
  const [activeItem, setActiveItem] = React.useState(null);
16237
16627
  const [draggedExerciseKey, setDraggedExerciseKey] = React.useState(NO_EXERCISE);
@@ -16267,7 +16657,9 @@ const ExerciseDndProvider = ({
16267
16657
  const raw = active.data.current;
16268
16658
  const item = raw?.type === "placed-chip" ? { type: "placed-chip", widgetId: raw.widgetId, label: raw.label, sourceOptionId: raw.sourceOptionId } : raw;
16269
16659
  setActiveItem(item);
16270
- if (item.type === "drag-option") setDraggedExerciseKey(exerciseKey);
16660
+ if (item.type === "drag-option" || item.type === "rich-drag-option") {
16661
+ setDraggedExerciseKey(exerciseKey);
16662
+ }
16271
16663
  playSound("drag-start");
16272
16664
  };
16273
16665
  const handleDragEnd = (event) => {
@@ -16310,14 +16702,16 @@ const ExerciseDndProvider = ({
16310
16702
  onDragCancel: handleDragCancel,
16311
16703
  children: [
16312
16704
  children,
16313
- /* @__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 }) })
16314
16706
  ]
16315
16707
  }
16316
16708
  )
16317
16709
  }
16318
16710
  );
16319
16711
  };
16320
- 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 });
16321
16715
  switch (item.type) {
16322
16716
  case "drag-option": {
16323
16717
  const data = item.data;
@@ -16336,22 +16730,22 @@ const Draggable = ({
16336
16730
  data,
16337
16731
  disabled = false,
16338
16732
  children,
16339
- className = ""
16733
+ className = "",
16734
+ behavior = "move"
16340
16735
  }) => {
16341
16736
  const dragItem = { id, type, data };
16342
16737
  const {
16343
16738
  attributes,
16344
16739
  listeners,
16345
16740
  setNodeRef,
16346
- transform,
16347
16741
  isDragging
16348
16742
  } = useDraggable({
16349
16743
  id,
16350
16744
  data: dragItem,
16351
16745
  disabled
16352
16746
  });
16747
+ const isCopy = behavior === "copy";
16353
16748
  const style = {
16354
- transform: CSS$1.Translate.toString(transform),
16355
16749
  cursor: disabled ? "default" : "grab",
16356
16750
  touchAction: "none"
16357
16751
  };
@@ -16360,7 +16754,7 @@ const Draggable = ({
16360
16754
  {
16361
16755
  ref: setNodeRef,
16362
16756
  style,
16363
- className: `draggable ${isDragging ? "dragging" : ""} ${className}`,
16757
+ className: `draggable ${isDragging && !isCopy ? "dragging" : ""} ${className}`,
16364
16758
  ...listeners,
16365
16759
  ...attributes,
16366
16760
  children
@@ -17102,6 +17496,16 @@ const SlotSizer = ({
17102
17496
  style: { ...style, visibility: "hidden" }
17103
17497
  }
17104
17498
  );
17499
+ const FixedInputChip = ({ value }) => /* @__PURE__ */ jsxRuntime.jsx(
17500
+ "button",
17501
+ {
17502
+ type: "button",
17503
+ className: "inline-input-chip",
17504
+ disabled: true,
17505
+ style: OVERLAY_STYLE,
17506
+ children: value
17507
+ }
17508
+ );
17105
17509
  const PlacedInputChip = ({
17106
17510
  widgetId,
17107
17511
  value,
@@ -17142,6 +17546,9 @@ const PlacedInputChip = ({
17142
17546
  }
17143
17547
  );
17144
17548
  };
17549
+ function getInputBoxWidth(chars) {
17550
+ return `${chars * 0.8 + 1}em`;
17551
+ }
17145
17552
  function getMaxInputLength(exercise, inputKey) {
17146
17553
  const widget = exercise.widgets[inputKey];
17147
17554
  if (widget?.type !== JSON_TYPES.INLINE_INPUT) return void 0;
@@ -17171,6 +17578,8 @@ function findNextInput(container, currentKey) {
17171
17578
  }
17172
17579
  const KeypadContext = React.createContext(false);
17173
17580
  const useHasKeypad = () => React.useContext(KeypadContext);
17581
+ const DragBankContext = React.createContext(false);
17582
+ const useHasDragBank = () => React.useContext(DragBankContext);
17174
17583
  const InlineInput = ({
17175
17584
  widgetId,
17176
17585
  value,
@@ -17188,6 +17597,7 @@ const InlineInput = ({
17188
17597
  const playSound = useSound();
17189
17598
  const locked = useExerciseLocked();
17190
17599
  const hasKeypad = useHasKeypad();
17600
+ const hasDragBank = useHasDragBank();
17191
17601
  const handleChange = React.useCallback((newValue, fromDrop = false, sourceOptionId) => {
17192
17602
  onChange(widgetId, newValue, fromDrop, sourceOptionId);
17193
17603
  }, [onChange, widgetId]);
@@ -17195,9 +17605,10 @@ const InlineInput = ({
17195
17605
  return applyDropToInput(widgetId, item, onChange);
17196
17606
  }, [onChange, widgetId]);
17197
17607
  const expectedLength = inputType === INPUT_TYPES.NUMERIC ? digits : length;
17198
- const dynamicWidth = `${(width ?? expectedLength ?? 5) * 0.8 + 1}em`;
17608
+ const dynamicWidth = getInputBoxWidth(width ?? expectedLength ?? 5);
17199
17609
  const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
17200
17610
  const disabledClass = disabled ? " inline-input--disabled" : "";
17611
+ const isFixedChip = hasDragBank && disabled && value !== "";
17201
17612
  return /* @__PURE__ */ jsxRuntime.jsx(
17202
17613
  Droppable,
17203
17614
  {
@@ -17206,7 +17617,10 @@ const InlineInput = ({
17206
17617
  onDrop: handleDrop,
17207
17618
  disabled: locked || disabled,
17208
17619
  className: "inline-widget inline-input-droppable",
17209
- children: isDropped && value !== "" ? (
17620
+ children: isFixedChip ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
17621
+ /* @__PURE__ */ jsxRuntime.jsx(SlotSizer, { className: "inline-input", style: { width: dynamicWidth }, placeholder }),
17622
+ /* @__PURE__ */ jsxRuntime.jsx(FixedInputChip, { value })
17623
+ ] }) : isDropped && value !== "" ? (
17210
17624
  // Show as chip only when value came from drag-drop
17211
17625
  /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
17212
17626
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -17263,11 +17677,13 @@ const DragOptionChip = ({
17263
17677
  onOptionClick,
17264
17678
  className = ""
17265
17679
  }) => {
17266
- const { usedOptionIds, hasDraggedOption, playHintSound } = useExerciseDnd();
17680
+ const { activeItem, usedOptionIds, hasDraggedOption, playHintSound } = useExerciseDnd();
17267
17681
  const locked = useExerciseLocked();
17268
17682
  const isUsed = usedOptionIds.has(optionId);
17683
+ const isPickedUp = activeItem?.type === "drag-option" && activeItem.id === optionId;
17269
17684
  const { jiggling: hinting, onAnimationEnd: onHintEnd } = useJiggle(hasDraggedOption);
17270
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `drag-option-slot ${className}`.trim(), children: isUsed ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-empty", "aria-hidden": "true" }) : /* @__PURE__ */ jsxRuntime.jsx(
17685
+ const emptySlot = /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-option-empty", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx("button", { className: "drag-option", disabled: true, children: value }) });
17686
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: `drag-option-slot ${className}`.trim(), children: isUsed ? emptySlot : /* @__PURE__ */ jsxRuntime.jsx(
17271
17687
  Draggable,
17272
17688
  {
17273
17689
  id: optionId,
@@ -17275,7 +17691,7 @@ const DragOptionChip = ({
17275
17691
  data: { value, label: value },
17276
17692
  disabled: locked,
17277
17693
  className: "drag-option-wrapper",
17278
- children: /* @__PURE__ */ jsxRuntime.jsx(
17694
+ children: isPickedUp ? emptySlot : /* @__PURE__ */ jsxRuntime.jsx(
17279
17695
  "button",
17280
17696
  {
17281
17697
  className: hinting && !locked ? "drag-option drag-option--hint" : "drag-option",
@@ -17393,100 +17809,476 @@ const InlineDragOption = ({
17393
17809
  }
17394
17810
  ) });
17395
17811
  };
17396
- const NumericKeypad = ({
17397
- value,
17398
- includeOperators = false,
17399
- includeDecimal = false,
17400
- onChange
17401
- }) => {
17402
- const playSound = useSound();
17403
- const locked = useExerciseLocked();
17404
- const handleDigit = (digit) => {
17405
- if (locked) return;
17406
- playSound("keypad-press");
17407
- onChange(value + digit);
17408
- };
17409
- const handleBackspace = () => {
17410
- if (locked) return;
17411
- playSound("keypad-press");
17412
- onChange(value.slice(0, -1));
17413
- };
17414
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `numeric-keypad ${includeOperators ? "numeric-keypad-with-operators" : ""}`, "data-tutorial": "keypad", children: [
17415
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "1", onClick: () => handleDigit("1"), children: "1" }),
17416
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "2", onClick: () => handleDigit("2"), children: "2" }),
17417
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "3", onClick: () => handleDigit("3"), children: "3" }),
17418
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "+", onClick: () => handleDigit("+"), children: "+" }),
17419
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "4", onClick: () => handleDigit("4"), children: "4" }),
17420
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "5", onClick: () => handleDigit("5"), children: "5" }),
17421
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "6", onClick: () => handleDigit("6"), children: "6" }),
17422
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "-", onClick: () => handleDigit("-"), children: "-" }),
17423
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "7", onClick: () => handleDigit("7"), children: "7" }),
17424
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "8", onClick: () => handleDigit("8"), children: "8" }),
17425
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "9", onClick: () => handleDigit("9"), children: "9" }),
17426
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "×", onClick: () => handleDigit("×"), children: "×" }),
17427
- includeDecimal ? /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-decimal", "data-key": ",", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsxRuntime.jsx("div", {}),
17428
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", "data-key": "0", onClick: () => handleDigit("0"), children: "0" }),
17429
- /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-backspace", "data-key": "backspace", onClick: handleBackspace, children: "⌫" }),
17430
- includeOperators && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", "data-key": "÷", onClick: () => handleDigit("÷"), children: "÷" })
17431
- ] });
17432
- };
17433
- const FractionInput = ({
17434
- widgetId,
17435
- numerator,
17436
- denominator,
17437
- values,
17438
- droppedInputs,
17439
- validationStatus = null,
17440
- numValidationStatus = null,
17441
- denValidationStatus = null,
17442
- onChange,
17443
- onFocus,
17444
- placeholder
17445
- }) => {
17446
- const playSound = useSound();
17447
- const locked = useExerciseLocked();
17448
- const hasKeypad = useHasKeypad();
17449
- const numKey = getFractionKey(widgetId, "num");
17450
- const denKey = getFractionKey(widgetId, "den");
17451
- const numValue = values[numKey] ?? "";
17452
- const denValue = values[denKey] ?? "";
17453
- const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
17454
- const handleDrop = React.useCallback((subKey) => (item) => {
17455
- return applyDropToInput(subKey, item, onChange);
17456
- }, [onChange]);
17457
- const renderSlot = (subKey, fixedValue, currentValue, label, slotStatus) => {
17458
- if (fixedValue !== null) {
17459
- return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-fixed", children: fixedValue });
17460
- }
17461
- const isDroppedHere = droppedInputs.has(subKey);
17462
- const slotClass = slotStatus ? ` widget--${slotStatus}` : statusClass;
17463
- return /* @__PURE__ */ jsxRuntime.jsx(
17464
- Droppable,
17465
- {
17466
- id: subKey,
17467
- accepts: ["drag-option", "placed-chip"],
17468
- onDrop: handleDrop(subKey),
17469
- disabled: locked,
17470
- className: "fraction-slot-droppable",
17471
- children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
17472
- /* @__PURE__ */ jsxRuntime.jsx(
17473
- SlotSizer,
17474
- {
17475
- className: "inline-input fraction-input",
17476
- size: placeholder ? placeholder.length : void 0,
17477
- placeholder
17478
- }
17479
- ),
17480
- /* @__PURE__ */ jsxRuntime.jsx(
17481
- PlacedInputChip,
17482
- {
17483
- widgetId: subKey,
17484
- value: currentValue,
17485
- className: `fraction-chip${slotClass}`,
17486
- onRemove: () => onChange(subKey, "", false)
17487
- }
17488
- )
17489
- ] }) : /* @__PURE__ */ jsxRuntime.jsx(
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
+ ] });
18224
+ };
18225
+ const FractionInput = ({
18226
+ widgetId,
18227
+ numerator,
18228
+ denominator,
18229
+ values,
18230
+ droppedInputs,
18231
+ validationStatus = null,
18232
+ numValidationStatus = null,
18233
+ denValidationStatus = null,
18234
+ onChange,
18235
+ onFocus,
18236
+ placeholder
18237
+ }) => {
18238
+ const playSound = useSound();
18239
+ const locked = useExerciseLocked();
18240
+ const hasKeypad = useHasKeypad();
18241
+ const numKey = getFractionKey(widgetId, "num");
18242
+ const denKey = getFractionKey(widgetId, "den");
18243
+ const numValue = values[numKey] ?? "";
18244
+ const denValue = values[denKey] ?? "";
18245
+ const statusClass = validationStatus ? ` widget--${validationStatus}` : "";
18246
+ const handleDrop = React.useCallback((subKey) => (item) => {
18247
+ return applyDropToInput(subKey, item, onChange);
18248
+ }, [onChange]);
18249
+ const renderSlot = (subKey, fixedValue, currentValue, label, slotStatus) => {
18250
+ if (fixedValue !== null) {
18251
+ return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "fraction-fixed", children: fixedValue });
18252
+ }
18253
+ const isDroppedHere = droppedInputs.has(subKey);
18254
+ const slotClass = slotStatus ? ` widget--${slotStatus}` : statusClass;
18255
+ return /* @__PURE__ */ jsxRuntime.jsx(
18256
+ Droppable,
18257
+ {
18258
+ id: subKey,
18259
+ accepts: ["drag-option", "placed-chip"],
18260
+ onDrop: handleDrop(subKey),
18261
+ disabled: locked,
18262
+ className: "fraction-slot-droppable",
18263
+ children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
18264
+ /* @__PURE__ */ jsxRuntime.jsx(
18265
+ SlotSizer,
18266
+ {
18267
+ className: "inline-input fraction-input",
18268
+ size: placeholder ? placeholder.length : void 0,
18269
+ placeholder
18270
+ }
18271
+ ),
18272
+ /* @__PURE__ */ jsxRuntime.jsx(
18273
+ PlacedInputChip,
18274
+ {
18275
+ widgetId: subKey,
18276
+ value: currentValue,
18277
+ className: `fraction-chip${slotClass}`,
18278
+ onRemove: () => onChange(subKey, "", false)
18279
+ }
18280
+ )
18281
+ ] }) : /* @__PURE__ */ jsxRuntime.jsx(
17490
18282
  "input",
17491
18283
  {
17492
18284
  id: getInputElementId(subKey),
@@ -19231,41 +20023,6 @@ const MatcherWidget = ({
19231
20023
  );
19232
20024
  };
19233
20025
  MatcherWidget.displayName = "MatcherWidget";
19234
- function isArabic(text) {
19235
- return /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text);
19236
- }
19237
- function hasLatinChars(text) {
19238
- return /[A-Za-z]/.test(text);
19239
- }
19240
- function itemContainsArabic(item) {
19241
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
19242
- return isArabic(item.value);
19243
- }
19244
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
19245
- return isArabic(item.value);
19246
- }
19247
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
19248
- return item.items.some((subItem) => itemContainsArabic(subItem));
19249
- }
19250
- return false;
19251
- }
19252
- function itemContainsLatin(item) {
19253
- if (item.type === JSON_TYPES.TEXT && "value" in item) {
19254
- return hasLatinChars(item.value);
19255
- }
19256
- if (item.type === JSON_TYPES.HIGHLIGHTED_TEXT && "value" in item) {
19257
- return hasLatinChars(item.value);
19258
- }
19259
- if (item.type === JSON_TYPES.GROUP && "items" in item) {
19260
- return item.items.some((subItem) => itemContainsLatin(subItem));
19261
- }
19262
- return false;
19263
- }
19264
- function shouldItemsUseRTL(items) {
19265
- const hasArabic = items.some(itemContainsArabic);
19266
- const hasLatin = items.some(itemContainsLatin);
19267
- return hasArabic && !hasLatin;
19268
- }
19269
20026
  function usePairLines({
19270
20027
  containerRef,
19271
20028
  pairs,
@@ -19679,9 +20436,15 @@ PairMatcherWidget.displayName = "PairMatcherWidget";
19679
20436
  function isInlineInputWidget(widget) {
19680
20437
  return widget.type === "inline-input";
19681
20438
  }
20439
+ function isDropInputWidget(widget) {
20440
+ return widget.type === "drop-input";
20441
+ }
19682
20442
  function isDragOptionsWidget(widget) {
19683
20443
  return widget.type === "drag-options";
19684
20444
  }
20445
+ function isDraggableOptionsWidget(widget) {
20446
+ return widget.type === "draggable-options";
20447
+ }
19685
20448
  function isInlineDragOptionWidget(widget) {
19686
20449
  return widget.type === "inline-drag-option";
19687
20450
  }
@@ -19710,7 +20473,7 @@ function isGeometryWidget(widget) {
19710
20473
  return widget.type === JSON_TYPES.GEOMETRY;
19711
20474
  }
19712
20475
  const GeometryWidget = React.lazy(async () => ({
19713
- default: (await Promise.resolve().then(() => require("./GeometryWidget-CMJTNSAG.js"))).GeometryWidget
20476
+ default: (await Promise.resolve().then(() => require("./GeometryWidget-CTTLw871.js"))).GeometryWidget
19714
20477
  }));
19715
20478
  const WidgetRenderer = ({
19716
20479
  widget,
@@ -19748,6 +20511,23 @@ const WidgetRenderer = ({
19748
20511
  }
19749
20512
  );
19750
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
+ }
19751
20531
  if (isDragOptionsWidget(widget)) {
19752
20532
  return /* @__PURE__ */ jsxRuntime.jsx(
19753
20533
  DragOptions,
@@ -19758,6 +20538,25 @@ const WidgetRenderer = ({
19758
20538
  }
19759
20539
  );
19760
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
+ }
19761
20560
  if (isInlineDragOptionWidget(widget)) {
19762
20561
  return /* @__PURE__ */ jsxRuntime.jsx(
19763
20562
  InlineDragOption,
@@ -20107,67 +20906,6 @@ const ImageItem = ({
20107
20906
  overlay
20108
20907
  ] });
20109
20908
  };
20110
- const EPSILON = 10 ** -INTERNAL_PRECISION;
20111
- const cleanFloat = (n) => parseFloat(n.toFixed(INTERNAL_PRECISION));
20112
- const roundTick = (n) => parseFloat(n.toFixed(DISPLAY_PRECISION));
20113
- const almostEqual = (a, b) => Math.abs(a - b) <= EPSILON;
20114
- const NUMBER_LINE_AXIS_ENDS = {
20115
- "arrow-right": { left: "tick", right: "arrow" },
20116
- "arrow-left": { left: "arrow", right: "tick" },
20117
- "arrow-both": { left: "arrow", right: "arrow" },
20118
- "line": { left: "line", right: "line" },
20119
- "line-left": { left: "line", right: "arrow" }
20120
- };
20121
- const toPercent = (value, min, max) => {
20122
- const raw = SIDE_PAD + (value - min) / (max - min) * (100 - 2 * SIDE_PAD);
20123
- return Math.max(SIDE_PAD, Math.min(100 - SIDE_PAD, raw));
20124
- };
20125
- const pixelToValue = (chipCenterX, axisLeft, axisWidth, min, max) => {
20126
- const relX = (chipCenterX - axisLeft) / axisWidth;
20127
- const fraction = (relX - SIDE_PAD / 100) / (1 - 2 * SIDE_PAD / 100);
20128
- const clamped = Math.max(0, Math.min(1, fraction));
20129
- return min + clamped * (max - min);
20130
- };
20131
- const getMajorTicks = (min, max, step) => {
20132
- if (step <= 0 || max <= min) return [min, max];
20133
- const count = Math.floor((max - min) / step);
20134
- const ticks = Array.from({ length: count + 1 }, (_, i) => cleanFloat(min + i * step));
20135
- const lastTick = ticks[ticks.length - 1];
20136
- if (roundTick(lastTick) !== roundTick(max)) {
20137
- ticks.push(max);
20138
- } else {
20139
- ticks[ticks.length - 1] = max;
20140
- }
20141
- return ticks;
20142
- };
20143
- const getMinorTickValues = (majorTicks, minorTicks) => {
20144
- if (minorTicks <= 0 || majorTicks.length < 2) return [];
20145
- const result = [];
20146
- for (let i = 0; i < majorTicks.length - 1; i++) {
20147
- const from = majorTicks[i];
20148
- const to = majorTicks[i + 1];
20149
- const interval = to - from;
20150
- for (let j = 1; j <= minorTicks; j++) {
20151
- result.push(cleanFloat(from + j / (minorTicks + 1) * interval));
20152
- }
20153
- }
20154
- return result;
20155
- };
20156
- const getSnapTicks = (majorTicks, minorTicks) => [
20157
- ...majorTicks,
20158
- ...getMinorTickValues(majorTicks, minorTicks)
20159
- ];
20160
- const snapToNearest = (raw, ticks) => {
20161
- if (ticks.length === 0) return cleanFloat(raw);
20162
- return ticks.reduce((best, t) => Math.abs(t - raw) < Math.abs(best - raw) ? t : best);
20163
- };
20164
- const getPositionWidgetId = (content) => {
20165
- for (const item of content) {
20166
- if (isWidgetRef(item)) return item.widgetId;
20167
- }
20168
- return null;
20169
- };
20170
- const toHighlightColor = (color) => color.startsWith("#") ? color : `var(--nl-highlight-${color})`;
20171
20909
  const getBandGeometry = (h, min, max) => {
20172
20910
  const leftPct = toPercent(Math.min(h.from, h.to), min, max);
20173
20911
  const rightPct = toPercent(Math.max(h.from, h.to), min, max);
@@ -21624,63 +22362,6 @@ const ClockRenderer = ({
21624
22362
  format === "12h" && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "clock-period-badge", children: hours >= 12 ? "PM" : "AM" })
21625
22363
  ] });
21626
22364
  };
21627
- function isTextItem(item) {
21628
- 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;
21629
- }
21630
- function groupLineItems(items) {
21631
- const groups = [];
21632
- let currentTextGroup = [];
21633
- for (const item of items) {
21634
- if (isTextItem(item)) {
21635
- currentTextGroup.push(item);
21636
- } else {
21637
- if (currentTextGroup.length > 0) {
21638
- const hasArabic = currentTextGroup.some(itemContainsArabic);
21639
- const hasLatin = currentTextGroup.some(itemContainsLatin);
21640
- const shouldApplyRTL = hasArabic && !hasLatin;
21641
- groups.push({ items: currentTextGroup, isTextGroup: true, hasArabic, hasLatin, shouldApplyRTL });
21642
- currentTextGroup = [];
21643
- }
21644
- groups.push({ items: [item], isTextGroup: false, hasArabic: false, hasLatin: false, shouldApplyRTL: false });
21645
- }
21646
- }
21647
- if (currentTextGroup.length > 0) {
21648
- const hasArabic = currentTextGroup.some(itemContainsArabic);
21649
- const hasLatin = currentTextGroup.some(itemContainsLatin);
21650
- const shouldApplyRTL = hasArabic && !hasLatin;
21651
- groups.push({ items: currentTextGroup, isTextGroup: true, hasArabic, hasLatin, shouldApplyRTL });
21652
- }
21653
- return groups;
21654
- }
21655
- function placeTableCells(rows, cols) {
21656
- const placements = /* @__PURE__ */ new Map();
21657
- const occupied = rows.map(() => []);
21658
- const totalRows = rows.length;
21659
- rows.forEach((row, rowIndex) => {
21660
- const taken = occupied[rowIndex] ?? [];
21661
- let col = 0;
21662
- for (const cell of row.cells) {
21663
- while (taken[col]) col++;
21664
- const colspan = cell.colspan || 1;
21665
- const rowspan = Math.min(cell.rowspan || 1, totalRows - rowIndex);
21666
- for (let r = rowIndex; r < rowIndex + rowspan; r++) {
21667
- const line = occupied[r];
21668
- if (!line) continue;
21669
- for (let c = col; c < col + colspan; c++) line[c] = true;
21670
- }
21671
- placements.set(cell.id, {
21672
- column: col + 1,
21673
- row: rowIndex + 1,
21674
- colspan,
21675
- rowspan,
21676
- isLastColumn: col + colspan >= cols,
21677
- isLastRow: rowIndex + rowspan >= totalRows
21678
- });
21679
- col += colspan;
21680
- }
21681
- });
21682
- return placements;
21683
- }
21684
22365
  const toValidationStatus = (v) => v === true ? "correct" : v === false ? "incorrect" : null;
21685
22366
  const isNumericText = (value) => /^[\d.,\s]+$/.test(value);
21686
22367
  const numberClass = (value) => isNumericText(value) ? "text-item-number" : "";
@@ -22012,7 +22693,7 @@ const LayoutRenderer = ({
22012
22693
  return null;
22013
22694
  }
22014
22695
  };
22015
- function LionHand({ className }) {
22696
+ function ChameleonHand({ className }) {
22016
22697
  const id = React.useId();
22017
22698
  const shadowId = `${id}-shadow`;
22018
22699
  const maskId = `${id}-mask`;
@@ -22039,34 +22720,100 @@ function LionHand({ className }) {
22039
22720
  y: "19",
22040
22721
  width: "49",
22041
22722
  height: "42",
22042
- children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M49.7484 33.9668C58.158 45.6281 37.9572 61.4399 28.1745 60.1051C17.1397 58.5995 5.03802 42.2474 10.4349 38.3224C15.8318 34.3974 18.9715 32.4031 23.1181 30.1278C24.1486 29.5624 25.1742 29.0263 26.1947 28.5358C33.7314 24.764 52.8138 16.0308 57.0969 21.97C59.9201 25.8848 54.8651 28.9522 48.1097 31.8806C48.658 32.518 49.2042 33.2123 49.7484 33.9668Z", fill: "#FCD592" })
22723
+ children: /* @__PURE__ */ jsxRuntime.jsx(
22724
+ "path",
22725
+ {
22726
+ d: "M49.7484 33.9668C58.158 45.6281 37.9572 61.4399 28.1745 60.1051C17.1397 58.5995 5.03802 42.2474 10.4349 38.3224C15.8318 34.3974 18.9715 32.4031 23.1181 30.1278C24.1486 29.5624 25.1742 29.0263 26.1947 28.5358C33.7314 24.764 52.8138 16.0308 57.0969 21.97C59.9201 25.8848 54.8651 28.9522 48.1097 31.8806C48.658 32.518 49.2042 33.2123 49.7484 33.9668Z",
22727
+ fill: "#B7DB4E"
22728
+ }
22729
+ )
22043
22730
  }
22044
22731
  ),
22045
22732
  /* @__PURE__ */ jsxRuntime.jsxs("g", { mask: `url(#${maskId})`, children: [
22046
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M49.7484 33.9672C58.158 45.6285 38.3118 60.3151 32.1037 64.7332C22.6672 71.4486 2.68204 43.9969 12.2424 37.1926C15.1884 35.0959 18.9716 32.4035 23.1182 30.1282C24.7308 29.2433 26.3314 28.4303 27.9195 27.7514C36.611 23.6398 54.4269 16.1501 58.4471 21.7248C61.34 25.7362 55.5686 28.9294 48.2024 31.9893C48.7196 32.5968 49.2349 33.2552 49.7484 33.9672Z", fill: "#FCD592" }),
22047
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12.2426 37.1924C2.68223 43.9967 22.6674 71.4485 32.1039 64.733C31.3434 63.6785 26.4317 61.1668 25.3134 59.6161C24.1951 58.0654 22.794 50.5211 22.123 49.5907C21.4521 48.6603 16.3219 45.7409 15.5637 44.4032C14.8055 43.0655 13.8979 39.4877 14.7258 35.4253L12.2426 37.1924Z", fill: "#6B291E" }),
22048
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M50.4102 36.1737C50.6823 37.524 52.0035 38.4016 53.3614 38.1339C54.7192 37.8662 55.5994 36.5546 55.3274 35.2043L52.8688 35.689L50.4102 36.1737ZM38.8014 26.9523C37.4241 27.0934 36.425 28.3178 36.5697 29.6872C36.7144 31.0565 37.9482 32.0522 39.3255 31.9111L39.0634 29.4317L38.8014 26.9523ZM52.8688 35.689C55.3274 35.2043 55.327 35.2023 55.3266 35.2004C55.3265 35.1997 55.326 35.1977 55.3258 35.1963C55.3252 35.1935 55.3246 35.1906 55.324 35.1875C55.3227 35.1815 55.3213 35.1749 55.3198 35.1679C55.3168 35.1539 55.3133 35.1379 55.3092 35.1201C55.3011 35.0845 55.2907 35.0415 55.2779 34.9916C55.2522 34.8918 55.2163 34.764 55.1676 34.6127C55.0703 34.3106 54.9205 33.911 54.6961 33.4507C54.247 32.5296 53.4925 31.3535 52.2552 30.241C49.7078 27.9508 45.5021 26.2658 38.8014 26.9523L39.0634 29.4317L39.3255 31.9111C44.9159 31.3384 47.6128 32.782 48.8954 33.9352C49.5731 34.5445 49.9653 35.1687 50.1842 35.6177C50.2938 35.8424 50.3584 36.0201 50.3919 36.1242C50.4086 36.1759 50.4172 36.2084 50.4197 36.2181C50.4209 36.223 50.4206 36.222 50.419 36.2148C50.4181 36.2111 50.417 36.2059 50.4155 36.1991C50.4148 36.1957 50.414 36.1919 50.4131 36.1876C50.4127 36.1855 50.4122 36.1833 50.4117 36.181C50.4115 36.1798 50.4111 36.178 50.411 36.1774C50.4106 36.1755 50.4102 36.1737 52.8688 35.689Z", fill: "#A2634A" }),
22049
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M54.7526 47.3947C55.5602 48.5146 57.1269 48.7734 58.2518 47.9729C59.3767 47.1723 59.634 45.6155 58.8264 44.4956L56.7895 45.9452L54.7526 47.3947ZM38.1539 36.5617C36.7805 36.7357 35.8113 37.9837 35.9891 39.3493C36.1669 40.7148 37.4244 41.6808 38.7978 41.5068L38.4758 39.0342L38.1539 36.5617ZM56.7895 45.9452L58.8264 44.4956C57.4277 42.5562 55.3182 40.1317 51.9378 38.4204C48.5385 36.6995 44.0772 35.8112 38.1539 36.5617L38.4758 39.0342L38.7978 41.5068C43.8796 40.863 47.3003 41.6624 49.6691 42.8616C52.0569 44.0704 53.6015 45.7985 54.7526 47.3947L56.7895 45.9452Z", fill: "#A2634A" })
22733
+ /* @__PURE__ */ jsxRuntime.jsx(
22734
+ "path",
22735
+ {
22736
+ d: "M49.7484 33.9672C58.158 45.6285 38.3118 60.3151 32.1037 64.7332C22.6672 71.4486 2.68204 43.9969 12.2424 37.1926C15.1884 35.0959 18.9716 32.4035 23.1182 30.1282C24.7308 29.2433 26.3314 28.4303 27.9195 27.7514C36.611 23.6398 54.4269 16.1501 58.4471 21.7248C61.34 25.7362 55.5686 28.9294 48.2024 31.9893C48.7196 32.5968 49.2349 33.2552 49.7484 33.9672Z",
22737
+ fill: "#B7DB4E"
22738
+ }
22739
+ ),
22740
+ /* @__PURE__ */ jsxRuntime.jsx(
22741
+ "path",
22742
+ {
22743
+ d: "M50.4102 36.1737C50.6823 37.524 52.0035 38.4016 53.3614 38.1339C54.7192 37.8662 55.5994 36.5546 55.3274 35.2043L52.8688 35.689L50.4102 36.1737ZM38.8014 26.9523C37.4241 27.0934 36.425 28.3178 36.5697 29.6872C36.7144 31.0565 37.9482 32.0522 39.3255 31.9111L39.0634 29.4317L38.8014 26.9523ZM52.8688 35.689C55.3274 35.2043 55.327 35.2023 55.3266 35.2004C55.3265 35.1997 55.326 35.1977 55.3258 35.1963C55.3252 35.1935 55.3246 35.1906 55.324 35.1875C55.3227 35.1815 55.3213 35.1749 55.3198 35.1679C55.3168 35.1539 55.3133 35.1379 55.3092 35.1201C55.3011 35.0845 55.2907 35.0415 55.2779 34.9916C55.2522 34.8918 55.2163 34.764 55.1676 34.6127C55.0703 34.3106 54.9205 33.911 54.6961 33.4507C54.247 32.5296 53.4925 31.3535 52.2552 30.241C49.7078 27.9508 45.5021 26.2658 38.8014 26.9523L39.0634 29.4317L39.3255 31.9111C44.9159 31.3384 47.6128 32.782 48.8954 33.9352C49.5731 34.5445 49.9653 35.1687 50.1842 35.6177C50.2938 35.8424 50.3584 36.0201 50.3919 36.1242C50.4086 36.1759 50.4172 36.2084 50.4197 36.2181C50.4209 36.223 50.4206 36.222 50.419 36.2148C50.4181 36.2111 50.417 36.2059 50.4155 36.1991C50.4148 36.1957 50.414 36.1919 50.4131 36.1876C50.4127 36.1855 50.4122 36.1833 50.4117 36.181C50.4115 36.1798 50.4111 36.178 50.411 36.1774C50.4106 36.1755 50.4102 36.1737 52.8688 35.689Z",
22744
+ fill: "#9CAF63"
22745
+ }
22746
+ ),
22747
+ /* @__PURE__ */ jsxRuntime.jsx(
22748
+ "path",
22749
+ {
22750
+ d: "M54.7526 47.3947C55.5602 48.5146 57.1269 48.7734 58.2518 47.9729C59.3767 47.1723 59.634 45.6155 58.8264 44.4956L56.7895 45.9452L54.7526 47.3947ZM38.1539 36.5617C36.7805 36.7357 35.8113 37.9837 35.9891 39.3493C36.1669 40.7148 37.4244 41.6808 38.7978 41.5068L38.4758 39.0342L38.1539 36.5617ZM56.7895 45.9452L58.8264 44.4956C57.4277 42.5562 55.3182 40.1317 51.9378 38.4204C48.5385 36.6995 44.0772 35.8112 38.1539 36.5617L38.4758 39.0342L38.7978 41.5068C43.8796 40.863 47.3003 41.6624 49.6691 42.8616C52.0569 44.0704 53.6015 45.7985 54.7526 47.3947L56.7895 45.9452Z",
22751
+ fill: "#9CAF63"
22752
+ }
22753
+ )
22050
22754
  ] })
22051
22755
  ] }),
22052
- /* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsxs("filter", { id: shadowId, x: "-1.99995", y: "0", width: "78.3658", height: "81.8518", filterUnits: "userSpaceOnUse", colorInterpolationFilters: "sRGB", children: [
22053
- /* @__PURE__ */ jsxRuntime.jsx("feFlood", { floodOpacity: "0", result: "BackgroundImageFix" }),
22054
- /* @__PURE__ */ jsxRuntime.jsx("feColorMatrix", { in: "SourceAlpha", type: "matrix", values: "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0", result: "hardAlpha" }),
22055
- /* @__PURE__ */ jsxRuntime.jsx("feOffset", { dy: "2" }),
22056
- /* @__PURE__ */ jsxRuntime.jsx("feGaussianBlur", { stdDeviation: "1" }),
22057
- /* @__PURE__ */ jsxRuntime.jsx("feComposite", { in2: "hardAlpha", operator: "out" }),
22058
- /* @__PURE__ */ jsxRuntime.jsx("feColorMatrix", { type: "matrix", values: "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" }),
22059
- /* @__PURE__ */ jsxRuntime.jsx("feBlend", { mode: "normal", in2: "BackgroundImageFix", result: "effect1_dropShadow" }),
22060
- /* @__PURE__ */ jsxRuntime.jsx("feBlend", { mode: "normal", in: "SourceGraphic", in2: "effect1_dropShadow", result: "shape" })
22061
- ] }) })
22756
+ /* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsxs(
22757
+ "filter",
22758
+ {
22759
+ id: shadowId,
22760
+ x: "-1.99995",
22761
+ y: "0",
22762
+ width: "78.3658",
22763
+ height: "81.8518",
22764
+ filterUnits: "userSpaceOnUse",
22765
+ colorInterpolationFilters: "sRGB",
22766
+ children: [
22767
+ /* @__PURE__ */ jsxRuntime.jsx("feFlood", { floodOpacity: "0", result: "BackgroundImageFix" }),
22768
+ /* @__PURE__ */ jsxRuntime.jsx(
22769
+ "feColorMatrix",
22770
+ {
22771
+ in: "SourceAlpha",
22772
+ type: "matrix",
22773
+ values: "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0",
22774
+ result: "hardAlpha"
22775
+ }
22776
+ ),
22777
+ /* @__PURE__ */ jsxRuntime.jsx("feOffset", { dy: "2" }),
22778
+ /* @__PURE__ */ jsxRuntime.jsx("feGaussianBlur", { stdDeviation: "1" }),
22779
+ /* @__PURE__ */ jsxRuntime.jsx("feComposite", { in2: "hardAlpha", operator: "out" }),
22780
+ /* @__PURE__ */ jsxRuntime.jsx(
22781
+ "feColorMatrix",
22782
+ {
22783
+ type: "matrix",
22784
+ values: "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
22785
+ }
22786
+ ),
22787
+ /* @__PURE__ */ jsxRuntime.jsx(
22788
+ "feBlend",
22789
+ {
22790
+ mode: "normal",
22791
+ in2: "BackgroundImageFix",
22792
+ result: "effect1_dropShadow"
22793
+ }
22794
+ ),
22795
+ /* @__PURE__ */ jsxRuntime.jsx(
22796
+ "feBlend",
22797
+ {
22798
+ mode: "normal",
22799
+ in: "SourceGraphic",
22800
+ in2: "effect1_dropShadow",
22801
+ result: "shape"
22802
+ }
22803
+ )
22804
+ ]
22805
+ }
22806
+ ) })
22062
22807
  ]
22063
22808
  }
22064
22809
  );
22065
22810
  }
22066
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 });
22067
22813
  const CYCLE_MS = 1400;
22068
22814
  const PRESS_AT_MS = 650;
22069
22815
  const PRESS_HOLD_MS = 220;
22816
+ const MAX_SPEECH_MS = 3e4;
22070
22817
  const HOLE_PAD = 10;
22071
22818
  const HOLE_RADIUS = 18;
22072
22819
  const HAND_W = 72;
@@ -22084,7 +22831,6 @@ function toRect(t) {
22084
22831
  if (r.width <= 0 || r.height <= 0) return null;
22085
22832
  return { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) };
22086
22833
  }
22087
- const centerOf = (r) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
22088
22834
  const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22089
22835
  const { steps } = tutorial;
22090
22836
  const [stepIndex, setStepIndex] = React.useState(
@@ -22120,6 +22866,8 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22120
22866
  tick();
22121
22867
  return () => cancelAnimationFrame(raf);
22122
22868
  }, [step]);
22869
+ const showBubble = options.showBubble !== false;
22870
+ const autoAdvance = options.autoAdvance === true || !showBubble;
22123
22871
  const [tapIndex, setTapIndex] = React.useState(0);
22124
22872
  const [tapCount, setTapCount] = React.useState(0);
22125
22873
  const [shownStep, setShownStep] = React.useState(stepIndex);
@@ -22129,11 +22877,13 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22129
22877
  setTapCount(0);
22130
22878
  }
22131
22879
  React.useEffect(() => {
22880
+ const targets = step.taps(ctxRef.current);
22881
+ if (autoAdvance && tapIndex >= targets.length) return;
22132
22882
  let release;
22133
22883
  const press = setTimeout(() => {
22134
22884
  setTapCount((n) => n + 1);
22135
- const targets = step.taps(ctxRef.current);
22136
- const el = targets[tapIndex % Math.max(1, targets.length)];
22885
+ const targets2 = step.taps(ctxRef.current);
22886
+ const el = targets2[tapIndex % Math.max(1, targets2.length)];
22137
22887
  if (el instanceof Element) {
22138
22888
  el.classList.add("met-tutorial-pressed");
22139
22889
  release = setTimeout(() => el.classList.remove("met-tutorial-pressed"), PRESS_HOLD_MS);
@@ -22146,23 +22896,51 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22146
22896
  if (release) clearTimeout(release);
22147
22897
  root.querySelectorAll(".met-tutorial-pressed").forEach((el) => el.classList.remove("met-tutorial-pressed"));
22148
22898
  };
22149
- }, [step, tapIndex, root]);
22899
+ }, [step, tapIndex, root, autoAdvance]);
22150
22900
  const text = options.texts?.[step.id] ?? step.text;
22151
22901
  const textRef = React.useRef(text);
22152
22902
  textRef.current = text;
22903
+ const onCloseRef = React.useRef(onClose);
22904
+ onCloseRef.current = onClose;
22905
+ const isLast = stepIndex === steps.length - 1;
22906
+ const [completedStep, setCompletedStep] = React.useState(null);
22153
22907
  React.useEffect(() => {
22154
22908
  optionsRef.current.onStep?.(stepIndex, step.id);
22155
- optionsRef.current.onSpeak?.(step.id, textRef.current);
22156
- }, [stepIndex, step]);
22157
- const isLast = stepIndex === steps.length - 1;
22909
+ const speech = new Promise((resolve2) => {
22910
+ resolve2(optionsRef.current.onSpeak?.(step.id, textRef.current));
22911
+ }).catch(() => {
22912
+ });
22913
+ if (!autoAdvance) return;
22914
+ const duration = Math.max(2, step.taps(ctxRef.current).length) * CYCLE_MS;
22915
+ let cancelled = false;
22916
+ let timer;
22917
+ const animation = new Promise((resolve2) => {
22918
+ timer = setTimeout(resolve2, duration);
22919
+ });
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(() => {
22925
+ if (cancelled) return;
22926
+ if (isLast) {
22927
+ if (showBubble) setCompletedStep(stepIndex);
22928
+ else onCloseRef.current(true);
22929
+ } else setStepIndex((i) => i + 1);
22930
+ });
22931
+ return () => {
22932
+ cancelled = true;
22933
+ clearTimeout(timer);
22934
+ clearTimeout(speechTimer);
22935
+ };
22936
+ }, [stepIndex, step, autoAdvance, isLast, showBubble]);
22937
+ const showNext = !autoAdvance || isLast && completedStep === stepIndex;
22158
22938
  const next = () => {
22159
22939
  if (isLast) onClose(true);
22160
22940
  else setStepIndex((i) => i + 1);
22161
22941
  };
22162
22942
  const overlayRef = React.useRef(null);
22163
22943
  const nextRef = React.useRef(null);
22164
- const onCloseRef = React.useRef(onClose);
22165
- onCloseRef.current = onClose;
22166
22944
  React.useEffect(() => {
22167
22945
  const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
22168
22946
  previous?.blur();
@@ -22195,15 +22973,15 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22195
22973
  }, []);
22196
22974
  const bubbleShown = layout !== null;
22197
22975
  React.useEffect(() => {
22198
- nextRef.current?.focus({ focusVisible: false });
22199
- }, [stepIndex, bubbleShown]);
22976
+ (nextRef.current ?? overlayRef.current)?.focus({ focusVisible: false });
22977
+ }, [stepIndex, bubbleShown, showNext]);
22200
22978
  const maskId = `met-tutorial-mask-${React.useId()}`;
22201
22979
  if (typeof document === "undefined") return null;
22202
22980
  const holes = (layout?.spotlight ?? []).filter((r) => r !== null);
22203
- const tapTarget = layout?.taps.length ? layout.taps[tapIndex % layout.taps.length] ?? null : null;
22981
+ const tapTarget = layout?.taps.length && (!autoAdvance || tapIndex < layout.taps.length) ? layout.taps[tapIndex % layout.taps.length] ?? null : null;
22204
22982
  const Scene = step.Scene;
22205
22983
  return reactDom.createPortal(
22206
- /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: overlayRef, className: "met-tutorial", role: "dialog", "aria-modal": "true", "aria-label": text, children: [
22984
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: overlayRef, className: "met-tutorial", tabIndex: -1, role: "dialog", "aria-modal": "true", "aria-label": text, children: [
22207
22985
  /* @__PURE__ */ jsxRuntime.jsxs("svg", { className: "met-tutorial-dim", "aria-hidden": "true", children: [
22208
22986
  /* @__PURE__ */ jsxRuntime.jsx("defs", { children: /* @__PURE__ */ jsxRuntime.jsxs("mask", { id: maskId, children: [
22209
22987
  /* @__PURE__ */ jsxRuntime.jsx("rect", { width: "100%", height: "100%", fill: "white" }),
@@ -22238,7 +23016,7 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22238
23016
  )),
22239
23017
  Scene && layout && /* @__PURE__ */ jsxRuntime.jsx(Scene, { ctx, spotlight: layout.spotlight, taps: layout.taps, tapCount }),
22240
23018
  tapTarget && /* @__PURE__ */ jsxRuntime.jsx(Hand, { target: tapTarget, tapKey: `${stepIndex}-${tapIndex}` }),
22241
- layout && /* @__PURE__ */ jsxRuntime.jsx(
23019
+ layout && showBubble && /* @__PURE__ */ jsxRuntime.jsx(
22242
23020
  Bubble,
22243
23021
  {
22244
23022
  layout,
@@ -22246,7 +23024,8 @@ const TutorialOverlay = ({ tutorial, root, json, options, onClose }) => {
22246
23024
  stepIndex,
22247
23025
  stepCount: steps.length,
22248
23026
  nextLabel: options.nextLabel ?? DEFAULT_NEXT_LABEL,
22249
- onSpeak: options.onSpeak && (options.canSpeak?.(step.id) ?? true) ? () => optionsRef.current.onSpeak?.(step.id, textRef.current) : void 0,
23027
+ showNext,
23028
+ onSpeak: !autoAdvance && options.onSpeak && (options.canSpeak?.(step.id) ?? true) ? () => optionsRef.current.onSpeak?.(step.id, textRef.current) : void 0,
22250
23029
  onNext: next,
22251
23030
  nextRef
22252
23031
  }
@@ -22274,12 +23053,12 @@ const Hand = ({ target, tapKey }) => {
22274
23053
  style: { left: TUTORIAL_HAND_TIP.x * HAND_W, top: TUTORIAL_HAND_TIP.y * HAND_H }
22275
23054
  }
22276
23055
  ),
22277
- /* @__PURE__ */ jsxRuntime.jsx(LionHand, {})
23056
+ /* @__PURE__ */ jsxRuntime.jsx(ChameleonHand, {})
22278
23057
  ] }, tapKey)
22279
23058
  }
22280
23059
  );
22281
23060
  };
22282
- const Bubble = ({ layout, text, stepIndex, stepCount, nextLabel, onSpeak, onNext, nextRef }) => {
23061
+ const Bubble = ({ layout, text, stepIndex, stepCount, nextLabel, showNext, onSpeak, onNext, nextRef }) => {
22283
23062
  const { anchor, root, vw, vh } = layout;
22284
23063
  const clampLeft = (x) => Math.min(Math.max(EDGE, x), Math.max(EDGE, vw - BUBBLE_W - EDGE));
22285
23064
  const style = anchor ? { left: clampLeft(anchor.x), bottom: Math.max(EDGE, vh - anchor.y + BUBBLE_GAP), width: BUBBLE_W } : { left: clampLeft((root?.x ?? 0) + EDGE * 2), top: (root?.y ?? 0) + EDGE * 2, width: BUBBLE_W };
@@ -22290,7 +23069,7 @@ const Bubble = ({ layout, text, stepIndex, stepCount, nextLabel, onSpeak, onNext
22290
23069
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 9v6h4l5 4V5L8 9H4z", fill: "currentColor" }),
22291
23070
  /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" })
22292
23071
  ] }) }),
22293
- /* @__PURE__ */ jsxRuntime.jsx("button", { ref: nextRef, type: "button", className: "met-tutorial-next", onClick: onNext, children: nextLabel })
23072
+ showNext && /* @__PURE__ */ jsxRuntime.jsx("button", { ref: nextRef, type: "button", className: "met-tutorial-next", onClick: onNext, children: nextLabel })
22294
23073
  ] }),
22295
23074
  stepCount > 1 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "met-tutorial-dots", "aria-hidden": "true", children: Array.from({ length: stepCount }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: i === stepIndex ? "is-current" : i < stepIndex ? "is-done" : "" }, i)) })
22296
23075
  ] });
@@ -22308,9 +23087,18 @@ function key(ctx, k) {
22308
23087
  }
22309
23088
  function answerBox(ctx) {
22310
23089
  const inputs = getTextInputs(ctx.root);
22311
- return inputs.find(canTakeInput) ?? inputs[0] ?? null;
23090
+ return inputs.find(canTakeInput) ?? inputs.find(canAdvanceTo) ?? null;
23091
+ }
23092
+ function answerDigits(ctx) {
23093
+ const id = answerBox(ctx)?.id.replace(/-input$/, "");
23094
+ const expected = id ? ctx.json.validation?.answers?.[id] : void 0;
23095
+ if (typeof expected !== "number" && typeof expected !== "string") return null;
23096
+ const value = String(expected);
23097
+ return /^\d+$/.test(value) ? [...value] : null;
22312
23098
  }
22313
23099
  function answerSlots(ctx) {
23100
+ const digits = answerDigits(ctx);
23101
+ if (digits) return digits.length;
22314
23102
  const id = answerBox(ctx)?.id.replace(/-input$/, "");
22315
23103
  const max = id ? getMaxInputLength(ctx.json, id) : void 0;
22316
23104
  return Math.min(MAX_SLOTS, Math.max(1, max ?? DEFAULT_SLOTS));
@@ -22326,40 +23114,49 @@ function checkTarget(ctx) {
22326
23114
  STAND_IN_CHECK_SIZE
22327
23115
  );
22328
23116
  }
22329
- const demoKeys = (ctx) => DEMO_KEYS.map((k) => key(ctx, k));
22330
- const StandInFill = ({ box, slots, filled }) => /* @__PURE__ */ jsxRuntime.jsx(
23117
+ const demoKeys = (ctx) => (answerDigits(ctx) ?? DEMO_KEYS).map((k) => key(ctx, k));
23118
+ const StandInFill = ({ box, slots, filled, digits }) => /* @__PURE__ */ jsxRuntime.jsx(
22331
23119
  "div",
22332
23120
  {
22333
23121
  className: "met-tutorial-fill",
23122
+ dir: "ltr",
22334
23123
  style: { left: box.x, top: box.y, width: box.width, height: box.height },
22335
- children: Array.from({ length: slots }, (_, i) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: i < filled ? "met-tutorial-fill-dot" : "met-tutorial-fill-slot", children: i < filled ? "●" : "_" }, i))
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))
22336
23125
  }
22337
23126
  );
22338
- const center = (r) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
22339
23127
  const HopScene = ({ ctx, spotlight, taps, tapCount }) => {
22340
23128
  const box = spotlight[1];
22341
23129
  if (!box) return null;
22342
23130
  const slots = answerSlots(ctx);
22343
- const filled = tapCount === 0 ? 0 : (tapCount - 1) % slots + 1;
23131
+ const digits = answerDigits(ctx);
23132
+ const filled = digits ? Math.min(tapCount, slots) : tapCount === 0 ? 0 : (tapCount - 1) % slots + 1;
22344
23133
  const from = tapCount > 0 ? taps[(tapCount - 1) % taps.length] : null;
22345
23134
  let arc = null;
22346
23135
  if (from) {
22347
- const a = center(from);
22348
- const b = center(box);
23136
+ const a = centerOf(from);
23137
+ const b = centerOf(box);
22349
23138
  const lift = Math.max(40, Math.hypot(b.x - a.x, b.y - a.y) * 0.35);
22350
23139
  arc = `M ${a.x} ${a.y} Q ${(a.x + b.x) / 2} ${Math.min(a.y, b.y) - lift} ${b.x} ${b.y}`;
22351
23140
  }
22352
23141
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
22353
23142
  arc && /* @__PURE__ */ jsxRuntime.jsx("svg", { className: "met-tutorial-arc", "aria-hidden": "true", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: arc }) }),
22354
- arc && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "met-tutorial-hop", style: { offsetPath: `path('${arc}')` } }, tapCount),
22355
- /* @__PURE__ */ jsxRuntime.jsx(StandInFill, { box, slots, filled })
23143
+ arc && /* @__PURE__ */ jsxRuntime.jsx(
23144
+ "div",
23145
+ {
23146
+ className: `met-tutorial-hop${digits ? " met-tutorial-hop--digit" : ""}`,
23147
+ style: { offsetPath: `path('${arc}')` },
23148
+ children: digits?.[(tapCount - 1) % digits.length]
23149
+ },
23150
+ tapCount
23151
+ ),
23152
+ /* @__PURE__ */ jsxRuntime.jsx(StandInFill, { box, slots, filled, digits })
22356
23153
  ] });
22357
23154
  };
22358
23155
  const CheckScene = ({ ctx, spotlight }) => {
22359
23156
  const box = spotlight[0];
22360
23157
  const check = spotlight[1];
22361
23158
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
22362
- box && /* @__PURE__ */ jsxRuntime.jsx(StandInFill, { box, slots: answerSlots(ctx), filled: answerSlots(ctx) }),
23159
+ box && /* @__PURE__ */ jsxRuntime.jsx(StandInFill, { box, slots: answerSlots(ctx), filled: answerSlots(ctx), digits: answerDigits(ctx) }),
22363
23160
  check && !ctx.submitTarget && /* @__PURE__ */ jsxRuntime.jsx(
22364
23161
  "div",
22365
23162
  {
@@ -22400,13 +23197,82 @@ const WIDGET_TUTORIALS = [keypadTutorial];
22400
23197
  function findWidgetTutorial(json) {
22401
23198
  return WIDGET_TUTORIALS.find((t) => t.appliesTo(json)) ?? null;
22402
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
+ }
23217
+ function getAnswerLengthCompletion(json, answerable, values, touched) {
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
+ };
23231
+ for (const widgetId of answerable) {
23232
+ const widget = json.widgets[widgetId];
23233
+ const expected = json.validation?.answers?.[widgetId];
23234
+ if (widget?.type === JSON_TYPES.INLINE_INPUT && !widget.disabled) {
23235
+ addTypedField(widgetId, widgetId, expected);
23236
+ } else if (widget?.type === JSON_TYPES.FRACTION) {
23237
+ const parts = typeof expected === "string" ? expected.split("/") : [];
23238
+ const validParts = parts.length === 2;
23239
+ if (widget.numerator === null) {
23240
+ const id = getFractionKey(widgetId, "num");
23241
+ addTypedField(id, widgetId, validParts ? parts[0] : void 0);
23242
+ }
23243
+ if (widget.denominator === null) {
23244
+ const id = getFractionKey(widgetId, "den");
23245
+ addTypedField(id, widgetId, validParts ? parts[1] : void 0);
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
+ });
23261
+ }
23262
+ }
23263
+ const answerLengthMatchedIds = fields.filter(({ widgetId, matches }) => touched.has(widgetId) && matches).map(({ id }) => id);
23264
+ return {
23265
+ areSubmitGuideCountsMatched: fields.every(({ guideMatches }) => guideMatches),
23266
+ isAnswerLengthMatched: fields.length > 0 && answerLengthMatchedIds.length === fields.length,
23267
+ answerLengthMatchedIds
23268
+ };
23269
+ }
22403
23270
  const TOOL_ASPECT_RATIO = {
22404
23271
  ruler: RULER_ASPECT_RATIO,
22405
23272
  protractor: PROTRACTOR_ASPECT_RATIO,
22406
23273
  "set-square": SET_SQUARE_ASPECT_RATIO
22407
23274
  };
22408
23275
  const FLIP_BTN_EXTRA = 44;
22409
- const INPUT_FINISH_IDLE_MS = 1e3;
22410
23276
  function warnOnInvalidThemeColors(theme) {
22411
23277
  if (process.env.NODE_ENV === "production" || !theme) return;
22412
23278
  if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return;
@@ -22422,16 +23288,15 @@ function buildInitialValues(json) {
22422
23288
  if (widget.type === JSON_TYPES.MATCHER || widget.type === JSON_TYPES.PAIR_MATCHER) {
22423
23289
  initial[id] = [];
22424
23290
  } else if (widget.type === JSON_TYPES.GRID_SYMMETRY) {
22425
- initial[id] = widget.cells.filter((cell) => isCellInInteractiveArea(
22426
- cell,
22427
- widget.rows,
22428
- widget.cols,
22429
- widget.interactiveArea
22430
- ));
23291
+ initial[id] = widget.cells.filter(
23292
+ (cell) => isCellInInteractiveArea(cell, widget.rows, widget.cols, widget.interactiveArea)
23293
+ );
22431
23294
  } else if (widget.type === JSON_TYPES.CHART_BAR || widget.type === JSON_TYPES.CHART_PICTO) {
22432
23295
  initial[id] = String(widget.value);
22433
23296
  } else if (widget.type === JSON_TYPES.GEOMETRY) {
22434
23297
  initial[id] = validation.serializeGeometryState(validation.createEmptyGeometryAttempt());
23298
+ } else if (widget.type === JSON_TYPES.INLINE_INPUT && widget.prefill !== void 0) {
23299
+ initial[id] = widget.prefill;
22435
23300
  }
22436
23301
  }
22437
23302
  return initial;
@@ -22444,9 +23309,6 @@ function hasJustFilledBox(json, key2, previous, value) {
22444
23309
  if (maxLength === void 0) return false;
22445
23310
  return String(previous ?? "").length < maxLength && String(value).length >= maxLength;
22446
23311
  }
22447
- function isTypedInputKey(json, key2) {
22448
- return json.widgets[key2]?.type === JSON_TYPES.INLINE_INPUT || Boolean(parseFractionKey(key2));
22449
- }
22450
23312
  function buildInitialValuesSignature(json) {
22451
23313
  return JSON.stringify({
22452
23314
  widgets: json.widgets,
@@ -22467,8 +23329,10 @@ function buildRevealedValues(json) {
22467
23329
  const widget = json.widgets[widgetId];
22468
23330
  if (widget?.type === JSON_TYPES.FRACTION && typeof expected === "string") {
22469
23331
  const [num, den] = expected.split("/", 2);
22470
- if (widget.numerator === null && num !== void 0) revealed[getFractionKey(widgetId, "num")] = num;
22471
- 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;
22472
23336
  continue;
22473
23337
  }
22474
23338
  revealed[widgetId] = typeof expected === "number" ? String(expected) : expected;
@@ -22500,490 +23364,571 @@ function buildRevealedDropSources(json, revealedValues) {
22500
23364
  const pool = [];
22501
23365
  const bank = json.widgets[DRAG_OPTIONS_WIDGET_ID];
22502
23366
  if (bank?.type === JSON_TYPES.DRAG_OPTIONS) {
22503
- 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
+ );
22504
23370
  }
22505
23371
  for (const [widgetId, widget] of Object.entries(json.widgets)) {
22506
- 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 });
22507
23374
  }
22508
23375
  const sources = /* @__PURE__ */ new Map();
22509
23376
  if (pool.length === 0) return sources;
22510
23377
  const used = /* @__PURE__ */ new Set();
22511
23378
  for (const [inputId, value] of Object.entries(revealedValues)) {
22512
23379
  if (typeof value !== "string" || value === "") continue;
22513
- 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
+ );
22514
23385
  if (!match) continue;
22515
23386
  used.add(match.optionId);
22516
23387
  sources.set(inputId, match.optionId);
22517
23388
  }
22518
23389
  return sources;
22519
23390
  }
22520
- const ExerciseRenderer = React.forwardRef(({
22521
- json,
22522
- onSubmit,
22523
- onChange,
22524
- showSubmitButton = false,
22525
- className = "",
22526
- soundEnabled = true,
22527
- sounds,
22528
- theme
22529
- }, ref) => {
22530
- const initialValuesSignature = React.useMemo(() => buildInitialValuesSignature(json), [json]);
22531
- const [values, setValues] = React.useState(() => buildInitialValues(json));
22532
- const valuesRef = React.useRef(values);
22533
- valuesRef.current = values;
22534
- const touchedRef = React.useRef(/* @__PURE__ */ new Set());
22535
- const pendingRef = React.useRef(/* @__PURE__ */ new Map());
22536
- const onChangeRef = React.useRef(onChange);
22537
- onChangeRef.current = onChange;
22538
- const clearPending = (key2) => {
22539
- const timer = pendingRef.current.get(key2);
22540
- if (timer === void 0) return false;
22541
- clearTimeout(timer);
22542
- pendingRef.current.delete(key2);
22543
- return true;
22544
- };
22545
- const clearAllPending = () => {
22546
- pendingRef.current.forEach((timer) => clearTimeout(timer));
22547
- pendingRef.current = /* @__PURE__ */ new Map();
22548
- };
22549
- const finishPending = (key2) => {
22550
- if (clearPending(key2)) onChangeRef.current?.(valuesRef.current);
22551
- };
22552
- React.useEffect(() => clearAllPending, []);
22553
- const [loadedValuesSignature, setLoadedValuesSignature] = React.useState(initialValuesSignature);
22554
- const [droppedSources, setDroppedSources] = React.useState(/* @__PURE__ */ new Map());
22555
- const [focusedInputId, setFocusedInputId] = React.useState(null);
22556
- const [validationDetails, setValidationDetails] = React.useState(null);
22557
- const rootRef = React.useRef(null);
22558
- const [resetKey, setResetKey] = React.useState(0);
22559
- const [revealed, setRevealed] = React.useState(false);
22560
- const tutorial = React.useMemo(() => findWidgetTutorial(json), [json]);
22561
- const [tutorialRun, setTutorialRun] = React.useState(null);
22562
- const themeStyle = React.useMemo(() => {
22563
- const style = {};
22564
- if (theme?.correct) style["--exercise-correct"] = theme.correct;
22565
- if (theme?.incorrect) style["--exercise-incorrect"] = theme.incorrect;
22566
- return Object.keys(style).length > 0 ? style : void 0;
22567
- }, [theme?.correct, theme?.incorrect]);
22568
- React.useEffect(() => {
22569
- warnOnInvalidThemeColors({ correct: theme?.correct, incorrect: theme?.incorrect });
22570
- }, [theme?.correct, theme?.incorrect]);
22571
- const playSound = useSoundPlayer(soundEnabled, sounds);
22572
- const externallyLocked = useExerciseLocked();
22573
- React.useLayoutEffect(() => {
22574
- if (loadedValuesSignature === initialValuesSignature) return;
22575
- setValues(buildInitialValues(json));
22576
- setDroppedSources(/* @__PURE__ */ new Map());
22577
- setFocusedInputId(null);
22578
- setValidationDetails(null);
22579
- setResetKey((k) => k + 1);
22580
- setLoadedValuesSignature(initialValuesSignature);
22581
- touchedRef.current = /* @__PURE__ */ new Set();
22582
- setTutorialRun(null);
22583
- clearAllPending();
22584
- }, [json, initialValuesSignature, loadedValuesSignature]);
22585
- const locked = validationDetails !== null || externallyLocked;
22586
- const lastResultRef = React.useRef(null);
22587
- const runSubmit = () => {
22588
- if (locked) return lastResultRef.current ?? validate(json, values);
22589
- clearAllPending();
22590
- const result = validate(json, values);
22591
- playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
22592
- const details = { ...result.details };
22593
- const slotValidation = (id, raw, expected) => ({
22594
- widgetId: id,
22595
- userValue: typeof raw === "string" ? raw : void 0,
22596
- isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
22597
- });
22598
- for (const [widgetId, widget] of Object.entries(json.widgets)) {
22599
- if (widget.type !== JSON_TYPES.FRACTION) continue;
22600
- if (!(widgetId in details)) continue;
22601
- const answer = json.validation?.answers?.[widgetId];
22602
- if (!answer) continue;
22603
- const [expectedNum, expectedDen] = answer.split("/", 2);
22604
- if (!expectedNum || !expectedDen) continue;
22605
- if (details[widgetId]?.isCorrect) {
22606
- if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
22607
- if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
22608
- } else {
22609
- if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
22610
- if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
22611
- }
22612
- }
22613
- setValidationDetails(details);
22614
- onSubmit?.(values);
22615
- lastResultRef.current = result;
22616
- return result;
22617
- };
22618
- React.useImperativeHandle(ref, () => ({
22619
- submit: runSubmit,
22620
- getValues: () => values,
22621
- getCompletion: () => {
22622
- const answerable = getAnswerableInputs(json);
22623
- const missingSet = new Set(getMissingInputs(json, valuesRef.current));
22624
- for (const id of answerable) {
22625
- if (!touchedRef.current.has(id)) missingSet.add(id);
22626
- }
22627
- const isAnyFilled = answerable.some((id) => !missingSet.has(id));
22628
- for (const key2 of pendingRef.current.keys()) {
22629
- const id = toBaseId(key2);
22630
- if (answerable.includes(id)) missingSet.add(id);
22631
- }
22632
- const missing = [...missingSet];
22633
- const filledIds = answerable.filter((id) => !missingSet.has(id));
22634
- return {
22635
- total: answerable.length,
22636
- filled: filledIds.length,
22637
- missing,
22638
- filledIds,
22639
- isComplete: answerable.length > 0 && missing.length === 0,
22640
- isAnyFilled
22641
- };
22642
- },
22643
- reset: () => {
22644
- const initial = buildInitialValues(json);
22645
- valuesRef.current = initial;
22646
- 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));
22647
23433
  setDroppedSources(/* @__PURE__ */ new Map());
22648
23434
  setFocusedInputId(null);
22649
23435
  setValidationDetails(null);
22650
23436
  setResetKey((k) => k + 1);
22651
- setRevealed(false);
22652
23437
  setLoadedValuesSignature(initialValuesSignature);
22653
23438
  touchedRef.current = /* @__PURE__ */ new Set();
22654
- clearAllPending();
22655
- },
22656
- revealAnswers: () => {
22657
- const revealedValues = buildRevealedValues(json);
22658
- if (Object.keys(revealedValues).length === 0) return;
22659
- clearAllPending();
22660
- playSound("answer-reveal");
22661
- const details = {};
22662
- for (const [widgetId, userValue] of Object.entries(revealedValues)) {
22663
- const widget = json.widgets[widgetId];
22664
- if (widget?.type === JSON_TYPES.GEOMETRY) {
22665
- const { isCorrect, result } = checkGeometryWidget(widget.json, userValue, widgetId);
22666
- 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
+ };
22667
23473
  } else {
22668
- 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
+ );
22669
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);
22670
23584
  touchedRef.current.add(toBaseId(widgetId));
22671
23585
  }
22672
- const next = { ...valuesRef.current, ...revealedValues };
23586
+ const previous = valuesRef.current[widgetId];
23587
+ const next = { ...valuesRef.current, [widgetId]: value };
22673
23588
  valuesRef.current = next;
22674
23589
  setValues(next);
22675
- setDroppedSources(buildRevealedDropSources(json, revealedValues));
22676
- setValidationDetails(details);
22677
- setRevealed(true);
22678
- setResetKey((k) => k + 1);
22679
- },
22680
- focusFirstInput: () => {
22681
- const container = rootRef.current ?? document.querySelector(".math-exercise-renderer");
22682
- if (!container) return;
22683
- const candidates = getTextInputs(container);
22684
- const target = candidates.find(canTakeInput) ?? (locked ? void 0 : candidates[0]);
22685
- target?.focus();
22686
- },
22687
- hasTutorial: () => tutorial !== null,
22688
- tutorialId: () => tutorial?.id ?? null,
22689
- startTutorial: (options = {}) => {
22690
- if (!tutorial || !rootRef.current) return false;
22691
- setTutorialRun((prev) => ({ options, run: (prev?.run ?? 0) + 1 }));
22692
- return true;
22693
- },
22694
- stopTutorial: () => setTutorialRun(null)
22695
- }), [json, values, onSubmit, playSound, locked, initialValuesSignature, tutorial]);
22696
- const focusNextInput = (currentKey) => {
22697
- if (!rootRef.current) return;
22698
- findNextInput(rootRef.current, currentKey)?.focus();
22699
- };
22700
- const handleChange = (widgetId, value, fromDrop = false, sourceOptionId, silent = false) => {
22701
- if (locked) return;
22702
- if (!silent) {
22703
- setRevealed(false);
22704
- touchedRef.current.add(toBaseId(widgetId));
22705
- }
22706
- const previous = valuesRef.current[widgetId];
22707
- const next = { ...valuesRef.current, [widgetId]: value };
22708
- valuesRef.current = next;
22709
- setValues(next);
22710
- setDroppedSources((prev) => {
22711
- const next2 = new Map(prev);
22712
- if (fromDrop && typeof value === "string" && value !== "" && sourceOptionId) {
22713
- next2.set(widgetId, sourceOptionId);
22714
- } else {
22715
- next2.delete(widgetId);
22716
- }
22717
- return next2;
22718
- });
22719
- if (!silent && isTypedInputKey(json, widgetId)) {
22720
- clearPending(widgetId);
22721
- const maxLength = getMaxInputLength(json, widgetId);
22722
- const isFull = maxLength !== void 0 && String(value).length >= maxLength;
22723
- if (!fromDrop && value !== "" && !isFull) {
22724
- pendingRef.current.set(
22725
- widgetId,
22726
- setTimeout(() => finishPending(widgetId), INPUT_FINISH_IDLE_MS)
22727
- );
22728
- }
22729
- }
22730
- onChange?.(next);
22731
- if (!fromDrop && !silent && hasJustFilledBox(json, widgetId, previous, value)) {
22732
- focusNextInput(widgetId);
22733
- }
22734
- };
22735
- const handleInputFocus = (key2) => {
22736
- for (const pendingKey of [...pendingRef.current.keys()]) {
22737
- if (pendingKey !== key2) finishPending(pendingKey);
22738
- }
22739
- setFocusedInputId(key2);
22740
- };
22741
- const handleSubmit = () => {
22742
- runSubmit();
22743
- };
22744
- const lineGroups = React.useMemo(() => {
22745
- const isRTLInlineWidget = (item) => {
22746
- if (item.type === JSON_TYPES.WIDGET_REF) {
22747
- const widget = json.widgets[item.widgetId];
22748
- 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);
22749
23602
  }
22750
- return RTL_INLINE_WIDGET_TYPES.has(item.type);
22751
23603
  };
22752
- const groups = [];
22753
- let currentGroup = [];
22754
- for (const item of json.layout) {
22755
- if (item.type === JSON_TYPES.QUESTION_TEXT) {
22756
- groups.push({
22757
- items: [item],
22758
- hasArabic: itemContainsArabic(item),
22759
- hasRTLInlineWidget: false,
22760
- subGroups: []
22761
- // Question text doesn't need sub-grouping
22762
- });
22763
- continue;
22764
- }
22765
- if (item.type === JSON_TYPES.TABLE) {
22766
- if (currentGroup.length > 0) {
22767
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22768
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22769
- const subGroups = groupLineItems(currentGroup);
22770
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22771
- currentGroup = [];
22772
- }
22773
- groups.push({
22774
- items: [item],
22775
- hasArabic: false,
22776
- hasRTLInlineWidget: false,
22777
- subGroups: []
22778
- });
22779
- continue;
22780
- }
22781
- if (item.type === JSON_TYPES.NUMBER_LINE) {
22782
- if (currentGroup.length > 0) {
22783
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22784
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22785
- const subGroups = groupLineItems(currentGroup);
22786
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22787
- currentGroup = [];
22788
- }
22789
- groups.push({
22790
- items: [item],
22791
- hasArabic: false,
22792
- hasRTLInlineWidget: false,
22793
- subGroups: []
22794
- });
22795
- continue;
22796
- }
22797
- if (item.type === JSON_TYPES.VERTICAL_OPERATION) {
22798
- if (currentGroup.length > 0) {
22799
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22800
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22801
- const subGroups = groupLineItems(currentGroup);
22802
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22803
- currentGroup = [];
22804
- }
22805
- groups.push({
22806
- items: [item],
22807
- hasArabic: false,
22808
- hasRTLInlineWidget: false,
22809
- subGroups: []
22810
- });
22811
- continue;
22812
- }
22813
- if (item.type === JSON_TYPES.CHART) {
22814
- if (currentGroup.length > 0) {
22815
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22816
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22817
- const subGroups = groupLineItems(currentGroup);
22818
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22819
- currentGroup = [];
22820
- }
22821
- groups.push({
22822
- items: [item],
22823
- hasArabic: false,
22824
- hasRTLInlineWidget: false,
22825
- subGroups: []
22826
- });
22827
- continue;
22828
- }
22829
- if (item.type === JSON_TYPES.LINEBREAK) {
22830
- if (currentGroup.length > 0) {
22831
- const hasArabic = currentGroup.some((item2) => itemContainsArabic(item2));
22832
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22833
- const subGroups = groupLineItems(currentGroup);
22834
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22835
- currentGroup = [];
22836
- }
22837
- } else {
22838
- currentGroup.push(item);
22839
- }
22840
- }
22841
- if (currentGroup.length > 0) {
22842
- const hasArabic = currentGroup.some((item) => itemContainsArabic(item));
22843
- const hasRTLInlineWidget = currentGroup.some(isRTLInlineWidget);
22844
- const subGroups = groupLineItems(currentGroup);
22845
- groups.push({ items: currentGroup, hasArabic, hasRTLInlineWidget, subGroups });
22846
- }
22847
- return groups;
22848
- }, [json.layout, json.widgets]);
22849
- const droppedInputs = React.useMemo(() => new Set(droppedSources.keys()), [droppedSources]);
22850
- const dragOptionsWidget = json.widgets["_drag_options"];
22851
- const rulerWidget = json.widgets[RULER_WIDGET_ID];
22852
- const protractorWidget = json.widgets[PROTRACTOR_WIDGET_ID];
22853
- const setSquareWidget = json.widgets[SET_SQUARE_WIDGET_ID];
22854
- const numericKeypadWidget = json.widgets[NUMERIC_KEYPAD_WIDGET_ID];
22855
- const { tools, toolsMinHeight } = React.useMemo(() => {
22856
- const list = [];
22857
- const PAD = 12;
22858
- const STAGGER = 24;
22859
- let maxH = 0;
22860
- const getSize = (w) => typeof w === "object" && w !== null && "size" in w && typeof w.size === "number" ? w.size : 1;
22861
- const add2 = (id, type, width) => {
22862
- const offset = list.length * STAGGER;
22863
- list.push({ id, type, width, initialPosition: { x: PAD + offset, y: PAD + offset } });
22864
- const extra = type === "set-square" ? FLIP_BTN_EXTRA : 0;
22865
- const h = PAD + Math.ceil(width * (TOOL_ASPECT_RATIO[type] ?? 0)) + extra + PAD;
22866
- if (h > maxH) maxH = h;
23604
+ const handleSubmit = () => {
23605
+ runSubmit();
22867
23606
  };
22868
- if (rulerWidget) add2("ruler-1", "ruler", Math.round(getSize(rulerWidget) * 240));
22869
- if (protractorWidget) add2("protractor-1", "protractor", Math.round(getSize(protractorWidget) * 180));
22870
- if (setSquareWidget) add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
22871
- return { tools: list, toolsMinHeight: maxH };
22872
- }, [rulerWidget, protractorWidget, setSquareWidget]);
22873
- 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.jsxs("div", { ref: rootRef, className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`, style: themeStyle, children: [
22874
- /* @__PURE__ */ jsxRuntime.jsxs(
22875
- "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,
22876
23741
  {
22877
- className: `exercise-layout-container ${numericKeypadWidget ? "has-keypad" : ""}`,
22878
- style: toolsMinHeight > 0 ? { minHeight: toolsMinHeight } : void 0,
22879
- children: [
22880
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "exercise-layout", children: lineGroups.map((group, groupIdx) => /* @__PURE__ */ jsxRuntime.jsx(
22881
- "div",
22882
- {
22883
- className: "exercise-line",
22884
- dir: group.hasArabic && group.hasRTLInlineWidget ? "rtl" : void 0,
22885
- children: group.subGroups.length > 0 ? (
22886
- // Render sub-groups for regular lines
22887
- group.subGroups.map((subGroup, subIdx) => subGroup.isTextGroup ? (
22888
- // Text group - only apply RTL for pure Arabic (no mixed languages)
22889
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-group", dir: subGroup.shouldApplyRTL ? "rtl" : void 0, children: subGroup.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
22890
- LayoutRenderer,
22891
- {
22892
- item,
22893
- widgets: json.widgets,
22894
- values,
22895
- droppedInputs,
22896
- validationDetails,
22897
- onChange: handleChange,
22898
- onInputFocus: handleInputFocus,
22899
- resetKey
22900
- },
22901
- item.id
22902
- )) }, subIdx)
22903
- ) : (
22904
- // Widget group - no dir wrapper, render directly
22905
- subGroup.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
22906
- LayoutRenderer,
22907
- {
22908
- item,
22909
- widgets: json.widgets,
22910
- values,
22911
- droppedInputs,
22912
- validationDetails,
22913
- onChange: handleChange,
22914
- onInputFocus: handleInputFocus,
22915
- resetKey
22916
- },
22917
- item.id
22918
- ))
22919
- ))
22920
- ) : (
22921
- // For QUESTION_TEXT items (no sub-groups)
22922
- group.items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
22923
- 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",
22924
23763
  {
22925
- item,
22926
- widgets: json.widgets,
22927
- values,
22928
- droppedInputs,
22929
- validationDetails,
22930
- onChange: handleChange,
22931
- onInputFocus: handleInputFocus,
22932
- resetKey
22933
- },
22934
- item.id
22935
- ))
22936
- )
22937
- },
22938
- groupIdx
22939
- )) }),
22940
- numericKeypadWidget && numericKeypadWidget.type === "numeric-keypad" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "numeric-keypad-container", children: /* @__PURE__ */ jsxRuntime.jsx(
22941
- WidgetRenderer,
22942
- {
22943
- widget: numericKeypadWidget,
22944
- widgetId: "_numeric_keypad",
22945
- value: focusedInputId ? values[focusedInputId] || "" : "",
22946
- onChange: () => {
22947
- },
22948
- onKeypadChange: (newValue) => {
22949
- if (!focusedInputId) return;
22950
- const maxLength = getMaxInputLength(json, focusedInputId);
22951
- if (maxLength !== void 0 && newValue.length > maxLength) return;
22952
- 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
+ )
22953
23768
  }
22954
- }
22955
- ) }),
22956
- tools.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(ToolsOverlay, { tools, resetKey })
22957
- ]
22958
- }
22959
- ),
22960
- dragOptionsWidget && dragOptionsWidget.type === "drag-options" && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "drag-options-container", children: /* @__PURE__ */ jsxRuntime.jsx(
22961
- WidgetRenderer,
22962
- {
22963
- widget: dragOptionsWidget,
22964
- widgetId: "_drag_options",
22965
- value: "",
22966
- onChange: () => {
22967
- }
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
+ ) }) })
22968
23928
  }
22969
- ) }),
22970
- showSubmitButton && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "exercise-submit-btn", disabled: locked, onClick: handleSubmit, children: "Check Answer" }),
22971
- tutorial && tutorialRun && rootRef.current && /* @__PURE__ */ jsxRuntime.jsx(
22972
- TutorialOverlay,
22973
- {
22974
- tutorial,
22975
- root: rootRef.current,
22976
- json,
22977
- options: tutorialRun.options,
22978
- onClose: (finished) => {
22979
- setTutorialRun(null);
22980
- if (finished) tutorialRun.options.onDone?.();
22981
- }
22982
- },
22983
- tutorialRun.run
22984
- )
22985
- ] }) }) }) }) });
22986
- });
23929
+ ) }) });
23930
+ }
23931
+ );
22987
23932
  ExerciseRenderer.displayName = "ExerciseRenderer";
22988
23933
  function renderExercise(options) {
22989
23934
  const container = typeof options.container === "string" ? document.querySelector(options.container) : options.container;
@@ -23056,6 +24001,12 @@ exports.DEFAULT_TABLE_BORDER_COLOR = DEFAULT_TABLE_BORDER_COLOR;
23056
24001
  exports.DisplayInlineNodeSchema = DisplayInlineNodeSchema;
23057
24002
  exports.DragOptionsJSONSchema = DragOptionsJSONSchema;
23058
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;
23059
24010
  exports.EngineError = EngineError;
23060
24011
  exports.ExerciseDocumentSchema = ExerciseDocumentSchema;
23061
24012
  exports.ExerciseJSONSchema = ExerciseJSONSchema;
@@ -23164,6 +24115,7 @@ exports.WIDGET_TUTORIALS = WIDGET_TUTORIALS;
23164
24115
  exports.WidgetJSONSchema = WidgetJSONSchema;
23165
24116
  exports.WidgetRefSchema = WidgetRefSchema;
23166
24117
  exports.WidgetRenderer = WidgetRenderer;
24118
+ exports.addDuplicateOptionNameIssues = addDuplicateOptionNameIssues;
23167
24119
  exports.alignGroupBarsToSeries = alignGroupBarsToSeries;
23168
24120
  exports.compile = compile;
23169
24121
  exports.compileWithAST = compileWithAST;
@@ -23180,6 +24132,9 @@ exports.createChartSeriesBar = createChartSeriesBar;
23180
24132
  exports.createColorText = createColorText;
23181
24133
  exports.createDisplayClock = createDisplayClock;
23182
24134
  exports.createDragOptions = createDragOptions;
24135
+ exports.createDraggableOption = createDraggableOption;
24136
+ exports.createDraggableOptions = createDraggableOptions;
24137
+ exports.createDropInput = createDropInput;
23183
24138
  exports.createExerciseDocument = createExerciseDocument;
23184
24139
  exports.createExpressionValidation = createExpressionValidation;
23185
24140
  exports.createFraction = createFraction;
@@ -23224,6 +24179,7 @@ exports.findWidgetTutorial = findWidgetTutorial;
23224
24179
  exports.getAnswerableInputs = getAnswerableInputs;
23225
24180
  exports.getFractionKey = getFractionKey;
23226
24181
  exports.getMissingInputs = getMissingInputs;
24182
+ exports.hasOptionContent = hasOptionContent;
23227
24183
  exports.isBoldText = isBoldText;
23228
24184
  exports.isCellInInteractiveArea = isCellInInteractiveArea;
23229
24185
  exports.isChartAxis = isChartAxis;
@@ -23241,6 +24197,10 @@ exports.isClockItem = isClockItem;
23241
24197
  exports.isColorText = isColorText;
23242
24198
  exports.isDragOptions = isDragOptions;
23243
24199
  exports.isDragOptionsJSON = isDragOptionsJSON;
24200
+ exports.isDraggableOptions = isDraggableOptions;
24201
+ exports.isDraggableOptionsJSON = isDraggableOptionsJSON;
24202
+ exports.isDropInput = isDropInput;
24203
+ exports.isDropInputJSON = isDropInputJSON;
23244
24204
  exports.isExerciseDocument = isExerciseDocument;
23245
24205
  exports.isExpressionValidation = isExpressionValidation;
23246
24206
  exports.isFraction = isFraction;
@@ -23270,6 +24230,7 @@ exports.isMixedValidation = isMixedValidation;
23270
24230
  exports.isNumericKeypad = isNumericKeypad;
23271
24231
  exports.isNumericKeypadJSON = isNumericKeypadJSON;
23272
24232
  exports.isOperatorItem = isOperatorItem;
24233
+ exports.isOptionContentNode = isOptionContentNode;
23273
24234
  exports.isPairMatcherBlock = isPairMatcherBlock;
23274
24235
  exports.isPairMatcherWidget = isPairMatcherWidget;
23275
24236
  exports.isProtractorTool = isProtractorTool;
@@ -23326,4 +24287,4 @@ exports.validateUniqueIds = validateUniqueIds;
23326
24287
  exports.validateVisualFractionCapacity = validateVisualFractionCapacity;
23327
24288
  exports.validateWidgetJSON = validateWidgetJSON;
23328
24289
  exports.validateWidgetJSONSafe = validateWidgetJSONSafe;
23329
- //# sourceMappingURL=index-CbKXPRsS.js.map
24290
+ //# sourceMappingURL=index-C9OHpy2h.js.map