@longsightgroup/qti3-player 0.3.0 → 0.5.1

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 (92) hide show
  1. package/README.md +153 -13
  2. package/dist/index.d.ts +13 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +6 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/player-adapter.d.ts +62 -0
  7. package/dist/player-adapter.d.ts.map +1 -0
  8. package/dist/player-adapter.js +119 -0
  9. package/dist/player-adapter.js.map +1 -0
  10. package/dist/player-dev.d.ts +4 -0
  11. package/dist/player-dev.d.ts.map +1 -0
  12. package/dist/player-dev.js +14 -0
  13. package/dist/player-dev.js.map +1 -0
  14. package/dist/player-element.d.ts +14 -1
  15. package/dist/player-element.d.ts.map +1 -1
  16. package/dist/player-element.js +57 -5
  17. package/dist/player-element.js.map +1 -1
  18. package/dist/player-locale.d.ts +8 -3
  19. package/dist/player-locale.d.ts.map +1 -1
  20. package/dist/player-locale.js +16 -175
  21. package/dist/player-locale.js.map +1 -1
  22. package/dist/player-message-catalog-default.d.ts +4 -0
  23. package/dist/player-message-catalog-default.d.ts.map +1 -0
  24. package/dist/player-message-catalog-default.js +118 -0
  25. package/dist/player-message-catalog-default.js.map +1 -0
  26. package/dist/player-message-catalog-validate.d.ts +31 -0
  27. package/dist/player-message-catalog-validate.d.ts.map +1 -0
  28. package/dist/player-message-catalog-validate.js +327 -0
  29. package/dist/player-message-catalog-validate.js.map +1 -0
  30. package/dist/player-message-catalog.d.ts +18 -0
  31. package/dist/player-message-catalog.d.ts.map +1 -0
  32. package/dist/player-message-catalog.js +40 -0
  33. package/dist/player-message-catalog.js.map +1 -0
  34. package/dist/player-message-keys.d.ts +6 -0
  35. package/dist/player-message-keys.d.ts.map +1 -0
  36. package/dist/player-message-keys.js +7 -0
  37. package/dist/player-message-keys.js.map +1 -0
  38. package/dist/player-message-manifest.d.ts +272 -0
  39. package/dist/player-message-manifest.d.ts.map +1 -0
  40. package/dist/player-message-manifest.js +83 -0
  41. package/dist/player-message-manifest.js.map +1 -0
  42. package/dist/player-message-overrides.d.ts +3 -0
  43. package/dist/player-message-overrides.d.ts.map +1 -0
  44. package/dist/player-message-overrides.js +28 -0
  45. package/dist/player-message-overrides.js.map +1 -0
  46. package/dist/player-message-resolver.d.ts +31 -0
  47. package/dist/player-message-resolver.d.ts.map +1 -0
  48. package/dist/player-message-resolver.js +110 -0
  49. package/dist/player-message-resolver.js.map +1 -0
  50. package/dist/player-messages.d.ts +0 -38
  51. package/dist/player-messages.d.ts.map +1 -1
  52. package/dist/player-types.d.ts +12 -2
  53. package/dist/player-types.d.ts.map +1 -1
  54. package/package.json +3 -3
  55. package/src/controls/remove-button.ts +8 -5
  56. package/src/index.ts +61 -5
  57. package/src/interactions/choice-interaction.ts +6 -2
  58. package/src/interactions/drawing-interaction.ts +14 -9
  59. package/src/interactions/end-attempt-interaction.ts +3 -3
  60. package/src/interactions/gap-match-interaction.ts +32 -13
  61. package/src/interactions/graphic-associate-interaction.ts +15 -10
  62. package/src/interactions/hotspot-interaction.ts +10 -6
  63. package/src/interactions/inline-choice-interaction.ts +4 -4
  64. package/src/interactions/interaction-registry.ts +12 -12
  65. package/src/interactions/match-interaction.ts +9 -6
  66. package/src/interactions/pair-interaction.ts +22 -14
  67. package/src/interactions/position-object-interaction.ts +22 -13
  68. package/src/interactions/select-point-interaction.ts +25 -13
  69. package/src/interactions/shared.ts +21 -4
  70. package/src/interactions/text-interaction.ts +14 -4
  71. package/src/interactions/upload-interaction.ts +6 -3
  72. package/src/player/content-state.ts +12 -1
  73. package/src/player/interaction-render.ts +4 -4
  74. package/src/player-adapter.ts +253 -0
  75. package/src/player-dev.ts +14 -0
  76. package/src/player-element.ts +78 -8
  77. package/src/player-locale.ts +28 -199
  78. package/src/player-message-catalog-default.ts +119 -0
  79. package/src/player-message-catalog-validate.ts +425 -0
  80. package/src/player-message-catalog.ts +72 -0
  81. package/src/player-message-keys.ts +12 -0
  82. package/src/player-message-manifest.ts +103 -0
  83. package/src/player-message-overrides.ts +38 -0
  84. package/src/player-message-resolver.ts +205 -0
  85. package/src/player-messages.ts +0 -30
  86. package/src/player-types.ts +15 -4
  87. package/src/reorder/a11y.ts +22 -7
  88. package/src/reorder/graphic-order-interaction.ts +23 -16
  89. package/src/reorder/list-controls.ts +8 -6
  90. package/src/reorder/order-interaction.ts +7 -5
  91. package/src/styles/base-styles.ts +20 -5
  92. package/src/styles/graphic-styles.ts +0 -6
@@ -0,0 +1,253 @@
1
+ import type {
2
+ QtiAttemptStateV1,
3
+ QtiCatalogSupportResolution,
4
+ QtiCatalogSupportResolutionOptions,
5
+ QtiScoreResult,
6
+ QtiTextToSpeechTraversal,
7
+ } from "@longsightgroup/qti3-core";
8
+ import type { PlayerMessageCatalog } from "./player-message-catalog.js";
9
+ import type { QtiAssessmentItemPlayer } from "./player-element.js";
10
+ import type { QtiPlayerMessageOverrides } from "./player-message-resolver.js";
11
+ import type {
12
+ QtiAssessmentItemPlayerEventDetailMap,
13
+ QtiAssessmentItemPlayerEventName,
14
+ QtiPlayerFetchXml,
15
+ QtiPlayerLoadOptions,
16
+ QtiPlayerResolveAsset,
17
+ QtiScoreAttemptOptions,
18
+ } from "./player-types.js";
19
+
20
+ export type QtiAssessmentItemPlayerAdapterEventPropName =
21
+ | "onReady"
22
+ | "onResponseChange"
23
+ | "onStateChange"
24
+ | "onScore"
25
+ | "onValidation"
26
+ | "onSuspend"
27
+ | "onEndAttempt"
28
+ | "onPortableCustomMount"
29
+ | "onDiagnostics"
30
+ | "onReset"
31
+ | "onRestore";
32
+
33
+ export type QtiAssessmentItemPlayerAdapterPropName =
34
+ | "xml"
35
+ | "loadOptions"
36
+ | "languageOfInterface"
37
+ | "messageCatalog"
38
+ | "messages"
39
+ | "onLoadError"
40
+ | QtiAssessmentItemPlayerAdapterEventPropName;
41
+
42
+ export type QtiAssessmentItemPlayerAdapterEventCallback<
43
+ T extends QtiAssessmentItemPlayerEventName,
44
+ > = (detail: QtiAssessmentItemPlayerEventDetailMap[T]) => void;
45
+
46
+ export type QtiAssessmentItemPlayerAdapterEventHandlerProps = {
47
+ [K in (typeof qtiAssessmentItemPlayerAdapterEventEntries)[number] as K[1]]?: QtiAssessmentItemPlayerAdapterEventCallback<
48
+ K[0]
49
+ >;
50
+ };
51
+
52
+ export interface QtiAssessmentItemPlayerAdapterProps extends QtiAssessmentItemPlayerAdapterEventHandlerProps {
53
+ /** Already-prepared candidate-safe XML for delivery, or authoring/preview XML in preview tools. */
54
+ xml?: string | undefined;
55
+ loadOptions?: QtiPlayerLoadOptions | undefined;
56
+ languageOfInterface?: string | undefined;
57
+ messageCatalog?: PlayerMessageCatalog | undefined;
58
+ messages?: QtiPlayerMessageOverrides | undefined;
59
+ onLoadError?: ((error: Error) => void) | undefined;
60
+ }
61
+
62
+ export interface QtiAssessmentItemPlayerHandle {
63
+ readonly element: QtiAssessmentItemPlayer;
64
+ loadXml(xml: string, options?: QtiPlayerLoadOptions): Promise<void>;
65
+ loadUrl(url: string, options?: QtiPlayerLoadOptions): Promise<void>;
66
+ scoreAttempt(options?: QtiScoreAttemptOptions): QtiScoreResult | undefined;
67
+ restore(state: QtiAttemptStateV1): void;
68
+ suspend(): void;
69
+ endAttempt(options?: QtiScoreAttemptOptions): void;
70
+ reset(): void;
71
+ clearItem(): void;
72
+ serialize(): QtiAttemptStateV1 | undefined;
73
+ getTextToSpeechTraversal(): QtiTextToSpeechTraversal | undefined;
74
+ getCatalogSupportResolution(
75
+ options?: QtiCatalogSupportResolutionOptions,
76
+ ): QtiCatalogSupportResolution | undefined;
77
+ }
78
+
79
+ export const qtiAssessmentItemPlayerAdapterEventEntries = [
80
+ ["qti-ready", "onReady"],
81
+ ["qti-responsechange", "onResponseChange"],
82
+ ["qti-statechange", "onStateChange"],
83
+ ["qti-score", "onScore"],
84
+ ["qti-validation", "onValidation"],
85
+ ["qti-suspend", "onSuspend"],
86
+ ["qti-endattempt", "onEndAttempt"],
87
+ ["qti-portable-custom-mount", "onPortableCustomMount"],
88
+ ["qti-diagnostics", "onDiagnostics"],
89
+ ["qti-reset", "onReset"],
90
+ ["qti-restore", "onRestore"],
91
+ ] as const satisfies readonly (readonly [
92
+ QtiAssessmentItemPlayerEventName,
93
+ QtiAssessmentItemPlayerAdapterEventPropName,
94
+ ])[];
95
+
96
+ export const qtiAssessmentItemPlayerAdapterPropNames = [
97
+ "xml",
98
+ "loadOptions",
99
+ "languageOfInterface",
100
+ "messageCatalog",
101
+ "messages",
102
+ "onLoadError",
103
+ ...qtiAssessmentItemPlayerAdapterEventEntries.map((entry) => entry[1]),
104
+ ] as const satisfies readonly QtiAssessmentItemPlayerAdapterPropName[];
105
+
106
+ const qtiAssessmentItemPlayerAdapterPropNameSet = new Set<string>(
107
+ qtiAssessmentItemPlayerAdapterPropNames,
108
+ );
109
+
110
+ export function isQtiAssessmentItemPlayerAdapterPropName(name: string): boolean {
111
+ return qtiAssessmentItemPlayerAdapterPropNameSet.has(name);
112
+ }
113
+
114
+ export type QtiAssessmentItemPlayerLoadDependencies = readonly [
115
+ string | undefined,
116
+ QtiPlayerLoadOptions["status"] | undefined,
117
+ boolean | undefined,
118
+ boolean | undefined,
119
+ QtiPlayerFetchXml | undefined,
120
+ QtiPlayerResolveAsset | undefined,
121
+ ];
122
+
123
+ export function qtiAssessmentItemPlayerLoadStateKey(
124
+ state: QtiAttemptStateV1 | undefined,
125
+ ): string | undefined {
126
+ if (!state) return undefined;
127
+ // JSON key order follows insertion order; in-place mutation without reload is not detected.
128
+ return JSON.stringify(state);
129
+ }
130
+
131
+ export function qtiAssessmentItemPlayerLoadDependencies(
132
+ loadOptions: QtiPlayerLoadOptions | undefined,
133
+ ): QtiAssessmentItemPlayerLoadDependencies {
134
+ return [
135
+ qtiAssessmentItemPlayerLoadStateKey(loadOptions?.state),
136
+ loadOptions?.status,
137
+ loadOptions?.sessionControl?.validateResponses,
138
+ loadOptions?.sessionControl?.showFeedback,
139
+ loadOptions?.fetchXml,
140
+ loadOptions?.resolveAsset,
141
+ ];
142
+ }
143
+
144
+ export function bindQtiAssessmentItemPlayerAdapterEvents(
145
+ element: QtiAssessmentItemPlayer,
146
+ getProps: () => QtiAssessmentItemPlayerAdapterProps,
147
+ ): () => void {
148
+ const removers = qtiAssessmentItemPlayerAdapterEventEntries.map(([eventName, propName]) => {
149
+ const listener = (event: Event) => {
150
+ const callback = getProps()[propName] as
151
+ | QtiAssessmentItemPlayerAdapterEventCallback<typeof eventName>
152
+ | undefined;
153
+ callback?.(
154
+ (event as CustomEvent<QtiAssessmentItemPlayerEventDetailMap[typeof eventName]>).detail,
155
+ );
156
+ };
157
+ element.addEventListener(eventName, listener);
158
+ return () => element.removeEventListener(eventName, listener);
159
+ });
160
+ return () => {
161
+ for (const remove of removers) remove();
162
+ };
163
+ }
164
+
165
+ export function syncQtiAssessmentItemPlayerAdapterChrome(
166
+ element: QtiAssessmentItemPlayer,
167
+ props: Pick<
168
+ QtiAssessmentItemPlayerAdapterProps,
169
+ "languageOfInterface" | "messageCatalog" | "messages"
170
+ >,
171
+ ): void {
172
+ element.languageOfInterface = props.languageOfInterface;
173
+ element.messageCatalog = props.messageCatalog;
174
+ element.messages = props.messages;
175
+ }
176
+
177
+ /** @deprecated Use {@link syncQtiAssessmentItemPlayerAdapterChrome}. */
178
+ export const syncQtiAssessmentItemPlayerAdapterMessages = syncQtiAssessmentItemPlayerAdapterChrome;
179
+
180
+ export interface QtiAssessmentItemPlayerAdapterLoadSyncInput {
181
+ xml?: string | undefined;
182
+ loadOptions?: QtiPlayerLoadOptions | undefined;
183
+ onLoadError?: ((error: Error) => void) | undefined;
184
+ }
185
+
186
+ export function createQtiAssessmentItemPlayerAdapterLoadSync(): {
187
+ run(
188
+ element: QtiAssessmentItemPlayer,
189
+ input: QtiAssessmentItemPlayerAdapterLoadSyncInput,
190
+ ): () => void;
191
+ } {
192
+ let loadSequence = 0;
193
+
194
+ return {
195
+ run(element, input) {
196
+ if (input.xml === undefined) {
197
+ loadSequence += 1;
198
+ element.clearItem();
199
+ return () => {};
200
+ }
201
+
202
+ let active = true;
203
+ const sequence = (loadSequence += 1);
204
+ void element
205
+ .loadXml(input.xml, input.loadOptions)
206
+ .then(() => {
207
+ if (!active || sequence !== loadSequence) return;
208
+ })
209
+ .catch((error: unknown) => {
210
+ if (!active || sequence !== loadSequence) return;
211
+ input.onLoadError?.(normalizeQtiAssessmentItemPlayerLoadError(error));
212
+ });
213
+
214
+ return () => {
215
+ active = false;
216
+ };
217
+ },
218
+ };
219
+ }
220
+
221
+ export function createQtiAssessmentItemPlayerHandle(
222
+ getElement: () => QtiAssessmentItemPlayer | null,
223
+ ): QtiAssessmentItemPlayerHandle {
224
+ return {
225
+ get element() {
226
+ return requiredElement(getElement);
227
+ },
228
+ loadXml: (xml, options) => requiredElement(getElement).loadXml(xml, options),
229
+ loadUrl: (url, options) => requiredElement(getElement).loadUrl(url, options),
230
+ scoreAttempt: (options) => requiredElement(getElement).scoreAttempt(options),
231
+ restore: (state) => requiredElement(getElement).restore(state),
232
+ suspend: () => requiredElement(getElement).suspend(),
233
+ endAttempt: (options) => requiredElement(getElement).endAttempt(options),
234
+ reset: () => requiredElement(getElement).reset(),
235
+ clearItem: () => requiredElement(getElement).clearItem(),
236
+ serialize: () => requiredElement(getElement).serialize(),
237
+ getTextToSpeechTraversal: () => requiredElement(getElement).getTextToSpeechTraversal(),
238
+ getCatalogSupportResolution: (options) =>
239
+ requiredElement(getElement).getCatalogSupportResolution(options),
240
+ };
241
+ }
242
+
243
+ export function normalizeQtiAssessmentItemPlayerLoadError(error: unknown): Error {
244
+ return error instanceof Error ? error : new Error(String(error));
245
+ }
246
+
247
+ function requiredElement(
248
+ getElement: () => QtiAssessmentItemPlayer | null,
249
+ ): QtiAssessmentItemPlayer {
250
+ const element = getElement();
251
+ if (!element) throw new Error("QTI assessment item player element is not mounted.");
252
+ return element;
253
+ }
@@ -0,0 +1,14 @@
1
+ /** True when dev warnings are enabled (non-production Node, or browser without NODE_ENV). */
2
+ export function playerDevWarningsEnabled(): boolean {
3
+ const nodeEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env
4
+ ?.NODE_ENV;
5
+ return nodeEnv !== "production";
6
+ }
7
+
8
+ const warnedKeys = new Set<string>();
9
+
10
+ export function warnPlayerMessageOnce(code: string, message: string): void {
11
+ if (!playerDevWarningsEnabled() || warnedKeys.has(code)) return;
12
+ warnedKeys.add(code);
13
+ console.warn(`[qti3-player] ${message}`);
14
+ }
@@ -26,7 +26,12 @@ import {
26
26
  collectEmbeddedInteractionDiagnostics,
27
27
  collectInteractionRenderDiagnostics,
28
28
  } from "./interactions/interaction-diagnostics.js";
29
+ import type { PlayerMessageCatalog } from "./player-message-catalog.js";
29
30
  import { defaultPlayerLocale, normalizedLocale, resolvePlayerMessages } from "./player-locale.js";
31
+ import type {
32
+ PlayerMessageResolver,
33
+ QtiPlayerMessageOverrides,
34
+ } from "./player-message-resolver.js";
30
35
  import { syncAttemptAvailability } from "./player/attempt-availability.js";
31
36
  import {
32
37
  currentTemplateValue,
@@ -48,7 +53,6 @@ import type {
48
53
  QtiAssessmentItemPlayerEventDetailMap,
49
54
  QtiAssessmentItemPlayerEventName,
50
55
  QtiPlayerLoadOptions,
51
- QtiPlayerMessageOverrides,
52
56
  QtiPlayerResolveAsset,
53
57
  QtiPlayerSessionControl,
54
58
  QtiScoreAttemptOptions,
@@ -80,11 +84,14 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
80
84
  private validationMessages: QtiDiagnostic[] = [];
81
85
  private authoringDiagnostics: QtiDiagnostic[] = [];
82
86
  private languageOfInterfaceOverride: string | undefined;
87
+ private messageCatalogOverride: PlayerMessageCatalog | undefined;
83
88
  private messageOverrides: QtiPlayerMessageOverrides = {};
89
+ private resolvedMessagesCache: PlayerMessageResolver | undefined;
84
90
  private sessionControl: Required<QtiPlayerSessionControl> = {
85
91
  validateResponses: true,
86
92
  showFeedback: true,
87
93
  };
94
+ private loadGeneration = 0;
88
95
 
89
96
  get languageOfInterface(): string {
90
97
  return (
@@ -97,6 +104,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
97
104
 
98
105
  set languageOfInterface(value: string | undefined) {
99
106
  this.languageOfInterfaceOverride = normalizedLocale(value);
107
+ this.invalidatePlayerMessages();
100
108
  this.rerenderIfLoaded();
101
109
  }
102
110
 
@@ -114,6 +122,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
114
122
 
115
123
  set messages(value: QtiPlayerMessageOverrides | undefined) {
116
124
  this.messageOverrides = value ?? {};
125
+ this.invalidatePlayerMessages();
126
+ this.rerenderIfLoaded();
127
+ }
128
+
129
+ get messageCatalog(): PlayerMessageCatalog | undefined {
130
+ return this.messageCatalogOverride;
131
+ }
132
+
133
+ set messageCatalog(value: PlayerMessageCatalog | undefined) {
134
+ this.messageCatalogOverride = value;
135
+ this.invalidatePlayerMessages();
117
136
  this.rerenderIfLoaded();
118
137
  }
119
138
 
@@ -121,15 +140,59 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
121
140
  if ((name !== "language-of-interface" && name !== "locale") || oldValue === newValue) {
122
141
  return;
123
142
  }
143
+ this.invalidatePlayerMessages();
124
144
  this.rerenderIfLoaded();
125
145
  }
126
146
 
147
+ private invalidatePlayerMessages(): void {
148
+ this.resolvedMessagesCache = undefined;
149
+ }
150
+
151
+ /** Clears the loaded item. Does not emit player events; declarative hosts control this via `xml`. */
152
+ clearItem(): void {
153
+ this.loadGeneration += 1;
154
+ delete this.documentModel;
155
+ delete this.session;
156
+ this.validationMessages = [];
157
+ this.authoringDiagnostics = [];
158
+ this.replaceChildren();
159
+ }
160
+
127
161
  async loadXml(xml: string, options: QtiPlayerLoadOptions = {}): Promise<void> {
162
+ const generation = this.beginLoad();
163
+ await this.applyLoadedXml(generation, xml, options);
164
+ }
165
+
166
+ async loadUrl(url: string, options: QtiPlayerLoadOptions = {}): Promise<void> {
167
+ const generation = this.beginLoad();
168
+ const fetchXml = options.fetchXml ?? defaultFetchXml;
169
+ const xml = await fetchXml(url);
170
+ if (!this.isCurrentLoad(generation)) return;
171
+ await this.applyLoadedXml(generation, xml, options);
172
+ }
173
+
174
+ private beginLoad(): number {
175
+ this.loadGeneration += 1;
176
+ return this.loadGeneration;
177
+ }
178
+
179
+ private isCurrentLoad(generation: number): boolean {
180
+ return generation === this.loadGeneration;
181
+ }
182
+
183
+ private async applyLoadedXml(
184
+ generation: number,
185
+ xml: string,
186
+ options: QtiPlayerLoadOptions,
187
+ ): Promise<void> {
188
+ if (!this.isCurrentLoad(generation)) return;
189
+
128
190
  this.sessionControl = {
129
191
  validateResponses: options.sessionControl?.validateResponses ?? true,
130
192
  showFeedback: options.sessionControl?.showFeedback ?? true,
131
193
  };
132
194
  this.resolveAsset = options.resolveAsset;
195
+
133
196
  const result = parseQtiXml(xml);
134
197
  const playerDiagnostics = result.document
135
198
  ? [
@@ -137,6 +200,8 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
137
200
  ...collectEmbeddedInteractionDiagnostics(result.document.item),
138
201
  ]
139
202
  : [];
203
+ if (!this.isCurrentLoad(generation)) return;
204
+
140
205
  this.dispatchEvent(
141
206
  new CustomEvent("qti-diagnostics", {
142
207
  detail: { diagnostics: [...result.diagnostics, ...playerDiagnostics] },
@@ -146,10 +211,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
146
211
  playerDiagnostics.filter((diagnostic) => diagnostic.severity === "error"),
147
212
  );
148
213
  if (!result.document) {
214
+ if (!this.isCurrentLoad(generation)) return;
149
215
  this.replaceChildren(errorView("Unable to parse QTI item."));
150
216
  return;
151
217
  }
152
218
 
219
+ if (!this.isCurrentLoad(generation)) return;
220
+
153
221
  this.documentModel = result.document;
154
222
  this.session = createItemSession(result.document, options.state);
155
223
  this.validationMessages = cloneDiagnostics(
@@ -163,11 +231,6 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
163
231
  this.emitStateChange();
164
232
  }
165
233
 
166
- async loadUrl(url: string, options: QtiPlayerLoadOptions = {}): Promise<void> {
167
- const fetchXml = options.fetchXml ?? defaultFetchXml;
168
- await this.loadXml(await fetchXml(url), options);
169
- }
170
-
171
234
  scoreAttempt(options: QtiScoreAttemptOptions = {}): QtiScoreResult | undefined {
172
235
  const session = this.session;
173
236
  if (!session) return undefined;
@@ -288,8 +351,15 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
288
351
  this.dispatchEvent(new CustomEvent<QtiAssessmentItemPlayerEventDetailMap[T]>(type, { detail }));
289
352
  }
290
353
 
291
- private playerMessages() {
292
- return resolvePlayerMessages(this.languageOfInterface, this.messageOverrides);
354
+ private playerMessages(): PlayerMessageResolver {
355
+ if (!this.resolvedMessagesCache) {
356
+ this.resolvedMessagesCache = resolvePlayerMessages(
357
+ this.languageOfInterface,
358
+ this.messageOverrides,
359
+ this.messageCatalogOverride,
360
+ );
361
+ }
362
+ return this.resolvedMessagesCache;
293
363
  }
294
364
 
295
365
  private rerenderIfLoaded(): void {
@@ -1,206 +1,35 @@
1
- import type { QtiPlayerMessages } from "./player-messages.js";
2
- import type { QtiPlayerMessageOverrides } from "./player-types.js";
3
-
4
- type QtiPlayerMessageCatalog = Readonly<Partial<QtiPlayerMessages>>;
5
-
6
- const defaultEnglishPlayerMessages: QtiPlayerMessages = {
7
- remove: () => "Remove",
8
- removePair: ({ label }) => `Remove ${label}`,
9
- clearDrawing: () => "Clear drawing",
10
- clearPoints: () => "Clear points",
11
- endAttempt: () => "End attempt",
12
- uploadResponse: () => "Upload response",
13
- movableObject: () => "Movable object",
14
- placeObject: () => "Place",
15
- inlineChoicePrompt: () => "Choose...",
16
- noPointSelected: () => "No point selected",
17
- noRegionSelected: () => "No region selected",
18
- noAssociationsMade: () => "No associations made",
19
- associationsMade: ({ count }) => `${count} ${count === 1 ? "association" : "associations"} made.`,
20
- associationPairLabel: ({ source, target }) => `${source} to ${target}`,
21
- hotspotSelectedChooseAnother: ({ label }) => `${label} selected. Choose another hotspot.`,
22
- moveChoice: ({ label, direction }) => `Move ${label} ${direction}`,
23
- movePoint: ({ direction }) => `Move point ${direction}`,
24
- moveObject: ({ direction }) => `Move object ${direction}`,
25
- };
26
-
27
- const playerMessages = {
28
- defaultEnglish: defaultEnglishPlayerMessages,
29
- spanish: {
30
- remove: () => "Quitar",
31
- removePair: ({ label }) => `Quitar ${label}`,
32
- clearDrawing: () => "Borrar dibujo",
33
- clearPoints: () => "Borrar puntos",
34
- endAttempt: () => "Finalizar intento",
35
- uploadResponse: () => "Subir respuesta",
36
- movableObject: () => "Objeto movible",
37
- placeObject: () => "Colocar",
38
- inlineChoicePrompt: () => "Elija...",
39
- noPointSelected: () => "Ningun punto seleccionado",
40
- noRegionSelected: () => "Ninguna region seleccionada",
41
- noAssociationsMade: () => "Ninguna asociacion realizada",
42
- associationsMade: ({ count }) =>
43
- `${count} ${count === 1 ? "asociacion realizada" : "asociaciones realizadas"}.`,
44
- associationPairLabel: ({ source, target }) => `${source} con ${target}`,
45
- hotspotSelectedChooseAnother: ({ label }) => `${label} seleccionado. Elija otro punto activo.`,
46
- moveChoice: ({ label, direction }) => `Mover ${label} ${spanishDirection(direction)}`,
47
- movePoint: ({ direction }) => `Mover punto ${spanishDirection(direction)}`,
48
- moveObject: ({ direction }) => `Mover objeto ${spanishDirection(direction)}`,
49
- },
50
- swedish: {
51
- remove: () => "Ta bort",
52
- removePair: ({ label }) => `Ta bort ${label}`,
53
- clearDrawing: () => "Rensa ritning",
54
- clearPoints: () => "Rensa punkter",
55
- endAttempt: () => "Avsluta forsok",
56
- uploadResponse: () => "Ladda upp svar",
57
- movableObject: () => "Flyttbart objekt",
58
- placeObject: () => "Placera",
59
- inlineChoicePrompt: () => "Valj...",
60
- noPointSelected: () => "Ingen punkt vald",
61
- noRegionSelected: () => "Ingen region vald",
62
- noAssociationsMade: () => "Inga associationer gjorda",
63
- associationsMade: ({ count }) =>
64
- `${count} ${count === 1 ? "association gjord" : "associationer gjorda"}.`,
65
- associationPairLabel: ({ source, target }) => `${source} med ${target}`,
66
- hotspotSelectedChooseAnother: ({ label }) => `${label} valt. Valj en annan hotspot.`,
67
- moveChoice: ({ label, direction }) => `Flytta ${label} ${swedishDirection(direction)}`,
68
- movePoint: ({ direction }) => `Flytta punkt ${swedishDirection(direction)}`,
69
- moveObject: ({ direction }) => `Flytta objekt ${swedishDirection(direction)}`,
70
- },
71
- german: {
72
- remove: () => "Entfernen",
73
- removePair: ({ label }) => `${label} entfernen`,
74
- clearDrawing: () => "Zeichnung loeschen",
75
- clearPoints: () => "Punkte loeschen",
76
- endAttempt: () => "Versuch beenden",
77
- uploadResponse: () => "Antwort hochladen",
78
- movableObject: () => "Bewegliches Objekt",
79
- placeObject: () => "Platzieren",
80
- inlineChoicePrompt: () => "Waehlen...",
81
- noPointSelected: () => "Kein Punkt ausgewaehlt",
82
- noRegionSelected: () => "Keine Region ausgewaehlt",
83
- noAssociationsMade: () => "Keine Zuordnungen erstellt",
84
- associationsMade: ({ count }) =>
85
- `${count} ${count === 1 ? "Zuordnung erstellt" : "Zuordnungen erstellt"}.`,
86
- associationPairLabel: ({ source, target }) => `${source} mit ${target}`,
87
- hotspotSelectedChooseAnother: ({ label }) =>
88
- `${label} ausgewaehlt. Waehlen Sie einen weiteren Hotspot.`,
89
- moveChoice: ({ label, direction }) => `${label} ${germanDirection(direction)} bewegen`,
90
- movePoint: ({ direction }) => `Punkt ${germanDirection(direction)} bewegen`,
91
- moveObject: ({ direction }) => `Objekt ${germanDirection(direction)} bewegen`,
92
- },
93
- portuguese: {
94
- remove: () => "Remover",
95
- removePair: ({ label }) => `Remover ${label}`,
96
- clearDrawing: () => "Limpar desenho",
97
- clearPoints: () => "Limpar pontos",
98
- endAttempt: () => "Finalizar tentativa",
99
- uploadResponse: () => "Enviar resposta",
100
- movableObject: () => "Objeto movel",
101
- placeObject: () => "Posicionar",
102
- inlineChoicePrompt: () => "Escolha...",
103
- noPointSelected: () => "Nenhum ponto selecionado",
104
- noRegionSelected: () => "Nenhuma regiao selecionada",
105
- noAssociationsMade: () => "Nenhuma associacao feita",
106
- associationsMade: ({ count }) =>
107
- `${count} ${count === 1 ? "associacao feita" : "associacoes feitas"}.`,
108
- associationPairLabel: ({ source, target }) => `${source} com ${target}`,
109
- hotspotSelectedChooseAnother: ({ label }) => `${label} selecionado. Escolha outro ponto ativo.`,
110
- moveChoice: ({ label, direction }) => `Mover ${label} ${portugueseDirection(direction)}`,
111
- movePoint: ({ direction }) => `Mover ponto ${portugueseDirection(direction)}`,
112
- moveObject: ({ direction }) => `Mover objeto ${portugueseDirection(direction)}`,
113
- },
114
- french: {
115
- remove: () => "Supprimer",
116
- removePair: ({ label }) => `Supprimer ${label}`,
117
- clearDrawing: () => "Effacer le dessin",
118
- clearPoints: () => "Effacer les points",
119
- endAttempt: () => "Terminer la tentative",
120
- uploadResponse: () => "Televerser la reponse",
121
- movableObject: () => "Objet mobile",
122
- placeObject: () => "Placer",
123
- inlineChoicePrompt: () => "Choisir...",
124
- noPointSelected: () => "Aucun point selectionne",
125
- noRegionSelected: () => "Aucune region selectionnee",
126
- noAssociationsMade: () => "Aucune association effectuee",
127
- associationsMade: ({ count }) =>
128
- `${count} ${count === 1 ? "association effectuee" : "associations effectuees"}.`,
129
- associationPairLabel: ({ source, target }) => `${source} avec ${target}`,
130
- hotspotSelectedChooseAnother: ({ label }) =>
131
- `${label} selectionne. Choisissez un autre point actif.`,
132
- moveChoice: ({ label, direction }) => `Deplacer ${label} vers ${frenchDirection(direction)}`,
133
- movePoint: ({ direction }) => `Deplacer le point vers ${frenchDirection(direction)}`,
134
- moveObject: ({ direction }) => `Deplacer l'objet vers ${frenchDirection(direction)}`,
135
- },
136
- } satisfies Record<string, QtiPlayerMessageCatalog>;
137
-
138
- const builtInPlayerMessageCatalogs: ReadonlyMap<string, QtiPlayerMessageCatalog> = new Map([
139
- ["en", playerMessages.defaultEnglish],
140
- ["es", playerMessages.spanish],
141
- ["es-es", playerMessages.spanish],
142
- ["es-mx", playerMessages.spanish],
143
- ["sv", playerMessages.swedish],
144
- ["sv-se", playerMessages.swedish],
145
- ["de", playerMessages.german],
146
- ["de-de", playerMessages.german],
147
- ["pt", playerMessages.portuguese],
148
- ["pt-br", playerMessages.portuguese],
149
- ["pt-pt", playerMessages.portuguese],
150
- ["fr", playerMessages.french],
151
- ["fr-ca", playerMessages.french],
152
- ["fr-fr", playerMessages.french],
153
- ]);
154
-
1
+ import type { PlayerMessageCatalog } from "./player-message-catalog.js";
2
+ import { applyPlayerMessageOverrides } from "./player-message-overrides.js";
3
+ import {
4
+ createPlayerMessageResolver,
5
+ defaultPlayerMessageResolver,
6
+ type PlayerMessageResolver,
7
+ } from "./player-message-resolver.js";
8
+ import { warnPlayerMessageOnce } from "./player-dev.js";
9
+ import type { QtiPlayerMessageOverrides } from "./player-message-resolver.js";
10
+
11
+ function isEnglishLocale(locale: string): boolean {
12
+ const normalized = normalizedLocale(locale) ?? locale;
13
+ return normalized.toLowerCase().startsWith("en");
14
+ }
15
+
16
+ /**
17
+ * Resolves player chrome messages: catalog (or English defaults) merged with overrides.
18
+ * `locale` does not select built-in catalogs; use `catalog` via `player.messageCatalog`.
19
+ */
155
20
  export function resolvePlayerMessages(
156
21
  locale: string,
157
22
  overrides: QtiPlayerMessageOverrides,
158
- ): QtiPlayerMessages {
159
- const catalog = builtInPlayerMessageCatalog(locale);
160
- return { ...defaultEnglishPlayerMessages, ...catalog, ...overrides };
161
- }
162
-
163
- function spanishDirection(direction: "up" | "down" | "left" | "right"): string {
164
- return { up: "arriba", down: "abajo", left: "a la izquierda", right: "a la derecha" }[direction];
165
- }
166
-
167
- function swedishDirection(direction: "up" | "down" | "left" | "right"): string {
168
- return { up: "upp", down: "ned", left: "vanster", right: "hoger" }[direction];
169
- }
170
-
171
- function germanDirection(direction: "up" | "down" | "left" | "right"): string {
172
- return { up: "nach oben", down: "nach unten", left: "nach links", right: "nach rechts" }[
173
- direction
174
- ];
175
- }
176
-
177
- function portugueseDirection(direction: "up" | "down" | "left" | "right"): string {
178
- return { up: "para cima", down: "para baixo", left: "para a esquerda", right: "para a direita" }[
179
- direction
180
- ];
181
- }
182
-
183
- function frenchDirection(direction: "up" | "down" | "left" | "right"): string {
184
- return { up: "le haut", down: "le bas", left: "la gauche", right: "la droite" }[direction];
185
- }
186
-
187
- function builtInPlayerMessageCatalog(locale: string): QtiPlayerMessageCatalog | undefined {
188
- for (const candidate of localeFallbacks(locale)) {
189
- const catalog = builtInPlayerMessageCatalogs.get(candidate);
190
- if (catalog) return catalog;
191
- }
192
- return undefined;
193
- }
194
-
195
- function localeFallbacks(locale: string): string[] {
196
- const normalized = normalizedLocale(locale)?.toLowerCase();
197
- if (!normalized) return ["en"];
198
- const parts = normalized.split("-");
199
- const fallbacks: string[] = [];
200
- for (let length = parts.length; length > 0; length -= 1) {
201
- fallbacks.push(parts.slice(0, length).join("-"));
23
+ catalog?: PlayerMessageCatalog,
24
+ ): PlayerMessageResolver {
25
+ if (!catalog && !isEnglishLocale(locale) && Object.keys(overrides).length === 0) {
26
+ warnPlayerMessageOnce(
27
+ `locale-without-catalog:${locale}`,
28
+ `language-of-interface is "${locale}" but no player.messageCatalog was set; chrome stays English. Load a locale file and assign player.messageCatalog.`,
29
+ );
202
30
  }
203
- return fallbacks.includes("en") ? fallbacks : [...fallbacks, "en"];
31
+ const base = catalog ? createPlayerMessageResolver(catalog) : defaultPlayerMessageResolver;
32
+ return applyPlayerMessageOverrides(base, overrides);
204
33
  }
205
34
 
206
35
  export function normalizedLocale(value: string | undefined | null): string | undefined {