@citolab/tspci 2.6.5 → 2.7.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,49 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [2.7.1](/compare/v2.7.0...v2.7.1) (2026-02-09)
7
+
8
+ **Note:** Version bump only for package @citolab/tspci
9
+
10
+
11
+
12
+
13
+
14
+ # [2.7.0](/compare/v2.6.8...v2.7.0) (2026-02-09)
15
+
16
+
17
+ ### Features
18
+
19
+ * add QTI 3 package support with bundling functionality 5a2951a
20
+
21
+
22
+
23
+
24
+
25
+ ## [2.6.8](/compare/v2.6.7...v2.6.8) (2026-02-02)
26
+
27
+ **Note:** Version bump only for package @citolab/tspci
28
+
29
+
30
+
31
+
32
+
33
+ ## [2.6.7](/compare/v2.6.6...v2.6.7) (2026-02-02)
34
+
35
+ **Note:** Version bump only for package @citolab/tspci
36
+
37
+
38
+
39
+
40
+
41
+ ## [2.6.6](/compare/v2.6.5...v2.6.6) (2026-02-02)
42
+
43
+ **Note:** Version bump only for package @citolab/tspci
44
+
45
+
46
+
47
+
48
+
6
49
  ## [2.6.5](/compare/v2.6.3-beta.3...v2.6.5) (2025-04-16)
7
50
 
8
51
  **Note:** Version bump only for package @citolab/tspci
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citolab/tspci",
3
- "version": "2.6.5",
3
+ "version": "2.7.1",
4
4
  "description": "an opiniated typescript ims-pci builder with tao integration",
5
5
  "author": "patrick <patrick.deklein@cito.nl>",
6
6
  "repository": "@citolab/tspci",
@@ -14,42 +14,33 @@
14
14
  "type": "module",
15
15
  "types": "types/index.d.ts",
16
16
  "dependencies": {
17
- "@rollup/plugin-alias": "^5.1.1",
18
- "@rollup/plugin-babel": "^6.0.4",
19
- "@rollup/plugin-commonjs": "^28.0.0",
17
+ "@compodoc/live-server": "^1.2.3",
18
+ "@rollup/plugin-alias": "^6.0.0",
19
+ "@rollup/plugin-commonjs": "^29.0.0",
20
20
  "@rollup/plugin-image": "^3.0.3",
21
21
  "@rollup/plugin-json": "^6.1.0",
22
- "@rollup/plugin-node-resolve": "^15.3.0",
23
- "@rollup/plugin-replace": "^6.0.1",
22
+ "@rollup/plugin-node-resolve": "^16.0.3",
23
+ "@rollup/plugin-replace": "^6.0.3",
24
24
  "@rollup/plugin-terser": "^0.4.4",
25
- "@rollup/plugin-typescript": "^12.1.0",
25
+ "@rollup/plugin-typescript": "^12.3.0",
26
26
  "@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
27
- "@tailwindcss/postcss": "^4.0.3",
28
- "@testing-library/dom": "^10.4.0",
29
- "@types/node": "^22.5.5",
30
- "@typescript-eslint/eslint-plugin": "^8.9.0",
31
- "@typescript-eslint/parser": "^8.9.0",
32
- "autoprefixer": "10.4.20",
33
- "chalk": "5.3.0",
34
- "chokidar": "^4.0.0",
27
+ "@tailwindcss/postcss": "^4.1.18",
28
+ "@types/node": "^25.1.0",
29
+ "@typescript-eslint/eslint-plugin": "^8.54.0",
30
+ "@typescript-eslint/parser": "^8.54.0",
31
+ "autoprefixer": "10.4.23",
32
+ "chalk": "5.6.2",
33
+ "chokidar": "^5.0.0",
35
34
  "clear": "0.1.0",
36
- "figlet": "1.8.0",
37
- "inquirer": "12.0.0",
38
- "live-server": "^1.2.2",
39
- "postcss": "^8.4.47",
40
- "postcss-import": "^16.1.0",
41
- "rollup": "^4.34.1",
35
+ "figlet": "1.10.0",
36
+ "inquirer": "13.2.2",
37
+ "postcss": "^8.5.6",
38
+ "postcss-import": "^16.1.1",
39
+ "rollup": "^4.57.1",
42
40
  "rollup-plugin-copy": "^3.5.0",
43
- "rollup-plugin-live-server": "^2.0.0",
44
41
  "rollup-plugin-livereload": "^2.0.5",
45
- "rollup-plugin-node-builtins": "^2.1.2",
46
- "rollup-plugin-node-globals": "^1.4.0",
47
42
  "rollup-plugin-postcss": "^4.0.2",
48
- "rollup-plugin-string": "^3.0.0",
49
- "tailwindcss": "^4.1.3"
43
+ "tailwindcss": "^4.1.18"
50
44
  },
51
- "resolutions": {
52
- "chokidar": "^4.0.0"
53
- },
54
- "gitHead": "06a99429f9555a99fe3f877fb55c4bcbb9282b8a"
45
+ "gitHead": "1481d1ed65ab57b07bf6a12ae9f7e402105d2964"
55
46
  }
@@ -2,144 +2,165 @@ import * as ctx from "qtiCustomInteractionContext";
2
2
  import configProps from "./config.json";
3
3
  import style from "./styles.css"; // import and bundle this style file ( you can use tailwind and nested css )
4
4
  import procenten from "./assets/procenten.png"; // image types are bundled inside the js
5
- class Pci {
6
- /**
7
- * @constructor
8
- * @param {string} typeIdentifier - The unique identifier for this PCI type.
9
- */
10
- typeIdentifier; // typeIdentifier is mandatory for all PCI's
11
- config; // reference to the interface of the config object which you get when getInstance is called by the player
12
- state; // keep a reference to the state
13
- shadowdom; // Not mandatory, but its wise to create a shadowroot
14
-
15
- constructor() {
16
- /**
17
- * @type {string}
18
- * @description The unique identifier for this PCI type.
19
- */
20
- this.typeIdentifier = "###___PCI_NAME___###"; // typeIdentifier is mandatory for all PCI's
21
- if (ctx) {
22
- ctx.register(this); // we assume the qtiCustomInteractionContext is avaible due to the import above
23
- }
24
- }
5
+ class PciInstance {
6
+ typeIdentifier = "###___PCI_NAME___###";
25
7
 
26
- /**
27
- * @method getInstance
28
- * @description Creates a new instance of this portable custom interaction.
29
- * This method will be called by the qtiCustomInteractionContext.
30
- *
31
- * @param {HTMLElement} dom - The DOM element to which this PCI is added.
32
- * @param {Object<ConfigProperties>} config - The configuration to apply to this PCI.
33
- * @param {string} state - A previously saved state to restore the PCI instance.
34
- * This state must have been obtained from a prior call to getState for an instance of the same typeIdentifier.
35
- */
36
- getInstance = (dom, config, state) => {
37
- config.properties = { ...configProps, ...config.properties }; // merge current props with incoming
8
+ constructor(dom, config, stateString) {
9
+ if (!dom) throw new Error("No DOM Element provided");
10
+ config.properties = { ...configProps, ...config.properties };
11
+ this.dom = dom;
38
12
  this.config = config;
39
- this.state = state ? state : "";
40
-
41
13
  this.shadowdom = dom.attachShadow({ mode: "closed" });
42
- this.render();
43
-
44
- if (this.config.boundTo && Object.keys(this.config.boundTo).length > 0) {
45
- const responseIdentifier = Object.keys(this.config.boundTo)[0];
46
- const response = this.config.boundTo[responseIdentifier];
47
- this.setResponse(response);
14
+
15
+ this.state = { value: "", touched: false, customValidity: "" };
16
+ if (stateString) {
17
+ try {
18
+ 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 : "";
23
+ } catch {
24
+ this.state.value = String(stateString);
25
+ this.state.touched = false;
26
+ }
48
27
  }
49
28
 
50
- const event = {
51
- interaction: this,
52
- responseIdentifier: this.config.responseIdentifier,
53
- valid: true,
54
- value: this.getResponse(),
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
+ }
39
+
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();
55
48
  };
56
- // dispatch a custom event to notify the Delivery System that the interaction has changed
57
- const interactionChangedEvent = new CustomEvent("qtiInteractionChanged", {
58
- detail: event,
59
- });
60
- this.shadowdom.dispatchEvent(interactionChangedEvent);
61
- };
62
49
 
63
- interactionChanged = () => {
64
- const event = {
50
+ this.render();
51
+
52
+ if (this.config.onready) {
53
+ this.config.onready(this, this.getState());
54
+ }
55
+ }
56
+
57
+ getInputEl = () => this.shadowdom.querySelector("input[type='number']");
58
+
59
+ dispatchInteractionChanged = () => {
60
+ const detail = {
65
61
  interaction: this,
66
62
  responseIdentifier: this.config.responseIdentifier,
67
- valid: true,
63
+ valid: this.checkValidity?.() ?? true,
68
64
  value: this.getResponse(),
69
65
  };
70
- // dispatch a custom event to notify the Delivery System that the interaction has changed
71
- const interactionChangedEvent = new CustomEvent("qti-interaction-changed", {
72
- detail: event,
66
+ const event = new CustomEvent("qti-interaction-changed", {
67
+ bubbles: true,
68
+ cancelable: true,
69
+ detail,
73
70
  });
74
- this.dom.dispatchEvent(interactionChangedEvent);
71
+ this.dom.dispatchEvent(event);
75
72
  };
76
73
 
77
- setResponse = (response) => {
78
- // TODO restore the response
79
- // Get the actual value by the variable type like:
80
- // - response?.base?.string or response?.base?.integer
81
- }
82
-
83
74
  render = () => {
84
- this.shadowdom.addEventListener("change", (e) => {
85
- const value = e.target.value;
86
- this.state = value;
87
- this.interactionChanged();
88
- });
89
75
  this.shadowdom.innerHTML = `<div class="pci-container">
90
76
  <h1>${this.config.properties.title}</h1>
91
77
  <div class="body">
92
- <img width="${+this.config.properties.width}" height="${+this.config.properties.height}" src="${procenten}" />
78
+ <img width="${+this.config.properties.width}" height="${+this.config
79
+ .properties.height}" src="${procenten}" />
93
80
  </div>
94
81
  <div class="interaction">
95
- <label for="tentacles">${this.config.properties.prompt}</label>
96
- <input type="number" value="${this.state}" min="0" max="100">%
82
+ <label>${this.config.properties.prompt}</label>
83
+ <input type="number" value="${this.state.value}" min="0" max="100">%
97
84
  </div>
98
85
  </div>`;
99
86
  const css = document.createElement("style");
100
87
  css.innerHTML = style;
101
88
  this.shadowdom.appendChild(css);
89
+
90
+ const input = this.getInputEl();
91
+ if (input) {
92
+ if (this.state.customValidity) input.setCustomValidity(this.state.customValidity);
93
+ input.addEventListener("input", this.onChange);
94
+ input.addEventListener("change", this.onChange);
95
+ }
96
+ };
97
+
98
+ setResponse = (response) => {
99
+ let next = "";
100
+ if (response && response.base) {
101
+ if (typeof response.base.integer === "number") next = String(response.base.integer);
102
+ else if (typeof response.base.string === "string") next = response.base.string;
103
+ }
104
+ this.state.value = next;
105
+ this.state.touched = true;
106
+ const input = this.getInputEl();
107
+ if (input) input.value = next;
102
108
  };
103
109
 
104
- /**
105
- * @method getResponse
106
- * @description Retrieves the value to assign to the bound QTI response variable.
107
- *
108
- * @returns {Object} - The response value.
109
- */
110
110
  getResponse = () => {
111
- const response = this.state === null || this.state === undefined ? undefined : { base: { integer: this.state } };
112
- return response;
111
+ if (!this.state.touched) return undefined;
112
+ const trimmed = String(this.state.value == null ? "" : this.state.value).trim();
113
+ if (!trimmed) return undefined;
114
+ const asNumber = Number(trimmed);
115
+ if (!Number.isFinite(asNumber)) return undefined;
116
+ return { base: { integer: Math.trunc(asNumber) } };
113
117
  };
114
118
 
115
- /**
116
- * @method getState
117
- * @description Retrieves the current state of this PCI. This state can be passed to getInstance to restore the instance later.
118
- *
119
- * @returns {string} - The current state of the PCI.
120
- */
121
- getState = () => this.state.toString();
122
-
123
-
124
- /**
125
- * @method resetResponse
126
- * @description Can set the response to a previously stored state.
127
- * Also a way in TAO to show the correct response in the score-editor.
128
- * @param {QtiVariableJSON} response - The response to set.
129
- * @returns {void}
130
- * @example
131
- * const response = {
132
- * base: {
133
- * integer: 100
134
- * }
135
- * };
136
- */
137
- setResponse = (response) => { }
138
- /**
139
- * @method oncompleted
140
- * @description called when the PCI is completed.
141
- */
142
- oncompleted = () => {};
119
+ getState = () => JSON.stringify(this.state);
120
+
121
+ checkValidity = () => {
122
+ const input = this.getInputEl();
123
+ if (!input) return undefined;
124
+ return input.checkValidity();
125
+ };
126
+
127
+ reportValidity = () => {
128
+ const input = this.getInputEl();
129
+ if (!input) return undefined;
130
+ return input.reportValidity();
131
+ };
132
+
133
+ setCustomValidity = (message) => {
134
+ this.state.customValidity = message == null ? "" : String(message);
135
+ const input = this.getInputEl();
136
+ if (input) input.setCustomValidity(this.state.customValidity);
137
+ };
138
+
139
+ getCustomValidity = () => this.state.customValidity ?? "";
140
+
141
+ oncompleted = () => {
142
+ const input = this.getInputEl();
143
+ if (input) {
144
+ input.removeEventListener("input", this.onChange);
145
+ input.removeEventListener("change", this.onChange);
146
+ }
147
+ };
148
+ }
149
+
150
+ const factory = {
151
+ typeIdentifier: "###___PCI_NAME___###",
152
+ getInstance: (dom, config, state) => new PciInstance(dom, config, state),
153
+ };
154
+
155
+ const engineContext =
156
+ ctx && typeof ctx.register === "function"
157
+ ? ctx
158
+ : typeof window !== "undefined" && window.qtiCustomInteractionContext
159
+ ? window.qtiCustomInteractionContext
160
+ : undefined;
161
+
162
+ if (engineContext && typeof engineContext.register === "function") {
163
+ engineContext.register(factory);
143
164
  }
144
165
 
145
- export default new Pci();
166
+ export default factory;
@@ -1,77 +1,97 @@
1
1
  import "preact/debug";
2
2
  import { render } from "preact";
3
- import { IStore } from "@citolab/preact-store";
3
+ import { IStore, Listener } from "@citolab/preact-store";
4
4
  import * as ctx from "qtiCustomInteractionContext";
5
5
  import Interaction from "./interaction";
6
6
  import style from "./styles.css";
7
- import { ConfigProperties, IMSpci, QtiInteractionChangedDetail, QtiVariableJSON } from "@citolab/tspci";
7
+ import { ConfigProperties, IMSpci, IMSpciFactory, QtiInteractionChangedDetail, QtiVariableJSON } from "@citolab/tspci";
8
8
  import configProps from "./config.json";
9
9
  import { StateModel, initStore } from "./store";
10
10
 
11
11
  type PropTypes = typeof configProps;
12
12
 
13
- class App implements IMSpci<PropTypes> {
14
- typeIdentifier = "###___PCI_NAME___###"; // typeIdentifier is mandatory for all PCI's
15
- config: ConfigProperties<PropTypes>; // reference to the interface of the config object which you get when getInstance is called by the player
16
- state: string; // keep a reference to the state
17
- shadowdom: ShadowRoot; // Not mandatory, but its wise to create a shadowroot
18
- store: IStore<StateModel>;
19
-
20
- private logActions: { type: string; payload: unknown }[] = []; // optional logActions
21
- private initialState: StateModel = { input: undefined }; // optional initial state
22
- constructor() {
23
- if (ctx) {
24
- ctx.register(this);
25
- }
26
- }
13
+ type PciStoredState = {
14
+ state: StateModel;
15
+ log: { type: string; payload: unknown; timestamp?: number }[];
16
+ };
17
+
18
+ class PciInstance implements IMSpci<PropTypes> {
19
+ typeIdentifier = "###___PCI_NAME___###";
20
+ private config: ConfigProperties<PropTypes>;
21
+ private dom: HTMLElement;
22
+ private shadowdom: ShadowRoot;
23
+ private store: IStore<StateModel>;
24
+ private storeListener: Listener<StateModel>;
25
+ private previousResponseJson: string = "";
26
+ private initialState: StateModel = { input: undefined };
27
27
 
28
- getInstance = (dom: HTMLElement, config: ConfigProperties<PropTypes>, stateString: string) => {
29
- config.properties = { ...configProps, ...config.properties }; // merge current props with incoming
28
+ constructor(dom: HTMLElement, config: ConfigProperties<PropTypes>, stateString?: string) {
29
+ if (!dom) throw new Error("No DOM Element provided");
30
+ config.properties = { ...configProps, ...config.properties };
31
+ this.dom = dom;
30
32
  this.config = config;
31
- if (!dom) {
32
- throw new Error("No DOM Element provided");
33
+ this.shadowdom = dom.attachShadow({ mode: "closed" });
34
+
35
+ let restoreData: PciStoredState | undefined;
36
+ if (stateString) {
37
+ try {
38
+ const parsed = JSON.parse(stateString) as Partial<PciStoredState>;
39
+ if (parsed && parsed.state && Array.isArray(parsed.log)) {
40
+ restoreData = { state: parsed.state as StateModel, log: parsed.log as PciStoredState["log"] };
41
+ }
42
+ } catch {
43
+ // ignore
44
+ }
33
45
  }
34
- this.logActions = stateString ? JSON.parse(stateString).log : [];
46
+
35
47
  this.store = initStore(this.initialState);
36
- try {
37
- const restoredState = stateString ? JSON.parse(stateString) : null;
38
- if (restoredState) {
39
- this.store.restoreState(restoredState?.state, this.logActions);
40
- }
41
- } catch (error) {
42
- console.log(error);
48
+ if (restoreData) {
49
+ this.store.restoreState(restoreData.state, restoreData.log);
43
50
  }
44
51
 
45
- this.shadowdom = dom.attachShadow({ mode: "closed" });
52
+ const boundResponse =
53
+ this.config.boundTo?.[this.config.responseIdentifier] ??
54
+ (this.config.boundTo && Object.keys(this.config.boundTo).length === 1
55
+ ? this.config.boundTo[Object.keys(this.config.boundTo)[0]]
56
+ : undefined);
57
+ if (boundResponse) {
58
+ this.setResponse?.(boundResponse);
59
+ }
46
60
 
47
- this.store.subscribe(() => {
48
- const event: QtiInteractionChangedDetail = {
61
+ this.render();
62
+
63
+ if (this.config.onready) {
64
+ this.config.onready(this, this.getState());
65
+ }
66
+
67
+ let isFirst = true;
68
+ this.storeListener = () => {
69
+ if (isFirst) {
70
+ isFirst = false;
71
+ this.previousResponseJson = JSON.stringify(this.getResponse() ?? null);
72
+ return;
73
+ }
74
+ const nextJson = JSON.stringify(this.getResponse() ?? null);
75
+ if (nextJson === this.previousResponseJson) return;
76
+ this.previousResponseJson = nextJson;
77
+
78
+ const detail: QtiInteractionChangedDetail = {
49
79
  interaction: this,
50
80
  responseIdentifier: this.config.responseIdentifier,
51
81
  valid: true,
52
82
  value: this.getResponse(),
53
83
  };
54
- // dispatch a custom event to notify the Delivery System that the interaction has changed
55
- const interactionChangedEvent = new CustomEvent("qti-interaction-changed", {
56
- detail: event,
84
+ const evt = new CustomEvent("qti-interaction-changed", {
85
+ bubbles: true,
86
+ cancelable: true,
87
+ detail,
57
88
  });
58
- dom.dispatchEvent(interactionChangedEvent);
59
- });
60
-
61
- this.render();
62
-
63
- if (this.config.boundTo && Object.keys(this.config.boundTo).length > 0) {
64
- const responseIdentifier = Object.keys(this.config.boundTo)[0];
65
- const response = this.config.boundTo[responseIdentifier];
66
- this.setResponse(response);
67
- }
68
-
69
- if (this.config.onready) {
70
- this.config.onready(this);
71
- }
72
- };
89
+ this.dom.dispatchEvent(evt);
90
+ };
91
+ this.store.subscribe(this.storeListener);
92
+ }
73
93
 
74
- render = () => {
94
+ private render = () => {
75
95
  render(null, this.shadowdom);
76
96
  const css = document.createElement("style");
77
97
  css.innerHTML = style;
@@ -80,28 +100,51 @@ class App implements IMSpci<PropTypes> {
80
100
  };
81
101
 
82
102
  setResponse = (response: QtiVariableJSON) => {
83
- // TODO restore the response
84
- // Get the actual value by the variable type like:
85
- // - response?.base?.string or response?.base?.integer
86
- }
87
-
103
+ const next =
104
+ typeof response?.base?.integer === "number"
105
+ ? response.base.integer
106
+ : typeof response?.base?.string === "string"
107
+ ? Number(response.base.string)
108
+ : undefined;
109
+ if (typeof next !== "number" || !Number.isFinite(next)) return;
110
+ this.store.dispatch({ type: "SET_INPUT", payload: { input: Math.trunc(next) } });
111
+ };
88
112
 
89
113
  getState = () =>
90
114
  JSON.stringify({
91
115
  state: this.store.getState(),
92
116
  log: this.store.getActions(),
93
- });
117
+ } satisfies PciStoredState);
94
118
 
95
119
  getResponse = () => {
96
- const state = this.store?.getState()?.input || undefined;
97
- if (state === undefined) return undefined;
98
- return {
99
- base: {
100
- integer: this.store.getState().input,
101
- },
102
- } as QtiVariableJSON;
103
- }
104
- oncompleted = () => { };
120
+ const value = this.store.getState().input;
121
+ if (value === undefined) return undefined;
122
+ return { base: { integer: value } } as QtiVariableJSON;
123
+ };
124
+
125
+ oncompleted = () => {
126
+ if (this.storeListener) {
127
+ this.store.unsubscribe(this.storeListener);
128
+ }
129
+ render(null, this.shadowdom);
130
+ };
131
+ }
132
+
133
+ const factory: IMSpciFactory<PropTypes> = {
134
+ typeIdentifier: "###___PCI_NAME___###",
135
+ getInstance: (dom, config, state) => new PciInstance(dom, config, state),
136
+ };
137
+
138
+ const engineContext =
139
+ ctx && typeof (ctx as unknown as { register?: unknown }).register === "function"
140
+ ? (ctx as unknown as { register: (f: IMSpciFactory<PropTypes>) => void })
141
+ : typeof window !== "undefined" && (window as unknown as { qtiCustomInteractionContext?: unknown }).qtiCustomInteractionContext
142
+ ? ((window as unknown as { qtiCustomInteractionContext: { register: (f: IMSpciFactory<PropTypes>) => void } })
143
+ .qtiCustomInteractionContext as { register: (f: IMSpciFactory<PropTypes>) => void })
144
+ : undefined;
145
+
146
+ if (engineContext) {
147
+ engineContext.register(factory);
105
148
  }
106
149
 
107
- export default new App();
150
+ export default factory;
@@ -1,4 +1,10 @@
1
- import { ConfigProperties, IMSpci, QtiInteractionChangedDetail, QtiVariableJSON } from "@citolab/tspci";
1
+ import {
2
+ ConfigProperties,
3
+ IMSpci,
4
+ IMSpciFactory,
5
+ QtiInteractionChangedDetail,
6
+ QtiVariableJSON,
7
+ } from "@citolab/tspci";
2
8
  import * as ctx from "qtiCustomInteractionContext";
3
9
  import configProps from "./config.json";
4
10
  import Interaction from "./interaction"; // import and bundle this interaction file
@@ -8,94 +14,177 @@ import procenten from "./assets/procenten.png"; // image types are bundled insid
8
14
  // Configuration
9
15
  type PropTypes = typeof configProps;
10
16
 
11
- class Pci implements IMSpci<PropTypes> {
12
- typeIdentifier = "###___PCI_NAME___###"; // typeIdentifier is mandatory for all PCI's
13
- config: ConfigProperties<PropTypes>; // reference to the interface of the config object which you get when getInstance is called by the player
14
- state: string; // keep a reference to the state
15
- shadowdom: ShadowRoot | Element; // Not mandatory, but its wise to create a shadowroot
16
- dom: HTMLElement;
17
+ type PciState = {
18
+ value: string;
19
+ touched: boolean;
20
+ customValidity?: string;
21
+ };
17
22
 
18
- constructor() {
19
- if (ctx) {
20
- ctx.register(this); // we assume the qtiCustomInteractionContext is avaible due to the import above
23
+ class PciInstance implements IMSpci<PropTypes> {
24
+ typeIdentifier = "###___PCI_NAME___###";
25
+ private config: ConfigProperties<PropTypes>;
26
+ private dom: HTMLElement;
27
+ private shadowdom: ShadowRoot;
28
+ private state: PciState;
29
+ private onChange: (e: Event) => void;
30
+
31
+ constructor(dom: HTMLElement, config: ConfigProperties<PropTypes>, stateString?: string) {
32
+ if (!dom) throw new Error("No DOM Element provided");
33
+ config.properties = { ...configProps, ...config.properties };
34
+ this.dom = dom;
35
+ this.config = config;
36
+ this.shadowdom = dom.attachShadow({ mode: "closed" });
37
+
38
+ this.state = { value: "", touched: false };
39
+ if (stateString) {
40
+ try {
41
+ const parsed = JSON.parse(stateString) as Partial<PciState>;
42
+ this.state = {
43
+ value: typeof parsed.value === "string" ? parsed.value : "",
44
+ touched: parsed.touched === true,
45
+ customValidity: typeof parsed.customValidity === "string" ? parsed.customValidity : undefined,
46
+ };
47
+ } catch {
48
+ this.state = { value: typeof stateString === "string" ? stateString : "", touched: false };
49
+ }
21
50
  }
22
- }
23
51
 
24
- // First in the lifecycle of a PCI, this method is called with the domElement ( usually qti-interaction-markup ) where we can add our dom tree.
25
- // config is the configuration object which has an onready
26
- getInstance = (dom: HTMLElement, config: ConfigProperties<PropTypes>, state: string) => {
27
- config.properties = { ...configProps, ...config.properties }; // merge current props with incoming
28
- this.config = config;
29
- this.state = state ? state : "";
30
- if (!dom) {
31
- throw new Error("No DOM Element provided");
52
+ const boundResponse =
53
+ this.config.boundTo?.[this.config.responseIdentifier] ??
54
+ (this.config.boundTo && Object.keys(this.config.boundTo).length === 1
55
+ ? this.config.boundTo[Object.keys(this.config.boundTo)[0]]
56
+ : undefined);
57
+ if (boundResponse && (boundResponse.base || boundResponse.list || boundResponse.record)) {
58
+ this.setResponse?.(boundResponse);
32
59
  }
33
- this.shadowdom = dom.attachShadow({ mode: "closed" });
34
- this.dom = dom;
60
+
61
+ this.onChange = (e: Event) => {
62
+ const target = e.target as HTMLInputElement | null;
63
+ if (!target) return;
64
+ const nextValue = target.value ?? "";
65
+ if (nextValue === this.state.value && this.state.touched) return;
66
+ this.state = { ...this.state, value: nextValue, touched: true };
67
+ this.dispatchInteractionChanged();
68
+ };
35
69
 
36
70
  this.render();
37
71
 
38
- if (this.config.boundTo && Object.keys(this.config.boundTo).length > 0) {
39
- const responseIdentifier = Object.keys(this.config.boundTo)[0];
40
- const response = this.config.boundTo[responseIdentifier];
41
- this.setResponse(response);
72
+ if (this.config.onready) {
73
+ this.config.onready(this, this.getState());
42
74
  }
75
+ }
43
76
 
44
- config.onready(this);
45
- };
46
-
47
- setResponse = (response: QtiVariableJSON) => {
48
- // TODO restore the response
49
- // Get the actual value by the variable type like:
50
- // - response?.base?.string or response?.base?.integer
77
+ private getInputEl(): HTMLInputElement | null {
78
+ return this.shadowdom.querySelector("input[type='number']");
51
79
  }
52
80
 
53
- private interactionChanged = () => {
54
- const event: QtiInteractionChangedDetail = {
81
+ private dispatchInteractionChanged() {
82
+ const detail: QtiInteractionChangedDetail = {
55
83
  interaction: this,
56
84
  responseIdentifier: this.config.responseIdentifier,
57
- valid: true,
85
+ valid: this.checkValidity?.() ?? true,
58
86
  value: this.getResponse(),
59
87
  };
60
- // dispatch a custom event to notify the Delivery System that the interaction has changed
61
- const interactionChangedEvent = new CustomEvent("qti-interaction-changed", {
62
- detail: event,
88
+ const event = new CustomEvent("qti-interaction-changed", {
89
+ bubbles: true,
90
+ cancelable: true,
91
+ detail,
63
92
  });
64
- this.dom.dispatchEvent(interactionChangedEvent);
65
- };
93
+ this.dom.dispatchEvent(event);
94
+ }
66
95
 
67
- private render = () => {
68
- this.shadowdom.addEventListener("change", (e: Event) => {
69
- const value = (e.target as HTMLInputElement)?.value || "";
70
- this.state = value;
71
- this.interactionChanged();
72
- });
96
+ private render() {
73
97
  this.shadowdom.innerHTML = `<div class="pci-container">
74
98
  <h1>${this.config.properties.title}</h1>
75
99
  <div class="body">
76
- <img width="${+this.config.properties.width}" height="${+this.config.properties.height}" src="${procenten}" />
100
+ <img width="${+this.config.properties.width}" height="${+this.config
101
+ .properties.height}" src="${procenten}" />
77
102
  </div>
78
103
  <div class="interaction">
79
- <label for="tentacles">${this.config.properties.prompt}</label>
80
- <input type="number" value="${this.state}" min="0" max="100">%
104
+ <label>${this.config.properties.prompt}</label>
105
+ <input type="number" value="${this.state.value}" min="0" max="100">%
81
106
  </div>
82
107
  </div>`;
108
+
83
109
  const css = document.createElement("style");
84
110
  css.innerHTML = style;
85
111
  this.shadowdom.appendChild(css);
112
+
113
+ const input = this.getInputEl();
114
+ if (input) {
115
+ if (this.state.customValidity) input.setCustomValidity(this.state.customValidity);
116
+ input.addEventListener("input", this.onChange);
117
+ input.addEventListener("change", this.onChange);
118
+ }
119
+ }
120
+
121
+ setResponse = (response: QtiVariableJSON) => {
122
+ const next =
123
+ typeof response?.base?.integer === "number"
124
+ ? String(response.base.integer)
125
+ : typeof response?.base?.string === "string"
126
+ ? response.base.string
127
+ : "";
128
+ this.state = { ...this.state, value: next, touched: true };
129
+ const input = this.getInputEl();
130
+ if (input) input.value = next;
86
131
  };
87
132
 
88
133
  getResponse = () => {
89
- const response =
90
- this.state === null || this.state === undefined
91
- ? undefined
92
- : ({ base: { integer: this.state } } as QtiVariableJSON);
93
- return response;
134
+ if (!this.state.touched) return undefined;
135
+ const trimmed = (this.state.value ?? "").toString().trim();
136
+ if (!trimmed) return undefined;
137
+ const asNumber = Number(trimmed);
138
+ if (!Number.isFinite(asNumber)) return undefined;
139
+ return { base: { integer: Math.trunc(asNumber) } } as QtiVariableJSON;
140
+ };
141
+
142
+ getState = () => JSON.stringify(this.state);
143
+
144
+ checkValidity = () => {
145
+ const input = this.getInputEl();
146
+ if (!input) return undefined;
147
+ return input.checkValidity();
148
+ };
149
+
150
+ reportValidity = () => {
151
+ const input = this.getInputEl();
152
+ if (!input) return undefined;
153
+ return input.reportValidity();
94
154
  };
95
155
 
96
- getState = () => this.state.toString();
156
+ setCustomValidity = (message: string) => {
157
+ this.state = { ...this.state, customValidity: message };
158
+ const input = this.getInputEl();
159
+ if (input) input.setCustomValidity(message);
160
+ };
161
+
162
+ getCustomValidity = () => this.state.customValidity ?? "";
163
+
164
+ oncompleted = () => {
165
+ const input = this.getInputEl();
166
+ if (input) {
167
+ input.removeEventListener("input", this.onChange);
168
+ input.removeEventListener("change", this.onChange);
169
+ }
170
+ };
171
+ }
172
+
173
+ const factory: IMSpciFactory<PropTypes> = {
174
+ typeIdentifier: "###___PCI_NAME___###",
175
+ getInstance: (dom, config, state) => new PciInstance(dom, config, state),
176
+ };
177
+
178
+ const engineContext =
179
+ ctx && typeof (ctx as unknown as { register?: unknown }).register === "function"
180
+ ? (ctx as unknown as { register: (f: IMSpciFactory<PropTypes>) => void })
181
+ : typeof window !== "undefined" && (window as unknown as { qtiCustomInteractionContext?: unknown }).qtiCustomInteractionContext
182
+ ? ((window as unknown as { qtiCustomInteractionContext: { register: (f: IMSpciFactory<PropTypes>) => void } })
183
+ .qtiCustomInteractionContext as { register: (f: IMSpciFactory<PropTypes>) => void })
184
+ : undefined;
97
185
 
98
- oncompleted = () => {};
186
+ if (engineContext) {
187
+ engineContext.register(factory);
99
188
  }
100
189
 
101
- export default new Pci();
190
+ export default factory;
package/readme.md CHANGED
@@ -60,6 +60,27 @@ To add tao using the cli you should run the following command inside your PCI fo
60
60
 
61
61
  For more info: [@citolab/tspci-tao](https://github.com/Citolab/tspci/tree/main/lib/tspci-tao)
62
62
 
63
+ ### QTI 3 package export
64
+
65
+ You can also export a QTI 3 package zip containing:
66
+ - one assessment item with identifier `ITM-PCI`
67
+ - one assessment test with a single testPart, section, and that item
68
+ - a QTI 3 manifest
69
+
70
+ To add QTI 3 support using the cli run:
71
+
72
+ ```sh
73
+ npm run tspci -- add --target qti3
74
+ ```
75
+
76
+ Then build the package:
77
+
78
+ ```sh
79
+ tspci --target qti3
80
+ ```
81
+
82
+ For more info: [@citolab/tspci-qti3](https://github.com/Citolab/tspci/tree/main/lib/tspci-qti3)
83
+
63
84
  ### Manual
64
85
 
65
86
  1️. **Install** by running: `npm i -D @citolab/tspci`
@@ -78,7 +99,8 @@ For more info: [@citolab/tspci-tao](https://github.com/Citolab/tspci/tree/main/l
78
99
  "dependencies": {},
79
100
  "devDependencies": {
80
101
  "@citolab/tspci": "^1.5.6",
81
- "@citolab/tspci-tao": "^1.0.0"
102
+ "@citolab/tspci-tao": "^1.0.0",
103
+ "@citolab/tspci-qti3": "^1.0.0"
82
104
  },
83
105
  "config": {
84
106
  "tspci": {
@@ -97,38 +119,34 @@ Add the following PCI in the src folder `index.ts`
97
119
 
98
120
  ```ts
99
121
  // src/index.ts
100
- import { ConfigProperties, IMSpci } from "@citolab/tspci";
101
- import * as ctx from "qtiCustomInteractionContext";
102
-
103
- class Pci implements IMSpci<{}> {
104
- typeIdentifier = "HelloWorld"; // same as in package.json
105
- shadowdom: ShadowRoot;
106
-
107
- constructor() {
108
- ctx && ctx.register(this);
109
- }
110
-
111
- getInstance = (dom: HTMLElement, config: ConfigProperties<any>, state: string) => {
112
- this.shadowdom = dom.attachShadow({ mode: "closed" });
113
- this.render();
114
- config.onready(this);
115
- };
116
-
117
- private render = () => {
118
- this.shadowdom.innerHTML = `<div>Hello-World</div>`;
119
- };
120
-
121
- getResponse = () => {
122
- return null;
123
- };
124
-
125
- getState = () => null;
126
-
127
- setResponse = () => {
128
-
129
- }
130
- }
131
- export default new Pci();
122
+ import { ConfigProperties, IMSpci, IMSpciFactory } from "@citolab/tspci";
123
+ import * as ctx from "qtiCustomInteractionContext";
124
+
125
+ class PciInstance implements IMSpci<{}> {
126
+ typeIdentifier = "HelloWorld"; // same as in package.json
127
+ private shadowdom: ShadowRoot;
128
+
129
+ constructor(dom: HTMLElement, config: ConfigProperties<{}>, state?: string) {
130
+ this.shadowdom = dom.attachShadow({ mode: "closed" });
131
+ this.render();
132
+ config.onready(this, this.getState());
133
+ }
134
+
135
+ private render = () => {
136
+ this.shadowdom.innerHTML = `<div>Hello-World</div>`;
137
+ };
138
+
139
+ getResponse = () => undefined;
140
+ getState = () => JSON.stringify({ v: 1 });
141
+ }
142
+
143
+ const factory: IMSpciFactory<{}> = {
144
+ typeIdentifier: "HelloWorld",
145
+ getInstance: (dom, config, state) => new PciInstance(dom, config, state),
146
+ };
147
+
148
+ ctx && ctx.register(factory);
149
+ export default factory;
132
150
  ```
133
151
 
134
152
  Add this in your root project: `global.d.ts` file
@@ -136,7 +154,7 @@ Add this in your root project: `global.d.ts` file
136
154
  ```ts
137
155
  // global.d.ts
138
156
  declare module "qtiCustomInteractionContext" {
139
- const register: { register: (PortableInteraction) => void };
157
+ const register: { register: (factory: { typeIdentifier: string; getInstance: Function }) => void };
140
158
  export = register;
141
159
  }
142
160
  ```
@@ -343,8 +361,9 @@ Also used in the TAO export for configuring PCIs in TAO
343
361
  // add to types
344
362
  + private config: ConfigProperties<PropTypes>;
345
363
 
364
+ // in your factory.getInstance(...) (or instance constructor):
346
365
  getInstance = (dom: HTMLElement, config: ConfigProperties<PropTypes>, stateString: string) => {
347
- + config.properties = { ...configProps, ...config.properties }; // merge our props with players
366
+ + config.properties = { ...configProps, ...config.properties }; // merge our props with the player's
348
367
  + this.config = config;
349
368
 
350
369
  // destructure props for use in PCI
@@ -3,7 +3,7 @@
3
3
  // https://www.npmjs.com/package/rollup-plugin-live-server
4
4
  // const ls = require("live-server");
5
5
 
6
- import ls from "live-server";
6
+ import ls from "@compodoc/live-server";
7
7
 
8
8
  export function liveServer(options = {}) {
9
9
  const directories = options.directories || [];
package/types/index.d.ts CHANGED
@@ -28,17 +28,6 @@ export interface ConfigProperties<T> {
28
28
  export interface IMSpci<T> {
29
29
  typeIdentifier: string;
30
30
 
31
- /** @access public
32
- * @method getInstance Create a new instance of this portable custom interaction
33
- * Will be called by the qtiCustomInteractionContext
34
- * @param {DOM Element} dom - the DOM Element this PCI is being added to
35
- * @param {Object} configuration - the configuration to apply to this PCI
36
- * @param {String} state - a previous saved state to apply to this instance.
37
- * This must have been obtained from a prior call to getState on an
38
- * instance of this type (same typeIdentifier)
39
- */
40
- getInstance: (dom: HTMLElement, configuration: ConfigProperties<T>, state: string) => void;
41
-
42
31
  /** @access public
43
32
  * @method getResponse
44
33
  * @return {Object} - the value to assign to the bound QTI response variable
@@ -52,8 +41,17 @@ export interface IMSpci<T> {
52
41
  */
53
42
  getState: () => string;
54
43
  oncompleted?: () => void;
55
- setResponse?: (response: QtiVariableJSON) => void; // No IMS specification for this, but best option to set the response when bound to a QTI variable
56
- destroy?: () => void; // Not used in IMS and not in TAO implementation, so not used here (optional)
44
+ checkValidity?: () => boolean | undefined;
45
+ reportValidity?: () => boolean | undefined;
46
+ setCustomValidity?: (message: string) => void;
47
+ getCustomValidity?: () => string;
48
+ setResponse?: (response: QtiVariableJSON) => void; // Not in the QTI 3.0 PCI instance interface, but used by some delivery engines/tools
49
+ destroy?: () => void; // Not used in QTI 3.0, but kept for compatibility
50
+ }
51
+
52
+ export interface IMSpciFactory<T> {
53
+ typeIdentifier: string;
54
+ getInstance: (dom: HTMLElement, configuration: ConfigProperties<T>, state?: string) => IMSpci<T>;
57
55
  }
58
56
 
59
57
  export interface directedPair {