@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/README.md +10 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +658 -73
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +737 -80
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.
|
|
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", () =>
|
|
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
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
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 = "
|
|
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
|
-
|
|
2135
|
-
|
|
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
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
-
|
|
3720
|
+
const normalizedName = name.toLowerCase();
|
|
3721
|
+
if (normalizedName.startsWith("on"))
|
|
3207
3722
|
return false;
|
|
3208
|
-
if (
|
|
3723
|
+
if (normalizedName === "style")
|
|
3209
3724
|
return false;
|
|
3210
|
-
if (
|
|
3725
|
+
if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
|
|
3211
3726
|
return isSafeUrl(value);
|
|
3212
3727
|
}
|
|
3213
|
-
return (
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
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;
|