@citolab/tspci 2.12.1 → 2.14.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.
- package/CHANGELOG.md +28 -0
- package/package.json +14 -4
- package/preset/base/AUTHORING.md +171 -0
- package/preset/base/global.d.ts +12 -2
- package/preset/base/package.json +10 -1
- package/preset/base/src/config.json +37 -5
- package/preset/base/src/styles.css +96 -3
- package/preset/javascript/src/index.js +116 -114
- package/preset/javascript/src/pie.js +46 -0
- package/preset/preact/src/index.tsx +117 -114
- package/preset/preact/src/interaction.tsx +47 -34
- package/preset/preact/src/pie.tsx +46 -0
- package/preset/preact/src/store.ts +12 -14
- package/preset/tailwind/src/styles.css +4 -27
- package/preset/typescript/src/index.ts +123 -127
- package/preset/typescript/src/pie.ts +51 -0
- package/public/index.html +196 -51
- package/readme.md +107 -17
- package/scripts/test-init-local.sh +28 -0
- package/src/authoring-frame.js +85 -0
- package/src/authoring.js +114 -0
- package/src/rollup-plugin-authoring.js +32 -0
- package/src/rollup-plugin-postcss-string.js +37 -1
- package/src/rollup.config.dev.js +18 -1
- package/src/rollup.config.js +2 -0
- package/tspci-development.png +0 -0
- package/types/index.d.ts +136 -0
- package/preset/base/src/assets/procenten.png +0 -0
|
@@ -1,167 +1,169 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
79
|
+
get input() {
|
|
80
|
+
return this.shadowdom.querySelector("input");
|
|
55
81
|
}
|
|
56
82
|
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
112
|
+
valid: this.checkValidity(),
|
|
64
113
|
value: this.getResponse(),
|
|
65
114
|
};
|
|
66
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return input.checkValidity();
|
|
138
|
+
setResponse = (response) => {
|
|
139
|
+
this.state = { value: fromResponse(response) };
|
|
140
|
+
this.render();
|
|
126
141
|
};
|
|
127
142
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
136
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, """);
|
|
@@ -1,166 +1,169 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
|
7
|
-
import { ConfigProperties, IMSpci, IMSpciFactory, QtiInteractionChangedDetail, QtiVariableJSON } from "@citolab/tspci";
|
|
8
|
-
import configProps from "./config.json";
|
|
9
|
-
import { StateModel, initStore } from "./store";
|
|
20
|
+
import { Actions, StateModel, initStore } from "./store";
|
|
10
21
|
|
|
11
|
-
|
|
22
|
+
// Like every data- attribute, each setting arrives as a string.
|
|
23
|
+
type PropTypes = PciProperties<typeof configProps>;
|
|
12
24
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
25
|
-
private previousResponseJson: string = "";
|
|
26
|
-
private initialState: StateModel = { input: undefined };
|
|
27
|
-
private customValidity: string = "";
|
|
52
|
+
private store: IStore<StateModel, Actions> = 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
|
-
|
|
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
|
-
|
|
64
|
+
// 1. The state from a previous session (whatever getState() returned).
|
|
37
65
|
if (stateString) {
|
|
38
66
|
try {
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
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
|
-
//
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.unsubscribe = 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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.
|
|
101
|
-
|
|
88
|
+
private previousResponse: string;
|
|
89
|
+
private unsubscribe = () => {};
|
|
90
|
+
private onStoreChange: Listener<StateModel> = () => {
|
|
91
|
+
const response = JSON.stringify(this.getResponse() ?? null);
|
|
92
|
+
if (response === this.previousResponse) return; // subscribe() calls us right away, and not every action changes it
|
|
93
|
+
this.previousResponse = response;
|
|
94
|
+
// composed lets the event cross the shadow root
|
|
95
|
+
const detail: QtiInteractionChangedDetail = {
|
|
96
|
+
interaction: this,
|
|
97
|
+
responseIdentifier: this.config.responseIdentifier,
|
|
98
|
+
valid: this.checkValidity(),
|
|
99
|
+
value: this.getResponse(),
|
|
100
|
+
};
|
|
101
|
+
this.dom.dispatchEvent(new CustomEvent("qti-interaction-changed", { bubbles: true, composed: true, detail }));
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
private render = () => {
|
|
105
|
+
const readOnly = ["review", "solution", "closed"].includes(this.config.status ?? "interacting");
|
|
106
|
+
render(
|
|
107
|
+
<>
|
|
108
|
+
<style>{style}</style>
|
|
109
|
+
<Interaction config={this.config.properties} store={this.store} readOnly={readOnly} />
|
|
110
|
+
</>,
|
|
111
|
+
this.shadowdom
|
|
112
|
+
);
|
|
113
|
+
this.input?.setCustomValidity(this.customValidity);
|
|
113
114
|
};
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
log: this.store.getActions(),
|
|
119
|
-
} satisfies PciStoredState);
|
|
116
|
+
private get input() {
|
|
117
|
+
return this.shadowdom.querySelector("input");
|
|
118
|
+
}
|
|
120
119
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (value
|
|
124
|
-
return { base: { integer: value } } as QtiVariableJSON;
|
|
120
|
+
/** Only a change is an action: keeps the log (and a replay) clean. */
|
|
121
|
+
private setValue = (value: string) => {
|
|
122
|
+
if (value !== this.store.getState().value) this.store.dispatch({ type: "SET_VALUE", payload: { value } });
|
|
125
123
|
};
|
|
126
124
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
/** Show the correct response the item declares (status "solution"); without one, keep what is there. */
|
|
126
|
+
showCorrectResponse = () => {
|
|
127
|
+
const correct = this.config.responseDeclaration?.correctResponse?.value;
|
|
128
|
+
if (correct !== undefined) this.setValue(fromResponse(correct));
|
|
131
129
|
};
|
|
132
130
|
|
|
131
|
+
// ---- the IMS PCI API ----
|
|
132
|
+
|
|
133
|
+
getResponse = () => toResponse(this.store.getState().value);
|
|
134
|
+
|
|
135
|
+
getState = () => JSON.stringify({ state: this.store.getState(), log: this.store.getActions() } satisfies PciStoredState);
|
|
136
|
+
|
|
137
|
+
setResponse = (response: QtiVariableJSON) => this.setValue(fromResponse(response));
|
|
138
|
+
|
|
139
|
+
checkValidity = () => this.input?.checkValidity() ?? true;
|
|
140
|
+
|
|
141
|
+
reportValidity = () => this.input?.reportValidity() ?? true;
|
|
142
|
+
|
|
133
143
|
setCustomValidity = (message: string) => {
|
|
134
|
-
this.customValidity = message;
|
|
135
|
-
|
|
136
|
-
if (input) input.setCustomValidity(message);
|
|
144
|
+
this.customValidity = message ?? "";
|
|
145
|
+
this.input?.setCustomValidity(this.customValidity);
|
|
137
146
|
};
|
|
138
147
|
|
|
139
148
|
getCustomValidity = () => this.customValidity;
|
|
140
149
|
|
|
150
|
+
/** The engine is done with this instance: unsubscribe and unmount (which also runs the components' cleanups). */
|
|
141
151
|
oncompleted = () => {
|
|
142
|
-
|
|
143
|
-
this.store.unsubscribe(this.storeListener);
|
|
144
|
-
}
|
|
152
|
+
this.unsubscribe();
|
|
145
153
|
render(null, this.shadowdom);
|
|
146
154
|
};
|
|
147
155
|
}
|
|
148
156
|
|
|
149
157
|
const factory: IMSpciFactory<PropTypes> = {
|
|
150
|
-
typeIdentifier: "###___PCI_NAME___###",
|
|
158
|
+
typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
|
|
151
159
|
getInstance: (dom, config, state) => new PciInstance(dom, config, state),
|
|
152
160
|
};
|
|
153
161
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
: undefined;
|
|
161
|
-
|
|
162
|
-
if (engineContext) {
|
|
163
|
-
engineContext.register(factory);
|
|
164
|
-
}
|
|
162
|
+
// Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
|
|
163
|
+
type Context = { register: (factory: IMSpciFactory<PropTypes>) => void };
|
|
164
|
+
const engineContext = [ctx as unknown as Context, (globalThis as { qtiCustomInteractionContext?: Context }).qtiCustomInteractionContext].find(
|
|
165
|
+
(c) => typeof c?.register === "function"
|
|
166
|
+
);
|
|
167
|
+
engineContext?.register(factory);
|
|
165
168
|
|
|
166
169
|
export default factory;
|