@longsightgroup/qti3-player 0.2.1 → 0.4.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.
Files changed (117) hide show
  1. package/README.md +7 -0
  2. package/dist/icons.d.ts +8 -0
  3. package/dist/icons.d.ts.map +1 -0
  4. package/dist/icons.js +45 -0
  5. package/dist/icons.js.map +1 -0
  6. package/dist/index.d.ts +3 -134
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -4712
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-support.d.ts +34 -0
  11. package/dist/interaction-support.d.ts.map +1 -0
  12. package/dist/interaction-support.js +189 -0
  13. package/dist/interaction-support.js.map +1 -0
  14. package/dist/movement.d.ts +3 -0
  15. package/dist/movement.d.ts.map +1 -0
  16. package/dist/movement.js +21 -0
  17. package/dist/movement.js.map +1 -0
  18. package/dist/player-element.d.ts +60 -0
  19. package/dist/player-element.d.ts.map +1 -0
  20. package/dist/player-element.js +367 -0
  21. package/dist/player-element.js.map +1 -0
  22. package/dist/player-locale.d.ts +6 -0
  23. package/dist/player-locale.d.ts.map +1 -0
  24. package/dist/player-locale.js +205 -0
  25. package/dist/player-locale.js.map +1 -0
  26. package/dist/player-messages.d.ts +40 -0
  27. package/dist/player-messages.d.ts.map +1 -0
  28. package/dist/player-messages.js +2 -0
  29. package/dist/player-messages.js.map +1 -0
  30. package/dist/player-styles.d.ts +3 -0
  31. package/dist/player-styles.d.ts.map +1 -0
  32. package/dist/player-styles.js +24 -0
  33. package/dist/player-styles.js.map +1 -0
  34. package/dist/player-types.d.ts +71 -0
  35. package/dist/player-types.d.ts.map +1 -0
  36. package/dist/player-types.js +2 -0
  37. package/dist/player-types.js.map +1 -0
  38. package/dist/player-validation-dom.d.ts +3 -0
  39. package/dist/player-validation-dom.d.ts.map +1 -0
  40. package/dist/player-validation-dom.js +28 -0
  41. package/dist/player-validation-dom.js.map +1 -0
  42. package/dist/player-validation.d.ts +13 -0
  43. package/dist/player-validation.d.ts.map +1 -0
  44. package/dist/player-validation.js +123 -0
  45. package/dist/player-validation.js.map +1 -0
  46. package/dist/portable-custom-support.d.ts +11 -0
  47. package/dist/portable-custom-support.d.ts.map +1 -0
  48. package/dist/portable-custom-support.js +70 -0
  49. package/dist/portable-custom-support.js.map +1 -0
  50. package/dist/response-limits.d.ts +9 -0
  51. package/dist/response-limits.d.ts.map +1 -0
  52. package/dist/response-limits.js +44 -0
  53. package/dist/response-limits.js.map +1 -0
  54. package/package.json +4 -4
  55. package/src/content/content-dom.ts +274 -0
  56. package/src/content/content-renderer.ts +114 -0
  57. package/src/controls/remove-button.ts +13 -0
  58. package/src/icons.ts +47 -0
  59. package/src/index.ts +26 -5307
  60. package/src/interaction-support.ts +263 -0
  61. package/src/interactions/choice-interaction.ts +92 -0
  62. package/src/interactions/drawing-interaction.ts +447 -0
  63. package/src/interactions/end-attempt-interaction.ts +19 -0
  64. package/src/interactions/gap-match-interaction.ts +337 -0
  65. package/src/interactions/graphic-associate-interaction.ts +324 -0
  66. package/src/interactions/graphic-context.ts +33 -0
  67. package/src/interactions/hotspot-interaction.ts +87 -0
  68. package/src/interactions/hottext-interaction.ts +81 -0
  69. package/src/interactions/inline-choice-interaction.ts +45 -0
  70. package/src/interactions/inline-controls.ts +21 -0
  71. package/src/interactions/interaction-diagnostics.ts +159 -0
  72. package/src/interactions/interaction-dispatch.ts +9 -0
  73. package/src/interactions/interaction-label.ts +10 -0
  74. package/src/interactions/interaction-registry.ts +209 -0
  75. package/src/interactions/match-interaction.ts +199 -0
  76. package/src/interactions/object-asset.ts +212 -0
  77. package/src/interactions/pair-interaction.ts +147 -0
  78. package/src/interactions/point-value.ts +41 -0
  79. package/src/interactions/portable-custom-interaction.ts +139 -0
  80. package/src/interactions/position-object-interaction.ts +210 -0
  81. package/src/interactions/routing.ts +27 -0
  82. package/src/interactions/select-point-interaction.ts +185 -0
  83. package/src/interactions/shared.ts +56 -0
  84. package/src/interactions/text-interaction.ts +127 -0
  85. package/src/interactions/unsupported-interaction.ts +25 -0
  86. package/src/interactions/upload-interaction.ts +16 -0
  87. package/src/movement.ts +29 -0
  88. package/src/player/attempt-availability.ts +36 -0
  89. package/src/player/content-state.ts +74 -0
  90. package/src/player/dynamic-body.ts +40 -0
  91. package/src/player/feedback-panel.ts +23 -0
  92. package/src/player/fetch-xml.ts +8 -0
  93. package/src/player/interaction-render.ts +89 -0
  94. package/src/player/render-shell.ts +44 -0
  95. package/src/player/resolve-assets.ts +12 -0
  96. package/src/player/validation-messages.ts +42 -0
  97. package/src/player-element.ts +493 -0
  98. package/src/player-locale.ts +232 -0
  99. package/src/player-messages.ts +31 -0
  100. package/src/player-styles.ts +25 -0
  101. package/src/player-types.ts +99 -0
  102. package/src/player-validation-dom.ts +31 -0
  103. package/src/player-validation.ts +158 -0
  104. package/src/portable-custom-support.ts +74 -0
  105. package/src/reorder/a11y.ts +40 -0
  106. package/src/reorder/graphic-order-interaction.ts +260 -0
  107. package/src/reorder/list-controls.ts +114 -0
  108. package/src/reorder/order-interaction.ts +75 -0
  109. package/src/response-limits.ts +47 -0
  110. package/src/styles/base-styles.ts +117 -0
  111. package/src/styles/choice-hottext-styles.ts +75 -0
  112. package/src/styles/control-styles.ts +113 -0
  113. package/src/styles/drawing-styles.ts +29 -0
  114. package/src/styles/gap-match-styles.ts +32 -0
  115. package/src/styles/graphic-styles.ts +294 -0
  116. package/src/styles/match-pair-styles.ts +61 -0
  117. package/src/styles/text-slider-styles.ts +34 -0
@@ -0,0 +1,274 @@
1
+ import type { QtiContentNode, QtiValue } from "@longsightgroup/qti3-core";
2
+
3
+ const htmlContentElements = new Set([
4
+ "a",
5
+ "abbr",
6
+ "b",
7
+ "bdi",
8
+ "bdo",
9
+ "blockquote",
10
+ "br",
11
+ "caption",
12
+ "cite",
13
+ "code",
14
+ "dd",
15
+ "dfn",
16
+ "div",
17
+ "dl",
18
+ "dt",
19
+ "em",
20
+ "figcaption",
21
+ "figure",
22
+ "h1",
23
+ "h2",
24
+ "h3",
25
+ "h4",
26
+ "h5",
27
+ "h6",
28
+ "hr",
29
+ "i",
30
+ "img",
31
+ "kbd",
32
+ "li",
33
+ "ol",
34
+ "p",
35
+ "pre",
36
+ "q",
37
+ "rb",
38
+ "rbc",
39
+ "rp",
40
+ "rt",
41
+ "rtc",
42
+ "ruby",
43
+ "samp",
44
+ "small",
45
+ "span",
46
+ "strong",
47
+ "sub",
48
+ "sup",
49
+ "table",
50
+ "tbody",
51
+ "td",
52
+ "tfoot",
53
+ "th",
54
+ "thead",
55
+ "tr",
56
+ "ul",
57
+ "var",
58
+ ]);
59
+
60
+ export const unsafeContentElements = new Set(["script", "style"]);
61
+
62
+ const mathMlElements = new Set([
63
+ "math",
64
+ "maction",
65
+ "maligngroup",
66
+ "malignmark",
67
+ "menclose",
68
+ "merror",
69
+ "mfenced",
70
+ "mfrac",
71
+ "mglyph",
72
+ "mi",
73
+ "mlabeledtr",
74
+ "mlongdiv",
75
+ "mmultiscripts",
76
+ "mn",
77
+ "mo",
78
+ "mover",
79
+ "mpadded",
80
+ "mphantom",
81
+ "mroot",
82
+ "mrow",
83
+ "ms",
84
+ "mscarries",
85
+ "mscarry",
86
+ "msgroup",
87
+ "msline",
88
+ "mspace",
89
+ "msqrt",
90
+ "msrow",
91
+ "mstack",
92
+ "mstyle",
93
+ "msub",
94
+ "msubsup",
95
+ "msup",
96
+ "mtable",
97
+ "mtd",
98
+ "mtext",
99
+ "mtr",
100
+ "munder",
101
+ "munderover",
102
+ "semantics",
103
+ ]);
104
+
105
+ export function contentElementName(qtiName: string): string | undefined {
106
+ if (qtiName === "qti-content-body" || qtiName === "qti-prompt") return undefined;
107
+ if (htmlContentElements.has(qtiName) || mathMlElements.has(qtiName)) return qtiName;
108
+ if (qtiName === "object") return "object";
109
+ if (qtiName === "qti-rubric-block") return "section";
110
+ if (qtiName === "qti-template-block") return "div";
111
+ if (qtiName === "qti-template-inline") return "span";
112
+ return undefined;
113
+ }
114
+
115
+ export function createContentElement(name: string): HTMLElement | MathMLElement {
116
+ if (mathMlElements.has(name)) {
117
+ return document.createElementNS("http://www.w3.org/1998/Math/MathML", name) as MathMLElement;
118
+ }
119
+ return document.createElement(name);
120
+ }
121
+
122
+ export function copySafeAttributes(element: Element, attributes: Record<string, string>): void {
123
+ for (const [name, value] of Object.entries(attributes)) {
124
+ if (!isSafeContentAttribute(name, value)) continue;
125
+ element.setAttribute(name, value);
126
+ if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
127
+ element.setAttribute("lang", value);
128
+ }
129
+ }
130
+ applySharedAccessibilityVocabulary(element, attributes);
131
+ }
132
+
133
+ export function applySharedAccessibilityVocabulary(
134
+ element: Element,
135
+ attributes: Record<string, string>,
136
+ ): void {
137
+ for (const [name, value] of Object.entries(attributes)) {
138
+ const ariaName = qtiAriaAttributeName(name);
139
+ if (!ariaName || hasAttributeName(attributes, ariaName)) continue;
140
+ element.setAttribute(ariaName, value);
141
+ }
142
+
143
+ const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
144
+ if (
145
+ suppressesScreenReaderSpeech(suppressTts) &&
146
+ !hasAttributeName(attributes, "aria-hidden") &&
147
+ !hasAttributeName(attributes, "data-qti-aria-hidden")
148
+ ) {
149
+ element.setAttribute("aria-hidden", "true");
150
+ }
151
+ }
152
+
153
+ function qtiAriaAttributeName(name: string): string | undefined {
154
+ const normalizedName = name.toLowerCase();
155
+ const prefix = "data-qti-aria-";
156
+ if (!normalizedName.startsWith(prefix)) return undefined;
157
+ const suffix = normalizedName.slice(prefix.length);
158
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) return undefined;
159
+ return `aria-${suffix}`;
160
+ }
161
+
162
+ function attributeValue(attributes: Record<string, string>, name: string): string | undefined {
163
+ const normalizedName = name.toLowerCase();
164
+ const entry = Object.entries(attributes).find(
165
+ ([attributeName]) => attributeName.toLowerCase() === normalizedName,
166
+ );
167
+ return entry?.[1];
168
+ }
169
+
170
+ function hasAttributeName(attributes: Record<string, string>, name: string): boolean {
171
+ return attributeValue(attributes, name) !== undefined;
172
+ }
173
+
174
+ function suppressesScreenReaderSpeech(value: string | undefined): boolean {
175
+ if (!value) return false;
176
+ const tokens = value
177
+ .toLowerCase()
178
+ .split(/[\s,]+/)
179
+ .filter(Boolean);
180
+ return tokens.includes("all") || tokens.includes("screen-reader");
181
+ }
182
+
183
+ function isSafeContentAttribute(name: string, value: string): boolean {
184
+ const normalizedName = name.toLowerCase();
185
+ if (normalizedName.startsWith("on")) return false;
186
+ if (normalizedName === "style") return false;
187
+ if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
188
+ return isSafeUrl(value);
189
+ }
190
+ return (
191
+ normalizedName === "alt" ||
192
+ normalizedName === "class" ||
193
+ normalizedName === "colspan" ||
194
+ normalizedName === "dir" ||
195
+ normalizedName === "headers" ||
196
+ normalizedName === "height" ||
197
+ normalizedName === "id" ||
198
+ normalizedName === "lang" ||
199
+ normalizedName === "role" ||
200
+ normalizedName === "rowspan" ||
201
+ normalizedName === "scope" ||
202
+ normalizedName === "title" ||
203
+ normalizedName === "type" ||
204
+ normalizedName === "width" ||
205
+ normalizedName === "xml:lang" ||
206
+ mathMlAttributeNames.has(normalizedName) ||
207
+ normalizedName.startsWith("aria-") ||
208
+ normalizedName.startsWith("data-")
209
+ );
210
+ }
211
+
212
+ const mathMlAttributeNames = new Set([
213
+ "accent",
214
+ "accentunder",
215
+ "align",
216
+ "columnalign",
217
+ "display",
218
+ "fence",
219
+ "largeop",
220
+ "lspace",
221
+ "mathbackground",
222
+ "mathcolor",
223
+ "mathsize",
224
+ "mathvariant",
225
+ "movablelimits",
226
+ "rowalign",
227
+ "rspace",
228
+ "separator",
229
+ "stretchy",
230
+ ]);
231
+
232
+ export function isSafeUrl(value: string): boolean {
233
+ return (
234
+ value.startsWith("#") ||
235
+ value.startsWith("/") ||
236
+ value.startsWith("./") ||
237
+ value.startsWith("../") ||
238
+ value.startsWith("http://") ||
239
+ value.startsWith("https://") ||
240
+ value.startsWith("data:image/") ||
241
+ value.startsWith("data:audio/") ||
242
+ value.startsWith("data:video/")
243
+ );
244
+ }
245
+
246
+ export function isResolvableAssetUrl(value: string): boolean {
247
+ return (
248
+ !value.startsWith("#") &&
249
+ !value.startsWith("data:") &&
250
+ !value.startsWith("blob:") &&
251
+ !value.startsWith("http://") &&
252
+ !value.startsWith("https://")
253
+ );
254
+ }
255
+
256
+ export function formatPrintedValue(value: QtiValue, format?: string): string {
257
+ if (value === null || value === undefined) return "";
258
+ const numericValue =
259
+ typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
260
+ if (Number.isFinite(numericValue) && format) {
261
+ const fixed = /^%\.(\d+)f$/.exec(format);
262
+ if (fixed) return numericValue.toFixed(Number(fixed[1]));
263
+ if (format === "%d" || format === "%i") return String(Math.trunc(numericValue));
264
+ }
265
+ if (Array.isArray(value)) return value.map((item) => String(item)).join(", ");
266
+ if (typeof value === "object") return JSON.stringify(value);
267
+ return String(value);
268
+ }
269
+
270
+ export function contentNodeText(node: QtiContentNode): string {
271
+ if (node.kind === "text") return node.text;
272
+ if ("children" in node) return node.children.map(contentNodeText).join("");
273
+ return "";
274
+ }
@@ -0,0 +1,114 @@
1
+ import type { QtiContentNode, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ contentElementName,
4
+ copySafeAttributes,
5
+ createContentElement,
6
+ formatPrintedValue,
7
+ unsafeContentElements,
8
+ } from "./content-dom.js";
9
+
10
+ export interface PlayerContentContext {
11
+ interactionAt(index: number): QtiInteraction | undefined;
12
+ renderBlockInteraction(interaction: QtiInteraction): HTMLElement;
13
+ renderEmbeddedInteraction(interaction: QtiInteraction): HTMLElement;
14
+ currentVariableValue(identifier: string): QtiValue;
15
+ mathTemplateValue(node: Extract<QtiContentNode, { kind: "element" }>): string | undefined;
16
+ isFeedbackVisible(node: Extract<QtiContentNode, { kind: "feedback" }>): boolean;
17
+ isTemplateContentVisible(element: HTMLElement): boolean;
18
+ }
19
+
20
+ export function renderContentNodes(nodes: QtiContentNode[], context: PlayerContentContext): Node[] {
21
+ return nodes.flatMap((node) => renderContentNode(node, context));
22
+ }
23
+
24
+ export function renderContentNode(node: QtiContentNode, context: PlayerContentContext): Node[] {
25
+ if (node.kind === "text") return [document.createTextNode(node.text)];
26
+ if (node.kind === "interaction") {
27
+ const interaction = context.interactionAt(node.interactionIndex);
28
+ if (!interaction) return [];
29
+ if (interaction.type === "inlineChoice" || interaction.type === "textEntry") {
30
+ return [context.renderEmbeddedInteraction(interaction)];
31
+ }
32
+ return [context.renderBlockInteraction(interaction)];
33
+ }
34
+ if (node.kind === "printedVariable") {
35
+ return [renderPrintedVariable(node.identifier, node.format, context)];
36
+ }
37
+ if (node.kind === "feedback") return renderFeedbackContent(node, context);
38
+ if (node.qtiName === "qti-template-block" || node.qtiName === "qti-template-inline") {
39
+ return [renderTemplateContent(node, context)];
40
+ }
41
+ if (node.qtiName === "qti-position-object-stage") {
42
+ return renderContentNodes(
43
+ node.children.filter(
44
+ (child) => !("qtiName" in child) || (child.qtiName !== "object" && child.qtiName !== "img"),
45
+ ),
46
+ context,
47
+ );
48
+ }
49
+ if (node.qtiName === "qti-prompt") {
50
+ const prompt = document.createElement("p");
51
+ copySafeAttributes(prompt, node.attributes);
52
+ prompt.classList.add("qti3-item-prompt");
53
+ prompt.append(...renderContentNodes(node.children, context));
54
+ return [prompt];
55
+ }
56
+
57
+ if (unsafeContentElements.has(node.qtiName)) return [];
58
+ const elementName = contentElementName(node.qtiName);
59
+ if (!elementName) return renderContentNodes(node.children, context);
60
+ const element = createContentElement(elementName);
61
+ copySafeAttributes(element, node.attributes);
62
+ const mathTemplateValue = context.mathTemplateValue(node);
63
+ if (mathTemplateValue === undefined) {
64
+ element.append(...renderContentNodes(node.children, context));
65
+ } else {
66
+ element.textContent = mathTemplateValue;
67
+ }
68
+ return [element];
69
+ }
70
+
71
+ function renderTemplateContent(
72
+ node: Extract<QtiContentNode, { kind: "element" }>,
73
+ context: PlayerContentContext,
74
+ ): HTMLElement {
75
+ const element = document.createElement(node.qtiName === "qti-template-block" ? "div" : "span");
76
+ copySafeAttributes(element, node.attributes);
77
+ element.classList.add(
78
+ node.qtiName === "qti-template-block" ? "qti3-template-block" : "qti3-template-inline",
79
+ );
80
+ element.dataset.templateIdentifier = node.attributes["template-identifier"] ?? "";
81
+ element.dataset.templateValueIdentifier = node.attributes.identifier ?? "";
82
+ element.dataset.showHide = node.attributes["show-hide"] === "hide" ? "hide" : "show";
83
+ element.hidden = !context.isTemplateContentVisible(element);
84
+ element.append(...renderContentNodes(node.children, context));
85
+ return element;
86
+ }
87
+
88
+ function renderPrintedVariable(
89
+ identifier: string,
90
+ format: string | undefined,
91
+ context: PlayerContentContext,
92
+ ): HTMLElement {
93
+ const output = document.createElement("output");
94
+ output.className = "qti3-printed-variable";
95
+ output.dataset.identifier = identifier;
96
+ if (format) output.dataset.format = format;
97
+ output.value = formatPrintedValue(context.currentVariableValue(identifier), format);
98
+ output.textContent = output.value;
99
+ return output;
100
+ }
101
+
102
+ function renderFeedbackContent(
103
+ node: Extract<QtiContentNode, { kind: "feedback" }>,
104
+ context: PlayerContentContext,
105
+ ): Node[] {
106
+ const element = document.createElement(node.feedbackType === "block" ? "section" : "span");
107
+ element.className = `qti3-feedback-${node.feedbackType}`;
108
+ element.dataset.feedbackIdentifier = node.identifier;
109
+ element.dataset.outcomeIdentifier = node.outcomeIdentifier;
110
+ element.dataset.showHide = node.showHide;
111
+ element.hidden = !context.isFeedbackVisible(node);
112
+ element.append(...renderContentNodes(node.children, context));
113
+ return [element];
114
+ }
@@ -0,0 +1,13 @@
1
+ import { trashIcon } from "../icons.js";
2
+ import type { QtiPlayerMessages } from "../player-messages.js";
3
+
4
+ export function removeButton(label: string | null, messages: QtiPlayerMessages): HTMLButtonElement {
5
+ const safeLabel = label?.trim() || messages.remove();
6
+ const button = document.createElement("button");
7
+ button.type = "button";
8
+ button.className = "qti3-icon-button qti3-remove-button";
9
+ button.title = messages.remove();
10
+ button.setAttribute("aria-label", messages.removePair({ label: safeLabel }));
11
+ button.append(trashIcon());
12
+ return button;
13
+ }
package/src/icons.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type IconPath = string | { d: string; fill?: string; stroke?: string };
2
+
3
+ export function inlineIcon(className: string, paths: IconPath[]): SVGSVGElement {
4
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
5
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
6
+ svg.setAttribute("width", "24");
7
+ svg.setAttribute("height", "24");
8
+ svg.setAttribute("viewBox", "0 0 24 24");
9
+ svg.setAttribute("fill", "none");
10
+ svg.setAttribute("stroke", "currentColor");
11
+ svg.setAttribute("stroke-width", "2");
12
+ svg.setAttribute("stroke-linecap", "round");
13
+ svg.setAttribute("stroke-linejoin", "round");
14
+ svg.setAttribute("aria-hidden", "true");
15
+ svg.setAttribute("focusable", "false");
16
+ svg.setAttribute("class", className);
17
+
18
+ for (const entry of paths) {
19
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
20
+ if (typeof entry === "string") {
21
+ path.setAttribute("d", entry);
22
+ } else {
23
+ path.setAttribute("d", entry.d);
24
+ if (entry.stroke) {
25
+ path.setAttribute("stroke", entry.stroke);
26
+ path.style.stroke = entry.stroke;
27
+ }
28
+ if (entry.fill) {
29
+ path.setAttribute("fill", entry.fill);
30
+ path.style.fill = entry.fill;
31
+ }
32
+ }
33
+ svg.append(path);
34
+ }
35
+ return svg;
36
+ }
37
+
38
+ export function trashIcon(): SVGSVGElement {
39
+ return inlineIcon("qti3-trash-icon", [
40
+ { d: "M0 0h24v24H0z", stroke: "none", fill: "none" },
41
+ "M4 7l16 0",
42
+ "M10 11l0 6",
43
+ "M14 11l0 6",
44
+ "M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",
45
+ "M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",
46
+ ]);
47
+ }