@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/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertQtiAttemptStateV1,
|
|
3
3
|
createItemSession,
|
|
4
|
+
createCatalogSupportResolution,
|
|
5
|
+
createTextToSpeechTraversal,
|
|
4
6
|
parseQtiXml,
|
|
5
7
|
visibleModalFeedback,
|
|
6
8
|
type QtiAssessmentItem,
|
|
@@ -13,7 +15,12 @@ import {
|
|
|
13
15
|
type QtiInteraction,
|
|
14
16
|
type QtiItemSession,
|
|
15
17
|
type QtiObjectAsset,
|
|
18
|
+
type QtiPortableCustomDefinition,
|
|
19
|
+
type QtiPortableCustomStateValue,
|
|
16
20
|
type QtiScoreResult,
|
|
21
|
+
type QtiCatalogSupportResolution,
|
|
22
|
+
type QtiCatalogSupportResolutionOptions,
|
|
23
|
+
type QtiTextToSpeechTraversal,
|
|
17
24
|
type QtiValue,
|
|
18
25
|
} from "@longsightgroup/qti3-core";
|
|
19
26
|
|
|
@@ -50,6 +57,15 @@ export interface QtiResponseChangeEventDetail {
|
|
|
50
57
|
value: QtiValue;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
export interface QtiPortableCustomMountEventDetail {
|
|
61
|
+
responseIdentifier: string;
|
|
62
|
+
interaction: QtiInteraction;
|
|
63
|
+
definition: QtiPortableCustomDefinition;
|
|
64
|
+
host: HTMLElement;
|
|
65
|
+
value: QtiValue;
|
|
66
|
+
state?: QtiPortableCustomStateValue | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
53
69
|
export type QtiScoreEventDetail = QtiScoreResult;
|
|
54
70
|
|
|
55
71
|
export interface QtiValidationEventDetail {
|
|
@@ -69,6 +85,7 @@ export interface QtiAssessmentItemPlayerEventDetailMap {
|
|
|
69
85
|
"qti-ready": QtiReadyEventDetail;
|
|
70
86
|
"qti-statechange": QtiStateChangeEventDetail;
|
|
71
87
|
"qti-responsechange": QtiResponseChangeEventDetail;
|
|
88
|
+
"qti-portable-custom-mount": QtiPortableCustomMountEventDetail;
|
|
72
89
|
"qti-score": QtiScoreEventDetail;
|
|
73
90
|
"qti-validation": QtiValidationEventDetail;
|
|
74
91
|
"qti-suspend": QtiSuspendEventDetail;
|
|
@@ -224,6 +241,18 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
224
241
|
return state;
|
|
225
242
|
}
|
|
226
243
|
|
|
244
|
+
getTextToSpeechTraversal(): QtiTextToSpeechTraversal | undefined {
|
|
245
|
+
if (!this.documentModel) return undefined;
|
|
246
|
+
return createTextToSpeechTraversal(this.documentModel);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
getCatalogSupportResolution(
|
|
250
|
+
options: QtiCatalogSupportResolutionOptions = {},
|
|
251
|
+
): QtiCatalogSupportResolution | undefined {
|
|
252
|
+
if (!this.documentModel) return undefined;
|
|
253
|
+
return createCatalogSupportResolution(this.documentModel, options);
|
|
254
|
+
}
|
|
255
|
+
|
|
227
256
|
private emitStateChange(state = this.serialize()): void {
|
|
228
257
|
if (!state) return;
|
|
229
258
|
this.dispatchPlayerEvent("qti-statechange", { state });
|
|
@@ -243,6 +272,10 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
243
272
|
this.applyDefaultStyles();
|
|
244
273
|
const root = document.createElement("article");
|
|
245
274
|
root.className = "qti3-player";
|
|
275
|
+
if (documentModel.item.language) {
|
|
276
|
+
root.lang = documentModel.item.language;
|
|
277
|
+
root.setAttribute("xml:lang", documentModel.item.language);
|
|
278
|
+
}
|
|
246
279
|
root.append(playerStyleElement());
|
|
247
280
|
|
|
248
281
|
if (documentModel.item.prompt && documentModel.item.body.length === 0) {
|
|
@@ -283,6 +316,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
283
316
|
field.dataset.responseIdentifier = interaction.responseIdentifier;
|
|
284
317
|
|
|
285
318
|
const heading = document.createElement("h3");
|
|
319
|
+
copySafeAttributes(heading, interaction.promptAttributes ?? {});
|
|
286
320
|
heading.textContent = interactionLabel(interaction);
|
|
287
321
|
field.append(heading);
|
|
288
322
|
if (interaction.responseIdentifier) {
|
|
@@ -371,7 +405,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
371
405
|
}
|
|
372
406
|
|
|
373
407
|
if (interaction.type === "portableCustom") {
|
|
374
|
-
field.append(renderPortableCustomResponse(interaction, update, currentValue));
|
|
408
|
+
field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
|
|
375
409
|
return field;
|
|
376
410
|
}
|
|
377
411
|
|
|
@@ -421,6 +455,99 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
421
455
|
return field;
|
|
422
456
|
}
|
|
423
457
|
|
|
458
|
+
private renderPortableCustomResponse(
|
|
459
|
+
interaction: QtiInteraction,
|
|
460
|
+
update: (value: QtiValue) => void,
|
|
461
|
+
currentValue: QtiValue,
|
|
462
|
+
): HTMLElement {
|
|
463
|
+
const definition =
|
|
464
|
+
interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
|
|
465
|
+
const responseIdentifier =
|
|
466
|
+
interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
|
|
467
|
+
const currentState = responseIdentifier
|
|
468
|
+
? this.currentInteractionState(responseIdentifier)
|
|
469
|
+
: undefined;
|
|
470
|
+
|
|
471
|
+
const group = document.createElement("div");
|
|
472
|
+
group.role = "group";
|
|
473
|
+
group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
|
|
474
|
+
|
|
475
|
+
const host = document.createElement("div");
|
|
476
|
+
host.className = "qti3-portable-custom-host";
|
|
477
|
+
host.tabIndex = 0;
|
|
478
|
+
host.dataset.responseIdentifier = responseIdentifier;
|
|
479
|
+
host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
|
|
480
|
+
host.dataset.module = definition.module ?? "";
|
|
481
|
+
host.dataset.qtiName = interaction.qtiName;
|
|
482
|
+
if (definition.interactionModules?.primaryConfiguration) {
|
|
483
|
+
host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
|
|
484
|
+
}
|
|
485
|
+
if (definition.interactionModules?.secondaryConfiguration) {
|
|
486
|
+
host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
|
|
487
|
+
}
|
|
488
|
+
if (currentState !== undefined) host.dataset.state = JSON.stringify(currentState);
|
|
489
|
+
host.setAttribute("role", "application");
|
|
490
|
+
host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
|
|
491
|
+
host.style.border = "1px solid CanvasText";
|
|
492
|
+
host.style.padding = "0.5rem";
|
|
493
|
+
host.style.marginBlockEnd = "0.5rem";
|
|
494
|
+
|
|
495
|
+
if (definition.interactionMarkup.length > 0) {
|
|
496
|
+
const markup = document.createElement("div");
|
|
497
|
+
markup.className = "qti3-portable-custom-markup";
|
|
498
|
+
markup.append(...this.renderContentNodes(definition.interactionMarkup));
|
|
499
|
+
host.append(markup);
|
|
500
|
+
} else {
|
|
501
|
+
host.textContent = "Portable custom interaction host";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const fallback = document.createElement("input");
|
|
505
|
+
fallback.type = "hidden";
|
|
506
|
+
fallback.className = "qti3-portable-custom-response";
|
|
507
|
+
fallback.hidden = true;
|
|
508
|
+
fallback.tabIndex = -1;
|
|
509
|
+
fallback.setAttribute("aria-hidden", "true");
|
|
510
|
+
fallback.value = scalarString(currentValue);
|
|
511
|
+
|
|
512
|
+
const handlePortableCustomEvent = (event: Event) => {
|
|
513
|
+
const state = portableCustomEventState(event);
|
|
514
|
+
const value = portableCustomEventValue(event);
|
|
515
|
+
const validity = portableCustomEventValidity(event);
|
|
516
|
+
if (state !== undefined && responseIdentifier && this.session) {
|
|
517
|
+
this.session.setInteractionState(responseIdentifier, state);
|
|
518
|
+
host.dataset.state = JSON.stringify(state);
|
|
519
|
+
}
|
|
520
|
+
if (value !== undefined) {
|
|
521
|
+
fallback.value = String(value ?? "");
|
|
522
|
+
update(value);
|
|
523
|
+
}
|
|
524
|
+
if (validity && responseIdentifier) {
|
|
525
|
+
this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
|
|
526
|
+
this.emitStateChange();
|
|
527
|
+
}
|
|
528
|
+
if (value === undefined && state !== undefined && !validity) this.emitStateChange();
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
|
|
532
|
+
host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
|
|
533
|
+
host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
|
|
534
|
+
host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
|
|
535
|
+
|
|
536
|
+
queueMicrotask(() => {
|
|
537
|
+
this.dispatchPlayerEvent("qti-portable-custom-mount", {
|
|
538
|
+
responseIdentifier,
|
|
539
|
+
interaction,
|
|
540
|
+
definition,
|
|
541
|
+
host,
|
|
542
|
+
value: currentValue,
|
|
543
|
+
state: currentState,
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
group.append(host, fallback);
|
|
548
|
+
return group;
|
|
549
|
+
}
|
|
550
|
+
|
|
424
551
|
private renderEmbeddedInteraction(interaction: QtiInteraction): HTMLElement {
|
|
425
552
|
if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
|
|
426
553
|
return this.renderInteraction(interaction);
|
|
@@ -480,11 +607,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
480
607
|
}
|
|
481
608
|
if (node.qtiName === "qti-prompt") {
|
|
482
609
|
const prompt = document.createElement("p");
|
|
483
|
-
prompt.
|
|
610
|
+
copySafeAttributes(prompt, node.attributes);
|
|
611
|
+
prompt.classList.add("qti3-item-prompt");
|
|
484
612
|
prompt.append(...this.renderContentNodes(node.children));
|
|
485
613
|
return [prompt];
|
|
486
614
|
}
|
|
487
615
|
|
|
616
|
+
if (unsafeContentElements.has(node.qtiName)) return [];
|
|
488
617
|
const elementName = contentElementName(node.qtiName);
|
|
489
618
|
if (!elementName) return this.renderContentNodes(node.children);
|
|
490
619
|
const element = createContentElement(elementName);
|
|
@@ -652,6 +781,32 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
652
781
|
return this.session?.serialize().responses[identifier] ?? null;
|
|
653
782
|
}
|
|
654
783
|
|
|
784
|
+
private currentInteractionState(identifier: string): QtiPortableCustomStateValue | undefined {
|
|
785
|
+
return this.session?.serialize().interactionStates?.[identifier];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private setPortableCustomValidity(
|
|
789
|
+
responseIdentifier: string,
|
|
790
|
+
valid: boolean,
|
|
791
|
+
message: string | undefined,
|
|
792
|
+
): void {
|
|
793
|
+
if (valid) {
|
|
794
|
+
this.clearValidationMessage(responseIdentifier);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const diagnostic: QtiDiagnostic = {
|
|
798
|
+
code: "response.portableCustom.validity",
|
|
799
|
+
severity: "error",
|
|
800
|
+
message: message?.trim() || `${responseIdentifier} is not valid.`,
|
|
801
|
+
path: responseIdentifier,
|
|
802
|
+
};
|
|
803
|
+
this.validationMessages = [
|
|
804
|
+
...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
|
|
805
|
+
diagnostic,
|
|
806
|
+
];
|
|
807
|
+
this.renderValidationMessages();
|
|
808
|
+
}
|
|
809
|
+
|
|
655
810
|
private applyDefaultStyles(): void {
|
|
656
811
|
this.style.color = "CanvasText";
|
|
657
812
|
this.style.backgroundColor = "Canvas";
|
|
@@ -1692,6 +1847,12 @@ function renderGraphicAssociateResponse(
|
|
|
1692
1847
|
const maximumAssociations =
|
|
1693
1848
|
interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
|
|
1694
1849
|
let selectedHotspot: QtiChoice | undefined;
|
|
1850
|
+
let draggedHotspot: QtiChoice | undefined;
|
|
1851
|
+
let dragPointerId: number | undefined;
|
|
1852
|
+
let dragStart: { x: number; y: number } | undefined;
|
|
1853
|
+
let dragStarted = false;
|
|
1854
|
+
let suppressNextClick = false;
|
|
1855
|
+
let previewLine: SVGLineElement | undefined;
|
|
1695
1856
|
|
|
1696
1857
|
const surface = document.createElement("div");
|
|
1697
1858
|
surface.className = "qti3-graphic-associate-surface";
|
|
@@ -1785,6 +1946,52 @@ function renderGraphicAssociateResponse(
|
|
|
1785
1946
|
renderState();
|
|
1786
1947
|
commit();
|
|
1787
1948
|
};
|
|
1949
|
+
const authoredPointFromPointer = (event: PointerEvent) => {
|
|
1950
|
+
const rect = surface.getBoundingClientRect();
|
|
1951
|
+
return {
|
|
1952
|
+
x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
|
|
1953
|
+
y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
|
|
1954
|
+
};
|
|
1955
|
+
};
|
|
1956
|
+
const removePreviewLine = () => {
|
|
1957
|
+
previewLine?.remove();
|
|
1958
|
+
previewLine = undefined;
|
|
1959
|
+
};
|
|
1960
|
+
const suppressFollowingClick = () => {
|
|
1961
|
+
suppressNextClick = true;
|
|
1962
|
+
setTimeout(() => {
|
|
1963
|
+
suppressNextClick = false;
|
|
1964
|
+
}, 0);
|
|
1965
|
+
};
|
|
1966
|
+
const updatePreviewLine = (source: QtiChoice, event: PointerEvent) => {
|
|
1967
|
+
const start = hotspotCenter(source, width, height);
|
|
1968
|
+
const end = authoredPointFromPointer(event);
|
|
1969
|
+
if (!previewLine) {
|
|
1970
|
+
previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
1971
|
+
previewLine.dataset.preview = "true";
|
|
1972
|
+
connections.append(previewLine);
|
|
1973
|
+
}
|
|
1974
|
+
previewLine.setAttribute("x1", String(start.x));
|
|
1975
|
+
previewLine.setAttribute("y1", String(start.y));
|
|
1976
|
+
previewLine.setAttribute("x2", String(end.x));
|
|
1977
|
+
previewLine.setAttribute("y2", String(end.y));
|
|
1978
|
+
};
|
|
1979
|
+
const hotspotFromPointer = (event: PointerEvent) => {
|
|
1980
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
1981
|
+
const button = element?.closest<HTMLButtonElement>(".qti3-graphic-associate-hotspot");
|
|
1982
|
+
const identifier = button?.dataset.choiceIdentifier;
|
|
1983
|
+
return choices.find((choice) => choice.identifier === identifier);
|
|
1984
|
+
};
|
|
1985
|
+
const finishDrag = (event: PointerEvent, source: QtiChoice) => {
|
|
1986
|
+
const target = hotspotFromPointer(event);
|
|
1987
|
+
removePreviewLine();
|
|
1988
|
+
if (target) {
|
|
1989
|
+
addPair(source, target);
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
selectedHotspot = undefined;
|
|
1993
|
+
renderState();
|
|
1994
|
+
};
|
|
1788
1995
|
const chooseHotspot = (choice: QtiChoice) => {
|
|
1789
1996
|
if (!selectedHotspot) {
|
|
1790
1997
|
selectedHotspot = choice;
|
|
@@ -1861,8 +2068,60 @@ function renderGraphicAssociateResponse(
|
|
|
1861
2068
|
button.setAttribute("aria-pressed", "false");
|
|
1862
2069
|
button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
|
|
1863
2070
|
button.style.position = "absolute";
|
|
2071
|
+
button.style.touchAction = "none";
|
|
1864
2072
|
placeHotspotButton(button, choice, width, height);
|
|
1865
|
-
button.addEventListener("click", () =>
|
|
2073
|
+
button.addEventListener("click", (event) => {
|
|
2074
|
+
if (suppressNextClick) {
|
|
2075
|
+
suppressNextClick = false;
|
|
2076
|
+
event.preventDefault();
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
chooseHotspot(choice);
|
|
2080
|
+
});
|
|
2081
|
+
button.addEventListener("pointerdown", (event) => {
|
|
2082
|
+
if (event.button !== 0) return;
|
|
2083
|
+
draggedHotspot = choice;
|
|
2084
|
+
dragPointerId = event.pointerId;
|
|
2085
|
+
dragStart = { x: event.clientX, y: event.clientY };
|
|
2086
|
+
dragStarted = false;
|
|
2087
|
+
button.setPointerCapture(event.pointerId);
|
|
2088
|
+
});
|
|
2089
|
+
button.addEventListener("pointermove", (event) => {
|
|
2090
|
+
if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart) return;
|
|
2091
|
+
const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
|
2092
|
+
if (!dragStarted && moved < 4) return;
|
|
2093
|
+
if (!dragStarted) {
|
|
2094
|
+
dragStarted = true;
|
|
2095
|
+
suppressFollowingClick();
|
|
2096
|
+
selectedHotspot = draggedHotspot;
|
|
2097
|
+
renderState();
|
|
2098
|
+
}
|
|
2099
|
+
updatePreviewLine(draggedHotspot, event);
|
|
2100
|
+
event.preventDefault();
|
|
2101
|
+
});
|
|
2102
|
+
button.addEventListener("pointerup", (event) => {
|
|
2103
|
+
if (dragPointerId !== event.pointerId || !draggedHotspot) return;
|
|
2104
|
+
const source = draggedHotspot;
|
|
2105
|
+
draggedHotspot = undefined;
|
|
2106
|
+
dragPointerId = undefined;
|
|
2107
|
+
dragStart = undefined;
|
|
2108
|
+
button.releasePointerCapture(event.pointerId);
|
|
2109
|
+
if (!dragStarted) return;
|
|
2110
|
+
dragStarted = false;
|
|
2111
|
+
suppressFollowingClick();
|
|
2112
|
+
finishDrag(event, source);
|
|
2113
|
+
event.preventDefault();
|
|
2114
|
+
});
|
|
2115
|
+
button.addEventListener("pointercancel", (event) => {
|
|
2116
|
+
if (dragPointerId !== event.pointerId) return;
|
|
2117
|
+
draggedHotspot = undefined;
|
|
2118
|
+
dragPointerId = undefined;
|
|
2119
|
+
dragStart = undefined;
|
|
2120
|
+
dragStarted = false;
|
|
2121
|
+
removePreviewLine();
|
|
2122
|
+
selectedHotspot = undefined;
|
|
2123
|
+
renderState();
|
|
2124
|
+
});
|
|
1866
2125
|
button.addEventListener("keydown", (event) => {
|
|
1867
2126
|
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
1868
2127
|
event.preventDefault();
|
|
@@ -1888,6 +2147,14 @@ function renderGapMatchResponse(
|
|
|
1888
2147
|
update: (value: QtiValue) => void,
|
|
1889
2148
|
currentValue: QtiValue,
|
|
1890
2149
|
): HTMLElement {
|
|
2150
|
+
if (
|
|
2151
|
+
interaction.type === "graphicGapMatch" &&
|
|
2152
|
+
interaction.object &&
|
|
2153
|
+
interaction.choices.some((choice) => choice.role === "hotspot")
|
|
2154
|
+
) {
|
|
2155
|
+
return renderGraphicGapMatchResponse(interaction, update, currentValue);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
1891
2158
|
const group = responseGroup();
|
|
1892
2159
|
appendGraphicContext(group, interaction);
|
|
1893
2160
|
|
|
@@ -2012,6 +2279,174 @@ function renderGapMatchResponse(
|
|
|
2012
2279
|
return group;
|
|
2013
2280
|
}
|
|
2014
2281
|
|
|
2282
|
+
function renderGraphicGapMatchResponse(
|
|
2283
|
+
interaction: QtiInteraction,
|
|
2284
|
+
update: (value: QtiValue) => void,
|
|
2285
|
+
currentValue: QtiValue,
|
|
2286
|
+
): HTMLElement {
|
|
2287
|
+
const group = responseGroup();
|
|
2288
|
+
const width = objectWidth(interaction);
|
|
2289
|
+
const height = objectHeight(interaction);
|
|
2290
|
+
const sources = sourceChoices(interaction);
|
|
2291
|
+
const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
|
|
2292
|
+
const assignments = new Map<string, QtiChoice>();
|
|
2293
|
+
let selectedSource: QtiChoice | undefined;
|
|
2294
|
+
let draggedSource: string | undefined;
|
|
2295
|
+
|
|
2296
|
+
for (const pair of valueToStrings(currentValue)) {
|
|
2297
|
+
const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
|
|
2298
|
+
const source = sources.find((choice) => choice.identifier === sourceIdentifier);
|
|
2299
|
+
if (source && gapIdentifier) assignments.set(gapIdentifier, source);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const surface = document.createElement("div");
|
|
2303
|
+
surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
|
|
2304
|
+
surface.role = "group";
|
|
2305
|
+
surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
|
|
2306
|
+
surface.style.position = "relative";
|
|
2307
|
+
surface.style.inlineSize = `${width}px`;
|
|
2308
|
+
surface.style.aspectRatio = `${width} / ${height}`;
|
|
2309
|
+
surface.style.maxInlineSize = "100%";
|
|
2310
|
+
surface.style.border = "1px solid CanvasText";
|
|
2311
|
+
surface.style.background = "Canvas";
|
|
2312
|
+
surface.style.overflow = "visible";
|
|
2313
|
+
surface.style.setProperty(
|
|
2314
|
+
"--qti3-graphic-gap-label-block-size",
|
|
2315
|
+
`${graphicGapLabelBlockSize(sources)}rem`,
|
|
2316
|
+
);
|
|
2317
|
+
|
|
2318
|
+
if (interaction.object?.data && objectIsImage(interaction.object)) {
|
|
2319
|
+
const image = document.createElement("img");
|
|
2320
|
+
image.src = interaction.object.data;
|
|
2321
|
+
image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
|
|
2322
|
+
image.style.position = "absolute";
|
|
2323
|
+
image.style.inset = "0";
|
|
2324
|
+
image.style.inlineSize = "100%";
|
|
2325
|
+
image.style.blockSize = "100%";
|
|
2326
|
+
image.style.objectFit = "contain";
|
|
2327
|
+
image.style.pointerEvents = "none";
|
|
2328
|
+
surface.append(image);
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
|
|
2332
|
+
sourceRegion.classList.add("qti3-graphic-gap-source-region");
|
|
2333
|
+
const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
|
|
2334
|
+
if (choicesWidth !== undefined) sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
|
|
2335
|
+
|
|
2336
|
+
const summary = document.createElement("p");
|
|
2337
|
+
summary.className = "qti3-selection-summary";
|
|
2338
|
+
summary.setAttribute("aria-live", "polite");
|
|
2339
|
+
|
|
2340
|
+
const commit = () => {
|
|
2341
|
+
update(
|
|
2342
|
+
[...assignments.entries()].map(
|
|
2343
|
+
([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`,
|
|
2344
|
+
),
|
|
2345
|
+
);
|
|
2346
|
+
};
|
|
2347
|
+
const syncSources = () => {
|
|
2348
|
+
for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
|
|
2349
|
+
button.setAttribute(
|
|
2350
|
+
"aria-pressed",
|
|
2351
|
+
button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false",
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
const clearSourceIfSingleUse = (source: QtiChoice, keepGapIdentifier: string) => {
|
|
2356
|
+
if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1) return;
|
|
2357
|
+
for (const [gapIdentifier, assigned] of assignments.entries()) {
|
|
2358
|
+
if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
|
|
2359
|
+
assignments.delete(gapIdentifier);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
const assign = (gap: QtiChoice, sourceIdentifier: string | undefined) => {
|
|
2364
|
+
const source = sources.find((choice) => choice.identifier === sourceIdentifier);
|
|
2365
|
+
if (!source) return;
|
|
2366
|
+
clearSourceIfSingleUse(source, gap.identifier);
|
|
2367
|
+
assignments.set(gap.identifier, source);
|
|
2368
|
+
selectedSource = undefined;
|
|
2369
|
+
syncSources();
|
|
2370
|
+
renderTargets();
|
|
2371
|
+
commit();
|
|
2372
|
+
};
|
|
2373
|
+
const targetLabel = (gap: QtiChoice, index: number) =>
|
|
2374
|
+
gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
|
|
2375
|
+
const renderTargetButton = (gap: QtiChoice, index: number): HTMLButtonElement => {
|
|
2376
|
+
const assigned = assignments.get(gap.identifier);
|
|
2377
|
+
const label = targetLabel(gap, index);
|
|
2378
|
+
const button = document.createElement("button");
|
|
2379
|
+
button.type = "button";
|
|
2380
|
+
button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
|
|
2381
|
+
button.dataset.gapIdentifier = gap.identifier;
|
|
2382
|
+
button.dataset.selected = assigned ? "true" : "false";
|
|
2383
|
+
button.setAttribute(
|
|
2384
|
+
"aria-label",
|
|
2385
|
+
assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`,
|
|
2386
|
+
);
|
|
2387
|
+
button.addEventListener("dragover", (event) => {
|
|
2388
|
+
event.preventDefault();
|
|
2389
|
+
button.classList.add("qti3-drop-target");
|
|
2390
|
+
});
|
|
2391
|
+
button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
|
|
2392
|
+
button.addEventListener("drop", (event) => {
|
|
2393
|
+
event.preventDefault();
|
|
2394
|
+
button.classList.remove("qti3-drop-target");
|
|
2395
|
+
assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
|
|
2396
|
+
});
|
|
2397
|
+
button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
|
|
2398
|
+
button.addEventListener("keydown", (event) => {
|
|
2399
|
+
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
|
2400
|
+
if (!assignments.has(gap.identifier)) return;
|
|
2401
|
+
event.preventDefault();
|
|
2402
|
+
assignments.delete(gap.identifier);
|
|
2403
|
+
renderTargets();
|
|
2404
|
+
commit();
|
|
2405
|
+
});
|
|
2406
|
+
placeHotspotButton(button, gap, width, height);
|
|
2407
|
+
if (assigned) {
|
|
2408
|
+
const assignedLabel = document.createElement("span");
|
|
2409
|
+
assignedLabel.className = "qti3-graphic-gap-label";
|
|
2410
|
+
assignedLabel.textContent = assigned.text;
|
|
2411
|
+
button.append(assignedLabel);
|
|
2412
|
+
}
|
|
2413
|
+
return button;
|
|
2414
|
+
};
|
|
2415
|
+
const renderTargets = () => {
|
|
2416
|
+
surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
|
|
2417
|
+
for (const [index, gap] of gaps.entries()) {
|
|
2418
|
+
surface.append(renderTargetButton(gap, index));
|
|
2419
|
+
}
|
|
2420
|
+
summary.textContent =
|
|
2421
|
+
assignments.size > 0
|
|
2422
|
+
? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
|
|
2423
|
+
: "No labels placed.";
|
|
2424
|
+
};
|
|
2425
|
+
|
|
2426
|
+
for (const source of sources) {
|
|
2427
|
+
const button = tokenButton(source);
|
|
2428
|
+
button.draggable = true;
|
|
2429
|
+
button.addEventListener("dragstart", (event) => {
|
|
2430
|
+
draggedSource = source.identifier;
|
|
2431
|
+
event.dataTransfer?.setData("text/plain", source.identifier);
|
|
2432
|
+
event.dataTransfer?.setDragImage(button, 8, 8);
|
|
2433
|
+
});
|
|
2434
|
+
button.addEventListener("dragend", () => {
|
|
2435
|
+
draggedSource = undefined;
|
|
2436
|
+
syncSources();
|
|
2437
|
+
});
|
|
2438
|
+
button.addEventListener("click", () => {
|
|
2439
|
+
selectedSource = source;
|
|
2440
|
+
syncSources();
|
|
2441
|
+
});
|
|
2442
|
+
sourceRegion.append(button);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
renderTargets();
|
|
2446
|
+
group.append(surface, sourceRegion, summary);
|
|
2447
|
+
return group;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2015
2450
|
function renderSelect(
|
|
2016
2451
|
interaction: QtiInteraction,
|
|
2017
2452
|
update: (value: QtiValue) => void,
|
|
@@ -2376,10 +2811,9 @@ function renderPositionObjectResponse(
|
|
|
2376
2811
|
const height = objectAssetHeight(stageObject, 300);
|
|
2377
2812
|
const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
|
|
2378
2813
|
const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
};
|
|
2814
|
+
const parsedPoint = parsePointValue(currentValue);
|
|
2815
|
+
let point = parsedPoint ?? { x: 0, y: 0 };
|
|
2816
|
+
let isPlaced = Boolean(parsedPoint);
|
|
2383
2817
|
|
|
2384
2818
|
const stage = document.createElement("div");
|
|
2385
2819
|
stage.className = "qti3-position-object-stage";
|
|
@@ -2393,8 +2827,9 @@ function renderPositionObjectResponse(
|
|
|
2393
2827
|
stage.style.border = "1px solid CanvasText";
|
|
2394
2828
|
stage.style.background = "Canvas";
|
|
2395
2829
|
stage.style.color = "CanvasText";
|
|
2396
|
-
stage.style.overflow = "
|
|
2830
|
+
stage.style.overflow = "visible";
|
|
2397
2831
|
stage.style.touchAction = "none";
|
|
2832
|
+
stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
|
|
2398
2833
|
|
|
2399
2834
|
if (stageObject?.data && objectIsImage(stageObject)) {
|
|
2400
2835
|
const image = document.createElement("img");
|
|
@@ -2446,10 +2881,24 @@ function renderPositionObjectResponse(
|
|
|
2446
2881
|
point.y = Math.max(0, Math.min(height, point.y));
|
|
2447
2882
|
};
|
|
2448
2883
|
const commit = () => {
|
|
2884
|
+
if (!isPlaced) return;
|
|
2449
2885
|
update(pointToString(point));
|
|
2450
2886
|
};
|
|
2451
2887
|
const syncMarker = () => {
|
|
2888
|
+
if (!isPlaced) {
|
|
2889
|
+
marker.dataset.placed = "false";
|
|
2890
|
+
marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
|
|
2891
|
+
marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
|
|
2892
|
+
coordinate.value = "";
|
|
2893
|
+
coordinate.textContent = "Object not placed";
|
|
2894
|
+
stage.setAttribute(
|
|
2895
|
+
"aria-label",
|
|
2896
|
+
`${readableType(interaction.type)} placement stage, object not placed`,
|
|
2897
|
+
);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2452
2900
|
clamp();
|
|
2901
|
+
marker.dataset.placed = "true";
|
|
2453
2902
|
marker.style.insetInlineStart = `${percent(point.x, width)}%`;
|
|
2454
2903
|
marker.style.insetBlockStart = `${percent(point.y, height)}%`;
|
|
2455
2904
|
coordinate.value = pointToString(point);
|
|
@@ -2465,9 +2914,16 @@ function renderPositionObjectResponse(
|
|
|
2465
2914
|
x: Math.round(((event.clientX - rect.left) / rect.width) * width),
|
|
2466
2915
|
y: Math.round(((event.clientY - rect.top) / rect.height) * height),
|
|
2467
2916
|
};
|
|
2917
|
+
isPlaced = true;
|
|
2468
2918
|
clamp();
|
|
2469
2919
|
};
|
|
2920
|
+
const ensureKeyboardPoint = () => {
|
|
2921
|
+
if (isPlaced) return;
|
|
2922
|
+
point = { x: 0, y: 0 };
|
|
2923
|
+
isPlaced = true;
|
|
2924
|
+
};
|
|
2470
2925
|
const moveBy = (dx: number, dy: number, emit = true) => {
|
|
2926
|
+
ensureKeyboardPoint();
|
|
2471
2927
|
point.x += dx;
|
|
2472
2928
|
point.y += dy;
|
|
2473
2929
|
syncMarker();
|
|
@@ -2479,22 +2935,30 @@ function renderPositionObjectResponse(
|
|
|
2479
2935
|
else if (event.key === "ArrowRight") moveBy(step, 0, false);
|
|
2480
2936
|
else if (event.key === "ArrowUp") moveBy(0, -step, false);
|
|
2481
2937
|
else if (event.key === "ArrowDown") moveBy(0, step, false);
|
|
2482
|
-
else if (event.key === "Enter" || event.key === " ")
|
|
2483
|
-
|
|
2938
|
+
else if (event.key === "Enter" || event.key === " ") {
|
|
2939
|
+
ensureKeyboardPoint();
|
|
2940
|
+
syncMarker();
|
|
2941
|
+
commit();
|
|
2942
|
+
} else return;
|
|
2484
2943
|
event.preventDefault();
|
|
2485
2944
|
};
|
|
2486
2945
|
|
|
2487
2946
|
let dragging = false;
|
|
2947
|
+
let dragMoved = false;
|
|
2488
2948
|
marker.addEventListener("pointerdown", (event) => {
|
|
2489
2949
|
dragging = true;
|
|
2950
|
+
dragMoved = false;
|
|
2490
2951
|
marker.setPointerCapture(event.pointerId);
|
|
2491
2952
|
marker.style.cursor = "grabbing";
|
|
2492
|
-
|
|
2493
|
-
|
|
2953
|
+
if (isPlaced) {
|
|
2954
|
+
pointFromPointer(event);
|
|
2955
|
+
syncMarker();
|
|
2956
|
+
}
|
|
2494
2957
|
event.preventDefault();
|
|
2495
2958
|
});
|
|
2496
2959
|
marker.addEventListener("pointermove", (event) => {
|
|
2497
2960
|
if (!dragging) return;
|
|
2961
|
+
dragMoved = true;
|
|
2498
2962
|
pointFromPointer(event);
|
|
2499
2963
|
syncMarker();
|
|
2500
2964
|
});
|
|
@@ -2503,9 +2967,11 @@ function renderPositionObjectResponse(
|
|
|
2503
2967
|
dragging = false;
|
|
2504
2968
|
marker.releasePointerCapture(event.pointerId);
|
|
2505
2969
|
marker.style.cursor = "grab";
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2970
|
+
if (dragMoved || isPlaced) {
|
|
2971
|
+
pointFromPointer(event);
|
|
2972
|
+
syncMarker();
|
|
2973
|
+
commit();
|
|
2974
|
+
}
|
|
2509
2975
|
});
|
|
2510
2976
|
marker.addEventListener("pointercancel", () => {
|
|
2511
2977
|
dragging = false;
|
|
@@ -2685,52 +3151,6 @@ function renderDrawingResponse(
|
|
|
2685
3151
|
return group;
|
|
2686
3152
|
}
|
|
2687
3153
|
|
|
2688
|
-
function renderPortableCustomResponse(
|
|
2689
|
-
interaction: QtiInteraction,
|
|
2690
|
-
update: (value: QtiValue) => void,
|
|
2691
|
-
currentValue: QtiValue,
|
|
2692
|
-
): HTMLElement {
|
|
2693
|
-
const group = document.createElement("div");
|
|
2694
|
-
group.role = "group";
|
|
2695
|
-
group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
|
|
2696
|
-
|
|
2697
|
-
const host = document.createElement("div");
|
|
2698
|
-
host.className = "qti3-portable-custom-host";
|
|
2699
|
-
host.tabIndex = 0;
|
|
2700
|
-
host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
|
|
2701
|
-
host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
|
|
2702
|
-
host.dataset.module = interaction.attributes.module ?? "";
|
|
2703
|
-
host.dataset.qtiName = interaction.qtiName;
|
|
2704
|
-
host.setAttribute("role", "application");
|
|
2705
|
-
host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
|
|
2706
|
-
host.textContent = "Portable custom interaction host";
|
|
2707
|
-
host.style.border = "1px solid CanvasText";
|
|
2708
|
-
host.style.padding = "0.5rem";
|
|
2709
|
-
host.style.marginBlockEnd = "0.5rem";
|
|
2710
|
-
|
|
2711
|
-
const fallback = document.createElement("input");
|
|
2712
|
-
fallback.value = scalarString(currentValue);
|
|
2713
|
-
fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
|
|
2714
|
-
fallback.addEventListener("input", () => update(fallback.value));
|
|
2715
|
-
fallback.addEventListener("change", () => update(fallback.value));
|
|
2716
|
-
|
|
2717
|
-
host.addEventListener("qti3-portable-custom-response", (event) => {
|
|
2718
|
-
const value = portableCustomEventValue(event);
|
|
2719
|
-
if (value === undefined) return;
|
|
2720
|
-
fallback.value = String(value ?? "");
|
|
2721
|
-
update(value);
|
|
2722
|
-
});
|
|
2723
|
-
host.addEventListener("qti3-pci-response", (event) => {
|
|
2724
|
-
const value = portableCustomEventValue(event);
|
|
2725
|
-
if (value === undefined) return;
|
|
2726
|
-
fallback.value = String(value ?? "");
|
|
2727
|
-
update(value);
|
|
2728
|
-
});
|
|
2729
|
-
|
|
2730
|
-
group.append(host, fallback);
|
|
2731
|
-
return group;
|
|
2732
|
-
}
|
|
2733
|
-
|
|
2734
3154
|
function renderHotspotResponse(
|
|
2735
3155
|
interaction: QtiInteraction,
|
|
2736
3156
|
update: (value: QtiValue) => void,
|
|
@@ -2884,6 +3304,7 @@ function configureMediaElement(
|
|
|
2884
3304
|
const sourceElement = document.createElement("source");
|
|
2885
3305
|
sourceElement.src = source.src;
|
|
2886
3306
|
if (source.type) sourceElement.type = source.type;
|
|
3307
|
+
copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
|
|
2887
3308
|
media.append(sourceElement);
|
|
2888
3309
|
}
|
|
2889
3310
|
for (const track of object.tracks) {
|
|
@@ -2894,6 +3315,7 @@ function configureMediaElement(
|
|
|
2894
3315
|
if (track.srclang) trackElement.srclang = track.srclang;
|
|
2895
3316
|
if (track.label) trackElement.label = track.label;
|
|
2896
3317
|
if (track.default) trackElement.default = true;
|
|
3318
|
+
copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
|
|
2897
3319
|
media.append(trackElement);
|
|
2898
3320
|
}
|
|
2899
3321
|
|
|
@@ -2907,6 +3329,30 @@ function copyMediaDataAttributes(element: HTMLElement, attributes: Record<string
|
|
|
2907
3329
|
}
|
|
2908
3330
|
}
|
|
2909
3331
|
|
|
3332
|
+
const sourceAttributeNames = new Set(["src", "srcset", "type"]);
|
|
3333
|
+
const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
|
|
3334
|
+
|
|
3335
|
+
function copySafeMediaChildAttributes(
|
|
3336
|
+
element: HTMLElement,
|
|
3337
|
+
attributes: Record<string, string>,
|
|
3338
|
+
controlledNames: Set<string>,
|
|
3339
|
+
): void {
|
|
3340
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
3341
|
+
const normalizedName = name.toLowerCase();
|
|
3342
|
+
if (controlledNames.has(normalizedName)) continue;
|
|
3343
|
+
if (
|
|
3344
|
+
normalizedName === "class" ||
|
|
3345
|
+
normalizedName === "id" ||
|
|
3346
|
+
normalizedName === "title" ||
|
|
3347
|
+
normalizedName === "media" ||
|
|
3348
|
+
normalizedName === "sizes" ||
|
|
3349
|
+
normalizedName.startsWith("data-")
|
|
3350
|
+
) {
|
|
3351
|
+
element.setAttribute(name, value);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
|
|
2910
3356
|
function mediaElementType(object: QtiObjectAsset): "audio" | "video" | undefined {
|
|
2911
3357
|
const types = [object.type, ...object.sources.map((source) => source.type)].filter(
|
|
2912
3358
|
(value): value is string => Boolean(value),
|
|
@@ -3036,6 +3482,10 @@ function choiceText(choices: QtiChoice[], identifier: string | undefined): strin
|
|
|
3036
3482
|
|
|
3037
3483
|
function sourceChoices(interaction: QtiInteraction): QtiChoice[] {
|
|
3038
3484
|
const choices = choicesOrFallback(interaction);
|
|
3485
|
+
if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
|
|
3486
|
+
const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
|
|
3487
|
+
return gapChoices.length > 0 ? gapChoices : choices;
|
|
3488
|
+
}
|
|
3039
3489
|
const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
|
|
3040
3490
|
const sources = choices.filter((choice) => sourceRoles.has(choice.role));
|
|
3041
3491
|
return sources.length > 0 ? sources : choices;
|
|
@@ -3218,6 +3668,20 @@ function dimension(value: string | undefined, fallback: number): number {
|
|
|
3218
3668
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
3219
3669
|
}
|
|
3220
3670
|
|
|
3671
|
+
function positivePixelValue(value: string | undefined): number | undefined {
|
|
3672
|
+
const parsed = Number(value);
|
|
3673
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
function graphicGapLabelBlockSize(sources: QtiChoice[]): number {
|
|
3677
|
+
const maxLength = Math.max(
|
|
3678
|
+
0,
|
|
3679
|
+
...sources.map((source) => (source.text || source.identifier).trim().length),
|
|
3680
|
+
);
|
|
3681
|
+
const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
|
|
3682
|
+
return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3221
3685
|
function placeHotspotButton(
|
|
3222
3686
|
button: HTMLButtonElement,
|
|
3223
3687
|
choice: QtiChoice,
|
|
@@ -3545,6 +4009,25 @@ function polylineElement(points: DrawingPoint[]): SVGPolylineElement {
|
|
|
3545
4009
|
return line;
|
|
3546
4010
|
}
|
|
3547
4011
|
|
|
4012
|
+
function portableCustomDefinitionFromAttributes(
|
|
4013
|
+
interaction: QtiInteraction,
|
|
4014
|
+
): QtiPortableCustomDefinition {
|
|
4015
|
+
return {
|
|
4016
|
+
responseIdentifier: interaction.responseIdentifier,
|
|
4017
|
+
customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
|
|
4018
|
+
module: interaction.attributes.module,
|
|
4019
|
+
interactionMarkup: [],
|
|
4020
|
+
templateVariables: [],
|
|
4021
|
+
contextVariables: [],
|
|
4022
|
+
stylesheets: [],
|
|
4023
|
+
dataAttributes: Object.fromEntries(
|
|
4024
|
+
Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-")),
|
|
4025
|
+
),
|
|
4026
|
+
attributes: interaction.attributes,
|
|
4027
|
+
source: interaction.source,
|
|
4028
|
+
};
|
|
4029
|
+
}
|
|
4030
|
+
|
|
3548
4031
|
function portableCustomEventValue(event: Event): QtiValue | undefined {
|
|
3549
4032
|
if (!("detail" in event)) return undefined;
|
|
3550
4033
|
const detail = event.detail as { value?: QtiValue; response?: QtiValue } | QtiValue | undefined;
|
|
@@ -3552,14 +4035,49 @@ function portableCustomEventValue(event: Event): QtiValue | undefined {
|
|
|
3552
4035
|
if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
|
|
3553
4036
|
if ("value" in detail) return detail.value ?? null;
|
|
3554
4037
|
if ("response" in detail) return detail.response ?? null;
|
|
4038
|
+
if ("state" in detail || "valid" in detail) return undefined;
|
|
3555
4039
|
}
|
|
3556
4040
|
return detail as QtiValue;
|
|
3557
4041
|
}
|
|
3558
4042
|
|
|
4043
|
+
function portableCustomEventState(event: Event): QtiPortableCustomStateValue | undefined {
|
|
4044
|
+
if (!("detail" in event)) return undefined;
|
|
4045
|
+
const detail = event.detail as { state?: unknown } | undefined;
|
|
4046
|
+
if (typeof detail !== "object" || detail === null || !("state" in detail)) return undefined;
|
|
4047
|
+
return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
function portableCustomEventValidity(
|
|
4051
|
+
event: Event,
|
|
4052
|
+
): { valid: boolean; message?: string | undefined } | undefined {
|
|
4053
|
+
if (!("detail" in event)) return undefined;
|
|
4054
|
+
const detail = event.detail as { valid?: unknown; message?: unknown } | undefined;
|
|
4055
|
+
if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
|
|
4056
|
+
return undefined;
|
|
4057
|
+
}
|
|
4058
|
+
return {
|
|
4059
|
+
valid: detail.valid,
|
|
4060
|
+
message: typeof detail.message === "string" ? detail.message : undefined,
|
|
4061
|
+
};
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
function isPortableCustomStateValue(value: unknown): value is QtiPortableCustomStateValue {
|
|
4065
|
+
if (value === null) return true;
|
|
4066
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
4067
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
4068
|
+
if (Array.isArray(value)) return value.every(isPortableCustomStateValue);
|
|
4069
|
+
if (typeof value === "object") {
|
|
4070
|
+
return Object.values(value as Record<string, unknown>).every(isPortableCustomStateValue);
|
|
4071
|
+
}
|
|
4072
|
+
return false;
|
|
4073
|
+
}
|
|
4074
|
+
|
|
3559
4075
|
const htmlContentElements = new Set([
|
|
3560
4076
|
"a",
|
|
3561
4077
|
"abbr",
|
|
3562
4078
|
"b",
|
|
4079
|
+
"bdi",
|
|
4080
|
+
"bdo",
|
|
3563
4081
|
"blockquote",
|
|
3564
4082
|
"br",
|
|
3565
4083
|
"caption",
|
|
@@ -3573,6 +4091,12 @@ const htmlContentElements = new Set([
|
|
|
3573
4091
|
"em",
|
|
3574
4092
|
"figcaption",
|
|
3575
4093
|
"figure",
|
|
4094
|
+
"h1",
|
|
4095
|
+
"h2",
|
|
4096
|
+
"h3",
|
|
4097
|
+
"h4",
|
|
4098
|
+
"h5",
|
|
4099
|
+
"h6",
|
|
3576
4100
|
"hr",
|
|
3577
4101
|
"i",
|
|
3578
4102
|
"img",
|
|
@@ -3582,6 +4106,12 @@ const htmlContentElements = new Set([
|
|
|
3582
4106
|
"p",
|
|
3583
4107
|
"pre",
|
|
3584
4108
|
"q",
|
|
4109
|
+
"rb",
|
|
4110
|
+
"rbc",
|
|
4111
|
+
"rp",
|
|
4112
|
+
"rt",
|
|
4113
|
+
"rtc",
|
|
4114
|
+
"ruby",
|
|
3585
4115
|
"samp",
|
|
3586
4116
|
"small",
|
|
3587
4117
|
"span",
|
|
@@ -3599,6 +4129,8 @@ const htmlContentElements = new Set([
|
|
|
3599
4129
|
"var",
|
|
3600
4130
|
]);
|
|
3601
4131
|
|
|
4132
|
+
const unsafeContentElements = new Set(["script", "style"]);
|
|
4133
|
+
|
|
3602
4134
|
const mathMlElements = new Set([
|
|
3603
4135
|
"math",
|
|
3604
4136
|
"maction",
|
|
@@ -3663,32 +4195,89 @@ function copySafeAttributes(element: Element, attributes: Record<string, string>
|
|
|
3663
4195
|
for (const [name, value] of Object.entries(attributes)) {
|
|
3664
4196
|
if (!isSafeContentAttribute(name, value)) continue;
|
|
3665
4197
|
element.setAttribute(name, value);
|
|
4198
|
+
if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
|
|
4199
|
+
element.setAttribute("lang", value);
|
|
4200
|
+
}
|
|
3666
4201
|
}
|
|
4202
|
+
applySharedAccessibilityVocabulary(element, attributes);
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
function applySharedAccessibilityVocabulary(
|
|
4206
|
+
element: Element,
|
|
4207
|
+
attributes: Record<string, string>,
|
|
4208
|
+
): void {
|
|
4209
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
4210
|
+
const ariaName = qtiAriaAttributeName(name);
|
|
4211
|
+
if (!ariaName || hasAttributeName(attributes, ariaName)) continue;
|
|
4212
|
+
element.setAttribute(ariaName, value);
|
|
4213
|
+
}
|
|
4214
|
+
|
|
4215
|
+
const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
|
|
4216
|
+
if (
|
|
4217
|
+
suppressesScreenReaderSpeech(suppressTts) &&
|
|
4218
|
+
!hasAttributeName(attributes, "aria-hidden") &&
|
|
4219
|
+
!hasAttributeName(attributes, "data-qti-aria-hidden")
|
|
4220
|
+
) {
|
|
4221
|
+
element.setAttribute("aria-hidden", "true");
|
|
4222
|
+
}
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
function qtiAriaAttributeName(name: string): string | undefined {
|
|
4226
|
+
const normalizedName = name.toLowerCase();
|
|
4227
|
+
const prefix = "data-qti-aria-";
|
|
4228
|
+
if (!normalizedName.startsWith(prefix)) return undefined;
|
|
4229
|
+
const suffix = normalizedName.slice(prefix.length);
|
|
4230
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) return undefined;
|
|
4231
|
+
return `aria-${suffix}`;
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
function attributeValue(attributes: Record<string, string>, name: string): string | undefined {
|
|
4235
|
+
const normalizedName = name.toLowerCase();
|
|
4236
|
+
const entry = Object.entries(attributes).find(
|
|
4237
|
+
([attributeName]) => attributeName.toLowerCase() === normalizedName,
|
|
4238
|
+
);
|
|
4239
|
+
return entry?.[1];
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
function hasAttributeName(attributes: Record<string, string>, name: string): boolean {
|
|
4243
|
+
return attributeValue(attributes, name) !== undefined;
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
function suppressesScreenReaderSpeech(value: string | undefined): boolean {
|
|
4247
|
+
if (!value) return false;
|
|
4248
|
+
const tokens = value
|
|
4249
|
+
.toLowerCase()
|
|
4250
|
+
.split(/[\s,]+/)
|
|
4251
|
+
.filter(Boolean);
|
|
4252
|
+
return tokens.includes("all") || tokens.includes("screen-reader");
|
|
3667
4253
|
}
|
|
3668
4254
|
|
|
3669
4255
|
function isSafeContentAttribute(name: string, value: string): boolean {
|
|
3670
|
-
|
|
3671
|
-
if (
|
|
3672
|
-
if (
|
|
4256
|
+
const normalizedName = name.toLowerCase();
|
|
4257
|
+
if (normalizedName.startsWith("on")) return false;
|
|
4258
|
+
if (normalizedName === "style") return false;
|
|
4259
|
+
if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
|
|
3673
4260
|
return isSafeUrl(value);
|
|
3674
4261
|
}
|
|
3675
4262
|
return (
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
4263
|
+
normalizedName === "alt" ||
|
|
4264
|
+
normalizedName === "class" ||
|
|
4265
|
+
normalizedName === "colspan" ||
|
|
4266
|
+
normalizedName === "dir" ||
|
|
4267
|
+
normalizedName === "headers" ||
|
|
4268
|
+
normalizedName === "height" ||
|
|
4269
|
+
normalizedName === "id" ||
|
|
4270
|
+
normalizedName === "lang" ||
|
|
4271
|
+
normalizedName === "role" ||
|
|
4272
|
+
normalizedName === "rowspan" ||
|
|
4273
|
+
normalizedName === "scope" ||
|
|
4274
|
+
normalizedName === "title" ||
|
|
4275
|
+
normalizedName === "type" ||
|
|
4276
|
+
normalizedName === "width" ||
|
|
4277
|
+
normalizedName === "xml:lang" ||
|
|
4278
|
+
mathMlAttributeNames.has(normalizedName) ||
|
|
4279
|
+
normalizedName.startsWith("aria-") ||
|
|
4280
|
+
normalizedName.startsWith("data-")
|
|
3692
4281
|
);
|
|
3693
4282
|
}
|
|
3694
4283
|
|
|
@@ -3834,6 +4423,23 @@ function playerStyleElement(): HTMLStyleElement {
|
|
|
3834
4423
|
margin-block: 0;
|
|
3835
4424
|
}
|
|
3836
4425
|
|
|
4426
|
+
.qti3-player .qti-hidden {
|
|
4427
|
+
display: none !important;
|
|
4428
|
+
}
|
|
4429
|
+
|
|
4430
|
+
.qti3-player .qti-visually-hidden {
|
|
4431
|
+
position: absolute !important;
|
|
4432
|
+
overflow: hidden !important;
|
|
4433
|
+
clip: rect(1px, 1px, 1px, 1px) !important;
|
|
4434
|
+
clip-path: inset(50%) !important;
|
|
4435
|
+
inline-size: 1px !important;
|
|
4436
|
+
block-size: 1px !important;
|
|
4437
|
+
margin: -1px !important;
|
|
4438
|
+
padding: 0 !important;
|
|
4439
|
+
border: 0 !important;
|
|
4440
|
+
white-space: nowrap !important;
|
|
4441
|
+
}
|
|
4442
|
+
|
|
3837
4443
|
.qti3-embedded-interaction {
|
|
3838
4444
|
display: inline-flex;
|
|
3839
4445
|
gap: 0.35rem;
|
|
@@ -4206,6 +4812,7 @@ function playerStyleElement(): HTMLStyleElement {
|
|
|
4206
4812
|
}
|
|
4207
4813
|
|
|
4208
4814
|
.qti3-graphic-associate-surface,
|
|
4815
|
+
.qti3-graphic-gap-match-surface,
|
|
4209
4816
|
.qti3-graphic-order-surface {
|
|
4210
4817
|
touch-action: manipulation;
|
|
4211
4818
|
}
|
|
@@ -4233,10 +4840,60 @@ function playerStyleElement(): HTMLStyleElement {
|
|
|
4233
4840
|
}
|
|
4234
4841
|
|
|
4235
4842
|
.qti3-graphic-associate-hotspot,
|
|
4843
|
+
.qti3-graphic-gap-hotspot,
|
|
4236
4844
|
.qti3-graphic-order-hotspot {
|
|
4237
4845
|
z-index: 2;
|
|
4238
4846
|
}
|
|
4239
4847
|
|
|
4848
|
+
.qti3-graphic-gap-match-surface {
|
|
4849
|
+
margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
|
|
4850
|
+
}
|
|
4851
|
+
|
|
4852
|
+
.qti3-graphic-gap-hotspot {
|
|
4853
|
+
display: grid;
|
|
4854
|
+
place-items: center;
|
|
4855
|
+
padding: 0;
|
|
4856
|
+
overflow: visible;
|
|
4857
|
+
border-style: dashed;
|
|
4858
|
+
background: rgb(255 255 255 / 0.08);
|
|
4859
|
+
color: CanvasText;
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4862
|
+
.qti3-graphic-gap-hotspot[data-selected="true"] {
|
|
4863
|
+
border-style: solid;
|
|
4864
|
+
background: color-mix(in srgb, Highlight 18%, Canvas);
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
.qti3-graphic-gap-label {
|
|
4868
|
+
position: absolute;
|
|
4869
|
+
inset-block-start: calc(100% + 0.2rem);
|
|
4870
|
+
inset-inline-start: 50%;
|
|
4871
|
+
transform: translateX(-50%);
|
|
4872
|
+
box-sizing: border-box;
|
|
4873
|
+
inline-size: max-content;
|
|
4874
|
+
max-inline-size: min(12rem, calc(100vw - 2rem));
|
|
4875
|
+
min-inline-size: 0;
|
|
4876
|
+
padding: 0.25rem 0.4rem;
|
|
4877
|
+
border: 1px solid CanvasText;
|
|
4878
|
+
border-radius: 0.25rem;
|
|
4879
|
+
background: Canvas;
|
|
4880
|
+
color: CanvasText;
|
|
4881
|
+
font-size: 0.75rem;
|
|
4882
|
+
font-weight: 700;
|
|
4883
|
+
line-height: 1.15;
|
|
4884
|
+
overflow-wrap: anywhere;
|
|
4885
|
+
pointer-events: none;
|
|
4886
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
|
|
4887
|
+
text-align: center;
|
|
4888
|
+
white-space: normal;
|
|
4889
|
+
}
|
|
4890
|
+
|
|
4891
|
+
@supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
|
|
4892
|
+
.qti3-graphic-gap-hotspot[data-selected="true"] {
|
|
4893
|
+
background: Canvas;
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4240
4897
|
.qti3-graphic-order-hotspot {
|
|
4241
4898
|
display: grid;
|
|
4242
4899
|
place-items: center;
|