@longsightgroup/qti3-player 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +393 -52
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/index.ts +4396 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type QtiAssessmentItem, type QtiAttemptStatus, type QtiAttemptStateV1, type QtiDiagnostic, type QtiScoreResult } from "@longsightgroup/qti3-core";
|
|
1
|
+
import { type QtiAssessmentItem, type QtiAttemptStatus, type QtiAttemptStateV1, type QtiDiagnostic, type QtiScoreResult, type QtiValue } from "@longsightgroup/qti3-core";
|
|
2
2
|
export interface QtiPlayerSessionControl {
|
|
3
3
|
validateResponses?: boolean | undefined;
|
|
4
4
|
showFeedback?: boolean | undefined;
|
|
@@ -21,6 +21,10 @@ export interface QtiReadyEventDetail {
|
|
|
21
21
|
export interface QtiStateChangeEventDetail {
|
|
22
22
|
state: QtiAttemptStateV1;
|
|
23
23
|
}
|
|
24
|
+
export interface QtiResponseChangeEventDetail {
|
|
25
|
+
responseIdentifier: string;
|
|
26
|
+
value: QtiValue;
|
|
27
|
+
}
|
|
24
28
|
export type QtiScoreEventDetail = QtiScoreResult;
|
|
25
29
|
export interface QtiValidationEventDetail {
|
|
26
30
|
validationMessages: QtiDiagnostic[];
|
|
@@ -35,6 +39,7 @@ export interface QtiEndAttemptEventDetail {
|
|
|
35
39
|
export interface QtiAssessmentItemPlayerEventDetailMap {
|
|
36
40
|
"qti-ready": QtiReadyEventDetail;
|
|
37
41
|
"qti-statechange": QtiStateChangeEventDetail;
|
|
42
|
+
"qti-responsechange": QtiResponseChangeEventDetail;
|
|
38
43
|
"qti-score": QtiScoreEventDetail;
|
|
39
44
|
"qti-validation": QtiValidationEventDetail;
|
|
40
45
|
"qti-suspend": QtiSuspendEventDetail;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EAGtB,KAAK,aAAa,EAKlB,KAAK,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -162,14 +162,6 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
162
162
|
root.append(this.renderInteraction(interaction));
|
|
163
163
|
}
|
|
164
164
|
}
|
|
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
165
|
const feedback = document.createElement("section");
|
|
174
166
|
feedback.className = "qti3-feedback";
|
|
175
167
|
feedback.role = "status";
|
|
@@ -200,9 +192,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
200
192
|
return;
|
|
201
193
|
this.session.respond(responseIdentifier, value);
|
|
202
194
|
this.clearValidationMessage(responseIdentifier);
|
|
203
|
-
this.
|
|
204
|
-
detail: { responseIdentifier, value },
|
|
205
|
-
}));
|
|
195
|
+
this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
|
|
206
196
|
this.emitStateChange();
|
|
207
197
|
};
|
|
208
198
|
const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
|
|
@@ -295,7 +285,11 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
295
285
|
return field;
|
|
296
286
|
}
|
|
297
287
|
if (interaction.type === "media") {
|
|
298
|
-
field.append(renderObjectAsset(interaction
|
|
288
|
+
field.append(renderObjectAsset(interaction, {
|
|
289
|
+
currentValue,
|
|
290
|
+
update,
|
|
291
|
+
isCompleted: () => this.attemptIsCompleted(),
|
|
292
|
+
}));
|
|
299
293
|
return field;
|
|
300
294
|
}
|
|
301
295
|
field.append(renderSelect(interaction, update, currentValue));
|
|
@@ -318,9 +312,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
318
312
|
return;
|
|
319
313
|
this.session.respond(responseIdentifier, value);
|
|
320
314
|
this.clearValidationMessage(responseIdentifier);
|
|
321
|
-
this.
|
|
322
|
-
detail: { responseIdentifier, value },
|
|
323
|
-
}));
|
|
315
|
+
this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
|
|
324
316
|
this.emitStateChange();
|
|
325
317
|
};
|
|
326
318
|
const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
|
|
@@ -432,7 +424,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
432
424
|
const article = this.querySelector(".qti3-player");
|
|
433
425
|
if (article)
|
|
434
426
|
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
|
|
427
|
+
for (const control of this.querySelectorAll(".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea")) {
|
|
436
428
|
control.disabled = completed;
|
|
437
429
|
}
|
|
438
430
|
for (const element of this.querySelectorAll(".qti3-interaction [tabindex]:not(button):not(input):not(select):not(textarea)")) {
|
|
@@ -523,20 +515,24 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
523
515
|
.map((interaction) => [interaction.responseIdentifier, interaction]));
|
|
524
516
|
const diagnostics = [];
|
|
525
517
|
for (const declaration of this.documentModel.item.responseDeclarations) {
|
|
526
|
-
if (declaration.correctResponse === null)
|
|
527
|
-
continue;
|
|
528
518
|
const interaction = interactionsByResponse.get(declaration.identifier);
|
|
519
|
+
if (declaration.correctResponse === null && interaction?.type !== "media")
|
|
520
|
+
continue;
|
|
529
521
|
const minimum = minimumRequiredResponses(interaction);
|
|
530
|
-
const count =
|
|
522
|
+
const count = interaction?.type === "media"
|
|
523
|
+
? mediaPlayCount(state.responses[declaration.identifier] ?? null)
|
|
524
|
+
: responseCount(state.responses[declaration.identifier] ?? null);
|
|
531
525
|
const maximum = maximumAllowedResponses(interaction);
|
|
532
526
|
if (count < minimum) {
|
|
533
527
|
diagnostics.push({
|
|
534
528
|
code: "response.required",
|
|
535
529
|
severity: "error",
|
|
536
530
|
message: interaction?.attributes["data-min-selections-message"] ??
|
|
537
|
-
(
|
|
538
|
-
? `${declaration.identifier} requires
|
|
539
|
-
:
|
|
531
|
+
(interaction?.type === "media"
|
|
532
|
+
? `${declaration.identifier} requires at least ${minimum} play${minimum === 1 ? "" : "s"}.`
|
|
533
|
+
: minimum === 1
|
|
534
|
+
? `${declaration.identifier} requires a response.`
|
|
535
|
+
: `${declaration.identifier} requires at least ${minimum} responses.`),
|
|
540
536
|
path: declaration.identifier,
|
|
541
537
|
});
|
|
542
538
|
}
|
|
@@ -545,7 +541,9 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
545
541
|
code: "response.maximum",
|
|
546
542
|
severity: "error",
|
|
547
543
|
message: interaction?.attributes["data-max-selections-message"] ??
|
|
548
|
-
|
|
544
|
+
(interaction?.type === "media"
|
|
545
|
+
? `${declaration.identifier} allows at most ${maximum} play${maximum === 1 ? "" : "s"}.`
|
|
546
|
+
: `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`),
|
|
549
547
|
path: declaration.identifier,
|
|
550
548
|
});
|
|
551
549
|
}
|
|
@@ -2199,8 +2197,17 @@ function renderDrawingResponse(interaction, update, currentValue) {
|
|
|
2199
2197
|
surface.style.border = "1px solid CanvasText";
|
|
2200
2198
|
surface.style.background = "Canvas";
|
|
2201
2199
|
surface.style.touchAction = "none";
|
|
2202
|
-
const
|
|
2200
|
+
const restoredStrokes = parseDrawingValue(currentValue);
|
|
2201
|
+
const authoredBackgroundHref = drawingBackgroundHref(interaction);
|
|
2202
|
+
let resolvedAuthoredBackgroundHref = authoredBackgroundHref;
|
|
2203
|
+
let activeBackgroundIsAuthored = restoredStrokes.length > 0 || !drawingResponseImage(currentValue);
|
|
2204
|
+
let activeBackgroundHref = restoredStrokes.length === 0
|
|
2205
|
+
? (drawingResponseImage(currentValue) ?? authoredBackgroundHref)
|
|
2206
|
+
: authoredBackgroundHref;
|
|
2203
2207
|
const resetSurface = () => {
|
|
2208
|
+
const background = activeBackgroundHref
|
|
2209
|
+
? drawingImageElement(activeBackgroundHref, width, height)
|
|
2210
|
+
: undefined;
|
|
2204
2211
|
surface.replaceChildren(...(background ? [background] : []));
|
|
2205
2212
|
};
|
|
2206
2213
|
resetSurface();
|
|
@@ -2208,22 +2215,35 @@ function renderDrawingResponse(interaction, update, currentValue) {
|
|
|
2208
2215
|
summary.className = "qti3-coordinate-output";
|
|
2209
2216
|
const strokes = [];
|
|
2210
2217
|
let activeStroke;
|
|
2211
|
-
|
|
2212
|
-
return points.map((point) => `${point.x} ${point.y}`).join(" ");
|
|
2213
|
-
};
|
|
2218
|
+
let commitVersion = 0;
|
|
2214
2219
|
const commit = (emitResponse = true) => {
|
|
2215
|
-
const
|
|
2216
|
-
if (emitResponse)
|
|
2217
|
-
|
|
2220
|
+
const version = ++commitVersion;
|
|
2221
|
+
if (emitResponse) {
|
|
2222
|
+
if (strokes.length === 0) {
|
|
2223
|
+
update(null);
|
|
2224
|
+
}
|
|
2225
|
+
else {
|
|
2226
|
+
void exportDrawingResponse(interaction, width, height, strokes, () => {
|
|
2227
|
+
const currentHref = currentDrawingBackgroundHref(surface);
|
|
2228
|
+
if (activeBackgroundIsAuthored && currentHref) {
|
|
2229
|
+
resolvedAuthoredBackgroundHref = currentHref;
|
|
2230
|
+
}
|
|
2231
|
+
return currentHref ?? activeBackgroundHref;
|
|
2232
|
+
}).then((value) => {
|
|
2233
|
+
if (version === commitVersion)
|
|
2234
|
+
update(value);
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2218
2238
|
const count = strokes.length;
|
|
2219
|
-
summary.value =
|
|
2239
|
+
summary.value = serializeDrawingStrokes(strokes);
|
|
2220
2240
|
summary.textContent =
|
|
2221
2241
|
count === 0 ? "No drawing strokes." : `${count} drawing stroke${count === 1 ? "" : "s"}.`;
|
|
2222
2242
|
surface.setAttribute("aria-label", count === 0
|
|
2223
2243
|
? "Drawing response surface, no strokes"
|
|
2224
2244
|
: `Drawing response surface, ${count} stroke${count === 1 ? "" : "s"}`);
|
|
2225
2245
|
};
|
|
2226
|
-
for (const points of
|
|
2246
|
+
for (const points of restoredStrokes) {
|
|
2227
2247
|
const element = polylineElement(points);
|
|
2228
2248
|
strokes.push({ points, element });
|
|
2229
2249
|
surface.append(element);
|
|
@@ -2281,6 +2301,12 @@ function renderDrawingResponse(interaction, update, currentValue) {
|
|
|
2281
2301
|
clear.addEventListener("click", () => {
|
|
2282
2302
|
strokes.splice(0, strokes.length);
|
|
2283
2303
|
activeStroke = undefined;
|
|
2304
|
+
if (activeBackgroundIsAuthored) {
|
|
2305
|
+
resolvedAuthoredBackgroundHref =
|
|
2306
|
+
currentDrawingBackgroundHref(surface) ?? resolvedAuthoredBackgroundHref;
|
|
2307
|
+
}
|
|
2308
|
+
activeBackgroundHref = resolvedAuthoredBackgroundHref;
|
|
2309
|
+
activeBackgroundIsAuthored = true;
|
|
2284
2310
|
resetSurface();
|
|
2285
2311
|
commit();
|
|
2286
2312
|
});
|
|
@@ -2400,27 +2426,19 @@ function renderHotspotResponse(interaction, update, currentValue) {
|
|
|
2400
2426
|
group.append(surface, selectedSummary);
|
|
2401
2427
|
return group;
|
|
2402
2428
|
}
|
|
2403
|
-
function renderObjectAsset(interaction) {
|
|
2429
|
+
function renderObjectAsset(interaction, mediaResponse = {}) {
|
|
2404
2430
|
const object = interaction.object;
|
|
2405
|
-
const type = object?.type ?? "";
|
|
2406
2431
|
const label = interaction.prompt ?? object?.text ?? "Media interaction";
|
|
2407
|
-
|
|
2432
|
+
const mediaType = object ? mediaElementType(object) : undefined;
|
|
2433
|
+
if (object && mediaType === "audio") {
|
|
2408
2434
|
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%";
|
|
2435
|
+
configureMediaElement(audio, interaction, object, label, mediaResponse);
|
|
2414
2436
|
audio.style.inlineSize = "100%";
|
|
2415
2437
|
return audio;
|
|
2416
2438
|
}
|
|
2417
|
-
if (object
|
|
2439
|
+
if (object && mediaType === "video") {
|
|
2418
2440
|
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%";
|
|
2441
|
+
configureMediaElement(video, interaction, object, label, mediaResponse);
|
|
2424
2442
|
if (object.width)
|
|
2425
2443
|
video.width = Number(object.width);
|
|
2426
2444
|
if (object.height)
|
|
@@ -2442,10 +2460,11 @@ function renderObjectAsset(interaction) {
|
|
|
2442
2460
|
const group = document.createElement("div");
|
|
2443
2461
|
group.role = "group";
|
|
2444
2462
|
group.setAttribute("aria-label", label);
|
|
2445
|
-
|
|
2463
|
+
const fallbackHref = object?.data ?? object?.sources.find((source) => source.src)?.src;
|
|
2464
|
+
if (fallbackHref) {
|
|
2446
2465
|
const link = document.createElement("a");
|
|
2447
|
-
link.href =
|
|
2448
|
-
link.textContent = object
|
|
2466
|
+
link.href = fallbackHref;
|
|
2467
|
+
link.textContent = object?.text || fallbackHref;
|
|
2449
2468
|
group.append(link);
|
|
2450
2469
|
}
|
|
2451
2470
|
else {
|
|
@@ -2453,6 +2472,113 @@ function renderObjectAsset(interaction) {
|
|
|
2453
2472
|
}
|
|
2454
2473
|
return group;
|
|
2455
2474
|
}
|
|
2475
|
+
function configureMediaElement(media, interaction, object, label, mediaResponse) {
|
|
2476
|
+
media.controls = mediaControlsMode(interaction, object) !== "none";
|
|
2477
|
+
media.preload = "none";
|
|
2478
|
+
media.autoplay = parseBooleanAttribute(interaction.attributes.autostart) ?? false;
|
|
2479
|
+
media.loop = parseBooleanAttribute(interaction.attributes.loop) ?? false;
|
|
2480
|
+
media.setAttribute("aria-label", label);
|
|
2481
|
+
media.style.maxInlineSize = "100%";
|
|
2482
|
+
copyMediaDataAttributes(media, interaction.attributes);
|
|
2483
|
+
copyMediaDataAttributes(media, object.attributes);
|
|
2484
|
+
if (object.data)
|
|
2485
|
+
media.src = object.data;
|
|
2486
|
+
for (const source of object.sources) {
|
|
2487
|
+
if (!source.src)
|
|
2488
|
+
continue;
|
|
2489
|
+
const sourceElement = document.createElement("source");
|
|
2490
|
+
sourceElement.src = source.src;
|
|
2491
|
+
if (source.type)
|
|
2492
|
+
sourceElement.type = source.type;
|
|
2493
|
+
media.append(sourceElement);
|
|
2494
|
+
}
|
|
2495
|
+
for (const track of object.tracks) {
|
|
2496
|
+
if (!track.src)
|
|
2497
|
+
continue;
|
|
2498
|
+
const trackElement = document.createElement("track");
|
|
2499
|
+
trackElement.src = track.src;
|
|
2500
|
+
if (track.kind)
|
|
2501
|
+
trackElement.kind = track.kind;
|
|
2502
|
+
if (track.srclang)
|
|
2503
|
+
trackElement.srclang = track.srclang;
|
|
2504
|
+
if (track.label)
|
|
2505
|
+
trackElement.label = track.label;
|
|
2506
|
+
if (track.default)
|
|
2507
|
+
trackElement.default = true;
|
|
2508
|
+
media.append(trackElement);
|
|
2509
|
+
}
|
|
2510
|
+
bindMediaPlayCount(media, interaction, mediaResponse);
|
|
2511
|
+
}
|
|
2512
|
+
function copyMediaDataAttributes(element, attributes) {
|
|
2513
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
2514
|
+
if (!name.startsWith("data-"))
|
|
2515
|
+
continue;
|
|
2516
|
+
element.setAttribute(name, value);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
function mediaElementType(object) {
|
|
2520
|
+
const types = [object.type, ...object.sources.map((source) => source.type)].filter((value) => Boolean(value));
|
|
2521
|
+
if (types.some((value) => value.startsWith("audio/")))
|
|
2522
|
+
return "audio";
|
|
2523
|
+
if (types.some((value) => value.startsWith("video/")))
|
|
2524
|
+
return "video";
|
|
2525
|
+
return undefined;
|
|
2526
|
+
}
|
|
2527
|
+
function mediaControlsMode(interaction, object) {
|
|
2528
|
+
return (interaction.attributes["data-qti-media-player-controls"] ??
|
|
2529
|
+
object.attributes["data-qti-media-player-controls"]);
|
|
2530
|
+
}
|
|
2531
|
+
function bindMediaPlayCount(media, interaction, mediaResponse) {
|
|
2532
|
+
if (!mediaResponse.update)
|
|
2533
|
+
return;
|
|
2534
|
+
let playCount = mediaPlayCount(mediaResponse.currentValue ?? null);
|
|
2535
|
+
let activePlaySession = false;
|
|
2536
|
+
let readyAfterEnded = false;
|
|
2537
|
+
const maximum = maximumMediaPlays(interaction);
|
|
2538
|
+
const syncState = () => {
|
|
2539
|
+
media.dataset.playCount = String(playCount);
|
|
2540
|
+
if (maximum !== undefined && playCount >= maximum && !activePlaySession) {
|
|
2541
|
+
media.dataset.maxPlaysReached = "true";
|
|
2542
|
+
}
|
|
2543
|
+
else {
|
|
2544
|
+
delete media.dataset.maxPlaysReached;
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
media.addEventListener("play", () => {
|
|
2548
|
+
if (mediaResponse.isCompleted?.()) {
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
if (!activePlaySession && maximum !== undefined && playCount >= maximum) {
|
|
2552
|
+
media.pause();
|
|
2553
|
+
syncState();
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
if (!activePlaySession && (readyAfterEnded || media.currentTime <= 0.25)) {
|
|
2557
|
+
playCount += 1;
|
|
2558
|
+
mediaResponse.update?.(playCount);
|
|
2559
|
+
activePlaySession = true;
|
|
2560
|
+
readyAfterEnded = false;
|
|
2561
|
+
syncState();
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
activePlaySession = true;
|
|
2565
|
+
readyAfterEnded = false;
|
|
2566
|
+
syncState();
|
|
2567
|
+
});
|
|
2568
|
+
media.addEventListener("ended", () => {
|
|
2569
|
+
activePlaySession = false;
|
|
2570
|
+
readyAfterEnded = true;
|
|
2571
|
+
syncState();
|
|
2572
|
+
});
|
|
2573
|
+
media.addEventListener("seeked", () => {
|
|
2574
|
+
if (!media.paused || media.currentTime > 0.25)
|
|
2575
|
+
return;
|
|
2576
|
+
activePlaySession = false;
|
|
2577
|
+
readyAfterEnded = false;
|
|
2578
|
+
syncState();
|
|
2579
|
+
});
|
|
2580
|
+
syncState();
|
|
2581
|
+
}
|
|
2456
2582
|
function objectIsImage(object) {
|
|
2457
2583
|
return Boolean(object.type?.startsWith("image/") ||
|
|
2458
2584
|
object.data?.startsWith("data:image/") ||
|
|
@@ -2597,6 +2723,42 @@ function parseDrawingValue(value) {
|
|
|
2597
2723
|
const raw = scalarString(value);
|
|
2598
2724
|
if (!raw)
|
|
2599
2725
|
return [];
|
|
2726
|
+
const metadata = drawingMetadataFromSvgDataUrl(raw);
|
|
2727
|
+
if (metadata)
|
|
2728
|
+
return parseDrawingStrokePayload(metadata);
|
|
2729
|
+
return parseDrawingStrokePayload(raw);
|
|
2730
|
+
}
|
|
2731
|
+
function drawingMetadataFromSvgDataUrl(raw) {
|
|
2732
|
+
if (!raw.startsWith("data:image/svg+xml"))
|
|
2733
|
+
return undefined;
|
|
2734
|
+
const commaIndex = raw.indexOf(",");
|
|
2735
|
+
if (commaIndex === -1)
|
|
2736
|
+
return undefined;
|
|
2737
|
+
const encoded = raw.slice(commaIndex + 1);
|
|
2738
|
+
let svg = "";
|
|
2739
|
+
try {
|
|
2740
|
+
svg = raw.slice(0, commaIndex).includes(";base64")
|
|
2741
|
+
? atob(encoded)
|
|
2742
|
+
: decodeURIComponent(encoded);
|
|
2743
|
+
}
|
|
2744
|
+
catch {
|
|
2745
|
+
return undefined;
|
|
2746
|
+
}
|
|
2747
|
+
const match = svg.match(/\sdata-qti3-strokes="([^"]*)"/);
|
|
2748
|
+
if (!match?.[1])
|
|
2749
|
+
return undefined;
|
|
2750
|
+
try {
|
|
2751
|
+
return decodeURIComponent(match[1]);
|
|
2752
|
+
}
|
|
2753
|
+
catch {
|
|
2754
|
+
return undefined;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
function drawingResponseImage(value) {
|
|
2758
|
+
const raw = scalarString(value);
|
|
2759
|
+
return raw?.startsWith("data:image/") ? raw : undefined;
|
|
2760
|
+
}
|
|
2761
|
+
function parseDrawingStrokePayload(raw) {
|
|
2600
2762
|
return raw
|
|
2601
2763
|
.split("|")
|
|
2602
2764
|
.map((stroke) => {
|
|
@@ -2733,20 +2895,177 @@ function svgPoint(surface, event) {
|
|
|
2733
2895
|
y: Math.max(0, Math.min(height, y)),
|
|
2734
2896
|
};
|
|
2735
2897
|
}
|
|
2736
|
-
function
|
|
2898
|
+
async function exportDrawingResponse(interaction, width, height, strokes, backgroundHref) {
|
|
2899
|
+
const href = backgroundHref();
|
|
2900
|
+
const mime = drawingResponseMime(interaction.object);
|
|
2901
|
+
if (mime === "image/svg+xml") {
|
|
2902
|
+
return svgDrawingDataUrl(interaction, width, height, strokes, await portableImageHref(href));
|
|
2903
|
+
}
|
|
2904
|
+
return rasterDrawingDataUrl(interaction, width, height, strokes, href, mime);
|
|
2905
|
+
}
|
|
2906
|
+
function drawingResponseMime(object) {
|
|
2907
|
+
const candidates = [
|
|
2908
|
+
object?.type,
|
|
2909
|
+
object?.data,
|
|
2910
|
+
...(object?.sources.map((source) => source.type ?? source.src) ?? []),
|
|
2911
|
+
];
|
|
2912
|
+
for (const candidate of candidates) {
|
|
2913
|
+
const mime = imageMime(candidate);
|
|
2914
|
+
if (mime)
|
|
2915
|
+
return mime;
|
|
2916
|
+
}
|
|
2917
|
+
return "image/svg+xml";
|
|
2918
|
+
}
|
|
2919
|
+
function imageMime(value) {
|
|
2920
|
+
if (!value)
|
|
2921
|
+
return undefined;
|
|
2922
|
+
const normalized = value.toLowerCase().split(";")[0] ?? "";
|
|
2923
|
+
if (normalized === "image/svg+xml")
|
|
2924
|
+
return "image/svg+xml";
|
|
2925
|
+
if (normalized === "image/png")
|
|
2926
|
+
return "image/png";
|
|
2927
|
+
if (normalized === "image/jpeg" || normalized === "image/jpg")
|
|
2928
|
+
return "image/jpeg";
|
|
2929
|
+
if (normalized === "image/webp")
|
|
2930
|
+
return "image/webp";
|
|
2931
|
+
const dataMime = value.match(/^data:([^;,]+)/i)?.[1]?.toLowerCase();
|
|
2932
|
+
if (dataMime)
|
|
2933
|
+
return imageMime(dataMime);
|
|
2934
|
+
if (/\.svg(?:[?#].*)?$/i.test(value))
|
|
2935
|
+
return "image/svg+xml";
|
|
2936
|
+
if (/\.png(?:[?#].*)?$/i.test(value))
|
|
2937
|
+
return "image/png";
|
|
2938
|
+
if (/\.jpe?g(?:[?#].*)?$/i.test(value))
|
|
2939
|
+
return "image/jpeg";
|
|
2940
|
+
if (/\.webp(?:[?#].*)?$/i.test(value))
|
|
2941
|
+
return "image/webp";
|
|
2942
|
+
return undefined;
|
|
2943
|
+
}
|
|
2944
|
+
function svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref) {
|
|
2945
|
+
const markup = svgDrawingMarkup(interaction, width, height, strokes, backgroundHref);
|
|
2946
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
|
|
2947
|
+
}
|
|
2948
|
+
function svgDrawingMarkup(interaction, width, height, strokes, backgroundHref) {
|
|
2949
|
+
const strokePayload = serializeDrawingStrokes(strokes);
|
|
2950
|
+
const background = backgroundHref && interaction.object && objectIsImage(interaction.object)
|
|
2951
|
+
? `<image href="${xmlAttribute(backgroundHref)}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`
|
|
2952
|
+
: "";
|
|
2953
|
+
const lines = strokes
|
|
2954
|
+
.map((stroke) => {
|
|
2955
|
+
return `<polyline points="${xmlAttribute(serializeSvgPoints(stroke.points))}" fill="none" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
2956
|
+
})
|
|
2957
|
+
.join("");
|
|
2958
|
+
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>`;
|
|
2959
|
+
}
|
|
2960
|
+
async function rasterDrawingDataUrl(interaction, width, height, strokes, backgroundHref, mime) {
|
|
2961
|
+
const canvas = document.createElement("canvas");
|
|
2962
|
+
canvas.width = width;
|
|
2963
|
+
canvas.height = height;
|
|
2964
|
+
const context = canvas.getContext("2d");
|
|
2965
|
+
if (!context)
|
|
2966
|
+
return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
|
|
2967
|
+
if (mime === "image/jpeg") {
|
|
2968
|
+
context.fillStyle = "#fff";
|
|
2969
|
+
context.fillRect(0, 0, width, height);
|
|
2970
|
+
}
|
|
2971
|
+
if (backgroundHref && interaction.object && objectIsImage(interaction.object)) {
|
|
2972
|
+
try {
|
|
2973
|
+
const image = await loadCanvasImage(backgroundHref);
|
|
2974
|
+
context.drawImage(image, 0, 0, width, height);
|
|
2975
|
+
}
|
|
2976
|
+
catch {
|
|
2977
|
+
// Export the candidate marks even when the authored background cannot be rasterized.
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
context.strokeStyle = "#000";
|
|
2981
|
+
context.lineWidth = 3;
|
|
2982
|
+
context.lineCap = "round";
|
|
2983
|
+
context.lineJoin = "round";
|
|
2984
|
+
for (const stroke of strokes) {
|
|
2985
|
+
const [first, ...rest] = stroke.points;
|
|
2986
|
+
if (!first)
|
|
2987
|
+
continue;
|
|
2988
|
+
context.beginPath();
|
|
2989
|
+
context.moveTo(first.x, first.y);
|
|
2990
|
+
for (const point of rest)
|
|
2991
|
+
context.lineTo(point.x, point.y);
|
|
2992
|
+
context.stroke();
|
|
2993
|
+
}
|
|
2994
|
+
try {
|
|
2995
|
+
return canvas.toDataURL(mime);
|
|
2996
|
+
}
|
|
2997
|
+
catch {
|
|
2998
|
+
return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
function loadCanvasImage(src) {
|
|
3002
|
+
return new Promise((resolve, reject) => {
|
|
3003
|
+
const image = new Image();
|
|
3004
|
+
image.addEventListener("load", () => resolve(image), { once: true });
|
|
3005
|
+
image.addEventListener("error", () => reject(new Error(`Unable to load ${src}`)), {
|
|
3006
|
+
once: true,
|
|
3007
|
+
});
|
|
3008
|
+
image.src = src;
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
async function portableImageHref(href) {
|
|
3012
|
+
if (!href || href.startsWith("data:"))
|
|
3013
|
+
return href;
|
|
3014
|
+
try {
|
|
3015
|
+
const response = await fetch(href);
|
|
3016
|
+
if (!response.ok)
|
|
3017
|
+
return href;
|
|
3018
|
+
return await blobToDataUrl(await response.blob());
|
|
3019
|
+
}
|
|
3020
|
+
catch {
|
|
3021
|
+
return href;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
function blobToDataUrl(blob) {
|
|
3025
|
+
return new Promise((resolve, reject) => {
|
|
3026
|
+
const reader = new FileReader();
|
|
3027
|
+
reader.addEventListener("load", () => {
|
|
3028
|
+
resolve(String(reader.result ?? ""));
|
|
3029
|
+
});
|
|
3030
|
+
reader.addEventListener("error", () => {
|
|
3031
|
+
reject(reader.error ?? new Error("Unable to read drawing background."));
|
|
3032
|
+
});
|
|
3033
|
+
reader.readAsDataURL(blob);
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
function drawingBackgroundHref(interaction) {
|
|
2737
3037
|
if (!interaction.object?.data || !objectIsImage(interaction.object))
|
|
2738
3038
|
return undefined;
|
|
3039
|
+
return interaction.object.data;
|
|
3040
|
+
}
|
|
3041
|
+
function drawingImageElement(href, width, height) {
|
|
2739
3042
|
const image = document.createElementNS("http://www.w3.org/2000/svg", "image");
|
|
2740
|
-
image.setAttribute("href",
|
|
3043
|
+
image.setAttribute("href", href);
|
|
2741
3044
|
image.setAttribute("width", String(width));
|
|
2742
3045
|
image.setAttribute("height", String(height));
|
|
2743
3046
|
image.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
2744
3047
|
image.setAttribute("aria-hidden", "true");
|
|
2745
3048
|
return image;
|
|
2746
3049
|
}
|
|
3050
|
+
function currentDrawingBackgroundHref(surface) {
|
|
3051
|
+
return surface.querySelector("image")?.getAttribute("href") ?? undefined;
|
|
3052
|
+
}
|
|
2747
3053
|
function serializeSvgPoints(points) {
|
|
2748
3054
|
return points.map((point) => `${point.x},${point.y}`).join(" ");
|
|
2749
3055
|
}
|
|
3056
|
+
function serializeDrawingStrokes(strokes) {
|
|
3057
|
+
return strokes.map((stroke) => serializeDrawingStroke(stroke.points)).join(" | ");
|
|
3058
|
+
}
|
|
3059
|
+
function serializeDrawingStroke(points) {
|
|
3060
|
+
return points.map((point) => `${point.x} ${point.y}`).join(" ");
|
|
3061
|
+
}
|
|
3062
|
+
function xmlAttribute(value) {
|
|
3063
|
+
return value
|
|
3064
|
+
.replaceAll("&", "&")
|
|
3065
|
+
.replaceAll('"', """)
|
|
3066
|
+
.replaceAll("<", "<")
|
|
3067
|
+
.replaceAll(">", ">");
|
|
3068
|
+
}
|
|
2750
3069
|
function polylineElement(points) {
|
|
2751
3070
|
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
|
2752
3071
|
line.setAttribute("points", serializeSvgPoints(points));
|
|
@@ -3075,7 +3394,6 @@ function playerStyleElement() {
|
|
|
3075
3394
|
margin-block-start: 0.75rem;
|
|
3076
3395
|
}
|
|
3077
3396
|
|
|
3078
|
-
.qti3-actions,
|
|
3079
3397
|
.qti3-reorder-item,
|
|
3080
3398
|
.qti3-token-region,
|
|
3081
3399
|
.qti3-pair-chip,
|
|
@@ -3512,6 +3830,8 @@ function responseCount(value) {
|
|
|
3512
3830
|
function maximumAllowedResponses(interaction) {
|
|
3513
3831
|
if (!interaction)
|
|
3514
3832
|
return undefined;
|
|
3833
|
+
if (interaction.type === "media")
|
|
3834
|
+
return maximumMediaPlays(interaction);
|
|
3515
3835
|
const explicit = interaction.attributes["max-choices"] ?? interaction.attributes["max-associations"];
|
|
3516
3836
|
if (explicit === undefined)
|
|
3517
3837
|
return undefined;
|
|
@@ -3523,12 +3843,33 @@ function maximumAllowedResponses(interaction) {
|
|
|
3523
3843
|
function minimumRequiredResponses(interaction) {
|
|
3524
3844
|
if (!interaction)
|
|
3525
3845
|
return 1;
|
|
3846
|
+
if (interaction.type === "media")
|
|
3847
|
+
return minimumMediaPlays(interaction);
|
|
3526
3848
|
const explicit = interaction.attributes["min-choices"] ?? interaction.attributes["min-associations"];
|
|
3527
3849
|
if (explicit === undefined)
|
|
3528
3850
|
return 1;
|
|
3529
3851
|
const parsed = Number(explicit);
|
|
3530
3852
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
|
|
3531
3853
|
}
|
|
3854
|
+
function minimumMediaPlays(interaction) {
|
|
3855
|
+
const parsed = Number(interaction.attributes["min-plays"] ?? "0");
|
|
3856
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
3857
|
+
}
|
|
3858
|
+
function maximumMediaPlays(interaction) {
|
|
3859
|
+
const parsed = Number(interaction.attributes["max-plays"] ?? "0");
|
|
3860
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
3861
|
+
}
|
|
3862
|
+
function mediaPlayCount(value) {
|
|
3863
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : 0;
|
|
3864
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
3865
|
+
}
|
|
3866
|
+
function parseBooleanAttribute(value) {
|
|
3867
|
+
if (value === "true" || value === "1")
|
|
3868
|
+
return true;
|
|
3869
|
+
if (value === "false" || value === "0")
|
|
3870
|
+
return false;
|
|
3871
|
+
return undefined;
|
|
3872
|
+
}
|
|
3532
3873
|
function matchMaxDiagnostics(responseIdentifier, interaction, response) {
|
|
3533
3874
|
const identifiers = responseChoiceIdentifiers(response);
|
|
3534
3875
|
if (identifiers.length === 0)
|