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