@longsightgroup/qti3-player 0.1.2 → 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");
@@ -179,6 +193,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
179
193
  if (interaction.responseIdentifier)
180
194
  field.dataset.responseIdentifier = interaction.responseIdentifier;
181
195
  const heading = document.createElement("h3");
196
+ copySafeAttributes(heading, interaction.promptAttributes ?? {});
182
197
  heading.textContent = interactionLabel(interaction);
183
198
  field.append(heading);
184
199
  if (interaction.responseIdentifier) {
@@ -253,7 +268,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
253
268
  return field;
254
269
  }
255
270
  if (interaction.type === "portableCustom") {
256
- field.append(renderPortableCustomResponse(interaction, update, currentValue));
271
+ field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
257
272
  return field;
258
273
  }
259
274
  if (interaction.type === "textEntry") {
@@ -295,6 +310,87 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
295
310
  field.append(renderSelect(interaction, update, currentValue));
296
311
  return field;
297
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
+ }
298
394
  renderEmbeddedInteraction(interaction) {
299
395
  if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
300
396
  return this.renderInteraction(interaction);
@@ -346,10 +442,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
346
442
  }
347
443
  if (node.qtiName === "qti-prompt") {
348
444
  const prompt = document.createElement("p");
349
- prompt.className = "qti3-item-prompt";
445
+ copySafeAttributes(prompt, node.attributes);
446
+ prompt.classList.add("qti3-item-prompt");
350
447
  prompt.append(...this.renderContentNodes(node.children));
351
448
  return [prompt];
352
449
  }
450
+ if (unsafeContentElements.has(node.qtiName))
451
+ return [];
353
452
  const elementName = contentElementName(node.qtiName);
354
453
  if (!elementName)
355
454
  return this.renderContentNodes(node.children);
@@ -489,6 +588,26 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
489
588
  currentResponseValue(identifier) {
490
589
  return this.session?.serialize().responses[identifier] ?? null;
491
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
+ }
492
611
  applyDefaultStyles() {
493
612
  this.style.color = "CanvasText";
494
613
  this.style.backgroundColor = "Canvas";
@@ -1407,6 +1526,12 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1407
1526
  const selectedPairs = valueToStrings(currentValue);
1408
1527
  const maximumAssociations = interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
1409
1528
  let selectedHotspot;
1529
+ let draggedHotspot;
1530
+ let dragPointerId;
1531
+ let dragStart;
1532
+ let dragStarted = false;
1533
+ let suppressNextClick = false;
1534
+ let previewLine;
1410
1535
  const surface = document.createElement("div");
1411
1536
  surface.className = "qti3-graphic-associate-surface";
1412
1537
  surface.role = "group";
@@ -1496,6 +1621,52 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1496
1621
  renderState();
1497
1622
  commit();
1498
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
+ };
1499
1670
  const chooseHotspot = (choice) => {
1500
1671
  if (!selectedHotspot) {
1501
1672
  selectedHotspot = choice;
@@ -1569,8 +1740,66 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1569
1740
  button.setAttribute("aria-pressed", "false");
1570
1741
  button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1571
1742
  button.style.position = "absolute";
1743
+ button.style.touchAction = "none";
1572
1744
  placeHotspotButton(button, choice, width, height);
1573
- 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
+ });
1574
1803
  button.addEventListener("keydown", (event) => {
1575
1804
  if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1576
1805
  event.preventDefault();
@@ -1592,6 +1821,11 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1592
1821
  return group;
1593
1822
  }
1594
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
+ }
1595
1829
  const group = responseGroup();
1596
1830
  appendGraphicContext(group, interaction);
1597
1831
  const sources = sourceChoices(interaction);
@@ -1701,6 +1935,153 @@ function renderGapMatchResponse(interaction, update, currentValue) {
1701
1935
  group.append(sourceRegion, gapRegion);
1702
1936
  return group;
1703
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
+ }
1704
2085
  function renderSelect(interaction, update, currentValue) {
1705
2086
  const select = document.createElement("select");
1706
2087
  select.className = "qti3-inline-select";
@@ -2019,10 +2400,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2019
2400
  const height = objectAssetHeight(stageObject, 300);
2020
2401
  const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
2021
2402
  const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
2022
- let point = parsePointValue(currentValue) ?? {
2023
- x: Math.round(width / 2),
2024
- y: Math.round(height / 2),
2025
- };
2403
+ const parsedPoint = parsePointValue(currentValue);
2404
+ let point = parsedPoint ?? { x: 0, y: 0 };
2405
+ let isPlaced = Boolean(parsedPoint);
2026
2406
  const stage = document.createElement("div");
2027
2407
  stage.className = "qti3-position-object-stage";
2028
2408
  stage.tabIndex = 0;
@@ -2035,8 +2415,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2035
2415
  stage.style.border = "1px solid CanvasText";
2036
2416
  stage.style.background = "Canvas";
2037
2417
  stage.style.color = "CanvasText";
2038
- stage.style.overflow = "hidden";
2418
+ stage.style.overflow = "visible";
2039
2419
  stage.style.touchAction = "none";
2420
+ stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
2040
2421
  if (stageObject?.data && objectIsImage(stageObject)) {
2041
2422
  const image = document.createElement("img");
2042
2423
  image.src = stageObject.data;
@@ -2085,10 +2466,22 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2085
2466
  point.y = Math.max(0, Math.min(height, point.y));
2086
2467
  };
2087
2468
  const commit = () => {
2469
+ if (!isPlaced)
2470
+ return;
2088
2471
  update(pointToString(point));
2089
2472
  };
2090
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
+ }
2091
2483
  clamp();
2484
+ marker.dataset.placed = "true";
2092
2485
  marker.style.insetInlineStart = `${percent(point.x, width)}%`;
2093
2486
  marker.style.insetBlockStart = `${percent(point.y, height)}%`;
2094
2487
  coordinate.value = pointToString(point);
@@ -2101,9 +2494,17 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2101
2494
  x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2102
2495
  y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2103
2496
  };
2497
+ isPlaced = true;
2104
2498
  clamp();
2105
2499
  };
2500
+ const ensureKeyboardPoint = () => {
2501
+ if (isPlaced)
2502
+ return;
2503
+ point = { x: 0, y: 0 };
2504
+ isPlaced = true;
2505
+ };
2106
2506
  const moveBy = (dx, dy, emit = true) => {
2507
+ ensureKeyboardPoint();
2107
2508
  point.x += dx;
2108
2509
  point.y += dy;
2109
2510
  syncMarker();
@@ -2120,24 +2521,32 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2120
2521
  moveBy(0, -step, false);
2121
2522
  else if (event.key === "ArrowDown")
2122
2523
  moveBy(0, step, false);
2123
- else if (event.key === "Enter" || event.key === " ")
2524
+ else if (event.key === "Enter" || event.key === " ") {
2525
+ ensureKeyboardPoint();
2526
+ syncMarker();
2124
2527
  commit();
2528
+ }
2125
2529
  else
2126
2530
  return;
2127
2531
  event.preventDefault();
2128
2532
  };
2129
2533
  let dragging = false;
2534
+ let dragMoved = false;
2130
2535
  marker.addEventListener("pointerdown", (event) => {
2131
2536
  dragging = true;
2537
+ dragMoved = false;
2132
2538
  marker.setPointerCapture(event.pointerId);
2133
2539
  marker.style.cursor = "grabbing";
2134
- pointFromPointer(event);
2135
- syncMarker();
2540
+ if (isPlaced) {
2541
+ pointFromPointer(event);
2542
+ syncMarker();
2543
+ }
2136
2544
  event.preventDefault();
2137
2545
  });
2138
2546
  marker.addEventListener("pointermove", (event) => {
2139
2547
  if (!dragging)
2140
2548
  return;
2549
+ dragMoved = true;
2141
2550
  pointFromPointer(event);
2142
2551
  syncMarker();
2143
2552
  });
@@ -2147,9 +2556,11 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2147
2556
  dragging = false;
2148
2557
  marker.releasePointerCapture(event.pointerId);
2149
2558
  marker.style.cursor = "grab";
2150
- pointFromPointer(event);
2151
- syncMarker();
2152
- commit();
2559
+ if (dragMoved || isPlaced) {
2560
+ pointFromPointer(event);
2561
+ syncMarker();
2562
+ commit();
2563
+ }
2153
2564
  });
2154
2565
  marker.addEventListener("pointercancel", () => {
2155
2566
  dragging = false;
@@ -2317,45 +2728,6 @@ function renderDrawingResponse(interaction, update, currentValue) {
2317
2728
  group.append(surface, summary, tools);
2318
2729
  return group;
2319
2730
  }
2320
- function renderPortableCustomResponse(interaction, update, currentValue) {
2321
- const group = document.createElement("div");
2322
- group.role = "group";
2323
- group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
2324
- const host = document.createElement("div");
2325
- host.className = "qti3-portable-custom-host";
2326
- host.tabIndex = 0;
2327
- host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
2328
- host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
2329
- host.dataset.module = interaction.attributes.module ?? "";
2330
- host.dataset.qtiName = interaction.qtiName;
2331
- host.setAttribute("role", "application");
2332
- host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
2333
- host.textContent = "Portable custom interaction host";
2334
- host.style.border = "1px solid CanvasText";
2335
- host.style.padding = "0.5rem";
2336
- host.style.marginBlockEnd = "0.5rem";
2337
- const fallback = document.createElement("input");
2338
- fallback.value = scalarString(currentValue);
2339
- fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
2340
- fallback.addEventListener("input", () => update(fallback.value));
2341
- fallback.addEventListener("change", () => update(fallback.value));
2342
- host.addEventListener("qti3-portable-custom-response", (event) => {
2343
- const value = portableCustomEventValue(event);
2344
- if (value === undefined)
2345
- return;
2346
- fallback.value = String(value ?? "");
2347
- update(value);
2348
- });
2349
- host.addEventListener("qti3-pci-response", (event) => {
2350
- const value = portableCustomEventValue(event);
2351
- if (value === undefined)
2352
- return;
2353
- fallback.value = String(value ?? "");
2354
- update(value);
2355
- });
2356
- group.append(host, fallback);
2357
- return group;
2358
- }
2359
2731
  function renderHotspotResponse(interaction, update, currentValue) {
2360
2732
  const group = responseGroup();
2361
2733
  const surface = document.createElement("div");
@@ -2490,6 +2862,7 @@ function configureMediaElement(media, interaction, object, label, mediaResponse)
2490
2862
  sourceElement.src = source.src;
2491
2863
  if (source.type)
2492
2864
  sourceElement.type = source.type;
2865
+ copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
2493
2866
  media.append(sourceElement);
2494
2867
  }
2495
2868
  for (const track of object.tracks) {
@@ -2505,6 +2878,7 @@ function configureMediaElement(media, interaction, object, label, mediaResponse)
2505
2878
  trackElement.label = track.label;
2506
2879
  if (track.default)
2507
2880
  trackElement.default = true;
2881
+ copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
2508
2882
  media.append(trackElement);
2509
2883
  }
2510
2884
  bindMediaPlayCount(media, interaction, mediaResponse);
@@ -2516,6 +2890,23 @@ function copyMediaDataAttributes(element, attributes) {
2516
2890
  element.setAttribute(name, value);
2517
2891
  }
2518
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
+ }
2519
2910
  function mediaElementType(object) {
2520
2911
  const types = [object.type, ...object.sources.map((source) => source.type)].filter((value) => Boolean(value));
2521
2912
  if (types.some((value) => value.startsWith("audio/")))
@@ -2625,6 +3016,10 @@ function choiceText(choices, identifier) {
2625
3016
  }
2626
3017
  function sourceChoices(interaction) {
2627
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
+ }
2628
3023
  const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
2629
3024
  const sources = choices.filter((choice) => sourceRoles.has(choice.role));
2630
3025
  return sources.length > 0 ? sources : choices;
@@ -2797,6 +3192,15 @@ function dimension(value, fallback) {
2797
3192
  const parsed = Number(value);
2798
3193
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
2799
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
+ }
2800
3204
  function placeHotspotButton(button, choice, width, height) {
2801
3205
  const coords = (choice.attributes.coords ?? "")
2802
3206
  .split(",")
@@ -3076,6 +3480,20 @@ function polylineElement(points) {
3076
3480
  line.setAttribute("stroke-linejoin", "round");
3077
3481
  return line;
3078
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
+ }
3079
3497
  function portableCustomEventValue(event) {
3080
3498
  if (!("detail" in event))
3081
3499
  return undefined;
@@ -3087,13 +3505,51 @@ function portableCustomEventValue(event) {
3087
3505
  return detail.value ?? null;
3088
3506
  if ("response" in detail)
3089
3507
  return detail.response ?? null;
3508
+ if ("state" in detail || "valid" in detail)
3509
+ return undefined;
3090
3510
  }
3091
3511
  return detail;
3092
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
+ }
3093
3547
  const htmlContentElements = new Set([
3094
3548
  "a",
3095
3549
  "abbr",
3096
3550
  "b",
3551
+ "bdi",
3552
+ "bdo",
3097
3553
  "blockquote",
3098
3554
  "br",
3099
3555
  "caption",
@@ -3107,6 +3563,12 @@ const htmlContentElements = new Set([
3107
3563
  "em",
3108
3564
  "figcaption",
3109
3565
  "figure",
3566
+ "h1",
3567
+ "h2",
3568
+ "h3",
3569
+ "h4",
3570
+ "h5",
3571
+ "h6",
3110
3572
  "hr",
3111
3573
  "i",
3112
3574
  "img",
@@ -3116,6 +3578,12 @@ const htmlContentElements = new Set([
3116
3578
  "p",
3117
3579
  "pre",
3118
3580
  "q",
3581
+ "rb",
3582
+ "rbc",
3583
+ "rp",
3584
+ "rt",
3585
+ "rtc",
3586
+ "ruby",
3119
3587
  "samp",
3120
3588
  "small",
3121
3589
  "span",
@@ -3132,6 +3600,7 @@ const htmlContentElements = new Set([
3132
3600
  "ul",
3133
3601
  "var",
3134
3602
  ]);
3603
+ const unsafeContentElements = new Set(["script", "style"]);
3135
3604
  const mathMlElements = new Set([
3136
3605
  "math",
3137
3606
  "maction",
@@ -3200,32 +3669,80 @@ function copySafeAttributes(element, attributes) {
3200
3669
  if (!isSafeContentAttribute(name, value))
3201
3670
  continue;
3202
3671
  element.setAttribute(name, value);
3672
+ if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
3673
+ element.setAttribute("lang", value);
3674
+ }
3203
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);
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");
3204
3718
  }
3205
3719
  function isSafeContentAttribute(name, value) {
3206
- if (name.startsWith("on"))
3720
+ const normalizedName = name.toLowerCase();
3721
+ if (normalizedName.startsWith("on"))
3207
3722
  return false;
3208
- if (name === "style")
3723
+ if (normalizedName === "style")
3209
3724
  return false;
3210
- if (name === "href" || name === "src" || name === "data") {
3725
+ if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
3211
3726
  return isSafeUrl(value);
3212
3727
  }
3213
- return (name === "alt" ||
3214
- name === "aria-label" ||
3215
- name === "aria-describedby" ||
3216
- name === "class" ||
3217
- name === "colspan" ||
3218
- name === "height" ||
3219
- name === "id" ||
3220
- name === "lang" ||
3221
- name === "role" ||
3222
- name === "rowspan" ||
3223
- name === "scope" ||
3224
- name === "title" ||
3225
- name === "type" ||
3226
- name === "width" ||
3227
- mathMlAttributeNames.has(name) ||
3228
- 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-"));
3229
3746
  }
3230
3747
  const mathMlAttributeNames = new Set([
3231
3748
  "accent",
@@ -3360,6 +3877,23 @@ function playerStyleElement() {
3360
3877
  margin-block: 0;
3361
3878
  }
3362
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
+
3363
3897
  .qti3-embedded-interaction {
3364
3898
  display: inline-flex;
3365
3899
  gap: 0.35rem;
@@ -3732,6 +4266,7 @@ function playerStyleElement() {
3732
4266
  }
3733
4267
 
3734
4268
  .qti3-graphic-associate-surface,
4269
+ .qti3-graphic-gap-match-surface,
3735
4270
  .qti3-graphic-order-surface {
3736
4271
  touch-action: manipulation;
3737
4272
  }
@@ -3759,10 +4294,60 @@ function playerStyleElement() {
3759
4294
  }
3760
4295
 
3761
4296
  .qti3-graphic-associate-hotspot,
4297
+ .qti3-graphic-gap-hotspot,
3762
4298
  .qti3-graphic-order-hotspot {
3763
4299
  z-index: 2;
3764
4300
  }
3765
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
+
3766
4351
  .qti3-graphic-order-hotspot {
3767
4352
  display: grid;
3768
4353
  place-items: center;