@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 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;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
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.dispatchEvent(new CustomEvent("qti-responsechange", {
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.dispatchEvent(new CustomEvent("qti-responsechange", {
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, .qti3-actions button")) {
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 = responseCount(state.responses[declaration.identifier] ?? null);
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
- (minimum === 1
538
- ? `${declaration.identifier} requires a response.`
539
- : `${declaration.identifier} requires at least ${minimum} responses.`),
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
- `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`,
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 background = drawingBackgroundImage(interaction, width, height);
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
- const serializeStroke = (points) => {
2212
- return points.map((point) => `${point.x} ${point.y}`).join(" ");
2213
- };
2218
+ let commitVersion = 0;
2214
2219
  const commit = (emitResponse = true) => {
2215
- const value = strokes.map((stroke) => serializeStroke(stroke.points)).join(" | ");
2216
- if (emitResponse)
2217
- update(value);
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 = 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 parseDrawingValue(currentValue)) {
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
- if (object?.data && type.startsWith("audio/")) {
2432
+ const mediaType = object ? mediaElementType(object) : undefined;
2433
+ if (object && mediaType === "audio") {
2408
2434
  const audio = document.createElement("audio");
2409
- audio.controls = true;
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?.data && type.startsWith("video/")) {
2439
+ if (object && mediaType === "video") {
2418
2440
  const video = document.createElement("video");
2419
- video.controls = true;
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
- if (object?.data) {
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 = object.data;
2448
- link.textContent = object.text || object.data;
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 drawingBackgroundImage(interaction, width, height) {
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", interaction.object.data);
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("&", "&amp;")
3065
+ .replaceAll('"', "&quot;")
3066
+ .replaceAll("<", "&lt;")
3067
+ .replaceAll(">", "&gt;");
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)