@superbuilders/incept-renderer 0.1.0

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.
@@ -0,0 +1,2495 @@
1
+ import * as React3 from 'react';
2
+ import { clsx } from 'clsx';
3
+ import { twMerge } from 'tailwind-merge';
4
+ import { createPortal } from 'react-dom';
5
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
6
+ import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
7
+ import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
8
+ import { cva } from 'class-variance-authority';
9
+ import * as LabelPrimitive from '@radix-ui/react-label';
10
+ import '@radix-ui/react-separator';
11
+ import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react';
12
+ import { useSensors, useSensor, PointerSensor, KeyboardSensor, defaultDropAnimationSideEffects, DndContext, closestCenter, DragOverlay, useDraggable, useDroppable } from '@dnd-kit/core';
13
+ import * as SelectPrimitive from '@radix-ui/react-select';
14
+ import { sortableKeyboardCoordinates, SortableContext, horizontalListSortingStrategy, verticalListSortingStrategy, arrayMove, useSortable } from '@dnd-kit/sortable';
15
+ import { CSS } from '@dnd-kit/utilities';
16
+
17
+ // src/components/qti-renderer.tsx
18
+ function cn(...inputs) {
19
+ return twMerge(clsx(inputs));
20
+ }
21
+
22
+ // src/html/sanitize.ts
23
+ var DEFAULT_CONFIG = {
24
+ // HTML content tags
25
+ allowedTags: /* @__PURE__ */ new Set([
26
+ // Text content
27
+ "p",
28
+ "span",
29
+ "div",
30
+ "br",
31
+ "hr",
32
+ // Formatting
33
+ "b",
34
+ "i",
35
+ "u",
36
+ "strong",
37
+ "em",
38
+ "mark",
39
+ "small",
40
+ "sub",
41
+ "sup",
42
+ "code",
43
+ "pre",
44
+ "kbd",
45
+ // Lists
46
+ "ul",
47
+ "ol",
48
+ "li",
49
+ "dl",
50
+ "dt",
51
+ "dd",
52
+ // Tables
53
+ "table",
54
+ "thead",
55
+ "tbody",
56
+ "tfoot",
57
+ "tr",
58
+ "th",
59
+ "td",
60
+ "caption",
61
+ "colgroup",
62
+ "col",
63
+ // Media
64
+ "img",
65
+ "audio",
66
+ "video",
67
+ "source",
68
+ "track",
69
+ // Semantic
70
+ "article",
71
+ "section",
72
+ "nav",
73
+ "aside",
74
+ "header",
75
+ "footer",
76
+ "main",
77
+ "figure",
78
+ "figcaption",
79
+ "blockquote",
80
+ "cite",
81
+ // Links
82
+ "a",
83
+ // Forms (for future interactive elements)
84
+ "label",
85
+ "button"
86
+ ]),
87
+ allowedAttributes: {
88
+ // Global attributes
89
+ "*": /* @__PURE__ */ new Set(["class", "id", "lang", "dir", "title", "style"]),
90
+ // Specific attributes
91
+ img: /* @__PURE__ */ new Set(["src", "alt", "width", "height", "style"]),
92
+ a: /* @__PURE__ */ new Set(["href", "target", "rel"]),
93
+ audio: /* @__PURE__ */ new Set(["src", "controls", "loop", "muted"]),
94
+ video: /* @__PURE__ */ new Set(["src", "controls", "loop", "muted", "width", "height", "poster"]),
95
+ source: /* @__PURE__ */ new Set(["src", "type"]),
96
+ track: /* @__PURE__ */ new Set(["src", "kind", "srclang", "label"])
97
+ },
98
+ allowDataAttributes: false,
99
+ allowMathML: true
100
+ };
101
+ var MATHML_TAGS = /* @__PURE__ */ new Set([
102
+ // Root
103
+ "math",
104
+ // Token elements
105
+ "mi",
106
+ "mn",
107
+ "mo",
108
+ "mtext",
109
+ "mspace",
110
+ "ms",
111
+ // Layout
112
+ "mrow",
113
+ "mfrac",
114
+ "msqrt",
115
+ "mroot",
116
+ "mstyle",
117
+ "merror",
118
+ "mpadded",
119
+ "mphantom",
120
+ "mfenced",
121
+ "menclose",
122
+ // Scripts and limits
123
+ "msub",
124
+ "msup",
125
+ "msubsup",
126
+ "munder",
127
+ "mover",
128
+ "munderover",
129
+ "mmultiscripts",
130
+ "mprescripts",
131
+ "none",
132
+ // Tables
133
+ "mtable",
134
+ "mtr",
135
+ "mtd",
136
+ "maligngroup",
137
+ "malignmark",
138
+ // Elementary math
139
+ "mstack",
140
+ "mlongdiv",
141
+ "msgroup",
142
+ "msrow",
143
+ "mscarries",
144
+ "mscarry",
145
+ "msline",
146
+ // Semantic
147
+ "semantics",
148
+ "annotation",
149
+ "annotation-xml"
150
+ ]);
151
+ function sanitizeHtml(html, config = {}) {
152
+ const cfg = { ...DEFAULT_CONFIG, ...config };
153
+ const dangerousPatterns = [
154
+ /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
155
+ /javascript:/gi,
156
+ /on\w+\s*=/gi,
157
+ // Event handlers
158
+ /<iframe\b/gi,
159
+ /<embed\b/gi,
160
+ /<object\b/gi,
161
+ /data:text\/html/gi
162
+ ];
163
+ let sanitized = html;
164
+ for (const pattern of dangerousPatterns) {
165
+ sanitized = sanitized.replace(pattern, "");
166
+ }
167
+ const cleaned = cleanHtml(sanitized, cfg);
168
+ return cleaned;
169
+ }
170
+ function cleanHtml(html, config) {
171
+ let cleaned = html;
172
+ const tagPattern = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
173
+ cleaned = cleaned.replace(tagPattern, (match, tagName, _attrs) => {
174
+ const tag = tagName.toLowerCase();
175
+ const isMathML = tag === "math" || cleaned.includes("<math");
176
+ if (isMathML && config.allowMathML && MATHML_TAGS.has(tag)) {
177
+ return match;
178
+ }
179
+ if (config.allowedTags.has(tag)) {
180
+ return cleanAttributesString(match, tag, config);
181
+ }
182
+ return "";
183
+ });
184
+ return cleaned;
185
+ }
186
+ function cleanAttributesString(tagString, tagName, config) {
187
+ const attrPattern = /\s+([a-zA-Z][a-zA-Z0-9-:]*)(?:="([^"]*)"|'([^']*)'|=([^\s>]+)|(?=\s|>))/g;
188
+ let cleanedTag = `<${tagString.startsWith("</") ? "/" : ""}${tagName}`;
189
+ let match = attrPattern.exec(tagString);
190
+ while (match !== null) {
191
+ const attrName = (match[1] || "").toLowerCase();
192
+ const attrValue = match[2] || match[3] || match[4] || "";
193
+ const globalAttrs = config.allowedAttributes["*"] || /* @__PURE__ */ new Set();
194
+ const tagAttrs = config.allowedAttributes[tagName] || /* @__PURE__ */ new Set();
195
+ let isAllowed = globalAttrs.has(attrName) || tagAttrs.has(attrName);
196
+ if (tagName === "img" && (attrName === "width" || attrName === "height" || attrName === "style")) {
197
+ isAllowed = false;
198
+ }
199
+ if (!isAllowed && config.allowDataAttributes && attrName.startsWith("data-")) {
200
+ isAllowed = true;
201
+ }
202
+ if (isAllowed && !isDangerousAttributeValue(attrName, attrValue)) {
203
+ cleanedTag += ` ${attrName}="${attrValue.replace(/"/g, "&quot;")}"`;
204
+ }
205
+ match = attrPattern.exec(tagString);
206
+ }
207
+ cleanedTag += ">";
208
+ return cleanedTag;
209
+ }
210
+ function isDangerousAttributeValue(name, value) {
211
+ const valueLower = value.toLowerCase().trim();
212
+ if (name === "href" || name === "src") {
213
+ if (valueLower.startsWith("javascript:") || valueLower.startsWith("data:text/html")) {
214
+ return true;
215
+ }
216
+ }
217
+ if (valueLower.includes("javascript:") || valueLower.includes("onerror=")) {
218
+ return true;
219
+ }
220
+ return false;
221
+ }
222
+ function sanitizeForDisplay(html) {
223
+ const hasMathML = html.toLowerCase().includes("<math");
224
+ return sanitizeHtml(html, {
225
+ allowMathML: hasMathML,
226
+ allowDataAttributes: true
227
+ });
228
+ }
229
+ function HTMLContent({
230
+ html,
231
+ className,
232
+ inlineEmbeds,
233
+ textEmbeds,
234
+ gapEmbeds,
235
+ renderInline,
236
+ renderTextEntry,
237
+ renderGap
238
+ }) {
239
+ const containerRef = React3.useRef(null);
240
+ const [targets, setTargets] = React3.useState({});
241
+ const safeHtml = React3.useMemo(() => sanitizeForDisplay(html), [html]);
242
+ React3.useEffect(() => {
243
+ const el = containerRef.current;
244
+ if (!el) return;
245
+ el.innerHTML = safeHtml;
246
+ const newTargets = {};
247
+ if (inlineEmbeds) {
248
+ const nodes = el.querySelectorAll("[data-qti-inline]");
249
+ for (const node of nodes) {
250
+ const responseId = node.getAttribute("data-qti-inline");
251
+ if (responseId && inlineEmbeds[responseId]) {
252
+ newTargets[responseId] = node;
253
+ }
254
+ }
255
+ }
256
+ if (textEmbeds) {
257
+ const nodes = el.querySelectorAll("[data-qti-text-entry]");
258
+ for (const node of nodes) {
259
+ const responseId = node.getAttribute("data-qti-text-entry");
260
+ if (responseId && textEmbeds[responseId]) {
261
+ newTargets[responseId] = node;
262
+ }
263
+ }
264
+ }
265
+ if (gapEmbeds) {
266
+ const nodes = el.querySelectorAll("[data-qti-gap]");
267
+ for (const node of nodes) {
268
+ const gapId = node.getAttribute("data-qti-gap");
269
+ if (gapId && gapEmbeds[gapId]) {
270
+ newTargets[`gap-${gapId}`] = node;
271
+ }
272
+ }
273
+ }
274
+ setTargets(newTargets);
275
+ }, [safeHtml, inlineEmbeds, textEmbeds, gapEmbeds]);
276
+ const portals = React3.useMemo(() => {
277
+ const items = [];
278
+ if (inlineEmbeds && renderInline) {
279
+ for (const [id, embed] of Object.entries(inlineEmbeds)) {
280
+ const target = targets[id];
281
+ if (target) {
282
+ items.push(createPortal(renderInline(embed), target, id));
283
+ }
284
+ }
285
+ }
286
+ if (textEmbeds && renderTextEntry) {
287
+ for (const [id, embed] of Object.entries(textEmbeds)) {
288
+ const target = targets[id];
289
+ if (target) {
290
+ items.push(createPortal(renderTextEntry(embed), target, id));
291
+ }
292
+ }
293
+ }
294
+ if (gapEmbeds && renderGap) {
295
+ for (const [id, gap] of Object.entries(gapEmbeds)) {
296
+ const target = targets[`gap-${id}`];
297
+ if (target) {
298
+ items.push(createPortal(renderGap(gap), target, `gap-${id}`));
299
+ }
300
+ }
301
+ }
302
+ return items;
303
+ }, [targets, inlineEmbeds, textEmbeds, gapEmbeds, renderInline, renderTextEntry, renderGap]);
304
+ React3.useEffect(() => {
305
+ const checkMathMLSupport = () => {
306
+ const mml = document.createElement("math");
307
+ mml.innerHTML = "<mspace/>";
308
+ const firstChild = mml.firstChild;
309
+ return firstChild instanceof Element && firstChild.namespaceURI === "http://www.w3.org/1998/Math/MathML";
310
+ };
311
+ if (!checkMathMLSupport() && containerRef.current?.querySelector("math")) ;
312
+ }, []);
313
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
314
+ /* @__PURE__ */ jsx(
315
+ "div",
316
+ {
317
+ ref: containerRef,
318
+ className: cn(
319
+ "qti-html-content text-foreground text-lg font-medium leading-relaxed",
320
+ // Ensure images never stretch or overflow
321
+ "[&_img]:max-w-full [&_img]:h-auto [&_img]:w-auto [&_img]:object-contain [&_img]:block [&_img]:mx-auto",
322
+ className
323
+ )
324
+ }
325
+ ),
326
+ portals
327
+ ] });
328
+ }
329
+ function FeedbackMessage({
330
+ responseId,
331
+ showFeedback,
332
+ perResponseFeedback
333
+ }) {
334
+ if (!showFeedback) return null;
335
+ const entry = perResponseFeedback?.[responseId];
336
+ const messageHtml = entry?.messageHtml;
337
+ if (!messageHtml) return null;
338
+ const isCorrect = entry?.isCorrect === true;
339
+ return /* @__PURE__ */ jsx("div", { className: "qti-feedback mt-3 p-6", "data-correct": isCorrect, children: /* @__PURE__ */ jsx(HTMLContent, { html: messageHtml }) });
340
+ }
341
+ var choiceIndicatorVariants = cva(
342
+ [
343
+ "cursor-pointer shrink-0 rounded-xs size-6 text-xs font-extrabold flex items-center justify-center outline-none",
344
+ "disabled:cursor-not-allowed disabled:opacity-50",
345
+ "data-[state=unchecked]:bg-background data-[state=unchecked]:border-2 data-[state=unchecked]:border-input data-[state=unchecked]:text-muted-foreground",
346
+ "data-[state=checked]:border-2 data-[state=checked]:border-[var(--choice-complement)] data-[state=checked]:text-[var(--choice-foreground)]",
347
+ "data-[filled=true]:data-[state=checked]:bg-[var(--choice-complement)]",
348
+ "data-[filled=true]:data-[state=checked]:shadow-[inset_0_0_0_2px_rgb(255_255_255)] dark:data-[filled=true]:data-[state=checked]:shadow-[inset_0_0_0_2px_rgb(255_255_255)]",
349
+ "focus-visible:ring-[var(--choice-complement)] focus-visible:ring-[3px]",
350
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
351
+ ],
352
+ {
353
+ variants: {
354
+ palette: {
355
+ default: [
356
+ "[--choice-foreground:var(--color-foreground)]",
357
+ "[--choice-complement:var(--color-muted-foreground)]"
358
+ ],
359
+ betta: [
360
+ "[--choice-foreground:var(--color-betta)]",
361
+ "[--choice-complement:var(--color-butterfly)]"
362
+ ],
363
+ cardinal: [
364
+ "[--choice-foreground:var(--color-cardinal)]",
365
+ "[--choice-complement:var(--color-fire-ant)]"
366
+ ],
367
+ bee: [
368
+ "[--choice-foreground:var(--color-bee)]",
369
+ "[--choice-complement:var(--color-lion)]"
370
+ ],
371
+ owl: [
372
+ "[--choice-foreground:var(--color-owl)]",
373
+ "[--choice-complement:var(--color-tree-frog)]"
374
+ ],
375
+ macaw: [
376
+ "[--choice-foreground:var(--color-macaw)]",
377
+ "[--choice-complement:var(--color-whale)]"
378
+ ]
379
+ }
380
+ },
381
+ defaultVariants: {
382
+ palette: "default"
383
+ }
384
+ }
385
+ );
386
+ function ChoiceIndicator({
387
+ className,
388
+ palette = "default",
389
+ letter,
390
+ type = "radio",
391
+ showLetter = true,
392
+ ...props
393
+ }) {
394
+ const baseClassName = cn(choiceIndicatorVariants({ palette }), className);
395
+ if (type === "checkbox") {
396
+ const checkboxProps = props;
397
+ return /* @__PURE__ */ jsx(
398
+ CheckboxPrimitive.Root,
399
+ {
400
+ "data-slot": "choice-indicator",
401
+ "data-palette": palette,
402
+ "data-filled": !showLetter,
403
+ className: baseClassName,
404
+ ...checkboxProps,
405
+ children: showLetter && /* @__PURE__ */ jsx("span", { className: "data-[state=unchecked]:block data-[state=checked]:hidden", children: letter })
406
+ }
407
+ );
408
+ }
409
+ const radioProps = props;
410
+ return /* @__PURE__ */ jsx(
411
+ RadioGroupPrimitive.Item,
412
+ {
413
+ "data-slot": "choice-indicator",
414
+ "data-palette": palette,
415
+ className: baseClassName,
416
+ ...radioProps,
417
+ children: letter
418
+ }
419
+ );
420
+ }
421
+ function Label({
422
+ className,
423
+ ...props
424
+ }) {
425
+ return /* @__PURE__ */ jsx(
426
+ LabelPrimitive.Root,
427
+ {
428
+ "data-slot": "label",
429
+ className: cn(
430
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
431
+ className
432
+ ),
433
+ ...props
434
+ }
435
+ );
436
+ }
437
+ function FieldSet({ className, ...props }) {
438
+ return /* @__PURE__ */ jsx(
439
+ "fieldset",
440
+ {
441
+ "data-slot": "field-set",
442
+ className: cn(
443
+ "flex flex-col gap-6",
444
+ "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
445
+ className
446
+ ),
447
+ ...props
448
+ }
449
+ );
450
+ }
451
+ function FieldGroup({ className, ...props }) {
452
+ return /* @__PURE__ */ jsx(
453
+ "div",
454
+ {
455
+ "data-slot": "field-group",
456
+ className: cn(
457
+ "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
458
+ className
459
+ ),
460
+ ...props
461
+ }
462
+ );
463
+ }
464
+ var fieldVariants = cva(
465
+ "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
466
+ {
467
+ variants: {
468
+ orientation: {
469
+ vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
470
+ horizontal: [
471
+ "flex-row items-center",
472
+ "[&>[data-slot=field-label]]:flex-auto",
473
+ "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px"
474
+ ],
475
+ responsive: [
476
+ "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
477
+ "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
478
+ "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px"
479
+ ]
480
+ }
481
+ },
482
+ defaultVariants: {
483
+ orientation: "vertical"
484
+ }
485
+ }
486
+ );
487
+ function Field({
488
+ className,
489
+ orientation = "vertical",
490
+ ...props
491
+ }) {
492
+ return /* @__PURE__ */ jsx(
493
+ "div",
494
+ {
495
+ role: "group",
496
+ "data-slot": "field",
497
+ "data-orientation": orientation,
498
+ className: cn(fieldVariants({ orientation }), className),
499
+ ...props
500
+ }
501
+ );
502
+ }
503
+ function FieldContent({ className, ...props }) {
504
+ return /* @__PURE__ */ jsx(
505
+ "div",
506
+ {
507
+ "data-slot": "field-content",
508
+ className: cn(
509
+ "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
510
+ className
511
+ ),
512
+ ...props
513
+ }
514
+ );
515
+ }
516
+ function FieldLabel({
517
+ className,
518
+ palette = "default",
519
+ ...props
520
+ }) {
521
+ return /* @__PURE__ */ jsx(
522
+ Label,
523
+ {
524
+ "data-slot": "field-label",
525
+ "data-palette": palette,
526
+ className: cn(
527
+ "group/field-label peer/field-label flex w-fit gap-2 leading-snug cursor-pointer",
528
+ "group-data-[disabled=true]/field:opacity-50",
529
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-xs has-[>[data-slot=field]]:p-4",
530
+ "has-[>[data-slot=field]]:border-2 has-[>[data-slot=field]]:shadow-[0_2px_0_var(--field-complement)]",
531
+ "has-[>[data-slot=field]]:hover:brightness-90 dark:has-[>[data-slot=field]]:hover:brightness-75",
532
+ "has-[>[data-slot=field]]:active:shadow-none has-[>[data-slot=field]]:active:translate-y-[2px]",
533
+ "[--field-background:var(--color-background)]",
534
+ "[--field-complement:var(--color-accent)]",
535
+ "has-[>[data-slot=field]]:bg-[var(--field-background)] has-[>[data-slot=field]]:border-accent has-[>[data-slot=field]]:text-foreground",
536
+ "has-[>[data-slot=field]]:has-data-[state=checked]:bg-[var(--field-foreground)]/10 has-[>[data-slot=field]]:has-data-[state=checked]:border-[var(--field-complement)] has-[>[data-slot=field]]:has-data-[state=checked]:text-[var(--field-foreground)]",
537
+ "has-data-[state=checked]:[--field-complement:var(--field-shadow)]",
538
+ "data-[palette=default]:[--field-foreground:var(--color-foreground)] data-[palette=default]:[--field-shadow:var(--color-muted-foreground)]",
539
+ "data-[palette=betta]:[--field-foreground:var(--color-betta)] data-[palette=betta]:[--field-shadow:var(--color-butterfly)]",
540
+ "data-[palette=cardinal]:[--field-foreground:var(--color-cardinal)] data-[palette=cardinal]:[--field-shadow:var(--color-fire-ant)]",
541
+ "data-[palette=bee]:[--field-foreground:var(--color-bee)] data-[palette=bee]:[--field-shadow:var(--color-lion)]",
542
+ "data-[palette=owl]:[--field-foreground:var(--color-owl)] data-[palette=owl]:[--field-shadow:var(--color-tree-frog)]",
543
+ "data-[palette=macaw]:[--field-foreground:var(--color-macaw)] data-[palette=macaw]:[--field-shadow:var(--color-whale)]",
544
+ className
545
+ ),
546
+ ...props
547
+ }
548
+ );
549
+ }
550
+ function FieldTitle({ className, ...props }) {
551
+ return /* @__PURE__ */ jsx(
552
+ "div",
553
+ {
554
+ "data-slot": "field-label",
555
+ className: cn(
556
+ "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
557
+ className
558
+ ),
559
+ ...props
560
+ }
561
+ );
562
+ }
563
+ cva(
564
+ [
565
+ "cursor-pointer",
566
+ "hover:brightness-90 dark:hover:brightness-75",
567
+ "text-[var(--radio-group-item-foreground)] bg-[var(--radio-group-item-background)] dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border outline-none",
568
+ "disabled:cursor-not-allowed disabled:opacity-50",
569
+ "data-[state=unchecked]:border-input",
570
+ "data-[state=checked]:border-[var(--radio-group-item-complement)]",
571
+ "focus-visible:ring-[var(--radio-group-item-foreground)] focus-visible:ring-[3px]",
572
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
573
+ ],
574
+ {
575
+ variants: {
576
+ palette: {
577
+ default: [
578
+ "[--radio-group-item-background:var(--color-accent)]",
579
+ "[--radio-group-item-foreground:var(--color-foreground)]",
580
+ "[--radio-group-item-complement:var(--color-foreground)]"
581
+ ],
582
+ betta: [
583
+ "[--radio-group-item-background:var(--color-accent)]",
584
+ "[--radio-group-item-foreground:var(--color-betta)]",
585
+ "[--radio-group-item-complement:var(--color-butterfly)]"
586
+ ],
587
+ cardinal: [
588
+ "[--radio-group-item-background:var(--color-accent)]",
589
+ "[--radio-group-item-foreground:var(--color-cardinal)]",
590
+ "[--radio-group-item-complement:var(--color-fire-ant)]"
591
+ ],
592
+ bee: [
593
+ "[--radio-group-item-background:var(--color-accent)]",
594
+ "[--radio-group-item-foreground:var(--color-bee)]",
595
+ "[--radio-group-item-complement:var(--color-lion)]"
596
+ ],
597
+ owl: [
598
+ "[--radio-group-item-background:var(--color-accent)]",
599
+ "[--radio-group-item-foreground:var(--color-owl)]",
600
+ "[--radio-group-item-complement:var(--color-tree-frog)]"
601
+ ],
602
+ macaw: [
603
+ "[--radio-group-item-background:var(--color-accent)]",
604
+ "[--radio-group-item-foreground:var(--color-macaw)]",
605
+ "[--radio-group-item-complement:var(--color-whale)]"
606
+ ]
607
+ }
608
+ },
609
+ defaultVariants: {
610
+ palette: "default"
611
+ }
612
+ }
613
+ );
614
+ function RadioGroup({
615
+ className,
616
+ ...props
617
+ }) {
618
+ return /* @__PURE__ */ jsx(
619
+ RadioGroupPrimitive.Root,
620
+ {
621
+ "data-slot": "radio-group",
622
+ className: cn("grid gap-3", className),
623
+ ...props
624
+ }
625
+ );
626
+ }
627
+ function ChoiceInteractionRenderer({
628
+ interaction,
629
+ response,
630
+ onAnswerSelect,
631
+ disabled = false,
632
+ hasSubmitted,
633
+ palette = "macaw",
634
+ isCorrect,
635
+ selectedChoicesCorrectness
636
+ }) {
637
+ const isMultiple = interaction.maxChoices > 1;
638
+ const selectedValues = React3.useMemo(() => {
639
+ if (!response) return [];
640
+ return Array.isArray(response) ? response : [response];
641
+ }, [response]);
642
+ const isImagesOnly = React3.useMemo(() => {
643
+ const hasImg = (html) => html.toLowerCase().includes("<img");
644
+ const stripped = (html) => html.replace(/<[^>]*>/g, "").trim();
645
+ return interaction.choices.length > 0 && interaction.choices.every((c) => hasImg(c.contentHtml) && stripped(c.contentHtml) === "");
646
+ }, [interaction.choices]);
647
+ const choiceCount = interaction.choices.length;
648
+ const gridClass = React3.useMemo(() => {
649
+ if (!isImagesOnly) return "space-y-2";
650
+ switch (choiceCount) {
651
+ case 4:
652
+ return "grid grid-cols-2 gap-4";
653
+ case 3:
654
+ return "grid grid-cols-3 gap-4";
655
+ case 2:
656
+ return "max-w-3xl mx-auto grid grid-cols-2 gap-6";
657
+ default:
658
+ return "grid grid-cols-2 md:grid-cols-3 gap-4";
659
+ }
660
+ }, [isImagesOnly, choiceCount]);
661
+ const imageCardClass = React3.useMemo(() => {
662
+ if (!isImagesOnly) return "";
663
+ return choiceCount === 2 ? "justify-center text-center [&_img]:max-h-52 md:[&_img]:max-h-60 ![&_img]:w-auto ![&_img]:h-auto ![&_img]:max-w-full ![&_img]:block [&_img]:mx-auto min-h-[220px] px-8 py-8" : "justify-center text-center [&_img]:max-h-40 md:[&_img]:max-h-48 ![&_img]:w-auto ![&_img]:h-auto ![&_img]:max-w-full ![&_img]:block [&_img]:mx-auto min-h-[180px]";
664
+ }, [isImagesOnly, choiceCount]);
665
+ const handleSingleChoice = (value) => {
666
+ if (!disabled && onAnswerSelect) {
667
+ onAnswerSelect(value);
668
+ }
669
+ };
670
+ const promptElement = interaction.promptHtml ? /* @__PURE__ */ jsx(HTMLContent, { html: interaction.promptHtml, className: "mb-4" }) : null;
671
+ if (!isMultiple) {
672
+ const submitted2 = hasSubmitted === true;
673
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
674
+ promptElement,
675
+ /* @__PURE__ */ jsx(
676
+ RadioGroup,
677
+ {
678
+ value: selectedValues[0] || "",
679
+ onValueChange: (value) => handleSingleChoice(value),
680
+ disabled: disabled || submitted2,
681
+ children: /* @__PURE__ */ jsx(FieldGroup, { children: /* @__PURE__ */ jsx(FieldSet, { children: isImagesOnly ? /* @__PURE__ */ jsx("div", { className: gridClass, children: interaction.choices.map((choice, index) => {
682
+ const isSelected = selectedValues.includes(choice.identifier);
683
+ const choicePalette = submitted2 && isSelected ? isCorrect ? "owl" : "cardinal" : palette;
684
+ const domId = `${interaction.responseIdentifier}-${choice.identifier}`;
685
+ return /* @__PURE__ */ jsx(FieldLabel, { htmlFor: domId, palette: choicePalette, children: /* @__PURE__ */ jsxs(Field, { orientation: "horizontal", "data-disabled": disabled || submitted2, children: [
686
+ /* @__PURE__ */ jsx(
687
+ ChoiceIndicator,
688
+ {
689
+ value: choice.identifier,
690
+ id: domId,
691
+ letter: String.fromCharCode(65 + index),
692
+ palette: choicePalette,
693
+ disabled: disabled || submitted2
694
+ }
695
+ ),
696
+ /* @__PURE__ */ jsx(FieldContent, { className: cn(imageCardClass), children: /* @__PURE__ */ jsx(FieldTitle, { children: /* @__PURE__ */ jsx(HTMLContent, { html: choice.contentHtml }) }) })
697
+ ] }) }, choice.identifier);
698
+ }) }) : interaction.choices.map((choice, index) => {
699
+ const isSelected = selectedValues.includes(choice.identifier);
700
+ const choicePalette = submitted2 && isSelected ? isCorrect ? "owl" : "cardinal" : palette;
701
+ const domId = `${interaction.responseIdentifier}-${choice.identifier}`;
702
+ return /* @__PURE__ */ jsx(FieldLabel, { htmlFor: domId, palette: choicePalette, children: /* @__PURE__ */ jsxs(Field, { orientation: "horizontal", "data-disabled": disabled || submitted2, children: [
703
+ /* @__PURE__ */ jsx(
704
+ ChoiceIndicator,
705
+ {
706
+ value: choice.identifier,
707
+ id: domId,
708
+ "aria-describedby": submitted2 && isSelected && choice.inlineFeedbackHtml ? `${domId}-fb` : void 0,
709
+ letter: String.fromCharCode(65 + index),
710
+ palette: choicePalette,
711
+ disabled: disabled || submitted2
712
+ }
713
+ ),
714
+ /* @__PURE__ */ jsxs(FieldContent, { children: [
715
+ /* @__PURE__ */ jsx(FieldTitle, { children: /* @__PURE__ */ jsx(HTMLContent, { html: choice.contentHtml }) }),
716
+ submitted2 && isSelected && choice.inlineFeedbackHtml ? /* @__PURE__ */ jsx(
717
+ "output",
718
+ {
719
+ id: `${domId}-fb`,
720
+ "aria-live": "polite",
721
+ className: cn(
722
+ "mt-2 pl-3 border-l-4 text-sm text-muted-foreground",
723
+ isCorrect ? "border-owl" : "border-cardinal"
724
+ ),
725
+ children: /* @__PURE__ */ jsx(HTMLContent, { html: choice.inlineFeedbackHtml })
726
+ }
727
+ ) : null
728
+ ] })
729
+ ] }) }, choice.identifier);
730
+ }) }) })
731
+ }
732
+ )
733
+ ] });
734
+ }
735
+ const submitted = hasSubmitted === true;
736
+ const atMax = selectedValues.length >= interaction.maxChoices;
737
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
738
+ promptElement,
739
+ /* @__PURE__ */ jsx(FieldGroup, { children: /* @__PURE__ */ jsx(FieldSet, { children: isImagesOnly ? /* @__PURE__ */ jsx("div", { className: gridClass, children: interaction.choices.map((choice, _index) => {
740
+ const isChecked = selectedValues.includes(choice.identifier);
741
+ const perSelection = selectedChoicesCorrectness?.find((c) => c.id === choice.identifier)?.isCorrect;
742
+ const selectedCorrect = perSelection ?? (isChecked ? isCorrect ?? false : false);
743
+ const choicePalette = submitted && isChecked ? selectedCorrect ? "owl" : "cardinal" : palette;
744
+ const domId = `${interaction.responseIdentifier}-${choice.identifier}`;
745
+ return /* @__PURE__ */ jsx(FieldLabel, { htmlFor: domId, palette: choicePalette, children: /* @__PURE__ */ jsxs(Field, { orientation: "horizontal", "data-disabled": disabled || submitted, children: [
746
+ /* @__PURE__ */ jsx(
747
+ ChoiceIndicator,
748
+ {
749
+ type: "checkbox",
750
+ id: domId,
751
+ showLetter: false,
752
+ palette: choicePalette,
753
+ checked: isChecked,
754
+ onCheckedChange: (checked) => {
755
+ if (disabled || submitted || !onAnswerSelect) return;
756
+ if (checked === true && !isChecked && atMax) return;
757
+ const next = checked === true ? [...selectedValues, choice.identifier] : selectedValues.filter((v) => v !== choice.identifier);
758
+ onAnswerSelect(next);
759
+ },
760
+ disabled: disabled || submitted || !isChecked && atMax,
761
+ "aria-describedby": submitted && isChecked && choice.inlineFeedbackHtml ? `${domId}-fb` : void 0
762
+ }
763
+ ),
764
+ /* @__PURE__ */ jsxs(FieldContent, { className: cn(imageCardClass), children: [
765
+ /* @__PURE__ */ jsx(FieldTitle, { children: /* @__PURE__ */ jsx(HTMLContent, { html: choice.contentHtml }) }),
766
+ submitted && isChecked && choice.inlineFeedbackHtml ? /* @__PURE__ */ jsx(
767
+ "output",
768
+ {
769
+ id: `${domId}-fb`,
770
+ "aria-live": "polite",
771
+ className: cn(
772
+ "mt-2 pl-3 border-l-4 text-sm text-muted-foreground",
773
+ selectedCorrect ? "border-owl" : "border-cardinal"
774
+ ),
775
+ children: /* @__PURE__ */ jsx(HTMLContent, { html: choice.inlineFeedbackHtml })
776
+ }
777
+ ) : null
778
+ ] })
779
+ ] }) }, choice.identifier);
780
+ }) }) : interaction.choices.map((choice, _index) => {
781
+ const isChecked = selectedValues.includes(choice.identifier);
782
+ const perSelection = selectedChoicesCorrectness?.find((c) => c.id === choice.identifier)?.isCorrect;
783
+ const selectedCorrect = perSelection ?? (isChecked ? isCorrect ?? false : false);
784
+ const choicePalette = submitted && isChecked ? selectedCorrect ? "owl" : "cardinal" : palette;
785
+ const domId = `${interaction.responseIdentifier}-${choice.identifier}`;
786
+ return /* @__PURE__ */ jsx(FieldLabel, { htmlFor: domId, palette: choicePalette, children: /* @__PURE__ */ jsxs(Field, { orientation: "horizontal", "data-disabled": disabled || submitted, children: [
787
+ /* @__PURE__ */ jsx(
788
+ ChoiceIndicator,
789
+ {
790
+ type: "checkbox",
791
+ id: domId,
792
+ showLetter: false,
793
+ palette: choicePalette,
794
+ checked: isChecked,
795
+ onCheckedChange: (checked) => {
796
+ if (disabled || submitted || !onAnswerSelect) return;
797
+ if (checked === true && !isChecked && atMax) return;
798
+ const next = checked === true ? [...selectedValues, choice.identifier] : selectedValues.filter((v) => v !== choice.identifier);
799
+ onAnswerSelect(next);
800
+ },
801
+ disabled: disabled || submitted || !isChecked && atMax,
802
+ "aria-describedby": submitted && isChecked && choice.inlineFeedbackHtml ? `${domId}-fb` : void 0
803
+ }
804
+ ),
805
+ /* @__PURE__ */ jsxs(FieldContent, { children: [
806
+ /* @__PURE__ */ jsx(FieldTitle, { children: /* @__PURE__ */ jsx(HTMLContent, { html: choice.contentHtml }) }),
807
+ submitted && isChecked && choice.inlineFeedbackHtml ? /* @__PURE__ */ jsx(
808
+ "output",
809
+ {
810
+ id: `${domId}-fb`,
811
+ "aria-live": "polite",
812
+ className: cn(
813
+ "mt-2 pl-3 border-l-4 text-sm text-muted-foreground",
814
+ selectedCorrect ? "border-owl" : "border-cardinal"
815
+ ),
816
+ children: /* @__PURE__ */ jsx(HTMLContent, { html: choice.inlineFeedbackHtml })
817
+ }
818
+ ) : null
819
+ ] })
820
+ ] }) }, choice.identifier);
821
+ }) }) })
822
+ ] });
823
+ }
824
+ function SafeInlineHTML({ html, className }) {
825
+ const ref = React3.useRef(null);
826
+ React3.useLayoutEffect(() => {
827
+ if (ref.current) {
828
+ ref.current.innerHTML = html;
829
+ }
830
+ }, [html]);
831
+ return /* @__PURE__ */ jsx("span", { ref, className });
832
+ }
833
+ function toQtiResponse(state) {
834
+ return Object.entries(state).map(([gapId, sourceId]) => `${sourceId} ${gapId}`);
835
+ }
836
+ function fromQtiResponse(response) {
837
+ const state = {};
838
+ for (const pair of response) {
839
+ const parts = pair.split(" ");
840
+ const sourceId = parts[0];
841
+ const gapId = parts[1];
842
+ if (sourceId && gapId) {
843
+ state[gapId] = sourceId;
844
+ }
845
+ }
846
+ return state;
847
+ }
848
+ var gapSlotVariants = cva(
849
+ [
850
+ "inline-flex items-center justify-center",
851
+ "min-w-[4rem] min-h-[1.75rem] px-2 mx-1 my-1",
852
+ "border rounded-xs align-middle",
853
+ "transition-all duration-150"
854
+ ],
855
+ {
856
+ variants: {
857
+ state: {
858
+ empty: "border-muted-foreground/30 bg-muted/20",
859
+ hover: "border-macaw bg-macaw/10",
860
+ filled: "border-macaw bg-macaw/10",
861
+ correct: "border-owl bg-owl/15",
862
+ incorrect: "border-cardinal bg-cardinal/15"
863
+ }
864
+ },
865
+ defaultVariants: {
866
+ state: "empty"
867
+ }
868
+ }
869
+ );
870
+ var sourceTokenVariants = cva(
871
+ [
872
+ "inline-flex items-center justify-center",
873
+ "h-7 px-2 rounded-xs border-2",
874
+ "text-sm font-semibold",
875
+ "transition-all duration-150",
876
+ "select-none"
877
+ ],
878
+ {
879
+ variants: {
880
+ state: {
881
+ idle: "bg-background border-border shadow-[0_2px_0_var(--color-border)] cursor-grab hover:border-macaw hover:shadow-[0_2px_0_var(--color-macaw)]",
882
+ selected: "bg-macaw/15 border-macaw shadow-[0_2px_0_var(--color-whale)] ring-2 ring-macaw/30",
883
+ dragging: "opacity-50 cursor-grabbing",
884
+ disabled: "opacity-50 cursor-not-allowed bg-muted/50 border-muted shadow-none"
885
+ }
886
+ },
887
+ defaultVariants: {
888
+ state: "idle"
889
+ }
890
+ }
891
+ );
892
+ function SourceToken({ id, contentHtml, disabled, isSelected, onClick, remainingUses }) {
893
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
894
+ id: `source-${id}`,
895
+ data: { type: "source", sourceId: id },
896
+ disabled: disabled || remainingUses <= 0
897
+ });
898
+ if (remainingUses <= 0) return null;
899
+ const getState = () => {
900
+ if (disabled) return "disabled";
901
+ if (isDragging) return "dragging";
902
+ if (isSelected) return "selected";
903
+ return "idle";
904
+ };
905
+ return /* @__PURE__ */ jsxs(
906
+ "button",
907
+ {
908
+ ref: setNodeRef,
909
+ type: "button",
910
+ onClick: disabled ? void 0 : onClick,
911
+ className: cn(sourceTokenVariants({ state: getState() }), "active:cursor-grabbing"),
912
+ disabled,
913
+ ...attributes,
914
+ ...listeners,
915
+ children: [
916
+ /* @__PURE__ */ jsx(SafeInlineHTML, { html: contentHtml }),
917
+ remainingUses > 1 && /* @__PURE__ */ jsxs("span", { className: "ml-1 text-xs text-muted-foreground", children: [
918
+ "\xD7",
919
+ remainingUses
920
+ ] })
921
+ ]
922
+ }
923
+ );
924
+ }
925
+ function GapSlot({ gapId, filledWith, onClear, onClick, disabled, isCorrect, hasSubmitted }) {
926
+ const { setNodeRef, isOver } = useDroppable({
927
+ id: `gap-${gapId}`,
928
+ data: { type: "gap", gapId },
929
+ disabled
930
+ });
931
+ const getState = () => {
932
+ if (hasSubmitted && filledWith) {
933
+ return isCorrect ? "correct" : "incorrect";
934
+ }
935
+ if (isOver) return "hover";
936
+ if (filledWith) return "filled";
937
+ return "empty";
938
+ };
939
+ return /* @__PURE__ */ jsx("span", { ref: setNodeRef, className: cn(gapSlotVariants({ state: getState() })), children: filledWith ? /* @__PURE__ */ jsxs(
940
+ "button",
941
+ {
942
+ type: "button",
943
+ onClick: disabled ? void 0 : onClear,
944
+ disabled,
945
+ className: cn(
946
+ "flex items-center gap-1",
947
+ !disabled && "hover:text-cardinal cursor-pointer",
948
+ disabled && "cursor-default"
949
+ ),
950
+ children: [
951
+ /* @__PURE__ */ jsx(SafeInlineHTML, { html: filledWith.contentHtml }),
952
+ !disabled && /* @__PURE__ */ jsx("span", { className: "text-xs opacity-60", children: "\xD7" })
953
+ ]
954
+ }
955
+ ) : /* @__PURE__ */ jsx(
956
+ "button",
957
+ {
958
+ type: "button",
959
+ onClick: disabled ? void 0 : onClick,
960
+ disabled,
961
+ className: cn(
962
+ "w-full h-full min-h-[1.5em] flex items-center justify-center",
963
+ !disabled && "cursor-pointer hover:bg-macaw/5"
964
+ ),
965
+ children: /* @__PURE__ */ jsx("span", { className: "opacity-30", children: "___" })
966
+ }
967
+ ) });
968
+ }
969
+ function GapMatchInteraction({
970
+ interaction,
971
+ response = [],
972
+ onAnswerSelect,
973
+ disabled = false,
974
+ hasSubmitted = false,
975
+ gapCorrectness
976
+ }) {
977
+ const currentPairs = React3.useMemo(() => fromQtiResponse(response), [response]);
978
+ const [selectedSourceId, setSelectedSourceId] = React3.useState(null);
979
+ const [activeDragId, setActiveDragId] = React3.useState(null);
980
+ const activeSource = React3.useMemo(
981
+ () => interaction.gapTexts.find((gt) => gt.id === activeDragId),
982
+ [activeDragId, interaction.gapTexts]
983
+ );
984
+ const getRemainingUses = (sourceId) => {
985
+ const source = interaction.gapTexts.find((gt) => gt.id === sourceId);
986
+ if (!source) return 0;
987
+ if (source.matchMax === 0) return 999;
988
+ const usedCount = Object.values(currentPairs).filter((id) => id === sourceId).length;
989
+ return source.matchMax - usedCount;
990
+ };
991
+ const handleSourceClick = (sourceId) => {
992
+ if (disabled || hasSubmitted) return;
993
+ setSelectedSourceId((prev) => prev === sourceId ? null : sourceId);
994
+ };
995
+ const handleGapClick = (gapId) => {
996
+ if (disabled || hasSubmitted || !selectedSourceId) return;
997
+ if (getRemainingUses(selectedSourceId) <= 0) {
998
+ setSelectedSourceId(null);
999
+ return;
1000
+ }
1001
+ const newPairs = { ...currentPairs, [gapId]: selectedSourceId };
1002
+ onAnswerSelect?.(toQtiResponse(newPairs));
1003
+ setSelectedSourceId(null);
1004
+ };
1005
+ const handleClearGap = (gapId) => {
1006
+ if (disabled || hasSubmitted) return;
1007
+ const newPairs = { ...currentPairs };
1008
+ delete newPairs[gapId];
1009
+ onAnswerSelect?.(toQtiResponse(newPairs));
1010
+ };
1011
+ const handleDragEnd = (event) => {
1012
+ setActiveDragId(null);
1013
+ const { active, over } = event;
1014
+ if (!over) return;
1015
+ const sourceData = active.data.current;
1016
+ const targetData = over.data.current;
1017
+ if (sourceData?.type === "source" && targetData?.type === "gap") {
1018
+ const sourceId = sourceData.sourceId;
1019
+ const gapId = targetData.gapId;
1020
+ if (typeof sourceId === "string" && typeof gapId === "string") {
1021
+ if (getRemainingUses(sourceId) <= 0) return;
1022
+ const newPairs = { ...currentPairs, [gapId]: sourceId };
1023
+ onAnswerSelect?.(toQtiResponse(newPairs));
1024
+ }
1025
+ }
1026
+ };
1027
+ const gapEmbeds = React3.useMemo(() => {
1028
+ const embeds = {};
1029
+ for (const gap of interaction.gaps) {
1030
+ embeds[gap.id] = gap;
1031
+ }
1032
+ return embeds;
1033
+ }, [interaction.gaps]);
1034
+ return /* @__PURE__ */ jsxs(
1035
+ DndContext,
1036
+ {
1037
+ collisionDetection: closestCenter,
1038
+ onDragStart: (e) => {
1039
+ const id = e.active.id;
1040
+ const sourceId = typeof id === "string" ? id.replace("source-", "") : null;
1041
+ setActiveDragId(sourceId);
1042
+ setSelectedSourceId(null);
1043
+ },
1044
+ onDragEnd: handleDragEnd,
1045
+ children: [
1046
+ /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1047
+ /* @__PURE__ */ jsxs(
1048
+ "div",
1049
+ {
1050
+ className: cn(
1051
+ "p-4 rounded-xl border-2 border-dashed",
1052
+ "flex flex-wrap gap-2 min-h-[3.5rem]",
1053
+ disabled || hasSubmitted ? "bg-muted/30 border-muted" : "bg-muted/10 border-muted-foreground/20"
1054
+ ),
1055
+ children: [
1056
+ interaction.gapTexts.map((source) => /* @__PURE__ */ jsx(
1057
+ SourceToken,
1058
+ {
1059
+ id: source.id,
1060
+ contentHtml: source.contentHtml,
1061
+ disabled: disabled || hasSubmitted,
1062
+ isSelected: selectedSourceId === source.id,
1063
+ onClick: () => handleSourceClick(source.id),
1064
+ remainingUses: getRemainingUses(source.id)
1065
+ },
1066
+ source.id
1067
+ )),
1068
+ interaction.gapTexts.every((s) => getRemainingUses(s.id) <= 0)
1069
+ ]
1070
+ }
1071
+ ),
1072
+ selectedSourceId && !disabled && !hasSubmitted && /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground animate-pulse", children: "Click a gap to place the selected token" }),
1073
+ /* @__PURE__ */ jsx("div", { className: "leading-relaxed text-lg", children: /* @__PURE__ */ jsx(
1074
+ HTMLContent,
1075
+ {
1076
+ html: interaction.contentHtml,
1077
+ gapEmbeds,
1078
+ renderGap: (gap) => {
1079
+ const sourceId = currentPairs[gap.id];
1080
+ const source = interaction.gapTexts.find((gt) => gt.id === sourceId);
1081
+ return /* @__PURE__ */ jsx(
1082
+ GapSlot,
1083
+ {
1084
+ gapId: gap.id,
1085
+ filledWith: source,
1086
+ onClear: () => handleClearGap(gap.id),
1087
+ onClick: () => handleGapClick(gap.id),
1088
+ disabled: disabled || hasSubmitted,
1089
+ isCorrect: gapCorrectness?.[gap.id],
1090
+ hasSubmitted
1091
+ }
1092
+ );
1093
+ }
1094
+ }
1095
+ ) })
1096
+ ] }),
1097
+ /* @__PURE__ */ jsx(DragOverlay, { dropAnimation: null, children: activeSource ? /* @__PURE__ */ jsx("div", { className: cn(sourceTokenVariants({ state: "selected" }), "shadow-xl cursor-grabbing"), children: /* @__PURE__ */ jsx(SafeInlineHTML, { html: activeSource.contentHtml }) }) : null })
1098
+ ]
1099
+ }
1100
+ );
1101
+ }
1102
+ var selectTriggerVariants = cva(
1103
+ [
1104
+ "cursor-pointer",
1105
+ "hover:brightness-90 dark:hover:brightness-75",
1106
+ "border-[var(--select-complement)] text-[var(--select-foreground)] bg-[var(--select-background)] inline-flex items-center justify-between gap-2 align-baseline rounded-xs px-3 py-2 text-sm whitespace-nowrap outline-none",
1107
+ "data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
1108
+ "[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
1109
+ "disabled:cursor-not-allowed disabled:opacity-50",
1110
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
1111
+ ],
1112
+ {
1113
+ variants: {
1114
+ palette: {
1115
+ default: [
1116
+ "[--select-background:var(--color-background)]",
1117
+ "[--select-foreground:var(--color-foreground)]",
1118
+ "[--select-complement:var(--color-accent)]"
1119
+ ],
1120
+ betta: [
1121
+ "[--select-background:var(--color-background)]",
1122
+ "[--select-foreground:var(--color-betta)]",
1123
+ "[--select-complement:var(--color-butterfly)]"
1124
+ ],
1125
+ cardinal: [
1126
+ "[--select-background:var(--color-background)]",
1127
+ "[--select-foreground:var(--color-cardinal)]",
1128
+ "[--select-complement:var(--color-fire-ant)]"
1129
+ ],
1130
+ bee: [
1131
+ "[--select-background:var(--color-background)]",
1132
+ "[--select-foreground:var(--color-bee)]",
1133
+ "[--select-complement:var(--color-lion)]"
1134
+ ],
1135
+ owl: [
1136
+ "[--select-background:var(--color-background)]",
1137
+ "[--select-foreground:var(--color-owl)]",
1138
+ "[--select-complement:var(--color-tree-frog)]"
1139
+ ],
1140
+ macaw: [
1141
+ "[--select-background:var(--color-background)]",
1142
+ "[--select-foreground:var(--color-macaw)]",
1143
+ "[--select-complement:var(--color-whale)]"
1144
+ ]
1145
+ },
1146
+ outline: {
1147
+ true: ["border-2 shadow-[0_2px_0_var(--select-complement)]"],
1148
+ false: ["border-none shadow-[0_3px_0_var(--select-complement)]"]
1149
+ }
1150
+ },
1151
+ defaultVariants: {
1152
+ palette: "default",
1153
+ outline: false
1154
+ }
1155
+ }
1156
+ );
1157
+ function Select({ ...props }) {
1158
+ return /* @__PURE__ */ jsx(SelectPrimitive.Root, { "data-slot": "select", ...props });
1159
+ }
1160
+ function SelectValue({
1161
+ className,
1162
+ ...props
1163
+ }) {
1164
+ return /* @__PURE__ */ jsx(
1165
+ SelectPrimitive.Value,
1166
+ {
1167
+ "data-slot": "select-value",
1168
+ className: cn("text-[var(--select-foreground)]", className),
1169
+ ...props
1170
+ }
1171
+ );
1172
+ }
1173
+ function SelectTrigger({
1174
+ className,
1175
+ size = "default",
1176
+ palette = "default",
1177
+ children,
1178
+ outline = true,
1179
+ style,
1180
+ ...props
1181
+ }) {
1182
+ return /* @__PURE__ */ jsxs(
1183
+ SelectPrimitive.Trigger,
1184
+ {
1185
+ "data-slot": "select-trigger",
1186
+ "data-size": size,
1187
+ "data-palette": palette,
1188
+ className: cn(selectTriggerVariants({ palette, outline, className })),
1189
+ style: { color: "var(--select-foreground)", ...style },
1190
+ ...props,
1191
+ children: [
1192
+ children,
1193
+ /* @__PURE__ */ jsx(SelectPrimitive.Icon, { asChild: true, children: /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 opacity-50" }) })
1194
+ ]
1195
+ }
1196
+ );
1197
+ }
1198
+ function SelectContent({
1199
+ className,
1200
+ children,
1201
+ position = "popper",
1202
+ align = "center",
1203
+ ...props
1204
+ }) {
1205
+ return /* @__PURE__ */ jsx(SelectPrimitive.Portal, { children: /* @__PURE__ */ jsxs(
1206
+ SelectPrimitive.Content,
1207
+ {
1208
+ "data-slot": "select-content",
1209
+ className: cn(
1210
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xs border border-accent shadow-[0_2px_0_var(--color-accent)]",
1211
+ position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
1212
+ className
1213
+ ),
1214
+ position,
1215
+ align,
1216
+ ...props,
1217
+ children: [
1218
+ /* @__PURE__ */ jsx(SelectScrollUpButton, {}),
1219
+ /* @__PURE__ */ jsx(
1220
+ SelectPrimitive.Viewport,
1221
+ {
1222
+ className: cn(
1223
+ "p-1",
1224
+ position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
1225
+ ),
1226
+ children
1227
+ }
1228
+ ),
1229
+ /* @__PURE__ */ jsx(SelectScrollDownButton, {})
1230
+ ]
1231
+ }
1232
+ ) });
1233
+ }
1234
+ function SelectItem({
1235
+ className,
1236
+ children,
1237
+ ...props
1238
+ }) {
1239
+ return /* @__PURE__ */ jsxs(
1240
+ SelectPrimitive.Item,
1241
+ {
1242
+ "data-slot": "select-item",
1243
+ className: cn(
1244
+ "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-xs py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
1245
+ className
1246
+ ),
1247
+ ...props,
1248
+ children: [
1249
+ /* @__PURE__ */ jsx("span", { className: "absolute right-2 flex size-3.5 items-center justify-center", children: /* @__PURE__ */ jsx(SelectPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-4" }) }) }),
1250
+ /* @__PURE__ */ jsx(SelectPrimitive.ItemText, { children })
1251
+ ]
1252
+ }
1253
+ );
1254
+ }
1255
+ function SelectScrollUpButton({
1256
+ className,
1257
+ ...props
1258
+ }) {
1259
+ return /* @__PURE__ */ jsx(
1260
+ SelectPrimitive.ScrollUpButton,
1261
+ {
1262
+ "data-slot": "select-scroll-up-button",
1263
+ className: cn("flex cursor-default items-center justify-center py-1", className),
1264
+ ...props,
1265
+ children: /* @__PURE__ */ jsx(ChevronUpIcon, { className: "size-4" })
1266
+ }
1267
+ );
1268
+ }
1269
+ function SelectScrollDownButton({
1270
+ className,
1271
+ ...props
1272
+ }) {
1273
+ return /* @__PURE__ */ jsx(
1274
+ SelectPrimitive.ScrollDownButton,
1275
+ {
1276
+ "data-slot": "select-scroll-down-button",
1277
+ className: cn("flex cursor-default items-center justify-center py-1", className),
1278
+ ...props,
1279
+ children: /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4" })
1280
+ }
1281
+ );
1282
+ }
1283
+
1284
+ // src/shared/shuffle.ts
1285
+ function xmur3(str) {
1286
+ let h = 1779033703 ^ str.length;
1287
+ for (let i = 0; i < str.length; i++) {
1288
+ h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
1289
+ h = h << 13 | h >>> 19;
1290
+ }
1291
+ return () => {
1292
+ h = Math.imul(h ^ h >>> 16, 2246822507);
1293
+ h = Math.imul(h ^ h >>> 13, 3266489909);
1294
+ const shifted = h >>> 16;
1295
+ h = h ^ shifted;
1296
+ return h >>> 0;
1297
+ };
1298
+ }
1299
+ function mulberry32(seed) {
1300
+ let state = seed;
1301
+ return () => {
1302
+ state = state + 1831565813;
1303
+ let t = state;
1304
+ t = Math.imul(t ^ t >>> 15, t | 1);
1305
+ t = t ^ t + Math.imul(t ^ t >>> 7, t | 61);
1306
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1307
+ };
1308
+ }
1309
+ function shuffleWithSeed(items, seed) {
1310
+ const out = items.slice();
1311
+ const seedFn = xmur3(seed);
1312
+ const rng = mulberry32(seedFn());
1313
+ for (let i = out.length - 1; i > 0; i--) {
1314
+ const j = Math.floor(rng() * (i + 1));
1315
+ const vi = out[i];
1316
+ const vj = out[j];
1317
+ if (vi === void 0 || vj === void 0) continue;
1318
+ out[i] = vj;
1319
+ out[j] = vi;
1320
+ }
1321
+ return out;
1322
+ }
1323
+ function SafeInlineHTML2({ html, className, style }) {
1324
+ const ref = React3.useRef(null);
1325
+ React3.useEffect(() => {
1326
+ if (ref.current) {
1327
+ ref.current.innerHTML = sanitizeForDisplay(html);
1328
+ }
1329
+ }, [html]);
1330
+ return /* @__PURE__ */ jsx("span", { ref, className, style });
1331
+ }
1332
+ function InlineInteraction({
1333
+ embed,
1334
+ response,
1335
+ onAnswerSelect,
1336
+ disabled = false,
1337
+ hasSubmitted,
1338
+ palette = "macaw",
1339
+ isCorrect
1340
+ }) {
1341
+ const hasValue = typeof response === "string" && response.length > 0;
1342
+ const neutralTriggerClass = hasValue ? "" : "border-border shadow-[0_3px_0_var(--color-border)] focus-visible:ring-0";
1343
+ const items = React3.useMemo(() => {
1344
+ return embed.shuffle ? shuffleWithSeed(embed.choices, embed.responseId) : embed.choices;
1345
+ }, [embed]);
1346
+ const feedbackPalette = hasSubmitted && hasValue ? isCorrect ? "owl" : "cardinal" : palette;
1347
+ const ariaInvalid = hasSubmitted && hasValue && isCorrect === false ? true : void 0;
1348
+ const selectedLabelHtml = React3.useMemo(() => {
1349
+ if (!hasValue) return "";
1350
+ const found = items.find((c) => c.id === response);
1351
+ return found?.contentHtml ?? "";
1352
+ }, [hasValue, items, response]);
1353
+ return /* @__PURE__ */ jsxs(
1354
+ Select,
1355
+ {
1356
+ value: hasValue ? response : "",
1357
+ onValueChange: (value) => {
1358
+ if (!disabled && onAnswerSelect) onAnswerSelect(value);
1359
+ },
1360
+ disabled: disabled || hasSubmitted,
1361
+ children: [
1362
+ /* @__PURE__ */ jsx(
1363
+ SelectTrigger,
1364
+ {
1365
+ size: "sm",
1366
+ className: neutralTriggerClass,
1367
+ palette: hasValue ? feedbackPalette : "default",
1368
+ "aria-invalid": ariaInvalid,
1369
+ children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "Select...", children: hasValue ? /* @__PURE__ */ jsx(
1370
+ SafeInlineHTML2,
1371
+ {
1372
+ html: selectedLabelHtml,
1373
+ className: "text-[var(--select-foreground)]",
1374
+ style: { color: "var(--select-foreground)" }
1375
+ }
1376
+ ) : null })
1377
+ }
1378
+ ),
1379
+ /* @__PURE__ */ jsx(SelectContent, { position: "item-aligned", children: items.map((c) => /* @__PURE__ */ jsx(SelectItem, { value: c.id, children: /* @__PURE__ */ jsx(SafeInlineHTML2, { html: c.contentHtml }) }, c.id)) })
1380
+ ]
1381
+ }
1382
+ );
1383
+ }
1384
+ function SafeInlineHTML3({ html, className }) {
1385
+ const ref = React3.useRef(null);
1386
+ React3.useLayoutEffect(() => {
1387
+ if (ref.current) {
1388
+ ref.current.innerHTML = html;
1389
+ }
1390
+ }, [html]);
1391
+ return /* @__PURE__ */ jsx("span", { ref, className });
1392
+ }
1393
+ function createPairKey(sourceId, targetId) {
1394
+ return `${sourceId} ${targetId}`;
1395
+ }
1396
+ function toQtiResponse2(state) {
1397
+ const pairs = [];
1398
+ for (const [targetId, sourceIds] of Object.entries(state)) {
1399
+ for (const sourceId of sourceIds) {
1400
+ pairs.push(createPairKey(sourceId, targetId));
1401
+ }
1402
+ }
1403
+ return pairs;
1404
+ }
1405
+ function fromQtiResponse2(response) {
1406
+ const state = {};
1407
+ for (const pair of response) {
1408
+ const parts = pair.split(" ");
1409
+ const sourceId = parts[0];
1410
+ const targetId = parts[1];
1411
+ if (sourceId && targetId) {
1412
+ if (!state[targetId]) {
1413
+ state[targetId] = [];
1414
+ }
1415
+ state[targetId].push(sourceId);
1416
+ }
1417
+ }
1418
+ return state;
1419
+ }
1420
+ var targetBoxVariants = cva(
1421
+ ["flex flex-col gap-2 p-3 min-h-[8rem] min-w-[10rem]", "border-2 rounded-xs", "transition-all duration-150"],
1422
+ {
1423
+ variants: {
1424
+ state: {
1425
+ empty: "border-border border-dashed bg-muted/10",
1426
+ hover: "border-macaw border-solid bg-macaw/10 shadow-[0_2px_0_var(--color-macaw)]",
1427
+ filled: "border-macaw border-solid bg-macaw/5 shadow-[0_2px_0_var(--color-whale)]",
1428
+ disabled: "opacity-60 bg-muted/30 border-dashed"
1429
+ }
1430
+ },
1431
+ defaultVariants: {
1432
+ state: "empty"
1433
+ }
1434
+ }
1435
+ );
1436
+ var sourceTokenVariants2 = cva(
1437
+ [
1438
+ "inline-flex items-center justify-center",
1439
+ "h-7 px-2 rounded-xs border-2",
1440
+ "text-sm font-semibold",
1441
+ "transition-all duration-150",
1442
+ "select-none"
1443
+ ],
1444
+ {
1445
+ variants: {
1446
+ state: {
1447
+ idle: "bg-background border-border shadow-[0_2px_0_var(--color-border)] cursor-grab hover:border-macaw hover:shadow-[0_2px_0_var(--color-macaw)]",
1448
+ dragging: "opacity-50 cursor-grabbing",
1449
+ placed: "bg-macaw/10 border-macaw shadow-[0_2px_0_var(--color-whale)]",
1450
+ correct: "bg-owl/15 border-owl shadow-[0_2px_0_var(--color-tree-frog)]",
1451
+ incorrect: "bg-cardinal/15 border-cardinal shadow-[0_2px_0_var(--color-fire-ant)]",
1452
+ disabled: "opacity-50 cursor-not-allowed bg-muted/50 border-muted shadow-none"
1453
+ }
1454
+ },
1455
+ defaultVariants: {
1456
+ state: "idle"
1457
+ }
1458
+ }
1459
+ );
1460
+ function PlacedToken({
1461
+ instanceId,
1462
+ sourceId,
1463
+ fromTargetId,
1464
+ fromIndex,
1465
+ contentHtml,
1466
+ disabled,
1467
+ state,
1468
+ onRemove
1469
+ }) {
1470
+ const canInteract = !disabled && state !== "correct" && state !== "incorrect";
1471
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
1472
+ id: instanceId,
1473
+ data: { type: "placed", sourceId, fromTargetId, fromIndex },
1474
+ disabled: !canInteract
1475
+ });
1476
+ const tokenState = isDragging ? "dragging" : state;
1477
+ return /* @__PURE__ */ jsxs(
1478
+ "button",
1479
+ {
1480
+ type: "button",
1481
+ ref: setNodeRef,
1482
+ ...attributes,
1483
+ ...listeners,
1484
+ onClick: canInteract ? onRemove : void 0,
1485
+ "aria-disabled": !canInteract,
1486
+ className: cn(
1487
+ sourceTokenVariants2({ state: tokenState }),
1488
+ canInteract && "cursor-grab hover:bg-muted/50 group",
1489
+ !canInteract && "cursor-default pointer-events-none"
1490
+ ),
1491
+ children: [
1492
+ /* @__PURE__ */ jsx(SafeInlineHTML3, { html: contentHtml }),
1493
+ canInteract && /* @__PURE__ */ jsx("span", { className: "text-xs opacity-40 group-hover:opacity-100 ml-1 transition-opacity", children: "\xD7" })
1494
+ ]
1495
+ }
1496
+ );
1497
+ }
1498
+ function TargetBox({ target, placedSources, disabled, onRemoveSource, pairCorrectness, hasSubmitted }) {
1499
+ const { setNodeRef, isOver } = useDroppable({
1500
+ id: `target-${target.id}`,
1501
+ data: { type: "target", targetId: target.id },
1502
+ disabled
1503
+ });
1504
+ const hasSources = placedSources.length > 0;
1505
+ const getState = () => {
1506
+ if (disabled) return "disabled";
1507
+ if (isOver) return "hover";
1508
+ if (hasSources) return "filled";
1509
+ return "empty";
1510
+ };
1511
+ const getTokenState = (sourceId) => {
1512
+ if (hasSubmitted) {
1513
+ const pairKey = createPairKey(sourceId, target.id);
1514
+ const isCorrect = pairCorrectness?.[pairKey];
1515
+ if (isCorrect === true) return "correct";
1516
+ if (isCorrect === false) return "incorrect";
1517
+ }
1518
+ if (disabled) return "disabled";
1519
+ return "placed";
1520
+ };
1521
+ return /* @__PURE__ */ jsxs("div", { ref: setNodeRef, className: cn(targetBoxVariants({ state: getState() })), children: [
1522
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-semibold text-center pb-2 border-b border-border/50 mb-2", children: /* @__PURE__ */ jsx(SafeInlineHTML3, { html: target.contentHtml }) }),
1523
+ placedSources.length === 0 ? /* @__PURE__ */ jsx("div", { className: "flex-1 flex items-center justify-center text-muted-foreground/50 text-sm", children: "Drop here" }) : /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-2", children: placedSources.map(({ instanceId, source, index }) => /* @__PURE__ */ jsx(
1524
+ PlacedToken,
1525
+ {
1526
+ instanceId,
1527
+ sourceId: source.id,
1528
+ fromTargetId: target.id,
1529
+ fromIndex: index,
1530
+ contentHtml: source.contentHtml,
1531
+ disabled: disabled || hasSubmitted,
1532
+ state: getTokenState(source.id),
1533
+ onRemove: () => onRemoveSource(instanceId)
1534
+ },
1535
+ instanceId
1536
+ )) })
1537
+ ] });
1538
+ }
1539
+ function SourcePoolToken({ source, remainingUses, disabled }) {
1540
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
1541
+ id: `pool-${source.id}`,
1542
+ data: { type: "source", sourceId: source.id },
1543
+ disabled: disabled || remainingUses <= 0
1544
+ });
1545
+ if (remainingUses <= 0) return null;
1546
+ const getState = () => {
1547
+ if (isDragging) return "dragging";
1548
+ if (disabled) return "disabled";
1549
+ return "idle";
1550
+ };
1551
+ return /* @__PURE__ */ jsxs("div", { ref: setNodeRef, className: cn(sourceTokenVariants2({ state: getState() })), ...attributes, ...listeners, children: [
1552
+ /* @__PURE__ */ jsx(SafeInlineHTML3, { html: source.contentHtml }),
1553
+ remainingUses > 1 && /* @__PURE__ */ jsxs("span", { className: "ml-2 text-xs text-muted-foreground", children: [
1554
+ "\xD7",
1555
+ remainingUses
1556
+ ] })
1557
+ ] });
1558
+ }
1559
+ function DroppablePool({ disabled, children }) {
1560
+ const { setNodeRef, isOver } = useDroppable({
1561
+ id: "source-pool",
1562
+ data: { type: "pool" },
1563
+ disabled
1564
+ });
1565
+ return /* @__PURE__ */ jsx(
1566
+ "div",
1567
+ {
1568
+ ref: setNodeRef,
1569
+ className: cn(
1570
+ "p-4 rounded-xl border-2 border-dashed",
1571
+ "flex flex-wrap justify-center gap-3 min-h-[4rem]",
1572
+ "transition-all duration-150",
1573
+ disabled ? "bg-muted/30 border-muted" : "bg-muted/10 border-muted-foreground/20",
1574
+ isOver && !disabled && "border-macaw bg-macaw/10"
1575
+ ),
1576
+ children
1577
+ }
1578
+ );
1579
+ }
1580
+ function MatchInteraction({
1581
+ interaction,
1582
+ response = [],
1583
+ onAnswerSelect,
1584
+ disabled = false,
1585
+ hasSubmitted = false,
1586
+ pairCorrectness
1587
+ }) {
1588
+ const sensors = useSensors(
1589
+ useSensor(PointerSensor, {
1590
+ activationConstraint: {
1591
+ distance: 5
1592
+ }
1593
+ })
1594
+ );
1595
+ const currentState = React3.useMemo(() => fromQtiResponse2(response), [response]);
1596
+ const [activeDragId, setActiveDragId] = React3.useState(null);
1597
+ const activeSource = React3.useMemo(() => {
1598
+ if (!activeDragId) return null;
1599
+ const sourceId = activeDragId.startsWith("pool-") ? activeDragId.replace("pool-", "") : activeDragId.split("-")[0];
1600
+ return interaction.sourceChoices.find((s) => s.id === sourceId);
1601
+ }, [activeDragId, interaction.sourceChoices]);
1602
+ const getSourceUsageCount = (sourceId) => {
1603
+ let count = 0;
1604
+ for (const sources of Object.values(currentState)) {
1605
+ count += sources.filter((id) => id === sourceId).length;
1606
+ }
1607
+ return count;
1608
+ };
1609
+ const getRemainingUses = (source) => {
1610
+ if (source.matchMax === 0) return 999;
1611
+ return source.matchMax - getSourceUsageCount(source.id);
1612
+ };
1613
+ const canTargetAcceptMore = (target) => {
1614
+ const placed = currentState[target.id]?.length ?? 0;
1615
+ if (target.matchMax === 0) return true;
1616
+ return placed < target.matchMax;
1617
+ };
1618
+ const handleDragEnd = (event) => {
1619
+ setActiveDragId(null);
1620
+ const { active, over } = event;
1621
+ if (!over) return;
1622
+ const activeData = active.data.current;
1623
+ const overData = over.data.current;
1624
+ if (overData?.type === "pool" && activeData?.type === "placed") {
1625
+ const fromTargetId = activeData.fromTargetId;
1626
+ const fromIndex = activeData.fromIndex;
1627
+ if (typeof fromTargetId !== "string" || typeof fromIndex !== "number") return;
1628
+ const newState = { ...currentState };
1629
+ const oldSources = newState[fromTargetId];
1630
+ if (oldSources) {
1631
+ newState[fromTargetId] = oldSources.filter((_, i) => i !== fromIndex);
1632
+ if (newState[fromTargetId].length === 0) {
1633
+ delete newState[fromTargetId];
1634
+ }
1635
+ }
1636
+ onAnswerSelect?.(toQtiResponse2(newState));
1637
+ return;
1638
+ }
1639
+ if (overData?.type !== "target") return;
1640
+ const toTargetId = overData.targetId;
1641
+ if (typeof toTargetId !== "string") return;
1642
+ const toTarget = interaction.targetChoices.find((t) => t.id === toTargetId);
1643
+ if (!toTarget) return;
1644
+ if (activeData?.type === "source") {
1645
+ const sourceId = activeData.sourceId;
1646
+ if (typeof sourceId !== "string") return;
1647
+ const source = interaction.sourceChoices.find((s) => s.id === sourceId);
1648
+ if (!source) return;
1649
+ if (getRemainingUses(source) <= 0) return;
1650
+ if (!canTargetAcceptMore(toTarget)) return;
1651
+ const newState = { ...currentState };
1652
+ if (!newState[toTargetId]) {
1653
+ newState[toTargetId] = [];
1654
+ }
1655
+ newState[toTargetId] = [...newState[toTargetId], sourceId];
1656
+ onAnswerSelect?.(toQtiResponse2(newState));
1657
+ return;
1658
+ }
1659
+ if (activeData?.type === "placed") {
1660
+ const sourceId = activeData.sourceId;
1661
+ const fromTargetId = activeData.fromTargetId;
1662
+ const fromIndex = activeData.fromIndex;
1663
+ if (typeof sourceId !== "string" || typeof fromTargetId !== "string" || typeof fromIndex !== "number") return;
1664
+ if (fromTargetId === toTargetId) return;
1665
+ if (!canTargetAcceptMore(toTarget)) return;
1666
+ const newState = { ...currentState };
1667
+ const oldSources = newState[fromTargetId];
1668
+ if (oldSources) {
1669
+ newState[fromTargetId] = oldSources.filter((_, i) => i !== fromIndex);
1670
+ if (newState[fromTargetId].length === 0) {
1671
+ delete newState[fromTargetId];
1672
+ }
1673
+ }
1674
+ if (!newState[toTargetId]) {
1675
+ newState[toTargetId] = [];
1676
+ }
1677
+ newState[toTargetId] = [...newState[toTargetId], sourceId];
1678
+ onAnswerSelect?.(toQtiResponse2(newState));
1679
+ }
1680
+ };
1681
+ const handleRemoveSource = (targetId, index) => {
1682
+ if (disabled || hasSubmitted) return;
1683
+ const newState = { ...currentState };
1684
+ const sources = newState[targetId];
1685
+ if (sources) {
1686
+ newState[targetId] = sources.filter((_, i) => i !== index);
1687
+ if (newState[targetId].length === 0) {
1688
+ delete newState[targetId];
1689
+ }
1690
+ }
1691
+ onAnswerSelect?.(toQtiResponse2(newState));
1692
+ };
1693
+ const getPlacedSources = (targetId) => {
1694
+ const sourceIds = currentState[targetId] ?? [];
1695
+ return sourceIds.map((sourceId, index) => {
1696
+ const source = interaction.sourceChoices.find((s) => s.id === sourceId);
1697
+ return {
1698
+ instanceId: `${sourceId}-${targetId}-${index}`,
1699
+ source: source ?? { id: sourceId, contentHtml: sourceId, matchMax: 1 },
1700
+ index
1701
+ };
1702
+ });
1703
+ };
1704
+ return /* @__PURE__ */ jsxs(
1705
+ DndContext,
1706
+ {
1707
+ sensors,
1708
+ collisionDetection: closestCenter,
1709
+ onDragStart: (e) => {
1710
+ setActiveDragId(String(e.active.id));
1711
+ },
1712
+ onDragEnd: handleDragEnd,
1713
+ children: [
1714
+ /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1715
+ interaction.promptHtml && /* @__PURE__ */ jsx("div", { className: "text-center mb-4", children: /* @__PURE__ */ jsx(SafeInlineHTML3, { html: interaction.promptHtml }) }),
1716
+ !hasSubmitted && /* @__PURE__ */ jsxs(DroppablePool, { disabled, children: [
1717
+ interaction.sourceChoices.map((source) => /* @__PURE__ */ jsx(
1718
+ SourcePoolToken,
1719
+ {
1720
+ source,
1721
+ remainingUses: getRemainingUses(source),
1722
+ disabled
1723
+ },
1724
+ source.id
1725
+ )),
1726
+ interaction.sourceChoices.every((s) => getRemainingUses(s) <= 0) && /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: "All items placed" })
1727
+ ] }),
1728
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap justify-center gap-4", children: interaction.targetChoices.map((target) => /* @__PURE__ */ jsx(
1729
+ TargetBox,
1730
+ {
1731
+ target,
1732
+ placedSources: getPlacedSources(target.id),
1733
+ disabled: disabled || hasSubmitted,
1734
+ onRemoveSource: (instanceId) => {
1735
+ const index = getPlacedSources(target.id).findIndex((p) => p.instanceId === instanceId);
1736
+ if (index !== -1) {
1737
+ handleRemoveSource(target.id, index);
1738
+ }
1739
+ },
1740
+ pairCorrectness,
1741
+ hasSubmitted
1742
+ },
1743
+ target.id
1744
+ )) })
1745
+ ] }),
1746
+ /* @__PURE__ */ jsx(DragOverlay, { dropAnimation: null, children: activeSource ? /* @__PURE__ */ jsx("div", { className: cn(sourceTokenVariants2({ state: "idle" }), "shadow-xl cursor-grabbing"), children: /* @__PURE__ */ jsx(SafeInlineHTML3, { html: activeSource.contentHtml }) }) : null })
1747
+ ]
1748
+ }
1749
+ );
1750
+ }
1751
+ function DragHandleIcon({ className }) {
1752
+ return /* @__PURE__ */ jsxs(
1753
+ "svg",
1754
+ {
1755
+ width: "12",
1756
+ height: "12",
1757
+ viewBox: "0 0 15 15",
1758
+ fill: "none",
1759
+ xmlns: "http://www.w3.org/2000/svg",
1760
+ className,
1761
+ "aria-hidden": "true",
1762
+ role: "img",
1763
+ children: [
1764
+ /* @__PURE__ */ jsx("title", { children: "Drag handle" }),
1765
+ /* @__PURE__ */ jsx(
1766
+ "path",
1767
+ {
1768
+ d: "M5.5 3C5.5 3.27614 5.27614 3.5 5 3.5C4.72386 3.5 4.5 3.27614 4.5 3C4.5 2.72386 4.72386 2.5 5 2.5C5.27614 2.5 5.5 2.72386 5.5 3ZM5.5 7.5C5.5 7.77614 5.27614 8 5 8C4.72386 8 4.5 7.77614 4.5 7.5C4.5 7.22386 4.72386 7 5 7C5.27614 7 5.5 7.22386 5.5 7.5ZM5.5 12C5.5 12.2761 5.27614 12.5 5 12.5C4.72386 12.5 4.5 12.2761 4.5 12C4.5 11.7239 4.72386 11.5 5 11.5C5.27614 11.5 5.5 11.7239 5.5 12ZM9.5 3C9.5 3.27614 9.27614 3.5 9 3.5C8.72386 3.5 8.5 3.27614 8.5 3C8.5 2.72386 8.72386 2.5 9 2.5C9.27614 2.5 9.5 2.72386 9.5 3ZM9.5 7.5C9.5 7.77614 9.27614 8 9 8C8.72386 8 8.5 7.77614 8.5 7.5C8.5 7.22386 8.72386 7 9 7C9.27614 7 9.5 7.22386 9.5 7.5ZM9.5 12C9.5 12.2761 9.27614 12.5 9 12.5C8.72386 12.5 8.5 12.2761 8.5 12C8.5 11.7239 8.72386 11.5 9 11.5C9.27614 11.5 9.5 11.7239 9.5 12Z",
1769
+ fill: "currentColor",
1770
+ fillRule: "evenodd",
1771
+ clipRule: "evenodd"
1772
+ }
1773
+ )
1774
+ ]
1775
+ }
1776
+ );
1777
+ }
1778
+ function SortableItem({
1779
+ id,
1780
+ contentHtml,
1781
+ disabled,
1782
+ isHorizontal
1783
+ }) {
1784
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled });
1785
+ const style = {
1786
+ transform: CSS.Transform.toString(transform),
1787
+ transition,
1788
+ opacity: isDragging ? 0.4 : 1,
1789
+ zIndex: isDragging ? 50 : "auto"
1790
+ };
1791
+ return /* @__PURE__ */ jsxs(
1792
+ "div",
1793
+ {
1794
+ ref: setNodeRef,
1795
+ style,
1796
+ ...attributes,
1797
+ ...listeners,
1798
+ className: cn(
1799
+ "bg-background border rounded-lg shadow-sm touch-none select-none",
1800
+ // Styling differs based on orientation
1801
+ isHorizontal ? "px-4 py-2 min-w-[100px] flex items-center justify-center text-center" : "p-4 w-full flex items-center gap-3",
1802
+ disabled ? "cursor-default opacity-90 bg-muted/50" : "cursor-grab active:cursor-grabbing hover:border-macaw/50 hover:shadow-md"
1803
+ ),
1804
+ children: [
1805
+ !isHorizontal && /* @__PURE__ */ jsx("div", { className: "text-muted-foreground shrink-0", children: /* @__PURE__ */ jsx(DragHandleIcon, {}) }),
1806
+ /* @__PURE__ */ jsx(HTMLContent, { html: contentHtml })
1807
+ ]
1808
+ }
1809
+ );
1810
+ }
1811
+ function DragOverlayItem({ contentHtml, isHorizontal }) {
1812
+ return /* @__PURE__ */ jsxs(
1813
+ "div",
1814
+ {
1815
+ className: cn(
1816
+ "bg-background border-2 border-macaw rounded-lg shadow-xl cursor-grabbing z-50",
1817
+ isHorizontal ? "px-4 py-2 min-w-[100px] flex items-center justify-center" : "p-4 w-full flex items-center gap-3"
1818
+ ),
1819
+ children: [
1820
+ !isHorizontal && /* @__PURE__ */ jsx("div", { className: "text-macaw shrink-0", children: /* @__PURE__ */ jsx(DragHandleIcon, {}) }),
1821
+ /* @__PURE__ */ jsx(HTMLContent, { html: contentHtml })
1822
+ ]
1823
+ }
1824
+ );
1825
+ }
1826
+ function OrderInteraction({
1827
+ interaction,
1828
+ response,
1829
+ onAnswerSelect,
1830
+ disabled,
1831
+ hasSubmitted,
1832
+ isCorrect
1833
+ }) {
1834
+ const items = React3.useMemo(() => {
1835
+ if (response && response.length > 0) {
1836
+ const mapped = [];
1837
+ for (const id of response) {
1838
+ const found = interaction.choices.find((c) => c.id === id);
1839
+ if (found) mapped.push(found);
1840
+ }
1841
+ return mapped;
1842
+ }
1843
+ return interaction.choices;
1844
+ }, [response, interaction.choices]);
1845
+ const [activeId, setActiveId] = React3.useState(null);
1846
+ const activeItem = React3.useMemo(() => items.find((i) => i.id === activeId), [activeId, items]);
1847
+ const sensors = useSensors(
1848
+ useSensor(PointerSensor),
1849
+ useSensor(KeyboardSensor, {
1850
+ coordinateGetter: sortableKeyboardCoordinates
1851
+ })
1852
+ );
1853
+ const handleDragStart = (event) => {
1854
+ const id = event.active.id;
1855
+ setActiveId(typeof id === "string" ? id : String(id));
1856
+ };
1857
+ const handleDragEnd = (event) => {
1858
+ const { active, over } = event;
1859
+ setActiveId(null);
1860
+ if (over && active.id !== over.id) {
1861
+ const oldIndex = items.findIndex((item) => item.id === active.id);
1862
+ const newIndex = items.findIndex((item) => item.id === over.id);
1863
+ const newOrder = arrayMove(items, oldIndex, newIndex);
1864
+ onAnswerSelect?.(newOrder.map((item) => item.id));
1865
+ }
1866
+ };
1867
+ const isHorizontal = interaction.orientation === "horizontal";
1868
+ const strategy = isHorizontal ? horizontalListSortingStrategy : verticalListSortingStrategy;
1869
+ const dropAnimation = {
1870
+ sideEffects: defaultDropAnimationSideEffects({
1871
+ styles: { active: { opacity: "0.4" } }
1872
+ })
1873
+ };
1874
+ const getContainerStyles = () => {
1875
+ if (!hasSubmitted) {
1876
+ return "bg-muted/30 border-transparent";
1877
+ }
1878
+ if (isCorrect) {
1879
+ return "bg-owl/10 border-owl";
1880
+ }
1881
+ return "bg-cardinal/10 border-cardinal";
1882
+ };
1883
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
1884
+ interaction.promptHtml && /* @__PURE__ */ jsx(HTMLContent, { html: interaction.promptHtml }),
1885
+ /* @__PURE__ */ jsxs(
1886
+ DndContext,
1887
+ {
1888
+ sensors,
1889
+ collisionDetection: closestCenter,
1890
+ onDragStart: handleDragStart,
1891
+ onDragEnd: handleDragEnd,
1892
+ children: [
1893
+ /* @__PURE__ */ jsx(SortableContext, { items: items.map((i) => i.id), strategy, disabled: disabled || hasSubmitted, children: /* @__PURE__ */ jsx(
1894
+ "div",
1895
+ {
1896
+ className: cn(
1897
+ "rounded-xl p-4 transition-colors border-2",
1898
+ isHorizontal ? "flex flex-wrap gap-3" : "flex flex-col gap-2",
1899
+ getContainerStyles()
1900
+ ),
1901
+ children: items.map((item) => /* @__PURE__ */ jsx(
1902
+ SortableItem,
1903
+ {
1904
+ id: item.id,
1905
+ contentHtml: item.contentHtml,
1906
+ disabled: !!(disabled || hasSubmitted),
1907
+ isHorizontal
1908
+ },
1909
+ item.id
1910
+ ))
1911
+ }
1912
+ ) }),
1913
+ /* @__PURE__ */ jsx(DragOverlay, { dropAnimation, children: activeItem ? /* @__PURE__ */ jsx(DragOverlayItem, { contentHtml: activeItem.contentHtml, isHorizontal }) : null })
1914
+ ]
1915
+ }
1916
+ )
1917
+ ] });
1918
+ }
1919
+ var textEntryVariants = cva(
1920
+ [
1921
+ // Base styles matching Select trigger
1922
+ "inline-flex items-center justify-center",
1923
+ "h-7 px-2 rounded-xs text-sm font-semibold",
1924
+ "bg-[var(--text-entry-background)] text-[var(--text-entry-foreground)]",
1925
+ "border border-[var(--text-entry-complement)]",
1926
+ // border = 1px width, border-[...] = color
1927
+ "shadow-[0_3px_0_var(--text-entry-complement)]",
1928
+ "transition-all duration-150",
1929
+ "hover:brightness-90 dark:hover:brightness-75",
1930
+ // Placeholder and focus (no ring - matches Select component)
1931
+ "placeholder:text-muted-foreground/60",
1932
+ "focus:outline-none",
1933
+ // Disabled state
1934
+ "disabled:cursor-not-allowed disabled:opacity-50",
1935
+ // Invalid state
1936
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
1937
+ ],
1938
+ {
1939
+ variants: {
1940
+ palette: {
1941
+ default: [
1942
+ "[--text-entry-background:var(--color-background)]",
1943
+ "[--text-entry-foreground:var(--color-foreground)]",
1944
+ "[--text-entry-complement:var(--color-accent)]"
1945
+ ],
1946
+ betta: [
1947
+ "[--text-entry-background:var(--color-background)]",
1948
+ "[--text-entry-foreground:var(--color-betta)]",
1949
+ "[--text-entry-complement:var(--color-butterfly)]"
1950
+ ],
1951
+ cardinal: [
1952
+ "[--text-entry-background:hsl(var(--cardinal)/0.15)]",
1953
+ "[--text-entry-foreground:var(--color-cardinal)]",
1954
+ "[--text-entry-complement:var(--color-fire-ant)]"
1955
+ ],
1956
+ bee: [
1957
+ "[--text-entry-background:var(--color-background)]",
1958
+ "[--text-entry-foreground:var(--color-bee)]",
1959
+ "[--text-entry-complement:var(--color-lion)]"
1960
+ ],
1961
+ owl: [
1962
+ "[--text-entry-background:hsl(var(--owl)/0.15)]",
1963
+ "[--text-entry-foreground:var(--color-owl)]",
1964
+ "[--text-entry-complement:var(--color-tree-frog)]"
1965
+ ],
1966
+ macaw: [
1967
+ "[--text-entry-background:var(--color-background)]",
1968
+ "[--text-entry-foreground:var(--color-macaw)]",
1969
+ "[--text-entry-complement:var(--color-whale)]"
1970
+ ]
1971
+ }
1972
+ },
1973
+ defaultVariants: {
1974
+ palette: "default"
1975
+ }
1976
+ }
1977
+ );
1978
+ function TextEntryInteraction({
1979
+ interaction,
1980
+ response,
1981
+ onAnswerSelect,
1982
+ disabled = false,
1983
+ hasSubmitted,
1984
+ palette = "macaw",
1985
+ isCorrect
1986
+ }) {
1987
+ const value = response ?? "";
1988
+ const hasValue = value.length > 0;
1989
+ const getActivePalette = () => {
1990
+ if (hasSubmitted && hasValue) {
1991
+ return isCorrect ? "owl" : "cardinal";
1992
+ }
1993
+ if (hasValue) {
1994
+ return palette;
1995
+ }
1996
+ return "default";
1997
+ };
1998
+ const activePalette = getActivePalette();
1999
+ const charWidth = interaction.expectedLength ?? 10;
2000
+ const inputWidth = `${Math.max(charWidth, 3) + 4}ch`;
2001
+ return /* @__PURE__ */ jsx("div", { className: "my-4 inline-block", children: /* @__PURE__ */ jsx(
2002
+ "input",
2003
+ {
2004
+ type: "text",
2005
+ value,
2006
+ onChange: (e) => onAnswerSelect?.(e.target.value),
2007
+ disabled: disabled || hasSubmitted,
2008
+ placeholder: interaction.placeholder ?? "...",
2009
+ pattern: interaction.patternMask,
2010
+ className: cn(textEntryVariants({ palette: activePalette })),
2011
+ style: { width: inputWidth, minWidth: "8ch" },
2012
+ "aria-invalid": hasSubmitted && hasValue && isCorrect === false ? true : void 0
2013
+ }
2014
+ ) });
2015
+ }
2016
+ function InlineTextEntry({
2017
+ embed,
2018
+ response,
2019
+ onAnswerSelect,
2020
+ disabled = false,
2021
+ hasSubmitted,
2022
+ palette = "macaw",
2023
+ isCorrect
2024
+ }) {
2025
+ const value = response ?? "";
2026
+ const hasValue = value.length > 0;
2027
+ const getActivePalette = () => {
2028
+ if (hasSubmitted && hasValue) {
2029
+ return isCorrect ? "owl" : "cardinal";
2030
+ }
2031
+ if (hasValue) {
2032
+ return palette;
2033
+ }
2034
+ return "default";
2035
+ };
2036
+ const activePalette = getActivePalette();
2037
+ const charWidth = embed.expectedLength ?? 4;
2038
+ const inputWidth = `${Math.max(charWidth, 2) + 3}ch`;
2039
+ return /* @__PURE__ */ jsx(
2040
+ "input",
2041
+ {
2042
+ type: "text",
2043
+ value,
2044
+ onChange: (e) => onAnswerSelect?.(e.target.value),
2045
+ disabled: disabled || hasSubmitted,
2046
+ placeholder: "...",
2047
+ pattern: embed.patternMask,
2048
+ className: cn(textEntryVariants({ palette: activePalette }), "align-baseline"),
2049
+ style: { width: inputWidth, minWidth: "6ch" },
2050
+ "aria-invalid": hasSubmitted && hasValue && isCorrect === false ? true : void 0
2051
+ }
2052
+ );
2053
+ }
2054
+ function OrderInteractionBlock({
2055
+ interaction,
2056
+ responses,
2057
+ onAnswerSelect,
2058
+ disabled,
2059
+ showFeedback,
2060
+ perResponseFeedback
2061
+ }) {
2062
+ const response = responses?.[interaction.responseId];
2063
+ const hasInitialized = React3.useRef(false);
2064
+ React3.useEffect(() => {
2065
+ if (hasInitialized.current) return;
2066
+ if (response) return;
2067
+ if (interaction.choices.length === 0) return;
2068
+ if (!onAnswerSelect) return;
2069
+ hasInitialized.current = true;
2070
+ const initialOrder = interaction.choices.map((c) => c.id);
2071
+ onAnswerSelect(interaction.responseId, initialOrder);
2072
+ }, [response, interaction.choices, interaction.responseId, onAnswerSelect]);
2073
+ let responseArray = [];
2074
+ if (Array.isArray(response)) {
2075
+ responseArray = response;
2076
+ } else if (typeof response === "string") {
2077
+ responseArray = [response];
2078
+ }
2079
+ const feedback = perResponseFeedback?.[interaction.responseId];
2080
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2081
+ /* @__PURE__ */ jsx(
2082
+ OrderInteraction,
2083
+ {
2084
+ interaction,
2085
+ response: responseArray,
2086
+ onAnswerSelect: (val) => onAnswerSelect?.(interaction.responseId, val),
2087
+ disabled,
2088
+ hasSubmitted: showFeedback,
2089
+ isCorrect: feedback?.isCorrect
2090
+ }
2091
+ ),
2092
+ /* @__PURE__ */ jsx(
2093
+ FeedbackMessage,
2094
+ {
2095
+ responseId: interaction.responseId,
2096
+ showFeedback,
2097
+ perResponseFeedback
2098
+ }
2099
+ )
2100
+ ] });
2101
+ }
2102
+ function GapMatchInteractionBlock({
2103
+ interaction,
2104
+ responses,
2105
+ onAnswerSelect,
2106
+ disabled,
2107
+ showFeedback,
2108
+ perResponseFeedback,
2109
+ selectedChoicesByResponse
2110
+ }) {
2111
+ const response = responses?.[interaction.responseId];
2112
+ let responseArray = [];
2113
+ if (Array.isArray(response)) {
2114
+ responseArray = response;
2115
+ } else if (typeof response === "string" && response) {
2116
+ responseArray = [response];
2117
+ }
2118
+ const gapCorrectness = React3.useMemo(() => {
2119
+ const entries = selectedChoicesByResponse?.[interaction.responseId];
2120
+ if (!entries || !showFeedback) return void 0;
2121
+ const result = {};
2122
+ for (const entry of entries) {
2123
+ const parts = entry.id.split(" ");
2124
+ const gapId = parts[1];
2125
+ if (gapId) {
2126
+ result[gapId] = entry.isCorrect;
2127
+ }
2128
+ }
2129
+ return result;
2130
+ }, [selectedChoicesByResponse, interaction.responseId, showFeedback]);
2131
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2132
+ /* @__PURE__ */ jsx(
2133
+ GapMatchInteraction,
2134
+ {
2135
+ interaction,
2136
+ response: responseArray,
2137
+ onAnswerSelect: (val) => onAnswerSelect?.(interaction.responseId, val),
2138
+ disabled,
2139
+ hasSubmitted: showFeedback,
2140
+ gapCorrectness
2141
+ }
2142
+ ),
2143
+ /* @__PURE__ */ jsx(
2144
+ FeedbackMessage,
2145
+ {
2146
+ responseId: interaction.responseId,
2147
+ showFeedback,
2148
+ perResponseFeedback
2149
+ }
2150
+ )
2151
+ ] });
2152
+ }
2153
+ function MatchInteractionBlock({
2154
+ interaction,
2155
+ responses,
2156
+ onAnswerSelect,
2157
+ disabled,
2158
+ showFeedback,
2159
+ perResponseFeedback,
2160
+ selectedChoicesByResponse
2161
+ }) {
2162
+ const response = responses?.[interaction.responseId];
2163
+ let responseArray = [];
2164
+ if (Array.isArray(response)) {
2165
+ responseArray = response;
2166
+ } else if (typeof response === "string" && response) {
2167
+ responseArray = [response];
2168
+ }
2169
+ const pairCorrectness = React3.useMemo(() => {
2170
+ const entries = selectedChoicesByResponse?.[interaction.responseId];
2171
+ if (!entries || !showFeedback) return void 0;
2172
+ const result = {};
2173
+ for (const entry of entries) {
2174
+ result[entry.id] = entry.isCorrect;
2175
+ }
2176
+ return result;
2177
+ }, [selectedChoicesByResponse, interaction.responseId, showFeedback]);
2178
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2179
+ /* @__PURE__ */ jsx(
2180
+ MatchInteraction,
2181
+ {
2182
+ interaction,
2183
+ response: responseArray,
2184
+ onAnswerSelect: (val) => onAnswerSelect?.(interaction.responseId, val),
2185
+ disabled,
2186
+ hasSubmitted: showFeedback,
2187
+ pairCorrectness
2188
+ }
2189
+ ),
2190
+ /* @__PURE__ */ jsx(
2191
+ FeedbackMessage,
2192
+ {
2193
+ responseId: interaction.responseId,
2194
+ showFeedback,
2195
+ perResponseFeedback
2196
+ }
2197
+ )
2198
+ ] });
2199
+ }
2200
+ function ContentBlockRenderer({
2201
+ block,
2202
+ responses,
2203
+ onAnswerSelect,
2204
+ showFeedback,
2205
+ disabled,
2206
+ selectedChoicesByResponse,
2207
+ perResponseFeedback
2208
+ }) {
2209
+ if (block.type === "stimulus") {
2210
+ return /* @__PURE__ */ jsx(HTMLContent, { html: block.html, className: "mb-6" });
2211
+ }
2212
+ if (block.type === "richStimulus") {
2213
+ const allResponseIds = [
2214
+ ...Object.values(block.inlineEmbeds).map((e) => e.responseId),
2215
+ ...Object.values(block.textEmbeds).map((e) => e.responseId)
2216
+ ];
2217
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2218
+ /* @__PURE__ */ jsx(
2219
+ HTMLContent,
2220
+ {
2221
+ html: block.html,
2222
+ className: "mb-6",
2223
+ inlineEmbeds: block.inlineEmbeds,
2224
+ textEmbeds: block.textEmbeds,
2225
+ renderInline: (embed) => {
2226
+ const currentValue = (() => {
2227
+ const v = responses?.[embed.responseId];
2228
+ return typeof v === "string" ? v : "";
2229
+ })();
2230
+ const perSelectionEntries = selectedChoicesByResponse?.[embed.responseId];
2231
+ const selectedIsCorrect = perSelectionEntries && currentValue ? perSelectionEntries.find((e) => e.id === currentValue)?.isCorrect : void 0;
2232
+ return /* @__PURE__ */ jsx(
2233
+ InlineInteraction,
2234
+ {
2235
+ embed,
2236
+ response: currentValue,
2237
+ onAnswerSelect: (v) => {
2238
+ if (onAnswerSelect) onAnswerSelect(embed.responseId, v);
2239
+ },
2240
+ disabled: disabled || showFeedback,
2241
+ hasSubmitted: showFeedback,
2242
+ isCorrect: selectedIsCorrect
2243
+ }
2244
+ );
2245
+ },
2246
+ renderTextEntry: (embed) => {
2247
+ const currentValue = (() => {
2248
+ const v = responses?.[embed.responseId];
2249
+ return typeof v === "string" ? v : "";
2250
+ })();
2251
+ const feedback = perResponseFeedback?.[embed.responseId];
2252
+ return /* @__PURE__ */ jsx(
2253
+ InlineTextEntry,
2254
+ {
2255
+ embed,
2256
+ response: currentValue,
2257
+ onAnswerSelect: (v) => {
2258
+ if (onAnswerSelect) onAnswerSelect(embed.responseId, v);
2259
+ },
2260
+ disabled: disabled || showFeedback,
2261
+ hasSubmitted: showFeedback,
2262
+ isCorrect: feedback?.isCorrect
2263
+ },
2264
+ embed.responseId
2265
+ );
2266
+ }
2267
+ }
2268
+ ),
2269
+ allResponseIds.map((responseId) => /* @__PURE__ */ jsx(
2270
+ FeedbackMessage,
2271
+ {
2272
+ responseId,
2273
+ showFeedback,
2274
+ perResponseFeedback
2275
+ },
2276
+ `fb-${responseId}`
2277
+ ))
2278
+ ] });
2279
+ }
2280
+ if (block.type === "interaction" && block.interaction) {
2281
+ const interaction = block.interaction;
2282
+ if (interaction.type === "text") {
2283
+ const response = responses?.[interaction.responseId];
2284
+ const currentValue = typeof response === "string" ? response : "";
2285
+ const feedback = perResponseFeedback?.[interaction.responseId];
2286
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2287
+ /* @__PURE__ */ jsx(
2288
+ TextEntryInteraction,
2289
+ {
2290
+ interaction,
2291
+ response: currentValue,
2292
+ onAnswerSelect: (value) => {
2293
+ if (onAnswerSelect) {
2294
+ onAnswerSelect(interaction.responseId, value);
2295
+ }
2296
+ },
2297
+ disabled,
2298
+ hasSubmitted: showFeedback,
2299
+ isCorrect: feedback?.isCorrect
2300
+ }
2301
+ ),
2302
+ /* @__PURE__ */ jsx(
2303
+ FeedbackMessage,
2304
+ {
2305
+ responseId: interaction.responseId,
2306
+ showFeedback,
2307
+ perResponseFeedback
2308
+ }
2309
+ )
2310
+ ] });
2311
+ }
2312
+ if (interaction.type === "choice") {
2313
+ const response = responses?.[interaction.responseId];
2314
+ let selected = [];
2315
+ if (Array.isArray(response)) {
2316
+ selected = response;
2317
+ } else if (typeof response === "string") {
2318
+ selected = [response];
2319
+ }
2320
+ const perSelectionEntries = selectedChoicesByResponse?.[interaction.responseId];
2321
+ const perSelectionTable = perSelectionEntries ? new Map(perSelectionEntries.map((e) => [e.id, e.isCorrect])) : void 0;
2322
+ let singleIsCorrect;
2323
+ if (interaction.cardinality === "single" && selected.length === 1 && perSelectionTable) {
2324
+ const firstCandidate = selected[0];
2325
+ if (typeof firstCandidate === "string") {
2326
+ singleIsCorrect = perSelectionTable.get(firstCandidate) === true;
2327
+ }
2328
+ }
2329
+ const toChoiceInteraction = (d) => {
2330
+ return {
2331
+ type: "choiceInteraction",
2332
+ responseIdentifier: d.responseId,
2333
+ shuffle: false,
2334
+ minChoices: d.minChoices,
2335
+ maxChoices: d.maxChoices,
2336
+ promptHtml: d.promptHtml,
2337
+ choices: d.choices.map((c) => ({
2338
+ identifier: c.id,
2339
+ contentHtml: c.contentHtml,
2340
+ inlineFeedbackHtml: c.inlineFeedbackHtml
2341
+ }))
2342
+ };
2343
+ };
2344
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2345
+ /* @__PURE__ */ jsx(
2346
+ ChoiceInteractionRenderer,
2347
+ {
2348
+ interaction: toChoiceInteraction(interaction),
2349
+ response,
2350
+ onAnswerSelect: (value) => {
2351
+ if (onAnswerSelect) {
2352
+ onAnswerSelect(interaction.responseId, value);
2353
+ }
2354
+ },
2355
+ disabled,
2356
+ hasSubmitted: showFeedback,
2357
+ isCorrect: singleIsCorrect,
2358
+ selectedChoicesCorrectness: (() => {
2359
+ const entries = selectedChoicesByResponse?.[interaction.responseId];
2360
+ if (!entries) return void 0;
2361
+ const table = new Map(entries.map((e) => [e.id, e.isCorrect]));
2362
+ return selected.map((id) => ({ id, isCorrect: table.get(id) === true }));
2363
+ })()
2364
+ }
2365
+ ),
2366
+ /* @__PURE__ */ jsx(
2367
+ FeedbackMessage,
2368
+ {
2369
+ responseId: interaction.responseId,
2370
+ showFeedback,
2371
+ perResponseFeedback
2372
+ }
2373
+ )
2374
+ ] });
2375
+ }
2376
+ if (interaction.type === "order") {
2377
+ return /* @__PURE__ */ jsx(
2378
+ OrderInteractionBlock,
2379
+ {
2380
+ interaction,
2381
+ responses,
2382
+ onAnswerSelect,
2383
+ disabled,
2384
+ showFeedback,
2385
+ perResponseFeedback
2386
+ }
2387
+ );
2388
+ }
2389
+ if (interaction.type === "gapMatch") {
2390
+ return /* @__PURE__ */ jsx(
2391
+ GapMatchInteractionBlock,
2392
+ {
2393
+ interaction,
2394
+ responses,
2395
+ onAnswerSelect,
2396
+ disabled,
2397
+ showFeedback,
2398
+ perResponseFeedback,
2399
+ selectedChoicesByResponse
2400
+ }
2401
+ );
2402
+ }
2403
+ if (interaction.type === "match") {
2404
+ return /* @__PURE__ */ jsx(
2405
+ MatchInteractionBlock,
2406
+ {
2407
+ interaction,
2408
+ responses,
2409
+ onAnswerSelect,
2410
+ disabled,
2411
+ showFeedback,
2412
+ perResponseFeedback,
2413
+ selectedChoicesByResponse
2414
+ }
2415
+ );
2416
+ }
2417
+ }
2418
+ return null;
2419
+ }
2420
+ function QTIRenderer({
2421
+ item,
2422
+ responses,
2423
+ onResponseChange,
2424
+ theme = "duolingo",
2425
+ showFeedback = false,
2426
+ disabled = false,
2427
+ choiceCorrectness,
2428
+ responseFeedback,
2429
+ overallFeedback,
2430
+ className
2431
+ }) {
2432
+ const themeAttr = theme === "duolingo" ? void 0 : theme;
2433
+ return /* @__PURE__ */ jsxs("div", { className: cn("qti-container", className), "data-qti-theme": themeAttr, children: [
2434
+ /* @__PURE__ */ jsx("div", { className: "space-y-6", children: item.contentBlocks.map((block, index) => /* @__PURE__ */ jsx(
2435
+ ContentBlockRenderer,
2436
+ {
2437
+ block,
2438
+ responses,
2439
+ onAnswerSelect: onResponseChange,
2440
+ showFeedback,
2441
+ disabled,
2442
+ selectedChoicesByResponse: choiceCorrectness,
2443
+ perResponseFeedback: responseFeedback
2444
+ },
2445
+ index
2446
+ )) }),
2447
+ showFeedback && overallFeedback?.messageHtml && /* @__PURE__ */ jsx("div", { className: "qti-feedback mt-6 p-6", "data-correct": overallFeedback.isCorrect, children: /* @__PURE__ */ jsx(HTMLContent, { html: overallFeedback.messageHtml }) })
2448
+ ] });
2449
+ }
2450
+ function QtiFeedbackBlock({ result, item, className }) {
2451
+ const feedbackBlock = React3.useMemo(() => {
2452
+ if (!result.feedbackIdentifier) {
2453
+ return null;
2454
+ }
2455
+ return item.itemBody.feedbackBlocks.find((fb) => fb.identifier === result.feedbackIdentifier);
2456
+ }, [result.feedbackIdentifier, item.itemBody.feedbackBlocks]);
2457
+ if (!feedbackBlock) {
2458
+ return null;
2459
+ }
2460
+ const isCorrect = result.status === "correct";
2461
+ return /* @__PURE__ */ jsxs("div", { className: cn("qti-feedback p-6", className), "data-correct": isCorrect, children: [
2462
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 mb-4", children: [
2463
+ /* @__PURE__ */ jsx(
2464
+ "div",
2465
+ {
2466
+ className: "qti-feedback-icon flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center",
2467
+ "data-correct": isCorrect,
2468
+ children: isCorrect ? /* @__PURE__ */ jsxs("svg", { className: "w-8 h-8 text-white", fill: "currentColor", viewBox: "0 0 24 24", children: [
2469
+ /* @__PURE__ */ jsx("title", { children: "Correct" }),
2470
+ /* @__PURE__ */ jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" })
2471
+ ] }) : /* @__PURE__ */ jsxs("svg", { className: "w-8 h-8 text-white", fill: "currentColor", viewBox: "0 0 24 24", children: [
2472
+ /* @__PURE__ */ jsx("title", { children: "Incorrect" }),
2473
+ /* @__PURE__ */ jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" })
2474
+ ] })
2475
+ }
2476
+ ),
2477
+ /* @__PURE__ */ jsxs("div", { className: "flex-1", children: [
2478
+ /* @__PURE__ */ jsx(
2479
+ "h3",
2480
+ {
2481
+ className: "text-xl font-extrabold",
2482
+ style: { color: isCorrect ? "var(--qti-feedback-correct-text)" : "var(--qti-feedback-incorrect-text)" },
2483
+ children: isCorrect ? "Correct!" : "Incorrect"
2484
+ }
2485
+ ),
2486
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: isCorrect ? "Nice work!" : "Try again" })
2487
+ ] })
2488
+ ] }),
2489
+ /* @__PURE__ */ jsx(HTMLContent, { html: feedbackBlock.contentHtml, className: "text-foreground" })
2490
+ ] });
2491
+ }
2492
+
2493
+ export { ChoiceInteractionRenderer, ContentBlockRenderer, FeedbackMessage, HTMLContent, QTIRenderer, QtiFeedbackBlock };
2494
+ //# sourceMappingURL=index.js.map
2495
+ //# sourceMappingURL=index.js.map