@longsightgroup/qti3-player 0.1.1 → 0.2.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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assertQtiAttemptStateV1, createItemSession, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
1
+ import { assertQtiAttemptStateV1, createItemSession, createCatalogSupportResolution, createTextToSpeechTraversal, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
2
2
  const HTMLElementBase = globalThis.HTMLElement ??
3
3
  class {
4
4
  replaceChildren() { }
@@ -129,6 +129,16 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
129
129
  state.validationMessages = cloneDiagnostics(this.validationMessages);
130
130
  return state;
131
131
  }
132
+ getTextToSpeechTraversal() {
133
+ if (!this.documentModel)
134
+ return undefined;
135
+ return createTextToSpeechTraversal(this.documentModel);
136
+ }
137
+ getCatalogSupportResolution(options = {}) {
138
+ if (!this.documentModel)
139
+ return undefined;
140
+ return createCatalogSupportResolution(this.documentModel, options);
141
+ }
132
142
  emitStateChange(state = this.serialize()) {
133
143
  if (!state)
134
144
  return;
@@ -144,6 +154,10 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
144
154
  this.applyDefaultStyles();
145
155
  const root = document.createElement("article");
146
156
  root.className = "qti3-player";
157
+ if (documentModel.item.language) {
158
+ root.lang = documentModel.item.language;
159
+ root.setAttribute("xml:lang", documentModel.item.language);
160
+ }
147
161
  root.append(playerStyleElement());
148
162
  if (documentModel.item.prompt && documentModel.item.body.length === 0) {
149
163
  const prompt = document.createElement("p");
@@ -162,14 +176,6 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
162
176
  root.append(this.renderInteraction(interaction));
163
177
  }
164
178
  }
165
- const actions = document.createElement("div");
166
- actions.className = "qti3-actions";
167
- const score = document.createElement("button");
168
- score.type = "button";
169
- score.textContent = "Score";
170
- score.addEventListener("click", () => this.scoreAttempt());
171
- actions.append(score);
172
- root.append(actions);
173
179
  const feedback = document.createElement("section");
174
180
  feedback.className = "qti3-feedback";
175
181
  feedback.role = "status";
@@ -187,6 +193,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
187
193
  if (interaction.responseIdentifier)
188
194
  field.dataset.responseIdentifier = interaction.responseIdentifier;
189
195
  const heading = document.createElement("h3");
196
+ copySafeAttributes(heading, interaction.promptAttributes ?? {});
190
197
  heading.textContent = interactionLabel(interaction);
191
198
  field.append(heading);
192
199
  if (interaction.responseIdentifier) {
@@ -200,9 +207,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
200
207
  return;
201
208
  this.session.respond(responseIdentifier, value);
202
209
  this.clearValidationMessage(responseIdentifier);
203
- this.dispatchEvent(new CustomEvent("qti-responsechange", {
204
- detail: { responseIdentifier, value },
205
- }));
210
+ this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
206
211
  this.emitStateChange();
207
212
  };
208
213
  const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
@@ -263,7 +268,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
263
268
  return field;
264
269
  }
265
270
  if (interaction.type === "portableCustom") {
266
- field.append(renderPortableCustomResponse(interaction, update, currentValue));
271
+ field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
267
272
  return field;
268
273
  }
269
274
  if (interaction.type === "textEntry") {
@@ -295,12 +300,97 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
295
300
  return field;
296
301
  }
297
302
  if (interaction.type === "media") {
298
- field.append(renderObjectAsset(interaction));
303
+ field.append(renderObjectAsset(interaction, {
304
+ currentValue,
305
+ update,
306
+ isCompleted: () => this.attemptIsCompleted(),
307
+ }));
299
308
  return field;
300
309
  }
301
310
  field.append(renderSelect(interaction, update, currentValue));
302
311
  return field;
303
312
  }
313
+ renderPortableCustomResponse(interaction, update, currentValue) {
314
+ const definition = interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
315
+ const responseIdentifier = interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
316
+ const currentState = responseIdentifier
317
+ ? this.currentInteractionState(responseIdentifier)
318
+ : undefined;
319
+ const group = document.createElement("div");
320
+ group.role = "group";
321
+ group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
322
+ const host = document.createElement("div");
323
+ host.className = "qti3-portable-custom-host";
324
+ host.tabIndex = 0;
325
+ host.dataset.responseIdentifier = responseIdentifier;
326
+ host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
327
+ host.dataset.module = definition.module ?? "";
328
+ host.dataset.qtiName = interaction.qtiName;
329
+ if (definition.interactionModules?.primaryConfiguration) {
330
+ host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
331
+ }
332
+ if (definition.interactionModules?.secondaryConfiguration) {
333
+ host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
334
+ }
335
+ if (currentState !== undefined)
336
+ host.dataset.state = JSON.stringify(currentState);
337
+ host.setAttribute("role", "application");
338
+ host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
339
+ host.style.border = "1px solid CanvasText";
340
+ host.style.padding = "0.5rem";
341
+ host.style.marginBlockEnd = "0.5rem";
342
+ if (definition.interactionMarkup.length > 0) {
343
+ const markup = document.createElement("div");
344
+ markup.className = "qti3-portable-custom-markup";
345
+ markup.append(...this.renderContentNodes(definition.interactionMarkup));
346
+ host.append(markup);
347
+ }
348
+ else {
349
+ host.textContent = "Portable custom interaction host";
350
+ }
351
+ const fallback = document.createElement("input");
352
+ fallback.type = "hidden";
353
+ fallback.className = "qti3-portable-custom-response";
354
+ fallback.hidden = true;
355
+ fallback.tabIndex = -1;
356
+ fallback.setAttribute("aria-hidden", "true");
357
+ fallback.value = scalarString(currentValue);
358
+ const handlePortableCustomEvent = (event) => {
359
+ const state = portableCustomEventState(event);
360
+ const value = portableCustomEventValue(event);
361
+ const validity = portableCustomEventValidity(event);
362
+ if (state !== undefined && responseIdentifier && this.session) {
363
+ this.session.setInteractionState(responseIdentifier, state);
364
+ host.dataset.state = JSON.stringify(state);
365
+ }
366
+ if (value !== undefined) {
367
+ fallback.value = String(value ?? "");
368
+ update(value);
369
+ }
370
+ if (validity && responseIdentifier) {
371
+ this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
372
+ this.emitStateChange();
373
+ }
374
+ if (value === undefined && state !== undefined && !validity)
375
+ this.emitStateChange();
376
+ };
377
+ host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
378
+ host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
379
+ host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
380
+ host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
381
+ queueMicrotask(() => {
382
+ this.dispatchPlayerEvent("qti-portable-custom-mount", {
383
+ responseIdentifier,
384
+ interaction,
385
+ definition,
386
+ host,
387
+ value: currentValue,
388
+ state: currentState,
389
+ });
390
+ });
391
+ group.append(host, fallback);
392
+ return group;
393
+ }
304
394
  renderEmbeddedInteraction(interaction) {
305
395
  if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
306
396
  return this.renderInteraction(interaction);
@@ -318,9 +408,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
318
408
  return;
319
409
  this.session.respond(responseIdentifier, value);
320
410
  this.clearValidationMessage(responseIdentifier);
321
- this.dispatchEvent(new CustomEvent("qti-responsechange", {
322
- detail: { responseIdentifier, value },
323
- }));
411
+ this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
324
412
  this.emitStateChange();
325
413
  };
326
414
  const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
@@ -354,10 +442,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
354
442
  }
355
443
  if (node.qtiName === "qti-prompt") {
356
444
  const prompt = document.createElement("p");
357
- prompt.className = "qti3-item-prompt";
445
+ copySafeAttributes(prompt, node.attributes);
446
+ prompt.classList.add("qti3-item-prompt");
358
447
  prompt.append(...this.renderContentNodes(node.children));
359
448
  return [prompt];
360
449
  }
450
+ if (unsafeContentElements.has(node.qtiName))
451
+ return [];
361
452
  const elementName = contentElementName(node.qtiName);
362
453
  if (!elementName)
363
454
  return this.renderContentNodes(node.children);
@@ -432,7 +523,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
432
523
  const article = this.querySelector(".qti3-player");
433
524
  if (article)
434
525
  article.dataset.status = this.dataset.status;
435
- for (const control of this.querySelectorAll(".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea, .qti3-actions button")) {
526
+ for (const control of this.querySelectorAll(".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea")) {
436
527
  control.disabled = completed;
437
528
  }
438
529
  for (const element of this.querySelectorAll(".qti3-interaction [tabindex]:not(button):not(input):not(select):not(textarea)")) {
@@ -497,6 +588,26 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
497
588
  currentResponseValue(identifier) {
498
589
  return this.session?.serialize().responses[identifier] ?? null;
499
590
  }
591
+ currentInteractionState(identifier) {
592
+ return this.session?.serialize().interactionStates?.[identifier];
593
+ }
594
+ setPortableCustomValidity(responseIdentifier, valid, message) {
595
+ if (valid) {
596
+ this.clearValidationMessage(responseIdentifier);
597
+ return;
598
+ }
599
+ const diagnostic = {
600
+ code: "response.portableCustom.validity",
601
+ severity: "error",
602
+ message: message?.trim() || `${responseIdentifier} is not valid.`,
603
+ path: responseIdentifier,
604
+ };
605
+ this.validationMessages = [
606
+ ...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
607
+ diagnostic,
608
+ ];
609
+ this.renderValidationMessages();
610
+ }
500
611
  applyDefaultStyles() {
501
612
  this.style.color = "CanvasText";
502
613
  this.style.backgroundColor = "Canvas";
@@ -523,20 +634,24 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
523
634
  .map((interaction) => [interaction.responseIdentifier, interaction]));
524
635
  const diagnostics = [];
525
636
  for (const declaration of this.documentModel.item.responseDeclarations) {
526
- if (declaration.correctResponse === null)
527
- continue;
528
637
  const interaction = interactionsByResponse.get(declaration.identifier);
638
+ if (declaration.correctResponse === null && interaction?.type !== "media")
639
+ continue;
529
640
  const minimum = minimumRequiredResponses(interaction);
530
- const count = responseCount(state.responses[declaration.identifier] ?? null);
641
+ const count = interaction?.type === "media"
642
+ ? mediaPlayCount(state.responses[declaration.identifier] ?? null)
643
+ : responseCount(state.responses[declaration.identifier] ?? null);
531
644
  const maximum = maximumAllowedResponses(interaction);
532
645
  if (count < minimum) {
533
646
  diagnostics.push({
534
647
  code: "response.required",
535
648
  severity: "error",
536
649
  message: interaction?.attributes["data-min-selections-message"] ??
537
- (minimum === 1
538
- ? `${declaration.identifier} requires a response.`
539
- : `${declaration.identifier} requires at least ${minimum} responses.`),
650
+ (interaction?.type === "media"
651
+ ? `${declaration.identifier} requires at least ${minimum} play${minimum === 1 ? "" : "s"}.`
652
+ : minimum === 1
653
+ ? `${declaration.identifier} requires a response.`
654
+ : `${declaration.identifier} requires at least ${minimum} responses.`),
540
655
  path: declaration.identifier,
541
656
  });
542
657
  }
@@ -545,7 +660,9 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
545
660
  code: "response.maximum",
546
661
  severity: "error",
547
662
  message: interaction?.attributes["data-max-selections-message"] ??
548
- `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`,
663
+ (interaction?.type === "media"
664
+ ? `${declaration.identifier} allows at most ${maximum} play${maximum === 1 ? "" : "s"}.`
665
+ : `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`),
549
666
  path: declaration.identifier,
550
667
  });
551
668
  }
@@ -1409,6 +1526,12 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1409
1526
  const selectedPairs = valueToStrings(currentValue);
1410
1527
  const maximumAssociations = interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
1411
1528
  let selectedHotspot;
1529
+ let draggedHotspot;
1530
+ let dragPointerId;
1531
+ let dragStart;
1532
+ let dragStarted = false;
1533
+ let suppressNextClick = false;
1534
+ let previewLine;
1412
1535
  const surface = document.createElement("div");
1413
1536
  surface.className = "qti3-graphic-associate-surface";
1414
1537
  surface.role = "group";
@@ -1498,6 +1621,52 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1498
1621
  renderState();
1499
1622
  commit();
1500
1623
  };
1624
+ const authoredPointFromPointer = (event) => {
1625
+ const rect = surface.getBoundingClientRect();
1626
+ return {
1627
+ x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
1628
+ y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
1629
+ };
1630
+ };
1631
+ const removePreviewLine = () => {
1632
+ previewLine?.remove();
1633
+ previewLine = undefined;
1634
+ };
1635
+ const suppressFollowingClick = () => {
1636
+ suppressNextClick = true;
1637
+ setTimeout(() => {
1638
+ suppressNextClick = false;
1639
+ }, 0);
1640
+ };
1641
+ const updatePreviewLine = (source, event) => {
1642
+ const start = hotspotCenter(source, width, height);
1643
+ const end = authoredPointFromPointer(event);
1644
+ if (!previewLine) {
1645
+ previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
1646
+ previewLine.dataset.preview = "true";
1647
+ connections.append(previewLine);
1648
+ }
1649
+ previewLine.setAttribute("x1", String(start.x));
1650
+ previewLine.setAttribute("y1", String(start.y));
1651
+ previewLine.setAttribute("x2", String(end.x));
1652
+ previewLine.setAttribute("y2", String(end.y));
1653
+ };
1654
+ const hotspotFromPointer = (event) => {
1655
+ const element = document.elementFromPoint(event.clientX, event.clientY);
1656
+ const button = element?.closest(".qti3-graphic-associate-hotspot");
1657
+ const identifier = button?.dataset.choiceIdentifier;
1658
+ return choices.find((choice) => choice.identifier === identifier);
1659
+ };
1660
+ const finishDrag = (event, source) => {
1661
+ const target = hotspotFromPointer(event);
1662
+ removePreviewLine();
1663
+ if (target) {
1664
+ addPair(source, target);
1665
+ return;
1666
+ }
1667
+ selectedHotspot = undefined;
1668
+ renderState();
1669
+ };
1501
1670
  const chooseHotspot = (choice) => {
1502
1671
  if (!selectedHotspot) {
1503
1672
  selectedHotspot = choice;
@@ -1571,8 +1740,66 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1571
1740
  button.setAttribute("aria-pressed", "false");
1572
1741
  button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1573
1742
  button.style.position = "absolute";
1743
+ button.style.touchAction = "none";
1574
1744
  placeHotspotButton(button, choice, width, height);
1575
- button.addEventListener("click", () => chooseHotspot(choice));
1745
+ button.addEventListener("click", (event) => {
1746
+ if (suppressNextClick) {
1747
+ suppressNextClick = false;
1748
+ event.preventDefault();
1749
+ return;
1750
+ }
1751
+ chooseHotspot(choice);
1752
+ });
1753
+ button.addEventListener("pointerdown", (event) => {
1754
+ if (event.button !== 0)
1755
+ return;
1756
+ draggedHotspot = choice;
1757
+ dragPointerId = event.pointerId;
1758
+ dragStart = { x: event.clientX, y: event.clientY };
1759
+ dragStarted = false;
1760
+ button.setPointerCapture(event.pointerId);
1761
+ });
1762
+ button.addEventListener("pointermove", (event) => {
1763
+ if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart)
1764
+ return;
1765
+ const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
1766
+ if (!dragStarted && moved < 4)
1767
+ return;
1768
+ if (!dragStarted) {
1769
+ dragStarted = true;
1770
+ suppressFollowingClick();
1771
+ selectedHotspot = draggedHotspot;
1772
+ renderState();
1773
+ }
1774
+ updatePreviewLine(draggedHotspot, event);
1775
+ event.preventDefault();
1776
+ });
1777
+ button.addEventListener("pointerup", (event) => {
1778
+ if (dragPointerId !== event.pointerId || !draggedHotspot)
1779
+ return;
1780
+ const source = draggedHotspot;
1781
+ draggedHotspot = undefined;
1782
+ dragPointerId = undefined;
1783
+ dragStart = undefined;
1784
+ button.releasePointerCapture(event.pointerId);
1785
+ if (!dragStarted)
1786
+ return;
1787
+ dragStarted = false;
1788
+ suppressFollowingClick();
1789
+ finishDrag(event, source);
1790
+ event.preventDefault();
1791
+ });
1792
+ button.addEventListener("pointercancel", (event) => {
1793
+ if (dragPointerId !== event.pointerId)
1794
+ return;
1795
+ draggedHotspot = undefined;
1796
+ dragPointerId = undefined;
1797
+ dragStart = undefined;
1798
+ dragStarted = false;
1799
+ removePreviewLine();
1800
+ selectedHotspot = undefined;
1801
+ renderState();
1802
+ });
1576
1803
  button.addEventListener("keydown", (event) => {
1577
1804
  if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1578
1805
  event.preventDefault();
@@ -1594,6 +1821,11 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1594
1821
  return group;
1595
1822
  }
1596
1823
  function renderGapMatchResponse(interaction, update, currentValue) {
1824
+ if (interaction.type === "graphicGapMatch" &&
1825
+ interaction.object &&
1826
+ interaction.choices.some((choice) => choice.role === "hotspot")) {
1827
+ return renderGraphicGapMatchResponse(interaction, update, currentValue);
1828
+ }
1597
1829
  const group = responseGroup();
1598
1830
  appendGraphicContext(group, interaction);
1599
1831
  const sources = sourceChoices(interaction);
@@ -1703,6 +1935,153 @@ function renderGapMatchResponse(interaction, update, currentValue) {
1703
1935
  group.append(sourceRegion, gapRegion);
1704
1936
  return group;
1705
1937
  }
1938
+ function renderGraphicGapMatchResponse(interaction, update, currentValue) {
1939
+ const group = responseGroup();
1940
+ const width = objectWidth(interaction);
1941
+ const height = objectHeight(interaction);
1942
+ const sources = sourceChoices(interaction);
1943
+ const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
1944
+ const assignments = new Map();
1945
+ let selectedSource;
1946
+ let draggedSource;
1947
+ for (const pair of valueToStrings(currentValue)) {
1948
+ const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
1949
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
1950
+ if (source && gapIdentifier)
1951
+ assignments.set(gapIdentifier, source);
1952
+ }
1953
+ const surface = document.createElement("div");
1954
+ surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
1955
+ surface.role = "group";
1956
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
1957
+ surface.style.position = "relative";
1958
+ surface.style.inlineSize = `${width}px`;
1959
+ surface.style.aspectRatio = `${width} / ${height}`;
1960
+ surface.style.maxInlineSize = "100%";
1961
+ surface.style.border = "1px solid CanvasText";
1962
+ surface.style.background = "Canvas";
1963
+ surface.style.overflow = "visible";
1964
+ surface.style.setProperty("--qti3-graphic-gap-label-block-size", `${graphicGapLabelBlockSize(sources)}rem`);
1965
+ if (interaction.object?.data && objectIsImage(interaction.object)) {
1966
+ const image = document.createElement("img");
1967
+ image.src = interaction.object.data;
1968
+ image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
1969
+ image.style.position = "absolute";
1970
+ image.style.inset = "0";
1971
+ image.style.inlineSize = "100%";
1972
+ image.style.blockSize = "100%";
1973
+ image.style.objectFit = "contain";
1974
+ image.style.pointerEvents = "none";
1975
+ surface.append(image);
1976
+ }
1977
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
1978
+ sourceRegion.classList.add("qti3-graphic-gap-source-region");
1979
+ const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
1980
+ if (choicesWidth !== undefined)
1981
+ sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
1982
+ const summary = document.createElement("p");
1983
+ summary.className = "qti3-selection-summary";
1984
+ summary.setAttribute("aria-live", "polite");
1985
+ const commit = () => {
1986
+ update([...assignments.entries()].map(([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`));
1987
+ };
1988
+ const syncSources = () => {
1989
+ for (const button of sourceRegion.querySelectorAll("button")) {
1990
+ button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
1991
+ }
1992
+ };
1993
+ const clearSourceIfSingleUse = (source, keepGapIdentifier) => {
1994
+ if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1)
1995
+ return;
1996
+ for (const [gapIdentifier, assigned] of assignments.entries()) {
1997
+ if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
1998
+ assignments.delete(gapIdentifier);
1999
+ }
2000
+ }
2001
+ };
2002
+ const assign = (gap, sourceIdentifier) => {
2003
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2004
+ if (!source)
2005
+ return;
2006
+ clearSourceIfSingleUse(source, gap.identifier);
2007
+ assignments.set(gap.identifier, source);
2008
+ selectedSource = undefined;
2009
+ syncSources();
2010
+ renderTargets();
2011
+ commit();
2012
+ };
2013
+ const targetLabel = (gap, index) => gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
2014
+ const renderTargetButton = (gap, index) => {
2015
+ const assigned = assignments.get(gap.identifier);
2016
+ const label = targetLabel(gap, index);
2017
+ const button = document.createElement("button");
2018
+ button.type = "button";
2019
+ button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
2020
+ button.dataset.gapIdentifier = gap.identifier;
2021
+ button.dataset.selected = assigned ? "true" : "false";
2022
+ button.setAttribute("aria-label", assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`);
2023
+ button.addEventListener("dragover", (event) => {
2024
+ event.preventDefault();
2025
+ button.classList.add("qti3-drop-target");
2026
+ });
2027
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
2028
+ button.addEventListener("drop", (event) => {
2029
+ event.preventDefault();
2030
+ button.classList.remove("qti3-drop-target");
2031
+ assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
2032
+ });
2033
+ button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
2034
+ button.addEventListener("keydown", (event) => {
2035
+ if (event.key !== "Delete" && event.key !== "Backspace")
2036
+ return;
2037
+ if (!assignments.has(gap.identifier))
2038
+ return;
2039
+ event.preventDefault();
2040
+ assignments.delete(gap.identifier);
2041
+ renderTargets();
2042
+ commit();
2043
+ });
2044
+ placeHotspotButton(button, gap, width, height);
2045
+ if (assigned) {
2046
+ const assignedLabel = document.createElement("span");
2047
+ assignedLabel.className = "qti3-graphic-gap-label";
2048
+ assignedLabel.textContent = assigned.text;
2049
+ button.append(assignedLabel);
2050
+ }
2051
+ return button;
2052
+ };
2053
+ const renderTargets = () => {
2054
+ surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
2055
+ for (const [index, gap] of gaps.entries()) {
2056
+ surface.append(renderTargetButton(gap, index));
2057
+ }
2058
+ summary.textContent =
2059
+ assignments.size > 0
2060
+ ? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
2061
+ : "No labels placed.";
2062
+ };
2063
+ for (const source of sources) {
2064
+ const button = tokenButton(source);
2065
+ button.draggable = true;
2066
+ button.addEventListener("dragstart", (event) => {
2067
+ draggedSource = source.identifier;
2068
+ event.dataTransfer?.setData("text/plain", source.identifier);
2069
+ event.dataTransfer?.setDragImage(button, 8, 8);
2070
+ });
2071
+ button.addEventListener("dragend", () => {
2072
+ draggedSource = undefined;
2073
+ syncSources();
2074
+ });
2075
+ button.addEventListener("click", () => {
2076
+ selectedSource = source;
2077
+ syncSources();
2078
+ });
2079
+ sourceRegion.append(button);
2080
+ }
2081
+ renderTargets();
2082
+ group.append(surface, sourceRegion, summary);
2083
+ return group;
2084
+ }
1706
2085
  function renderSelect(interaction, update, currentValue) {
1707
2086
  const select = document.createElement("select");
1708
2087
  select.className = "qti3-inline-select";
@@ -2021,10 +2400,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2021
2400
  const height = objectAssetHeight(stageObject, 300);
2022
2401
  const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
2023
2402
  const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
2024
- let point = parsePointValue(currentValue) ?? {
2025
- x: Math.round(width / 2),
2026
- y: Math.round(height / 2),
2027
- };
2403
+ const parsedPoint = parsePointValue(currentValue);
2404
+ let point = parsedPoint ?? { x: 0, y: 0 };
2405
+ let isPlaced = Boolean(parsedPoint);
2028
2406
  const stage = document.createElement("div");
2029
2407
  stage.className = "qti3-position-object-stage";
2030
2408
  stage.tabIndex = 0;
@@ -2037,8 +2415,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2037
2415
  stage.style.border = "1px solid CanvasText";
2038
2416
  stage.style.background = "Canvas";
2039
2417
  stage.style.color = "CanvasText";
2040
- stage.style.overflow = "hidden";
2418
+ stage.style.overflow = "visible";
2041
2419
  stage.style.touchAction = "none";
2420
+ stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
2042
2421
  if (stageObject?.data && objectIsImage(stageObject)) {
2043
2422
  const image = document.createElement("img");
2044
2423
  image.src = stageObject.data;
@@ -2087,10 +2466,22 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2087
2466
  point.y = Math.max(0, Math.min(height, point.y));
2088
2467
  };
2089
2468
  const commit = () => {
2469
+ if (!isPlaced)
2470
+ return;
2090
2471
  update(pointToString(point));
2091
2472
  };
2092
2473
  const syncMarker = () => {
2474
+ if (!isPlaced) {
2475
+ marker.dataset.placed = "false";
2476
+ marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
2477
+ marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
2478
+ coordinate.value = "";
2479
+ coordinate.textContent = "Object not placed";
2480
+ stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage, object not placed`);
2481
+ return;
2482
+ }
2093
2483
  clamp();
2484
+ marker.dataset.placed = "true";
2094
2485
  marker.style.insetInlineStart = `${percent(point.x, width)}%`;
2095
2486
  marker.style.insetBlockStart = `${percent(point.y, height)}%`;
2096
2487
  coordinate.value = pointToString(point);
@@ -2103,9 +2494,17 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2103
2494
  x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2104
2495
  y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2105
2496
  };
2497
+ isPlaced = true;
2106
2498
  clamp();
2107
2499
  };
2500
+ const ensureKeyboardPoint = () => {
2501
+ if (isPlaced)
2502
+ return;
2503
+ point = { x: 0, y: 0 };
2504
+ isPlaced = true;
2505
+ };
2108
2506
  const moveBy = (dx, dy, emit = true) => {
2507
+ ensureKeyboardPoint();
2109
2508
  point.x += dx;
2110
2509
  point.y += dy;
2111
2510
  syncMarker();
@@ -2122,24 +2521,32 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2122
2521
  moveBy(0, -step, false);
2123
2522
  else if (event.key === "ArrowDown")
2124
2523
  moveBy(0, step, false);
2125
- else if (event.key === "Enter" || event.key === " ")
2524
+ else if (event.key === "Enter" || event.key === " ") {
2525
+ ensureKeyboardPoint();
2526
+ syncMarker();
2126
2527
  commit();
2528
+ }
2127
2529
  else
2128
2530
  return;
2129
2531
  event.preventDefault();
2130
2532
  };
2131
2533
  let dragging = false;
2534
+ let dragMoved = false;
2132
2535
  marker.addEventListener("pointerdown", (event) => {
2133
2536
  dragging = true;
2537
+ dragMoved = false;
2134
2538
  marker.setPointerCapture(event.pointerId);
2135
2539
  marker.style.cursor = "grabbing";
2136
- pointFromPointer(event);
2137
- syncMarker();
2540
+ if (isPlaced) {
2541
+ pointFromPointer(event);
2542
+ syncMarker();
2543
+ }
2138
2544
  event.preventDefault();
2139
2545
  });
2140
2546
  marker.addEventListener("pointermove", (event) => {
2141
2547
  if (!dragging)
2142
2548
  return;
2549
+ dragMoved = true;
2143
2550
  pointFromPointer(event);
2144
2551
  syncMarker();
2145
2552
  });
@@ -2149,9 +2556,11 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2149
2556
  dragging = false;
2150
2557
  marker.releasePointerCapture(event.pointerId);
2151
2558
  marker.style.cursor = "grab";
2152
- pointFromPointer(event);
2153
- syncMarker();
2154
- commit();
2559
+ if (dragMoved || isPlaced) {
2560
+ pointFromPointer(event);
2561
+ syncMarker();
2562
+ commit();
2563
+ }
2155
2564
  });
2156
2565
  marker.addEventListener("pointercancel", () => {
2157
2566
  dragging = false;
@@ -2199,8 +2608,17 @@ function renderDrawingResponse(interaction, update, currentValue) {
2199
2608
  surface.style.border = "1px solid CanvasText";
2200
2609
  surface.style.background = "Canvas";
2201
2610
  surface.style.touchAction = "none";
2202
- const background = drawingBackgroundImage(interaction, width, height);
2611
+ const restoredStrokes = parseDrawingValue(currentValue);
2612
+ const authoredBackgroundHref = drawingBackgroundHref(interaction);
2613
+ let resolvedAuthoredBackgroundHref = authoredBackgroundHref;
2614
+ let activeBackgroundIsAuthored = restoredStrokes.length > 0 || !drawingResponseImage(currentValue);
2615
+ let activeBackgroundHref = restoredStrokes.length === 0
2616
+ ? (drawingResponseImage(currentValue) ?? authoredBackgroundHref)
2617
+ : authoredBackgroundHref;
2203
2618
  const resetSurface = () => {
2619
+ const background = activeBackgroundHref
2620
+ ? drawingImageElement(activeBackgroundHref, width, height)
2621
+ : undefined;
2204
2622
  surface.replaceChildren(...(background ? [background] : []));
2205
2623
  };
2206
2624
  resetSurface();
@@ -2208,22 +2626,35 @@ function renderDrawingResponse(interaction, update, currentValue) {
2208
2626
  summary.className = "qti3-coordinate-output";
2209
2627
  const strokes = [];
2210
2628
  let activeStroke;
2211
- const serializeStroke = (points) => {
2212
- return points.map((point) => `${point.x} ${point.y}`).join(" ");
2213
- };
2629
+ let commitVersion = 0;
2214
2630
  const commit = (emitResponse = true) => {
2215
- const value = strokes.map((stroke) => serializeStroke(stroke.points)).join(" | ");
2216
- if (emitResponse)
2217
- update(value);
2631
+ const version = ++commitVersion;
2632
+ if (emitResponse) {
2633
+ if (strokes.length === 0) {
2634
+ update(null);
2635
+ }
2636
+ else {
2637
+ void exportDrawingResponse(interaction, width, height, strokes, () => {
2638
+ const currentHref = currentDrawingBackgroundHref(surface);
2639
+ if (activeBackgroundIsAuthored && currentHref) {
2640
+ resolvedAuthoredBackgroundHref = currentHref;
2641
+ }
2642
+ return currentHref ?? activeBackgroundHref;
2643
+ }).then((value) => {
2644
+ if (version === commitVersion)
2645
+ update(value);
2646
+ });
2647
+ }
2648
+ }
2218
2649
  const count = strokes.length;
2219
- summary.value = value;
2650
+ summary.value = serializeDrawingStrokes(strokes);
2220
2651
  summary.textContent =
2221
2652
  count === 0 ? "No drawing strokes." : `${count} drawing stroke${count === 1 ? "" : "s"}.`;
2222
2653
  surface.setAttribute("aria-label", count === 0
2223
2654
  ? "Drawing response surface, no strokes"
2224
2655
  : `Drawing response surface, ${count} stroke${count === 1 ? "" : "s"}`);
2225
2656
  };
2226
- for (const points of parseDrawingValue(currentValue)) {
2657
+ for (const points of restoredStrokes) {
2227
2658
  const element = polylineElement(points);
2228
2659
  strokes.push({ points, element });
2229
2660
  surface.append(element);
@@ -2281,6 +2712,12 @@ function renderDrawingResponse(interaction, update, currentValue) {
2281
2712
  clear.addEventListener("click", () => {
2282
2713
  strokes.splice(0, strokes.length);
2283
2714
  activeStroke = undefined;
2715
+ if (activeBackgroundIsAuthored) {
2716
+ resolvedAuthoredBackgroundHref =
2717
+ currentDrawingBackgroundHref(surface) ?? resolvedAuthoredBackgroundHref;
2718
+ }
2719
+ activeBackgroundHref = resolvedAuthoredBackgroundHref;
2720
+ activeBackgroundIsAuthored = true;
2284
2721
  resetSurface();
2285
2722
  commit();
2286
2723
  });
@@ -2291,45 +2728,6 @@ function renderDrawingResponse(interaction, update, currentValue) {
2291
2728
  group.append(surface, summary, tools);
2292
2729
  return group;
2293
2730
  }
2294
- function renderPortableCustomResponse(interaction, update, currentValue) {
2295
- const group = document.createElement("div");
2296
- group.role = "group";
2297
- group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
2298
- const host = document.createElement("div");
2299
- host.className = "qti3-portable-custom-host";
2300
- host.tabIndex = 0;
2301
- host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
2302
- host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
2303
- host.dataset.module = interaction.attributes.module ?? "";
2304
- host.dataset.qtiName = interaction.qtiName;
2305
- host.setAttribute("role", "application");
2306
- host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
2307
- host.textContent = "Portable custom interaction host";
2308
- host.style.border = "1px solid CanvasText";
2309
- host.style.padding = "0.5rem";
2310
- host.style.marginBlockEnd = "0.5rem";
2311
- const fallback = document.createElement("input");
2312
- fallback.value = scalarString(currentValue);
2313
- fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
2314
- fallback.addEventListener("input", () => update(fallback.value));
2315
- fallback.addEventListener("change", () => update(fallback.value));
2316
- host.addEventListener("qti3-portable-custom-response", (event) => {
2317
- const value = portableCustomEventValue(event);
2318
- if (value === undefined)
2319
- return;
2320
- fallback.value = String(value ?? "");
2321
- update(value);
2322
- });
2323
- host.addEventListener("qti3-pci-response", (event) => {
2324
- const value = portableCustomEventValue(event);
2325
- if (value === undefined)
2326
- return;
2327
- fallback.value = String(value ?? "");
2328
- update(value);
2329
- });
2330
- group.append(host, fallback);
2331
- return group;
2332
- }
2333
2731
  function renderHotspotResponse(interaction, update, currentValue) {
2334
2732
  const group = responseGroup();
2335
2733
  const surface = document.createElement("div");
@@ -2400,27 +2798,19 @@ function renderHotspotResponse(interaction, update, currentValue) {
2400
2798
  group.append(surface, selectedSummary);
2401
2799
  return group;
2402
2800
  }
2403
- function renderObjectAsset(interaction) {
2801
+ function renderObjectAsset(interaction, mediaResponse = {}) {
2404
2802
  const object = interaction.object;
2405
- const type = object?.type ?? "";
2406
2803
  const label = interaction.prompt ?? object?.text ?? "Media interaction";
2407
- if (object?.data && type.startsWith("audio/")) {
2804
+ const mediaType = object ? mediaElementType(object) : undefined;
2805
+ if (object && mediaType === "audio") {
2408
2806
  const audio = document.createElement("audio");
2409
- audio.controls = true;
2410
- audio.preload = "none";
2411
- audio.src = object.data;
2412
- audio.setAttribute("aria-label", label);
2413
- audio.style.maxInlineSize = "100%";
2807
+ configureMediaElement(audio, interaction, object, label, mediaResponse);
2414
2808
  audio.style.inlineSize = "100%";
2415
2809
  return audio;
2416
2810
  }
2417
- if (object?.data && type.startsWith("video/")) {
2811
+ if (object && mediaType === "video") {
2418
2812
  const video = document.createElement("video");
2419
- video.controls = true;
2420
- video.preload = "none";
2421
- video.src = object.data;
2422
- video.setAttribute("aria-label", label);
2423
- video.style.maxInlineSize = "100%";
2813
+ configureMediaElement(video, interaction, object, label, mediaResponse);
2424
2814
  if (object.width)
2425
2815
  video.width = Number(object.width);
2426
2816
  if (object.height)
@@ -2442,10 +2832,11 @@ function renderObjectAsset(interaction) {
2442
2832
  const group = document.createElement("div");
2443
2833
  group.role = "group";
2444
2834
  group.setAttribute("aria-label", label);
2445
- if (object?.data) {
2835
+ const fallbackHref = object?.data ?? object?.sources.find((source) => source.src)?.src;
2836
+ if (fallbackHref) {
2446
2837
  const link = document.createElement("a");
2447
- link.href = object.data;
2448
- link.textContent = object.text || object.data;
2838
+ link.href = fallbackHref;
2839
+ link.textContent = object?.text || fallbackHref;
2449
2840
  group.append(link);
2450
2841
  }
2451
2842
  else {
@@ -2453,6 +2844,132 @@ function renderObjectAsset(interaction) {
2453
2844
  }
2454
2845
  return group;
2455
2846
  }
2847
+ function configureMediaElement(media, interaction, object, label, mediaResponse) {
2848
+ media.controls = mediaControlsMode(interaction, object) !== "none";
2849
+ media.preload = "none";
2850
+ media.autoplay = parseBooleanAttribute(interaction.attributes.autostart) ?? false;
2851
+ media.loop = parseBooleanAttribute(interaction.attributes.loop) ?? false;
2852
+ media.setAttribute("aria-label", label);
2853
+ media.style.maxInlineSize = "100%";
2854
+ copyMediaDataAttributes(media, interaction.attributes);
2855
+ copyMediaDataAttributes(media, object.attributes);
2856
+ if (object.data)
2857
+ media.src = object.data;
2858
+ for (const source of object.sources) {
2859
+ if (!source.src)
2860
+ continue;
2861
+ const sourceElement = document.createElement("source");
2862
+ sourceElement.src = source.src;
2863
+ if (source.type)
2864
+ sourceElement.type = source.type;
2865
+ copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
2866
+ media.append(sourceElement);
2867
+ }
2868
+ for (const track of object.tracks) {
2869
+ if (!track.src)
2870
+ continue;
2871
+ const trackElement = document.createElement("track");
2872
+ trackElement.src = track.src;
2873
+ if (track.kind)
2874
+ trackElement.kind = track.kind;
2875
+ if (track.srclang)
2876
+ trackElement.srclang = track.srclang;
2877
+ if (track.label)
2878
+ trackElement.label = track.label;
2879
+ if (track.default)
2880
+ trackElement.default = true;
2881
+ copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
2882
+ media.append(trackElement);
2883
+ }
2884
+ bindMediaPlayCount(media, interaction, mediaResponse);
2885
+ }
2886
+ function copyMediaDataAttributes(element, attributes) {
2887
+ for (const [name, value] of Object.entries(attributes)) {
2888
+ if (!name.startsWith("data-"))
2889
+ continue;
2890
+ element.setAttribute(name, value);
2891
+ }
2892
+ }
2893
+ const sourceAttributeNames = new Set(["src", "srcset", "type"]);
2894
+ const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
2895
+ function copySafeMediaChildAttributes(element, attributes, controlledNames) {
2896
+ for (const [name, value] of Object.entries(attributes)) {
2897
+ const normalizedName = name.toLowerCase();
2898
+ if (controlledNames.has(normalizedName))
2899
+ continue;
2900
+ if (normalizedName === "class" ||
2901
+ normalizedName === "id" ||
2902
+ normalizedName === "title" ||
2903
+ normalizedName === "media" ||
2904
+ normalizedName === "sizes" ||
2905
+ normalizedName.startsWith("data-")) {
2906
+ element.setAttribute(name, value);
2907
+ }
2908
+ }
2909
+ }
2910
+ function mediaElementType(object) {
2911
+ const types = [object.type, ...object.sources.map((source) => source.type)].filter((value) => Boolean(value));
2912
+ if (types.some((value) => value.startsWith("audio/")))
2913
+ return "audio";
2914
+ if (types.some((value) => value.startsWith("video/")))
2915
+ return "video";
2916
+ return undefined;
2917
+ }
2918
+ function mediaControlsMode(interaction, object) {
2919
+ return (interaction.attributes["data-qti-media-player-controls"] ??
2920
+ object.attributes["data-qti-media-player-controls"]);
2921
+ }
2922
+ function bindMediaPlayCount(media, interaction, mediaResponse) {
2923
+ if (!mediaResponse.update)
2924
+ return;
2925
+ let playCount = mediaPlayCount(mediaResponse.currentValue ?? null);
2926
+ let activePlaySession = false;
2927
+ let readyAfterEnded = false;
2928
+ const maximum = maximumMediaPlays(interaction);
2929
+ const syncState = () => {
2930
+ media.dataset.playCount = String(playCount);
2931
+ if (maximum !== undefined && playCount >= maximum && !activePlaySession) {
2932
+ media.dataset.maxPlaysReached = "true";
2933
+ }
2934
+ else {
2935
+ delete media.dataset.maxPlaysReached;
2936
+ }
2937
+ };
2938
+ media.addEventListener("play", () => {
2939
+ if (mediaResponse.isCompleted?.()) {
2940
+ return;
2941
+ }
2942
+ if (!activePlaySession && maximum !== undefined && playCount >= maximum) {
2943
+ media.pause();
2944
+ syncState();
2945
+ return;
2946
+ }
2947
+ if (!activePlaySession && (readyAfterEnded || media.currentTime <= 0.25)) {
2948
+ playCount += 1;
2949
+ mediaResponse.update?.(playCount);
2950
+ activePlaySession = true;
2951
+ readyAfterEnded = false;
2952
+ syncState();
2953
+ return;
2954
+ }
2955
+ activePlaySession = true;
2956
+ readyAfterEnded = false;
2957
+ syncState();
2958
+ });
2959
+ media.addEventListener("ended", () => {
2960
+ activePlaySession = false;
2961
+ readyAfterEnded = true;
2962
+ syncState();
2963
+ });
2964
+ media.addEventListener("seeked", () => {
2965
+ if (!media.paused || media.currentTime > 0.25)
2966
+ return;
2967
+ activePlaySession = false;
2968
+ readyAfterEnded = false;
2969
+ syncState();
2970
+ });
2971
+ syncState();
2972
+ }
2456
2973
  function objectIsImage(object) {
2457
2974
  return Boolean(object.type?.startsWith("image/") ||
2458
2975
  object.data?.startsWith("data:image/") ||
@@ -2499,6 +3016,10 @@ function choiceText(choices, identifier) {
2499
3016
  }
2500
3017
  function sourceChoices(interaction) {
2501
3018
  const choices = choicesOrFallback(interaction);
3019
+ if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
3020
+ const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
3021
+ return gapChoices.length > 0 ? gapChoices : choices;
3022
+ }
2502
3023
  const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
2503
3024
  const sources = choices.filter((choice) => sourceRoles.has(choice.role));
2504
3025
  return sources.length > 0 ? sources : choices;
@@ -2597,6 +3118,42 @@ function parseDrawingValue(value) {
2597
3118
  const raw = scalarString(value);
2598
3119
  if (!raw)
2599
3120
  return [];
3121
+ const metadata = drawingMetadataFromSvgDataUrl(raw);
3122
+ if (metadata)
3123
+ return parseDrawingStrokePayload(metadata);
3124
+ return parseDrawingStrokePayload(raw);
3125
+ }
3126
+ function drawingMetadataFromSvgDataUrl(raw) {
3127
+ if (!raw.startsWith("data:image/svg+xml"))
3128
+ return undefined;
3129
+ const commaIndex = raw.indexOf(",");
3130
+ if (commaIndex === -1)
3131
+ return undefined;
3132
+ const encoded = raw.slice(commaIndex + 1);
3133
+ let svg = "";
3134
+ try {
3135
+ svg = raw.slice(0, commaIndex).includes(";base64")
3136
+ ? atob(encoded)
3137
+ : decodeURIComponent(encoded);
3138
+ }
3139
+ catch {
3140
+ return undefined;
3141
+ }
3142
+ const match = svg.match(/\sdata-qti3-strokes="([^"]*)"/);
3143
+ if (!match?.[1])
3144
+ return undefined;
3145
+ try {
3146
+ return decodeURIComponent(match[1]);
3147
+ }
3148
+ catch {
3149
+ return undefined;
3150
+ }
3151
+ }
3152
+ function drawingResponseImage(value) {
3153
+ const raw = scalarString(value);
3154
+ return raw?.startsWith("data:image/") ? raw : undefined;
3155
+ }
3156
+ function parseDrawingStrokePayload(raw) {
2600
3157
  return raw
2601
3158
  .split("|")
2602
3159
  .map((stroke) => {
@@ -2635,6 +3192,15 @@ function dimension(value, fallback) {
2635
3192
  const parsed = Number(value);
2636
3193
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
2637
3194
  }
3195
+ function positivePixelValue(value) {
3196
+ const parsed = Number(value);
3197
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
3198
+ }
3199
+ function graphicGapLabelBlockSize(sources) {
3200
+ const maxLength = Math.max(0, ...sources.map((source) => (source.text || source.identifier).trim().length));
3201
+ const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
3202
+ return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
3203
+ }
2638
3204
  function placeHotspotButton(button, choice, width, height) {
2639
3205
  const coords = (choice.attributes.coords ?? "")
2640
3206
  .split(",")
@@ -2733,20 +3299,177 @@ function svgPoint(surface, event) {
2733
3299
  y: Math.max(0, Math.min(height, y)),
2734
3300
  };
2735
3301
  }
2736
- function drawingBackgroundImage(interaction, width, height) {
3302
+ async function exportDrawingResponse(interaction, width, height, strokes, backgroundHref) {
3303
+ const href = backgroundHref();
3304
+ const mime = drawingResponseMime(interaction.object);
3305
+ if (mime === "image/svg+xml") {
3306
+ return svgDrawingDataUrl(interaction, width, height, strokes, await portableImageHref(href));
3307
+ }
3308
+ return rasterDrawingDataUrl(interaction, width, height, strokes, href, mime);
3309
+ }
3310
+ function drawingResponseMime(object) {
3311
+ const candidates = [
3312
+ object?.type,
3313
+ object?.data,
3314
+ ...(object?.sources.map((source) => source.type ?? source.src) ?? []),
3315
+ ];
3316
+ for (const candidate of candidates) {
3317
+ const mime = imageMime(candidate);
3318
+ if (mime)
3319
+ return mime;
3320
+ }
3321
+ return "image/svg+xml";
3322
+ }
3323
+ function imageMime(value) {
3324
+ if (!value)
3325
+ return undefined;
3326
+ const normalized = value.toLowerCase().split(";")[0] ?? "";
3327
+ if (normalized === "image/svg+xml")
3328
+ return "image/svg+xml";
3329
+ if (normalized === "image/png")
3330
+ return "image/png";
3331
+ if (normalized === "image/jpeg" || normalized === "image/jpg")
3332
+ return "image/jpeg";
3333
+ if (normalized === "image/webp")
3334
+ return "image/webp";
3335
+ const dataMime = value.match(/^data:([^;,]+)/i)?.[1]?.toLowerCase();
3336
+ if (dataMime)
3337
+ return imageMime(dataMime);
3338
+ if (/\.svg(?:[?#].*)?$/i.test(value))
3339
+ return "image/svg+xml";
3340
+ if (/\.png(?:[?#].*)?$/i.test(value))
3341
+ return "image/png";
3342
+ if (/\.jpe?g(?:[?#].*)?$/i.test(value))
3343
+ return "image/jpeg";
3344
+ if (/\.webp(?:[?#].*)?$/i.test(value))
3345
+ return "image/webp";
3346
+ return undefined;
3347
+ }
3348
+ function svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref) {
3349
+ const markup = svgDrawingMarkup(interaction, width, height, strokes, backgroundHref);
3350
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
3351
+ }
3352
+ function svgDrawingMarkup(interaction, width, height, strokes, backgroundHref) {
3353
+ const strokePayload = serializeDrawingStrokes(strokes);
3354
+ const background = backgroundHref && interaction.object && objectIsImage(interaction.object)
3355
+ ? `<image href="${xmlAttribute(backgroundHref)}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`
3356
+ : "";
3357
+ const lines = strokes
3358
+ .map((stroke) => {
3359
+ return `<polyline points="${xmlAttribute(serializeSvgPoints(stroke.points))}" fill="none" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`;
3360
+ })
3361
+ .join("");
3362
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><metadata id="qti3-drawing-response" data-qti3-strokes="${xmlAttribute(encodeURIComponent(strokePayload))}"></metadata>${background}${lines}</svg>`;
3363
+ }
3364
+ async function rasterDrawingDataUrl(interaction, width, height, strokes, backgroundHref, mime) {
3365
+ const canvas = document.createElement("canvas");
3366
+ canvas.width = width;
3367
+ canvas.height = height;
3368
+ const context = canvas.getContext("2d");
3369
+ if (!context)
3370
+ return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
3371
+ if (mime === "image/jpeg") {
3372
+ context.fillStyle = "#fff";
3373
+ context.fillRect(0, 0, width, height);
3374
+ }
3375
+ if (backgroundHref && interaction.object && objectIsImage(interaction.object)) {
3376
+ try {
3377
+ const image = await loadCanvasImage(backgroundHref);
3378
+ context.drawImage(image, 0, 0, width, height);
3379
+ }
3380
+ catch {
3381
+ // Export the candidate marks even when the authored background cannot be rasterized.
3382
+ }
3383
+ }
3384
+ context.strokeStyle = "#000";
3385
+ context.lineWidth = 3;
3386
+ context.lineCap = "round";
3387
+ context.lineJoin = "round";
3388
+ for (const stroke of strokes) {
3389
+ const [first, ...rest] = stroke.points;
3390
+ if (!first)
3391
+ continue;
3392
+ context.beginPath();
3393
+ context.moveTo(first.x, first.y);
3394
+ for (const point of rest)
3395
+ context.lineTo(point.x, point.y);
3396
+ context.stroke();
3397
+ }
3398
+ try {
3399
+ return canvas.toDataURL(mime);
3400
+ }
3401
+ catch {
3402
+ return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
3403
+ }
3404
+ }
3405
+ function loadCanvasImage(src) {
3406
+ return new Promise((resolve, reject) => {
3407
+ const image = new Image();
3408
+ image.addEventListener("load", () => resolve(image), { once: true });
3409
+ image.addEventListener("error", () => reject(new Error(`Unable to load ${src}`)), {
3410
+ once: true,
3411
+ });
3412
+ image.src = src;
3413
+ });
3414
+ }
3415
+ async function portableImageHref(href) {
3416
+ if (!href || href.startsWith("data:"))
3417
+ return href;
3418
+ try {
3419
+ const response = await fetch(href);
3420
+ if (!response.ok)
3421
+ return href;
3422
+ return await blobToDataUrl(await response.blob());
3423
+ }
3424
+ catch {
3425
+ return href;
3426
+ }
3427
+ }
3428
+ function blobToDataUrl(blob) {
3429
+ return new Promise((resolve, reject) => {
3430
+ const reader = new FileReader();
3431
+ reader.addEventListener("load", () => {
3432
+ resolve(String(reader.result ?? ""));
3433
+ });
3434
+ reader.addEventListener("error", () => {
3435
+ reject(reader.error ?? new Error("Unable to read drawing background."));
3436
+ });
3437
+ reader.readAsDataURL(blob);
3438
+ });
3439
+ }
3440
+ function drawingBackgroundHref(interaction) {
2737
3441
  if (!interaction.object?.data || !objectIsImage(interaction.object))
2738
3442
  return undefined;
3443
+ return interaction.object.data;
3444
+ }
3445
+ function drawingImageElement(href, width, height) {
2739
3446
  const image = document.createElementNS("http://www.w3.org/2000/svg", "image");
2740
- image.setAttribute("href", interaction.object.data);
3447
+ image.setAttribute("href", href);
2741
3448
  image.setAttribute("width", String(width));
2742
3449
  image.setAttribute("height", String(height));
2743
3450
  image.setAttribute("preserveAspectRatio", "xMidYMid meet");
2744
3451
  image.setAttribute("aria-hidden", "true");
2745
3452
  return image;
2746
3453
  }
3454
+ function currentDrawingBackgroundHref(surface) {
3455
+ return surface.querySelector("image")?.getAttribute("href") ?? undefined;
3456
+ }
2747
3457
  function serializeSvgPoints(points) {
2748
3458
  return points.map((point) => `${point.x},${point.y}`).join(" ");
2749
3459
  }
3460
+ function serializeDrawingStrokes(strokes) {
3461
+ return strokes.map((stroke) => serializeDrawingStroke(stroke.points)).join(" | ");
3462
+ }
3463
+ function serializeDrawingStroke(points) {
3464
+ return points.map((point) => `${point.x} ${point.y}`).join(" ");
3465
+ }
3466
+ function xmlAttribute(value) {
3467
+ return value
3468
+ .replaceAll("&", "&amp;")
3469
+ .replaceAll('"', "&quot;")
3470
+ .replaceAll("<", "&lt;")
3471
+ .replaceAll(">", "&gt;");
3472
+ }
2750
3473
  function polylineElement(points) {
2751
3474
  const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
2752
3475
  line.setAttribute("points", serializeSvgPoints(points));
@@ -2757,6 +3480,20 @@ function polylineElement(points) {
2757
3480
  line.setAttribute("stroke-linejoin", "round");
2758
3481
  return line;
2759
3482
  }
3483
+ function portableCustomDefinitionFromAttributes(interaction) {
3484
+ return {
3485
+ responseIdentifier: interaction.responseIdentifier,
3486
+ customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
3487
+ module: interaction.attributes.module,
3488
+ interactionMarkup: [],
3489
+ templateVariables: [],
3490
+ contextVariables: [],
3491
+ stylesheets: [],
3492
+ dataAttributes: Object.fromEntries(Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-"))),
3493
+ attributes: interaction.attributes,
3494
+ source: interaction.source,
3495
+ };
3496
+ }
2760
3497
  function portableCustomEventValue(event) {
2761
3498
  if (!("detail" in event))
2762
3499
  return undefined;
@@ -2768,13 +3505,51 @@ function portableCustomEventValue(event) {
2768
3505
  return detail.value ?? null;
2769
3506
  if ("response" in detail)
2770
3507
  return detail.response ?? null;
3508
+ if ("state" in detail || "valid" in detail)
3509
+ return undefined;
2771
3510
  }
2772
3511
  return detail;
2773
3512
  }
3513
+ function portableCustomEventState(event) {
3514
+ if (!("detail" in event))
3515
+ return undefined;
3516
+ const detail = event.detail;
3517
+ if (typeof detail !== "object" || detail === null || !("state" in detail))
3518
+ return undefined;
3519
+ return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
3520
+ }
3521
+ function portableCustomEventValidity(event) {
3522
+ if (!("detail" in event))
3523
+ return undefined;
3524
+ const detail = event.detail;
3525
+ if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
3526
+ return undefined;
3527
+ }
3528
+ return {
3529
+ valid: detail.valid,
3530
+ message: typeof detail.message === "string" ? detail.message : undefined,
3531
+ };
3532
+ }
3533
+ function isPortableCustomStateValue(value) {
3534
+ if (value === null)
3535
+ return true;
3536
+ if (typeof value === "string" || typeof value === "boolean")
3537
+ return true;
3538
+ if (typeof value === "number")
3539
+ return Number.isFinite(value);
3540
+ if (Array.isArray(value))
3541
+ return value.every(isPortableCustomStateValue);
3542
+ if (typeof value === "object") {
3543
+ return Object.values(value).every(isPortableCustomStateValue);
3544
+ }
3545
+ return false;
3546
+ }
2774
3547
  const htmlContentElements = new Set([
2775
3548
  "a",
2776
3549
  "abbr",
2777
3550
  "b",
3551
+ "bdi",
3552
+ "bdo",
2778
3553
  "blockquote",
2779
3554
  "br",
2780
3555
  "caption",
@@ -2788,6 +3563,12 @@ const htmlContentElements = new Set([
2788
3563
  "em",
2789
3564
  "figcaption",
2790
3565
  "figure",
3566
+ "h1",
3567
+ "h2",
3568
+ "h3",
3569
+ "h4",
3570
+ "h5",
3571
+ "h6",
2791
3572
  "hr",
2792
3573
  "i",
2793
3574
  "img",
@@ -2797,6 +3578,12 @@ const htmlContentElements = new Set([
2797
3578
  "p",
2798
3579
  "pre",
2799
3580
  "q",
3581
+ "rb",
3582
+ "rbc",
3583
+ "rp",
3584
+ "rt",
3585
+ "rtc",
3586
+ "ruby",
2800
3587
  "samp",
2801
3588
  "small",
2802
3589
  "span",
@@ -2813,6 +3600,7 @@ const htmlContentElements = new Set([
2813
3600
  "ul",
2814
3601
  "var",
2815
3602
  ]);
3603
+ const unsafeContentElements = new Set(["script", "style"]);
2816
3604
  const mathMlElements = new Set([
2817
3605
  "math",
2818
3606
  "maction",
@@ -2881,32 +3669,80 @@ function copySafeAttributes(element, attributes) {
2881
3669
  if (!isSafeContentAttribute(name, value))
2882
3670
  continue;
2883
3671
  element.setAttribute(name, value);
3672
+ if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
3673
+ element.setAttribute("lang", value);
3674
+ }
3675
+ }
3676
+ applySharedAccessibilityVocabulary(element, attributes);
3677
+ }
3678
+ function applySharedAccessibilityVocabulary(element, attributes) {
3679
+ for (const [name, value] of Object.entries(attributes)) {
3680
+ const ariaName = qtiAriaAttributeName(name);
3681
+ if (!ariaName || hasAttributeName(attributes, ariaName))
3682
+ continue;
3683
+ element.setAttribute(ariaName, value);
2884
3684
  }
3685
+ const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
3686
+ if (suppressesScreenReaderSpeech(suppressTts) &&
3687
+ !hasAttributeName(attributes, "aria-hidden") &&
3688
+ !hasAttributeName(attributes, "data-qti-aria-hidden")) {
3689
+ element.setAttribute("aria-hidden", "true");
3690
+ }
3691
+ }
3692
+ function qtiAriaAttributeName(name) {
3693
+ const normalizedName = name.toLowerCase();
3694
+ const prefix = "data-qti-aria-";
3695
+ if (!normalizedName.startsWith(prefix))
3696
+ return undefined;
3697
+ const suffix = normalizedName.slice(prefix.length);
3698
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix))
3699
+ return undefined;
3700
+ return `aria-${suffix}`;
3701
+ }
3702
+ function attributeValue(attributes, name) {
3703
+ const normalizedName = name.toLowerCase();
3704
+ const entry = Object.entries(attributes).find(([attributeName]) => attributeName.toLowerCase() === normalizedName);
3705
+ return entry?.[1];
3706
+ }
3707
+ function hasAttributeName(attributes, name) {
3708
+ return attributeValue(attributes, name) !== undefined;
3709
+ }
3710
+ function suppressesScreenReaderSpeech(value) {
3711
+ if (!value)
3712
+ return false;
3713
+ const tokens = value
3714
+ .toLowerCase()
3715
+ .split(/[\s,]+/)
3716
+ .filter(Boolean);
3717
+ return tokens.includes("all") || tokens.includes("screen-reader");
2885
3718
  }
2886
3719
  function isSafeContentAttribute(name, value) {
2887
- if (name.startsWith("on"))
3720
+ const normalizedName = name.toLowerCase();
3721
+ if (normalizedName.startsWith("on"))
2888
3722
  return false;
2889
- if (name === "style")
3723
+ if (normalizedName === "style")
2890
3724
  return false;
2891
- if (name === "href" || name === "src" || name === "data") {
3725
+ if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
2892
3726
  return isSafeUrl(value);
2893
3727
  }
2894
- return (name === "alt" ||
2895
- name === "aria-label" ||
2896
- name === "aria-describedby" ||
2897
- name === "class" ||
2898
- name === "colspan" ||
2899
- name === "height" ||
2900
- name === "id" ||
2901
- name === "lang" ||
2902
- name === "role" ||
2903
- name === "rowspan" ||
2904
- name === "scope" ||
2905
- name === "title" ||
2906
- name === "type" ||
2907
- name === "width" ||
2908
- mathMlAttributeNames.has(name) ||
2909
- name.startsWith("data-"));
3728
+ return (normalizedName === "alt" ||
3729
+ normalizedName === "class" ||
3730
+ normalizedName === "colspan" ||
3731
+ normalizedName === "dir" ||
3732
+ normalizedName === "headers" ||
3733
+ normalizedName === "height" ||
3734
+ normalizedName === "id" ||
3735
+ normalizedName === "lang" ||
3736
+ normalizedName === "role" ||
3737
+ normalizedName === "rowspan" ||
3738
+ normalizedName === "scope" ||
3739
+ normalizedName === "title" ||
3740
+ normalizedName === "type" ||
3741
+ normalizedName === "width" ||
3742
+ normalizedName === "xml:lang" ||
3743
+ mathMlAttributeNames.has(normalizedName) ||
3744
+ normalizedName.startsWith("aria-") ||
3745
+ normalizedName.startsWith("data-"));
2910
3746
  }
2911
3747
  const mathMlAttributeNames = new Set([
2912
3748
  "accent",
@@ -3041,6 +3877,23 @@ function playerStyleElement() {
3041
3877
  margin-block: 0;
3042
3878
  }
3043
3879
 
3880
+ .qti3-player .qti-hidden {
3881
+ display: none !important;
3882
+ }
3883
+
3884
+ .qti3-player .qti-visually-hidden {
3885
+ position: absolute !important;
3886
+ overflow: hidden !important;
3887
+ clip: rect(1px, 1px, 1px, 1px) !important;
3888
+ clip-path: inset(50%) !important;
3889
+ inline-size: 1px !important;
3890
+ block-size: 1px !important;
3891
+ margin: -1px !important;
3892
+ padding: 0 !important;
3893
+ border: 0 !important;
3894
+ white-space: nowrap !important;
3895
+ }
3896
+
3044
3897
  .qti3-embedded-interaction {
3045
3898
  display: inline-flex;
3046
3899
  gap: 0.35rem;
@@ -3075,7 +3928,6 @@ function playerStyleElement() {
3075
3928
  margin-block-start: 0.75rem;
3076
3929
  }
3077
3930
 
3078
- .qti3-actions,
3079
3931
  .qti3-reorder-item,
3080
3932
  .qti3-token-region,
3081
3933
  .qti3-pair-chip,
@@ -3414,6 +4266,7 @@ function playerStyleElement() {
3414
4266
  }
3415
4267
 
3416
4268
  .qti3-graphic-associate-surface,
4269
+ .qti3-graphic-gap-match-surface,
3417
4270
  .qti3-graphic-order-surface {
3418
4271
  touch-action: manipulation;
3419
4272
  }
@@ -3441,10 +4294,60 @@ function playerStyleElement() {
3441
4294
  }
3442
4295
 
3443
4296
  .qti3-graphic-associate-hotspot,
4297
+ .qti3-graphic-gap-hotspot,
3444
4298
  .qti3-graphic-order-hotspot {
3445
4299
  z-index: 2;
3446
4300
  }
3447
4301
 
4302
+ .qti3-graphic-gap-match-surface {
4303
+ margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
4304
+ }
4305
+
4306
+ .qti3-graphic-gap-hotspot {
4307
+ display: grid;
4308
+ place-items: center;
4309
+ padding: 0;
4310
+ overflow: visible;
4311
+ border-style: dashed;
4312
+ background: rgb(255 255 255 / 0.08);
4313
+ color: CanvasText;
4314
+ }
4315
+
4316
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
4317
+ border-style: solid;
4318
+ background: color-mix(in srgb, Highlight 18%, Canvas);
4319
+ }
4320
+
4321
+ .qti3-graphic-gap-label {
4322
+ position: absolute;
4323
+ inset-block-start: calc(100% + 0.2rem);
4324
+ inset-inline-start: 50%;
4325
+ transform: translateX(-50%);
4326
+ box-sizing: border-box;
4327
+ inline-size: max-content;
4328
+ max-inline-size: min(12rem, calc(100vw - 2rem));
4329
+ min-inline-size: 0;
4330
+ padding: 0.25rem 0.4rem;
4331
+ border: 1px solid CanvasText;
4332
+ border-radius: 0.25rem;
4333
+ background: Canvas;
4334
+ color: CanvasText;
4335
+ font-size: 0.75rem;
4336
+ font-weight: 700;
4337
+ line-height: 1.15;
4338
+ overflow-wrap: anywhere;
4339
+ pointer-events: none;
4340
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
4341
+ text-align: center;
4342
+ white-space: normal;
4343
+ }
4344
+
4345
+ @supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
4346
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
4347
+ background: Canvas;
4348
+ }
4349
+ }
4350
+
3448
4351
  .qti3-graphic-order-hotspot {
3449
4352
  display: grid;
3450
4353
  place-items: center;
@@ -3512,6 +4415,8 @@ function responseCount(value) {
3512
4415
  function maximumAllowedResponses(interaction) {
3513
4416
  if (!interaction)
3514
4417
  return undefined;
4418
+ if (interaction.type === "media")
4419
+ return maximumMediaPlays(interaction);
3515
4420
  const explicit = interaction.attributes["max-choices"] ?? interaction.attributes["max-associations"];
3516
4421
  if (explicit === undefined)
3517
4422
  return undefined;
@@ -3523,12 +4428,33 @@ function maximumAllowedResponses(interaction) {
3523
4428
  function minimumRequiredResponses(interaction) {
3524
4429
  if (!interaction)
3525
4430
  return 1;
4431
+ if (interaction.type === "media")
4432
+ return minimumMediaPlays(interaction);
3526
4433
  const explicit = interaction.attributes["min-choices"] ?? interaction.attributes["min-associations"];
3527
4434
  if (explicit === undefined)
3528
4435
  return 1;
3529
4436
  const parsed = Number(explicit);
3530
4437
  return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
3531
4438
  }
4439
+ function minimumMediaPlays(interaction) {
4440
+ const parsed = Number(interaction.attributes["min-plays"] ?? "0");
4441
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
4442
+ }
4443
+ function maximumMediaPlays(interaction) {
4444
+ const parsed = Number(interaction.attributes["max-plays"] ?? "0");
4445
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
4446
+ }
4447
+ function mediaPlayCount(value) {
4448
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : 0;
4449
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
4450
+ }
4451
+ function parseBooleanAttribute(value) {
4452
+ if (value === "true" || value === "1")
4453
+ return true;
4454
+ if (value === "false" || value === "0")
4455
+ return false;
4456
+ return undefined;
4457
+ }
3532
4458
  function matchMaxDiagnostics(responseIdentifier, interaction, response) {
3533
4459
  const identifiers = responseChoiceIdentifiers(response);
3534
4460
  if (identifiers.length === 0)