@citolab/tspci 2.12.0 → 2.13.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.
@@ -1,167 +1,169 @@
1
- import * as ctx from "qtiCustomInteractionContext";
2
- import configProps from "./config.json";
3
- import style from "./styles.css"; // import and bundle this style file ( you can use tailwind and nested css )
4
- import procenten from "./assets/procenten.png"; // image types are bundled inside the js
1
+ /*
2
+ * A Portable Custom Interaction (PCI) in plain JavaScript.
3
+ *
4
+ * The delivery engine (the player) loads this bundle, which registers a factory with qtiCustomInteractionContext.
5
+ * For every interaction in an item the engine calls factory.getInstance(dom, config, state); the instance renders
6
+ * into `dom` and reports its answer through getResponse().
7
+ *
8
+ * The demo: "what percentage of the circle is coloured?". The circle is drawn from the settings in src/config.json
9
+ * (pie.js); the answer is a whole number, reported as { base: { integer: 30 } } (see config.tspci.response in
10
+ * package.json and AUTHORING.md).
11
+ */
12
+ import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
13
+ import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
14
+ import style from "./styles.css"; // bundled as a string, added to the shadow root below
15
+ import { escapeHtml, pieSvg } from "./pie.js";
16
+ // Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src>.
17
+
18
+ /** The response for the text in the input: a whole number, or undefined while there is no answer. */
19
+ function toResponse(value) {
20
+ const trimmed = value.trim();
21
+ if (trimmed === "") return undefined; // nothing answered, nothing reported
22
+ const n = Number(trimmed);
23
+ // One answer, one response: "30", "030" and "30.0" all report 30.
24
+ return Number.isFinite(n) ? { base: { integer: Math.round(n) } } : undefined;
25
+ }
26
+
27
+ /** The input text for a response (setResponse, boundTo) or a correct response (status "solution"). */
28
+ function fromResponse(value) {
29
+ if (Array.isArray(value)) return fromResponse(value[0]);
30
+ if (value && typeof value === "object") {
31
+ const base = value.base;
32
+ return fromResponse(base?.integer ?? base?.float ?? base?.string);
33
+ }
34
+ return value === undefined || value === null ? "" : String(value);
35
+ }
36
+
5
37
  class PciInstance {
6
38
  typeIdentifier = "###___PCI_NAME___###";
39
+ /** What getState() saves and the constructor restores: the text in the input, also when it is not a number yet. */
40
+ state = { value: "" };
41
+ customValidity = "";
7
42
 
8
43
  constructor(dom, config, stateString) {
9
44
  if (!dom) throw new Error("No DOM Element provided");
45
+ // The item's data- attributes override the defaults from config.json. Like every data- attribute, each setting
46
+ // arrives as a string.
10
47
  config.properties = { ...configProps, ...config.properties };
11
48
  this.dom = dom;
12
49
  this.config = config;
50
+ // A closed shadow root keeps the item's CSS out and ours in.
13
51
  this.shadowdom = dom.attachShadow({ mode: "closed" });
14
52
 
15
- this.state = { value: "", touched: false, customValidity: "" };
53
+ // 1. The state from a previous session (whatever getState() returned).
16
54
  if (stateString) {
17
55
  try {
18
56
  const parsed = JSON.parse(stateString);
19
- this.state.value = typeof parsed?.value === "string" ? parsed.value : "";
20
- this.state.touched = parsed?.touched === true;
21
- this.state.customValidity =
22
- typeof parsed?.customValidity === "string" ? parsed.customValidity : "";
57
+ this.state = { value: typeof parsed?.value === "string" ? parsed.value : "" };
23
58
  } catch {
24
- this.state.value = String(stateString);
25
- this.state.touched = false;
59
+ // not ours: start empty
26
60
  }
27
61
  }
62
+ // 2. A response the engine binds to the interaction (boundTo) wins over the state.
63
+ const boundTo = config.boundTo ?? {};
64
+ const bound = boundTo[config.responseIdentifier] ?? (Object.keys(boundTo).length === 1 ? Object.values(boundTo)[0] : undefined);
65
+ if (bound) this.state = { value: fromResponse(bound) };
28
66
 
29
- const boundResponse =
30
- (this.config.boundTo && this.config.responseIdentifier
31
- ? this.config.boundTo[this.config.responseIdentifier]
32
- : undefined) ||
33
- (this.config.boundTo && Object.keys(this.config.boundTo).length === 1
34
- ? this.config.boundTo[Object.keys(this.config.boundTo)[0]]
35
- : undefined);
36
- if (boundResponse) {
37
- this.setResponse(boundResponse);
38
- }
67
+ this.render();
68
+ // 3. Status "solution": show the correct response of the item instead (AUTHORING.md, "Correct answers").
69
+ if (config.status === "solution") this.showCorrectResponse();
39
70
 
40
- this.onChange = (e) => {
41
- const target = e && e.target;
42
- if (!target) return;
43
- const nextValue = typeof target.value === "string" ? target.value : "";
44
- if (nextValue === this.state.value && this.state.touched) return;
45
- this.state.value = nextValue;
46
- this.state.touched = true;
47
- this.dispatchInteractionChanged();
48
- };
71
+ config.onready?.(this, this.getState());
72
+ }
49
73
 
50
- this.render();
74
+ /** Read-only when the candidate can no longer answer. */
75
+ get readOnly() {
76
+ return ["review", "solution", "closed"].includes(this.config.status ?? "interacting");
77
+ }
51
78
 
52
- if (this.config.onready) {
53
- this.config.onready(this, this.getState());
54
- }
79
+ get input() {
80
+ return this.shadowdom.querySelector("input");
55
81
  }
56
82
 
57
- getInputEl = () => this.shadowdom.querySelector("input[type='number']");
83
+ render() {
84
+ const p = this.config.properties;
85
+ // <label for> ties the question to the input, so a screen reader reads it and a click on it focuses the input.
86
+ this.shadowdom.innerHTML = `
87
+ <style>${style}</style>
88
+ <div class="pci">
89
+ <h2 class="title">${escapeHtml(p.title)}</h2>
90
+ ${pieSvg(p)}
91
+ <label class="question" for="answer">${escapeHtml(p.question)}</label>
92
+ <div class="answer">
93
+ <input id="answer" type="number" inputmode="numeric" min="0" max="100" step="1" autocomplete="off"
94
+ value="${escapeHtml(this.state.value)}" ${this.readOnly ? "disabled" : ""} />
95
+ <span class="unit">%</span>
96
+ </div>
97
+ </div>`;
98
+
99
+ const input = this.input;
100
+ input.setCustomValidity(this.customValidity);
101
+ this.markValidity();
102
+ input.addEventListener("input", this.onInput);
103
+ }
58
104
 
59
- dispatchInteractionChanged = () => {
105
+ onInput = () => {
106
+ this.state = { value: this.input.value };
107
+ this.markValidity();
108
+ // Tell the engine the response changed; composed lets the event cross the shadow root.
60
109
  const detail = {
61
110
  interaction: this,
62
111
  responseIdentifier: this.config.responseIdentifier,
63
- valid: this.checkValidity?.() ?? true,
112
+ valid: this.checkValidity(),
64
113
  value: this.getResponse(),
65
114
  };
66
- const event = new CustomEvent("qti-interaction-changed", {
67
- bubbles: true,
68
- composed: true,
69
- cancelable: true,
70
- detail,
71
- });
72
- this.dom.dispatchEvent(event);
115
+ this.dom.dispatchEvent(new CustomEvent("qti-interaction-changed", { bubbles: true, composed: true, detail }));
73
116
  };
74
117
 
75
- render = () => {
76
- this.shadowdom.innerHTML = `<div class="pci-container">
77
- <h1>${this.config.properties.title}</h1>
78
- <div class="body">
79
- <img width="${+this.config.properties.width}" height="${+this.config
80
- .properties.height}" src="${procenten}" />
81
- </div>
82
- <div class="interaction">
83
- <label>${this.config.properties.prompt}</label>
84
- <input type="number" value="${this.state.value}" min="0" max="100">%
85
- </div>
86
- </div>`;
87
- const css = document.createElement("style");
88
- css.innerHTML = style;
89
- this.shadowdom.appendChild(css);
90
-
91
- const input = this.getInputEl();
92
- if (input) {
93
- if (this.state.customValidity) input.setCustomValidity(this.state.customValidity);
94
- input.addEventListener("input", this.onChange);
95
- input.addEventListener("change", this.onChange);
96
- }
97
- };
118
+ /** aria-invalid tells assistive technology, and styles.css shows it with a red outline. */
119
+ markValidity() {
120
+ const input = this.input;
121
+ input.setAttribute("aria-invalid", String(input.value !== "" && !input.checkValidity()));
122
+ }
98
123
 
99
- setResponse = (response) => {
100
- let next = "";
101
- if (response && response.base) {
102
- if (typeof response.base.integer === "number") next = String(response.base.integer);
103
- else if (typeof response.base.string === "string") next = response.base.string;
104
- }
105
- this.state.value = next;
106
- this.state.touched = true;
107
- const input = this.getInputEl();
108
- if (input) input.value = next;
124
+ /** Show the correct response the item declares (status "solution"); without one, keep what is there. */
125
+ showCorrectResponse = () => {
126
+ const correct = this.config.responseDeclaration?.correctResponse?.value;
127
+ if (correct === undefined) return;
128
+ this.state = { value: fromResponse(correct) };
129
+ this.render();
109
130
  };
110
131
 
111
- getResponse = () => {
112
- if (!this.state.touched) return undefined;
113
- const trimmed = String(this.state.value == null ? "" : this.state.value).trim();
114
- if (!trimmed) return undefined;
115
- const asNumber = Number(trimmed);
116
- if (!Number.isFinite(asNumber)) return undefined;
117
- return { base: { integer: Math.trunc(asNumber) } };
118
- };
132
+ // ---- the IMS PCI API ----
133
+
134
+ getResponse = () => toResponse(this.state.value);
119
135
 
120
136
  getState = () => JSON.stringify(this.state);
121
137
 
122
- checkValidity = () => {
123
- const input = this.getInputEl();
124
- if (!input) return undefined;
125
- return input.checkValidity();
138
+ setResponse = (response) => {
139
+ this.state = { value: fromResponse(response) };
140
+ this.render();
126
141
  };
127
142
 
128
- reportValidity = () => {
129
- const input = this.getInputEl();
130
- if (!input) return undefined;
131
- return input.reportValidity();
132
- };
143
+ checkValidity = () => this.input?.checkValidity() ?? true;
144
+
145
+ reportValidity = () => this.input?.reportValidity() ?? true;
133
146
 
134
147
  setCustomValidity = (message) => {
135
- this.state.customValidity = message == null ? "" : String(message);
136
- const input = this.getInputEl();
137
- if (input) input.setCustomValidity(this.state.customValidity);
148
+ this.customValidity = message ?? "";
149
+ this.input?.setCustomValidity(this.customValidity);
138
150
  };
139
151
 
140
- getCustomValidity = () => this.state.customValidity ?? "";
152
+ getCustomValidity = () => this.customValidity;
141
153
 
154
+ /** The engine is done with this instance: release what you hold (listeners on window, timers, ...). */
142
155
  oncompleted = () => {
143
- const input = this.getInputEl();
144
- if (input) {
145
- input.removeEventListener("input", this.onChange);
146
- input.removeEventListener("change", this.onChange);
147
- }
156
+ this.input?.removeEventListener("input", this.onInput);
148
157
  };
149
158
  }
150
159
 
151
160
  const factory = {
152
- typeIdentifier: "###___PCI_NAME___###",
161
+ typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
153
162
  getInstance: (dom, config, state) => new PciInstance(dom, config, state),
154
163
  };
155
164
 
156
- const engineContext =
157
- ctx && typeof ctx.register === "function"
158
- ? ctx
159
- : typeof window !== "undefined" && window.qtiCustomInteractionContext
160
- ? window.qtiCustomInteractionContext
161
- : undefined;
162
-
163
- if (engineContext && typeof engineContext.register === "function") {
164
- engineContext.register(factory);
165
- }
165
+ // Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
166
+ const engineContext = [ctx, globalThis.qtiCustomInteractionContext].find((c) => typeof c?.register === "function");
167
+ engineContext?.register(factory);
166
168
 
167
169
  export default factory;
@@ -0,0 +1,46 @@
1
+ /** Settings arrive as strings (they are data- attributes) and an author may type anything: parse and clamp them. */
2
+ const toInt = (value, fallback, min, max) => {
3
+ const n = Number.parseInt(value, 10);
4
+ return Number.isNaN(n) ? fallback : Math.min(max, Math.max(min, n));
5
+ };
6
+
7
+ /** The pie the settings describe; the limits match the min/max in config.json. */
8
+ export function pieSettings(p) {
9
+ const segments = toInt(p.segments, 10, 2, 24);
10
+ return {
11
+ segments,
12
+ colored: toInt(p.colored, 0, 0, segments),
13
+ size: toInt(p.size, 200, 100, 400),
14
+ color: p.color || "#3b82f6",
15
+ };
16
+ }
17
+
18
+ /** An SVG circle of `segments` equal slices, the first `colored` ones filled, clockwise from the top. */
19
+ export function pieSvg(p) {
20
+ const { segments, colored, size, color } = pieSettings(p);
21
+ const c = size / 2; // centre
22
+ const r = c - 4; // leave room for the outline
23
+ const point = (i) => {
24
+ const angle = -Math.PI / 2 + (2 * Math.PI * i) / segments;
25
+ return `${(c + r * Math.cos(angle)).toFixed(2)} ${(c + r * Math.sin(angle)).toFixed(2)}`;
26
+ };
27
+ const slices = Array.from(
28
+ { length: segments },
29
+ (_, i) =>
30
+ `<path class="slice" d="M ${c} ${c} L ${point(i)} A ${r} ${r} 0 0 1 ${point(i + 1)} Z"${
31
+ i < colored ? ` fill="${escapeHtml(color)}"` : ""
32
+ }/>`
33
+ ).join("");
34
+ // role="img" + aria-label: a screen reader reads one description instead of a list of paths.
35
+ // Translate the label when your items are not in English.
36
+ return `<svg class="pie" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}"
37
+ role="img" aria-label="A circle in ${segments} equal slices, ${colored} of them coloured">${slices}</svg>`;
38
+ }
39
+
40
+ /** Settings and answers end up in innerHTML: escape them, or an author's "<" breaks the markup. */
41
+ export const escapeHtml = (value) =>
42
+ String(value ?? "")
43
+ .replace(/&/g, "&amp;")
44
+ .replace(/</g, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/"/g, "&quot;");
@@ -1,166 +1,168 @@
1
- import "preact/debug";
1
+ /*
2
+ * A Portable Custom Interaction (PCI) with Preact, Tailwind and a store.
3
+ *
4
+ * The delivery engine (the player) loads this bundle, which registers a factory with qtiCustomInteractionContext.
5
+ * For every interaction in an item the engine calls factory.getInstance(dom, config, state); the instance renders
6
+ * into `dom` and reports its answer through getResponse().
7
+ *
8
+ * This file is the PCI API; interaction.tsx is the UI, store.ts the state. The demo: "what percentage of the circle
9
+ * is coloured?". The circle is drawn from the settings in src/config.json (pie.tsx); the answer is a whole number,
10
+ * reported as { base: { integer: 30 } } (see config.tspci.response in package.json and AUTHORING.md).
11
+ */
12
+ import "preact/debug"; // readable warnings in the browser console while developing
2
13
  import { render } from "preact";
3
- import { IStore, Listener } from "@citolab/preact-store";
4
- import * as ctx from "qtiCustomInteractionContext";
14
+ import { Action, IStore, Listener } from "@citolab/preact-store";
15
+ import { ConfigProperties, IMSpci, IMSpciFactory, PciProperties, QtiInteractionChangedDetail, QtiVariableJSON } from "@citolab/tspci";
16
+ import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
17
+ import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
18
+ import style from "./styles.css"; // Tailwind, compiled to a string, added to the shadow root below
5
19
  import Interaction from "./interaction";
6
- import style from "./styles.css";
7
- import { ConfigProperties, IMSpci, IMSpciFactory, QtiInteractionChangedDetail, QtiVariableJSON } from "@citolab/tspci";
8
- import configProps from "./config.json";
9
20
  import { StateModel, initStore } from "./store";
10
21
 
11
- type PropTypes = typeof configProps;
22
+ // Like every data- attribute, each setting arrives as a string.
23
+ type PropTypes = PciProperties<typeof configProps>;
12
24
 
13
- type PciStoredState = {
14
- state: StateModel;
15
- log: { type: string; payload: unknown; timestamp?: number }[];
16
- };
25
+ /** What getState() saves: the state and the actions that led to it. */
26
+ type PciStoredState = { state: StateModel; log: Action<unknown>[] };
27
+
28
+ /** The response for the text in the input: a whole number, or undefined while there is no answer. */
29
+ function toResponse(value: string): QtiVariableJSON | undefined {
30
+ const trimmed = value.trim();
31
+ if (trimmed === "") return undefined; // nothing answered, nothing reported
32
+ const n = Number(trimmed);
33
+ // One answer, one response: "30", "030" and "30.0" all report 30.
34
+ return Number.isFinite(n) ? { base: { integer: Math.round(n) } } : undefined;
35
+ }
36
+
37
+ /** The input text for a response (setResponse, boundTo) or a correct response (status "solution"). */
38
+ function fromResponse(value: unknown): string {
39
+ if (Array.isArray(value)) return fromResponse(value[0]);
40
+ if (value && typeof value === "object") {
41
+ const base = (value as QtiVariableJSON).base;
42
+ return fromResponse(base?.integer ?? base?.float ?? base?.string);
43
+ }
44
+ return value === undefined || value === null ? "" : String(value);
45
+ }
17
46
 
18
47
  class PciInstance implements IMSpci<PropTypes> {
19
48
  typeIdentifier = "###___PCI_NAME___###";
20
49
  private config: ConfigProperties<PropTypes>;
21
50
  private dom: HTMLElement;
22
51
  private shadowdom: ShadowRoot;
23
- private store: IStore<StateModel>;
24
- private storeListener: Listener<StateModel>;
25
- private previousResponseJson: string = "";
26
- private initialState: StateModel = { input: undefined };
27
- private customValidity: string = "";
52
+ private store: IStore<StateModel> = initStore();
53
+ private customValidity = "";
28
54
 
29
55
  constructor(dom: HTMLElement, config: ConfigProperties<PropTypes>, stateString?: string) {
30
56
  if (!dom) throw new Error("No DOM Element provided");
31
- config.properties = { ...configProps, ...config.properties };
57
+ // The item's data- attributes override the defaults from config.json.
58
+ config.properties = { ...(configProps as unknown as PropTypes), ...config.properties };
32
59
  this.dom = dom;
33
60
  this.config = config;
61
+ // A closed shadow root keeps the item's CSS out and ours (Tailwind) in.
34
62
  this.shadowdom = dom.attachShadow({ mode: "closed" });
35
63
 
36
- let restoreData: PciStoredState | undefined;
64
+ // 1. The state from a previous session (whatever getState() returned).
37
65
  if (stateString) {
38
66
  try {
39
- const parsed = JSON.parse(stateString) as Partial<PciStoredState>;
40
- if (parsed && parsed.state && Array.isArray(parsed.log)) {
41
- restoreData = { state: parsed.state as StateModel, log: parsed.log as PciStoredState["log"] };
42
- }
67
+ const { state, log } = JSON.parse(stateString) as PciStoredState;
68
+ if (typeof state?.value === "string" && Array.isArray(log)) this.store.restoreState(state, log);
43
69
  } catch {
44
- // ignore
70
+ // not ours: start empty
45
71
  }
46
72
  }
73
+ // 2. A response the engine binds to the interaction (boundTo) wins over the state.
74
+ const boundTo = config.boundTo ?? {};
75
+ const bound = boundTo[config.responseIdentifier] ?? (Object.keys(boundTo).length === 1 ? Object.values(boundTo)[0] : undefined);
76
+ if (bound) this.setResponse(bound);
77
+ // 3. Status "solution": show the correct response of the item instead (AUTHORING.md, "Correct answers").
78
+ if (config.status === "solution") this.showCorrectResponse();
47
79
 
48
- this.store = initStore(this.initialState);
49
- if (restoreData) {
50
- this.store.restoreState(restoreData.state, restoreData.log);
51
- }
52
-
53
- const boundResponse =
54
- this.config.boundTo?.[this.config.responseIdentifier] ??
55
- (this.config.boundTo && Object.keys(this.config.boundTo).length === 1
56
- ? this.config.boundTo[Object.keys(this.config.boundTo)[0]]
57
- : undefined);
58
- if (boundResponse) {
59
- this.setResponse?.(boundResponse);
60
- }
80
+ // From here on, tell the engine when the response changes.
81
+ this.previousResponse = JSON.stringify(this.getResponse() ?? null);
82
+ this.store.subscribe(this.onStoreChange);
61
83
 
62
84
  this.render();
63
-
64
- if (this.config.onready) {
65
- this.config.onready(this, this.getState());
66
- }
67
-
68
- let isFirst = true;
69
- this.storeListener = () => {
70
- if (isFirst) {
71
- isFirst = false;
72
- this.previousResponseJson = JSON.stringify(this.getResponse() ?? null);
73
- return;
74
- }
75
- const nextJson = JSON.stringify(this.getResponse() ?? null);
76
- if (nextJson === this.previousResponseJson) return;
77
- this.previousResponseJson = nextJson;
78
-
79
- const detail: QtiInteractionChangedDetail = {
80
- interaction: this,
81
- responseIdentifier: this.config.responseIdentifier,
82
- valid: true,
83
- value: this.getResponse(),
84
- };
85
- const evt = new CustomEvent("qti-interaction-changed", {
86
- bubbles: true,
87
- composed: true,
88
- cancelable: true,
89
- detail,
90
- });
91
- this.dom.dispatchEvent(evt);
92
- };
93
- this.store.subscribe(this.storeListener);
85
+ config.onready?.(this, this.getState());
94
86
  }
95
87
 
96
- private render = () => {
97
- render(null, this.shadowdom);
98
- const css = document.createElement("style");
99
- css.innerHTML = style;
100
- this.shadowdom.appendChild(css);
101
- render(<Interaction config={this.config.properties} dom={this.shadowdom} store={this.store} />, this.shadowdom);
88
+ private previousResponse: string;
89
+ private onStoreChange: Listener<StateModel> = () => {
90
+ const response = JSON.stringify(this.getResponse() ?? null);
91
+ if (response === this.previousResponse) return; // subscribe() calls us right away, and not every action changes it
92
+ this.previousResponse = response;
93
+ // composed lets the event cross the shadow root
94
+ const detail: QtiInteractionChangedDetail = {
95
+ interaction: this,
96
+ responseIdentifier: this.config.responseIdentifier,
97
+ valid: this.checkValidity(),
98
+ value: this.getResponse(),
99
+ };
100
+ this.dom.dispatchEvent(new CustomEvent("qti-interaction-changed", { bubbles: true, composed: true, detail }));
102
101
  };
103
102
 
104
- setResponse = (response: QtiVariableJSON) => {
105
- const next =
106
- typeof response?.base?.integer === "number"
107
- ? response.base.integer
108
- : typeof response?.base?.string === "string"
109
- ? Number(response.base.string)
110
- : undefined;
111
- if (typeof next !== "number" || !Number.isFinite(next)) return;
112
- this.store.dispatch({ type: "SET_INPUT", payload: { input: Math.trunc(next) } });
103
+ private render = () => {
104
+ const readOnly = ["review", "solution", "closed"].includes(this.config.status ?? "interacting");
105
+ render(
106
+ <>
107
+ <style>{style}</style>
108
+ <Interaction config={this.config.properties} store={this.store} readOnly={readOnly} />
109
+ </>,
110
+ this.shadowdom
111
+ );
112
+ this.input?.setCustomValidity(this.customValidity);
113
113
  };
114
114
 
115
- getState = () =>
116
- JSON.stringify({
117
- state: this.store.getState(),
118
- log: this.store.getActions(),
119
- } satisfies PciStoredState);
115
+ private get input() {
116
+ return this.shadowdom.querySelector("input");
117
+ }
120
118
 
121
- getResponse = () => {
122
- const value = this.store.getState().input;
123
- if (value === undefined) return undefined;
124
- return { base: { integer: value } } as QtiVariableJSON;
119
+ /** Only a change is an action: keeps the log (and a replay) clean. */
120
+ private setValue = (value: string) => {
121
+ if (value !== this.store.getState().value) this.store.dispatch({ type: "SET_VALUE", payload: { value } });
125
122
  };
126
123
 
127
- checkValidity = () => {
128
- const input = this.shadowdom.querySelector("input");
129
- if (!input) return undefined;
130
- return input.checkValidity();
124
+ /** Show the correct response the item declares (status "solution"); without one, keep what is there. */
125
+ showCorrectResponse = () => {
126
+ const correct = this.config.responseDeclaration?.correctResponse?.value;
127
+ if (correct !== undefined) this.setValue(fromResponse(correct));
131
128
  };
132
129
 
130
+ // ---- the IMS PCI API ----
131
+
132
+ getResponse = () => toResponse(this.store.getState().value);
133
+
134
+ getState = () => JSON.stringify({ state: this.store.getState(), log: this.store.getActions() } satisfies PciStoredState);
135
+
136
+ setResponse = (response: QtiVariableJSON) => this.setValue(fromResponse(response));
137
+
138
+ checkValidity = () => this.input?.checkValidity() ?? true;
139
+
140
+ reportValidity = () => this.input?.reportValidity() ?? true;
141
+
133
142
  setCustomValidity = (message: string) => {
134
- this.customValidity = message;
135
- const input = this.shadowdom.querySelector("input");
136
- if (input) input.setCustomValidity(message);
143
+ this.customValidity = message ?? "";
144
+ this.input?.setCustomValidity(this.customValidity);
137
145
  };
138
146
 
139
147
  getCustomValidity = () => this.customValidity;
140
148
 
149
+ /** The engine is done with this instance: unsubscribe and unmount (which also runs the components' cleanups). */
141
150
  oncompleted = () => {
142
- if (this.storeListener) {
143
- this.store.unsubscribe(this.storeListener);
144
- }
151
+ this.store.unsubscribe(this.onStoreChange);
145
152
  render(null, this.shadowdom);
146
153
  };
147
154
  }
148
155
 
149
156
  const factory: IMSpciFactory<PropTypes> = {
150
- typeIdentifier: "###___PCI_NAME___###",
157
+ typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
151
158
  getInstance: (dom, config, state) => new PciInstance(dom, config, state),
152
159
  };
153
160
 
154
- const engineContext =
155
- ctx && typeof (ctx as unknown as { register?: unknown }).register === "function"
156
- ? (ctx as unknown as { register: (f: IMSpciFactory<PropTypes>) => void })
157
- : typeof window !== "undefined" && (window as unknown as { qtiCustomInteractionContext?: unknown }).qtiCustomInteractionContext
158
- ? ((window as unknown as { qtiCustomInteractionContext: { register: (f: IMSpciFactory<PropTypes>) => void } })
159
- .qtiCustomInteractionContext as { register: (f: IMSpciFactory<PropTypes>) => void })
160
- : undefined;
161
-
162
- if (engineContext) {
163
- engineContext.register(factory);
164
- }
161
+ // Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
162
+ type Context = { register: (factory: IMSpciFactory<PropTypes>) => void };
163
+ const engineContext = [ctx as unknown as Context, (globalThis as { qtiCustomInteractionContext?: Context }).qtiCustomInteractionContext].find(
164
+ (c) => typeof c?.register === "function"
165
+ );
166
+ engineContext?.register(factory);
165
167
 
166
168
  export default factory;