@citolab/tspci 2.14.0 → 2.16.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 +18 -0
- package/package.json +2 -2
- package/preset/base/AUTHORING.md +61 -3
- package/preset/base/package.json +2 -1
- package/preset/base/tsconfig.json +1 -1
- package/preset/javascript/src/index.js +14 -1
- package/preset/preact/src/index.tsx +14 -0
- package/preset/preact/src/pie.tsx +7 -4
- package/preset/typescript/src/index.ts +14 -1
- package/preset/typescript/src/pie.ts +1 -1
- package/public/index.html +79 -2
- package/readme.md +15 -2
- package/release-notes.md +10 -0
- package/src/authoring.js +57 -4
- package/src/cli.js +6 -5
- package/types/index.d.ts +30 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
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.16.0](https://github.com/Citolab/tspci/compare/v2.15.1...v2.16.0) (2026-11-02)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **cli:** enhance watcher to persist until dev server stops ([169759b](https://github.com/Citolab/tspci/commit/169759ba66b43c92661de5a9d8f59fcb42a96f0b))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# [2.15.0](https://github.com/Citolab/tspci/compare/v2.14.0...v2.15.0) (2026-10-17)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **tspci:** correct response from the settings, margins and tools in the authoring descriptor ([86f2628](https://github.com/Citolab/tspci/commit/86f2628b6c71492a9c38db24172ae72701dea645))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
6
24
|
# [2.14.0](https://github.com/Citolab/tspci/compare/v2.13.0...v2.14.0) (2026-10-14)
|
|
7
25
|
|
|
8
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@citolab/tspci",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "an opiniated typescript ims-pci builder with tao integration",
|
|
5
5
|
"author": "patrick <patrick.deklein@cito.nl>",
|
|
6
6
|
"repository": {
|
|
@@ -65,5 +65,5 @@
|
|
|
65
65
|
"access": "public",
|
|
66
66
|
"registry": "https://registry.npmjs.org/"
|
|
67
67
|
},
|
|
68
|
-
"gitHead": "
|
|
68
|
+
"gitHead": "1536c32b716a6b6015f95bb3948b0e95ec9c9297"
|
|
69
69
|
}
|
package/preset/base/AUTHORING.md
CHANGED
|
@@ -43,7 +43,7 @@ or an object with a `default` plus hints for the author's form:
|
|
|
43
43
|
| `color` | a colour picker | `"#4b9f4a"` |
|
|
44
44
|
| `colorList` | a list of colour pickers | `"#c91a09, #0055bf"` |
|
|
45
45
|
| `hidden` | nothing (set by the system, not the author) | |
|
|
46
|
-
| `pci` | a "Configure…" button that opens your PCI's own editor (section
|
|
46
|
+
| `pci` | a "Configure…" button that opens your PCI's own editor (section 6) | whatever your editor writes, as a string |
|
|
47
47
|
|
|
48
48
|
A plain value gets an editor from its value: `"true"`/`"false"` a switch, a whole number a number field, the rest a
|
|
49
49
|
text field. `label` is one string or one per language. An authoring system that does not know an editor shows a
|
|
@@ -75,7 +75,12 @@ type PropTypes = PciProperties<typeof configProps>; // { width: string; mode: st
|
|
|
75
75
|
`{ base: { integer: 30 } }` is `single` / `integer`. The package's item declares it, and an authoring system uses it
|
|
76
76
|
for every new item with your PCI.
|
|
77
77
|
- **`score`** lists the scoring your PCI is made for: `MATCH_CORRECT` (the response equals the correct one) and/or
|
|
78
|
-
`NONE` (not scored, or scored by a person). TAO offers exactly these.
|
|
78
|
+
`NONE` (not scored, or scored by a person). TAO offers exactly these. The first one is the default.
|
|
79
|
+
- **`correctResponse: "settings"`** says your PCI can tell the correct response from its settings
|
|
80
|
+
(`getCorrectResponse`, section 4). Leave it out when the correct answer is not in the settings.
|
|
81
|
+
- **`tolerance`** on a `single` response (or on a record field) says which margin a score rule may give it: `range`
|
|
82
|
+
for an `integer` or `float`, `area` for a `point` (section 4).
|
|
83
|
+
- **`role: "tool"`** (or a `score` of only `["NONE"]`) makes your PCI a tool that is never scored (section 5).
|
|
79
84
|
|
|
80
85
|
A PCI that reports several values (a moment and a position, say) uses one variable with **record** cardinality:
|
|
81
86
|
QTI binds a PCI to exactly one response variable, and a record has named fields of their own type.
|
|
@@ -118,8 +123,58 @@ reports becomes the correct response, or one of several accepted answers. Scorin
|
|
|
118
123
|
author checks the answer and how a candidate sees it in review.
|
|
119
124
|
- **Margins live in the item, not in the PCI.** A time window or an area is response processing
|
|
120
125
|
(`qti-field-value`, `qti-gte`/`qti-lte`, `qti-inside`) that the authoring system writes from the author's rules.
|
|
126
|
+
Your PCI reports the raw answer and only says which kind of margin fits, with `tolerance`: `range` (0,75 ± 0,05) or
|
|
127
|
+
`area` (within 1 of the point (3, 4), in your PCI's own units). Prefer snapping when the answer has steps (a number
|
|
128
|
+
line in quarters): then the exact match already works and nobody needs a margin.
|
|
121
129
|
|
|
122
|
-
|
|
130
|
+
### The correct response from the settings
|
|
131
|
+
|
|
132
|
+
In a configurable PCI the correct answer usually follows from the settings: 3 of 10 slices coloured → 30. Instead of
|
|
133
|
+
letting the author answer your PCI again after every change, export `getCorrectResponse` next to `getInstance`:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const factory: IMSpciFactory<PropTypes> = {
|
|
137
|
+
typeIdentifier: "myPci",
|
|
138
|
+
getInstance: (dom, config, state) => new PciInstance(dom, config, state),
|
|
139
|
+
// the data- properties as strings (a missing one means its default) → what getResponse() reports for the answer
|
|
140
|
+
getCorrectResponse: (properties) => {
|
|
141
|
+
const { segments, colored } = pieSettings({ ...configProps, ...properties });
|
|
142
|
+
return { base: { integer: Math.round((colored / segments) * 100) } };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
and set `"correctResponse": "settings"` in `config.tspci`. An authoring system that supports it fills the item's
|
|
148
|
+
correct response with it and keeps it in line when the author changes a setting; the author can still choose another
|
|
149
|
+
answer. Players never call it. Rules:
|
|
150
|
+
|
|
151
|
+
- **Parse the settings exactly as your drawing does** (share the function), so the correct answer and what the
|
|
152
|
+
student sees cannot disagree.
|
|
153
|
+
- **The student must be able to see what is asked.** If a setting changes the correct answer, it must change what the
|
|
154
|
+
student sees before answering (the fraction to place, the coordinates, the number of slices): a "correct value"
|
|
155
|
+
setting that is never shown makes the question unanswerable.
|
|
156
|
+
- **Return a response your PCI can show**: `setResponse(getCorrectResponse(p))` on an instance with the same settings
|
|
157
|
+
must give the same `getResponse()`.
|
|
158
|
+
- **`undefined` when there is no single answer** for these settings (3/5 on a line in steps of 0.25): the authoring
|
|
159
|
+
system then asks the author.
|
|
160
|
+
- **No DOM, no instance**: it may run on its own, in another frame, before any instance exists.
|
|
161
|
+
|
|
162
|
+
## 5. A tool: never scored
|
|
163
|
+
|
|
164
|
+
Not every PCI is a question. A ruler, a protractor, a calculator or a scratch number line helps the student, and its
|
|
165
|
+
use is never scored. Mark it with `"role": "tool"` (or a `score` of only `["NONE"]`) in `config.tspci`:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
"config": { "tspci": { "typeIdentifier": "ruler", "role": "tool", "score": ["NONE"],
|
|
169
|
+
"response": { "cardinality": "single", "baseType": "string" } } }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
QTI binds every PCI to a response variable, so a tool still has one; it may stay `undefined`, or carry the tool's
|
|
173
|
+
state (where the ruler lies) for review. An authoring system shows it as "not scored" and offers no correct answer;
|
|
174
|
+
`correctResponse` and `tolerance` make no sense on a tool, and the build warns about them. Everything else still
|
|
175
|
+
applies: settings, `getState()` and restore, the read-only statuses, keyboard use.
|
|
176
|
+
|
|
177
|
+
## 6. Authoring mode: settings your PCI edits itself
|
|
123
178
|
|
|
124
179
|
Some settings cannot be a text field or a number: a shape, a set of areas, a path. Give such a setting
|
|
125
180
|
`"editor": "pci"` and let your PCI edit it. Your PCI then has a second mode, for the item author:
|
|
@@ -167,5 +222,8 @@ the property. See `docs/authoring-mode.md` in the tspci repository for the proto
|
|
|
167
222
|
- [ ] `config.tspci.response` matches what `getResponse()` returns
|
|
168
223
|
- [ ] `getResponse()` gives the same response for the same answer, and `undefined` without one
|
|
169
224
|
- [ ] `setResponse()` / `boundTo` and `status: "solution"` show a given response
|
|
225
|
+
- [ ] when the correct answer follows from the settings: `getCorrectResponse` and `"correctResponse": "settings"`, and
|
|
226
|
+
every setting that changes the correct answer is visible to the student
|
|
227
|
+
- [ ] a tool: `"role": "tool"`, no correct response
|
|
170
228
|
- [ ] `npm run prod` writes `dist/authoring.json` without warnings
|
|
171
229
|
- [ ] with an authoring mode: delivery works without the `pci` properties (it uses the defaults)
|
package/preset/base/package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
|
|
13
13
|
import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
|
|
14
14
|
import style from "./styles.css"; // bundled as a string, added to the shadow root below
|
|
15
|
-
import { escapeHtml, pieSvg } from "./pie.js";
|
|
15
|
+
import { escapeHtml, pieSettings, pieSvg } from "./pie.js";
|
|
16
16
|
// Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src>.
|
|
17
17
|
|
|
18
18
|
/** The response for the text in the input: a whole number, or undefined while there is no answer. */
|
|
@@ -157,9 +157,22 @@ class PciInstance {
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The correct response for a configuration, for authoring systems (config.tspci.correctResponse "settings" in
|
|
162
|
+
* package.json, AUTHORING.md "Correct answers"): they call it whenever the author changes a setting and fill the item's
|
|
163
|
+
* correct response with it. It gets the data- properties as strings (a missing one means its default), uses the same
|
|
164
|
+
* parsing as the drawing, so the two cannot disagree, and returns what getResponse() reports for that answer.
|
|
165
|
+
* Players never call it, and it must not touch the DOM.
|
|
166
|
+
*/
|
|
167
|
+
function getCorrectResponse(properties) {
|
|
168
|
+
const { segments, colored } = pieSettings({ ...configProps, ...properties });
|
|
169
|
+
return { base: { integer: Math.round((colored / segments) * 100) } };
|
|
170
|
+
}
|
|
171
|
+
|
|
160
172
|
const factory = {
|
|
161
173
|
typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
|
|
162
174
|
getInstance: (dom, config, state) => new PciInstance(dom, config, state),
|
|
175
|
+
getCorrectResponse,
|
|
163
176
|
};
|
|
164
177
|
|
|
165
178
|
// Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
|
|
@@ -17,6 +17,7 @@ import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery
|
|
|
17
17
|
import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
|
|
18
18
|
import style from "./styles.css"; // Tailwind, compiled to a string, added to the shadow root below
|
|
19
19
|
import Interaction from "./interaction";
|
|
20
|
+
import { pieSettings } from "./pie";
|
|
20
21
|
import { Actions, StateModel, initStore } from "./store";
|
|
21
22
|
|
|
22
23
|
// Like every data- attribute, each setting arrives as a string.
|
|
@@ -154,9 +155,22 @@ class PciInstance implements IMSpci<PropTypes> {
|
|
|
154
155
|
};
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The correct response for a configuration, for authoring systems (config.tspci.correctResponse "settings" in
|
|
160
|
+
* package.json, AUTHORING.md "Correct answers"): they call it whenever the author changes a setting and fill the item's
|
|
161
|
+
* correct response with it. It gets the data- properties as strings (a missing one means its default), uses the same
|
|
162
|
+
* parsing as the drawing, so the two cannot disagree, and returns what getResponse() reports for that answer.
|
|
163
|
+
* Players never call it, and it must not touch the DOM.
|
|
164
|
+
*/
|
|
165
|
+
function getCorrectResponse(properties: Partial<PropTypes>): QtiVariableJSON | undefined {
|
|
166
|
+
const { segments, colored } = pieSettings({ ...(configProps as unknown as PropTypes), ...properties });
|
|
167
|
+
return { base: { integer: Math.round((colored / segments) * 100) } };
|
|
168
|
+
}
|
|
169
|
+
|
|
157
170
|
const factory: IMSpciFactory<PropTypes> = {
|
|
158
171
|
typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
|
|
159
172
|
getInstance: (dom, config, state) => new PciInstance(dom, config, state),
|
|
173
|
+
getCorrectResponse,
|
|
160
174
|
};
|
|
161
175
|
|
|
162
176
|
// Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
|
|
@@ -9,12 +9,15 @@ const toInt = (value: string, fallback: number, min: number, max: number) => {
|
|
|
9
9
|
return Number.isNaN(n) ? fallback : Math.min(max, Math.max(min, n));
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
/** The pie the settings describe; the limits match the min/max in config.json. */
|
|
13
|
+
export function pieSettings(p: Partial<PropTypes>) {
|
|
14
|
+
const segments = toInt(p.segments, 10, 2, 24);
|
|
15
|
+
return { segments, colored: toInt(p.colored, 0, 0, segments), size: toInt(p.size, 200, 100, 400) };
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
/** A circle of `segments` equal slices, the first `colored` ones filled, clockwise from the top. */
|
|
13
19
|
export const Pie = ({ config }: { config: PropTypes }) => {
|
|
14
|
-
|
|
15
|
-
const segments = toInt(config.segments, 10, 2, 24);
|
|
16
|
-
const colored = toInt(config.colored, 0, 0, segments);
|
|
17
|
-
const size = toInt(config.size, 200, 100, 400);
|
|
20
|
+
const { segments, colored, size } = pieSettings(config);
|
|
18
21
|
const c = size / 2; // centre
|
|
19
22
|
const r = c - 4; // leave room for the outline
|
|
20
23
|
const point = (i: number) => {
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
|
|
21
21
|
import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
|
|
22
22
|
import style from "./styles.css"; // bundled as a string, added to the shadow root below
|
|
23
|
-
import { escapeHtml, pieSvg } from "./pie";
|
|
23
|
+
import { escapeHtml, pieSettings, pieSvg } from "./pie";
|
|
24
24
|
// Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src>.
|
|
25
25
|
|
|
26
26
|
// Like every data- attribute, each setting arrives as a string.
|
|
@@ -172,9 +172,22 @@ class PciInstance implements IMSpci<PropTypes> {
|
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The correct response for a configuration, for authoring systems (config.tspci.correctResponse "settings" in
|
|
177
|
+
* package.json, AUTHORING.md "Correct answers"): they call it whenever the author changes a setting and fill the item's
|
|
178
|
+
* correct response with it. It gets the data- properties as strings (a missing one means its default), uses the same
|
|
179
|
+
* parsing as the drawing, so the two cannot disagree, and returns what getResponse() reports for that answer.
|
|
180
|
+
* Players never call it, and it must not touch the DOM.
|
|
181
|
+
*/
|
|
182
|
+
function getCorrectResponse(properties: Partial<PropTypes>): QtiVariableJSON | undefined {
|
|
183
|
+
const { segments, colored } = pieSettings({ ...(configProps as unknown as PropTypes), ...properties });
|
|
184
|
+
return { base: { integer: Math.round((colored / segments) * 100) } };
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
const factory: IMSpciFactory<PropTypes> = {
|
|
176
188
|
typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
|
|
177
189
|
getInstance: (dom, config, state) => new PciInstance(dom, config, state),
|
|
190
|
+
getCorrectResponse,
|
|
178
191
|
};
|
|
179
192
|
|
|
180
193
|
// Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
|
|
@@ -10,7 +10,7 @@ const toInt = (value: string, fallback: number, min: number, max: number) => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
/** The pie the settings describe; the limits match the min/max in config.json. */
|
|
13
|
-
export function pieSettings(p: PropTypes) {
|
|
13
|
+
export function pieSettings(p: Partial<PropTypes>) {
|
|
14
14
|
const segments = toInt(p.segments, 10, 2, 24);
|
|
15
15
|
return {
|
|
16
16
|
segments,
|
package/public/index.html
CHANGED
|
@@ -424,6 +424,82 @@
|
|
|
424
424
|
})
|
|
425
425
|
.catch(() => {});
|
|
426
426
|
|
|
427
|
+
// The media slots (authoring.json `markup`, editor "media"): an authoring system with a media bank puts the author's
|
|
428
|
+
// file in qti-interaction-markup. Here you choose one from disk. It stays in this browser (Cache Storage, per PCI), so
|
|
429
|
+
// it is still there after the reload every build does; the PCI gets it as a blob: URL (its iframe is of this origin).
|
|
430
|
+
const devMedia = {}; // slot -> { name, type, url }
|
|
431
|
+
const mediaSlots = () =>
|
|
432
|
+
Object.entries(authoringDescriptor?.markup || {}).filter(([, spec]) => (spec?.editor || "media") === "media");
|
|
433
|
+
const mediaCache = () => caches.open(`tspci-media-${authoringDescriptor?.typeIdentifier || "pci"}`);
|
|
434
|
+
const mediaKey = (slot) => new URL(`/__tspci_media__/${encodeURIComponent(slot)}`, location.href).href;
|
|
435
|
+
const mediaLoaded = authoringLoaded
|
|
436
|
+
.then(async () => {
|
|
437
|
+
if (!mediaSlots().length || !("caches" in window)) return;
|
|
438
|
+
const cache = await mediaCache();
|
|
439
|
+
for (const [slot] of mediaSlots()) {
|
|
440
|
+
const response = await cache.match(mediaKey(slot));
|
|
441
|
+
if (!response) continue;
|
|
442
|
+
const blob = await response.blob();
|
|
443
|
+
const name = decodeURIComponent(response.headers.get("x-file-name") || slot);
|
|
444
|
+
devMedia[slot] = { name, type: blob.type, url: URL.createObjectURL(blob) };
|
|
445
|
+
}
|
|
446
|
+
renderConfigPanel();
|
|
447
|
+
})
|
|
448
|
+
.catch((error) => console.warn("media: could not read Cache Storage", error));
|
|
449
|
+
|
|
450
|
+
async function setMedia(slot, file) {
|
|
451
|
+
const cache = await mediaCache();
|
|
452
|
+
if (devMedia[slot]) URL.revokeObjectURL(devMedia[slot].url);
|
|
453
|
+
if (file) {
|
|
454
|
+
const headers = { "content-type": file.type || "application/octet-stream", "x-file-name": encodeURIComponent(file.name) };
|
|
455
|
+
await cache.put(mediaKey(slot), new Response(file, { headers }));
|
|
456
|
+
devMedia[slot] = { name: file.name, type: file.type, url: URL.createObjectURL(file) };
|
|
457
|
+
} else {
|
|
458
|
+
await cache.delete(mediaKey(slot));
|
|
459
|
+
delete devMedia[slot];
|
|
460
|
+
}
|
|
461
|
+
renderConfigPanel();
|
|
462
|
+
refreshCurrentPci();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The chosen files as the elements an authoring system writes: its `element`, else what `accept` asks for. */
|
|
466
|
+
function mediaMarkupHtml() {
|
|
467
|
+
return mediaSlots()
|
|
468
|
+
.map(([slot, spec]) => {
|
|
469
|
+
const media = devMedia[slot];
|
|
470
|
+
if (!media) return "";
|
|
471
|
+
const element =
|
|
472
|
+
spec.element || (spec.accept || []).map((type) => type.split("/")[0]).find((kind) => kind === "video" || kind === "audio") || "img";
|
|
473
|
+
const src = escapeHtmlAttribute(media.url);
|
|
474
|
+
return element === "img"
|
|
475
|
+
? `<img src="${src}" alt="${escapeHtmlAttribute(media.name.replace(/\.[^.]*$/, ""))}">`
|
|
476
|
+
: `<${element} src="${src}" controls></${element}>`;
|
|
477
|
+
})
|
|
478
|
+
.join("");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function MediaControls() {
|
|
482
|
+
const slots = mediaSlots();
|
|
483
|
+
if (!slots.length) return null;
|
|
484
|
+
return html`<div class="mb-3 rounded border border-dashed p-2">
|
|
485
|
+
${slots.map(([slot, spec]) => {
|
|
486
|
+
const media = devMedia[slot];
|
|
487
|
+
return html`<div class="mb-2">
|
|
488
|
+
<label class="form-label text-muted mb-1" title="markup: ${slot}">${authoringText(spec.label, slot)}${spec.required ? " *" : ""}</label>
|
|
489
|
+
<input type="file" class="form-control form-control-sm" accept="${(spec.accept || []).join(",")}"
|
|
490
|
+
onChange="${(e) => e.target.files[0] && setMedia(slot, e.target.files[0])}" />
|
|
491
|
+
${media
|
|
492
|
+
? html`<div class="d-flex align-items-center gap-2 small mt-1">
|
|
493
|
+
<code class="text-truncate" title="${media.name}">${media.name}</code>
|
|
494
|
+
<button type="button" class="btn btn-sm btn-link p-0" onClick="${() => setMedia(slot, null)}">remove</button>
|
|
495
|
+
</div>`
|
|
496
|
+
: html`<div class="small text-muted mt-1">None: the markup has no ${spec.element || slot}.</div>`}
|
|
497
|
+
</div>`;
|
|
498
|
+
})}
|
|
499
|
+
<p class="small text-muted mb-0">Goes in <code>qti-interaction-markup</code>, as an authoring system puts it there. The file stays in this browser.</p>
|
|
500
|
+
</div>`;
|
|
501
|
+
}
|
|
502
|
+
|
|
427
503
|
const authoringText = (text, fallback) =>
|
|
428
504
|
!text ? fallback : typeof text === "string" ? text : text.en || Object.values(text)[0] || fallback;
|
|
429
505
|
const toHex = (c) => {
|
|
@@ -562,6 +638,7 @@
|
|
|
562
638
|
: ""}`
|
|
563
639
|
: html`No <code>authoring.json</code> yet: build once, or see AUTHORING.md`}
|
|
564
640
|
</p>
|
|
641
|
+
<${MediaControls} />
|
|
565
642
|
<${AuthoringControls} />
|
|
566
643
|
<fieldset disabled=${!!authoringSession}>
|
|
567
644
|
<form onSubmit="${(e) => e.preventDefault()}">
|
|
@@ -930,7 +1007,7 @@
|
|
|
930
1007
|
<qti-interaction-module id="pci" primary-path="./index"></qti-interaction-module>
|
|
931
1008
|
</qti-interaction-modules>
|
|
932
1009
|
<qti-interaction-markup>
|
|
933
|
-
<div data-qti-pci-runtime-host style="height:100%"></div
|
|
1010
|
+
<div data-qti-pci-runtime-host style="height:100%"></div>${mediaMarkupHtml()}
|
|
934
1011
|
</qti-interaction-markup>
|
|
935
1012
|
</qti-portable-custom-interaction>
|
|
936
1013
|
</qti-item-body>
|
|
@@ -964,7 +1041,7 @@
|
|
|
964
1041
|
if (typeof globalThis.off !== "function") {
|
|
965
1042
|
globalThis.off = () => {};
|
|
966
1043
|
}
|
|
967
|
-
await Promise.all([loadQtiComponents(), authoringLoaded]);
|
|
1044
|
+
await Promise.all([loadQtiComponents(), authoringLoaded, mediaLoaded]);
|
|
968
1045
|
loadItem();
|
|
969
1046
|
};
|
|
970
1047
|
</script>
|
package/readme.md
CHANGED
|
@@ -68,6 +68,8 @@ Tailwind is a popular css framework and can be used to give a nice look and feel
|
|
|
68
68
|
|
|
69
69
|
preact is a small version of react. You can develop your PCI using hooks and the render function as you are familiar with in react. When preact is selected the cli will also add a store to manage state. This can easily be used to store all user actions in the PCI, and even replay them.
|
|
70
70
|
|
|
71
|
+
One trap with SVG: Preact writes attributes on SVG elements exactly as you spell them, and SVG attribute names are case-sensitive. `tabIndex` on an SVG element does nothing, so a keyboard user can never reach it; write `tabindex` (on HTML elements both work).
|
|
72
|
+
|
|
71
73
|
### specific implementations like TAO.
|
|
72
74
|
|
|
73
75
|
We also support to create a PCI that can be imported in TAO. Therefor you should use @citolab/tspci-tao.
|
|
@@ -131,20 +133,31 @@ have:
|
|
|
131
133
|
},
|
|
132
134
|
"markup": {},
|
|
133
135
|
"response": { "cardinality": "single", "baseType": "string" },
|
|
136
|
+
"role": "question",
|
|
134
137
|
"score": ["MATCH_CORRECT", "NONE"]
|
|
135
138
|
}
|
|
136
139
|
```
|
|
137
140
|
|
|
138
141
|
The qti3 export declares the item's response from `config.tspci.response` (it used to be `single`/`string` always),
|
|
139
142
|
and the build warns about what an authoring system could not use: an unknown editor, an enum default that is not one
|
|
140
|
-
of its options, a record field without a base type.
|
|
143
|
+
of its options, a record field without a base type, a margin on a type it cannot measure, a correct response on a tool.
|
|
141
144
|
|
|
142
145
|
**Scoring.** Authors set the correct answer by answering your PCI in a preview: what `getResponse()` reports becomes
|
|
143
146
|
the correct response, or one of several accepted answers, and scoring compares responses. So report the meaning,
|
|
144
147
|
not the route (the same answer must give the same response), report the declared type, return `undefined` without an
|
|
145
148
|
answer, and show a given response (`setResponse`, `boundTo`, and `responseDeclaration.correctResponse` in
|
|
146
149
|
`status: "solution"`). Margins (a time window, an area) live in the item's response processing, which the authoring
|
|
147
|
-
system writes
|
|
150
|
+
system writes; `tolerance` (`range` for numbers, `area` for points) on the response or a record field says which kind
|
|
151
|
+
fits.
|
|
152
|
+
|
|
153
|
+
**The correct response from the settings.** When the correct answer follows from the settings (3 of 10 slices → 30),
|
|
154
|
+
the registered object gets `getCorrectResponse(properties)` next to `getInstance`, and `config.tspci.correctResponse`
|
|
155
|
+
is `"settings"`. An authoring system fills the item's correct response with it and keeps it in line when the author
|
|
156
|
+
changes a setting. Every setting that changes the correct answer must be visible to the student, or the question
|
|
157
|
+
cannot be answered. Players never call it; the templates `tspci init` makes have one.
|
|
158
|
+
|
|
159
|
+
**Tools.** A PCI that helps the student but is never scored (a ruler, a calculator) has `config.tspci.role: "tool"`
|
|
160
|
+
(or a `score` of only `["NONE"]`). It still has a response variable, as QTI requires, but no correct response.
|
|
148
161
|
|
|
149
162
|
**Authoring mode.** A setting with `"editor": "pci"` is edited by the PCI itself: next to `getInstance`, the registered
|
|
150
163
|
object has `getAuthoringInstance(dom, config)`, which returns `{ getConfig, validate?, destroy? }`. An authoring
|
package/release-notes.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
# 2.15.0
|
|
2
|
+
|
|
3
|
+
- `getCorrectResponse(properties)` on the registered object (typed on `IMSpciFactory`): the correct response for the item's settings, so an authoring system can fill the correct response and keep it in line when the author changes a setting; `config.tspci.correctResponse: "settings"` puts it in `authoring.json`
|
|
4
|
+
- `tolerance` on a `single` response (it was already on record fields): `range` for an integer or float, `area` for a point, the margin a score rule may give; the build warns when it does not fit the base type
|
|
5
|
+
- tools: `config.tspci.role: "tool"` (or a `score` of only `["NONE"]`) marks a PCI that is never scored, such as a ruler or a calculator; every descriptor now has a `role`, and the build warns about a score, a correct response or a margin on a tool
|
|
6
|
+
- the templates of `tspci init` have a `getCorrectResponse` that shares the drawing's parsing of the settings, and `"correctResponse": "settings"`
|
|
7
|
+
- the generated tsconfig uses `lib: es2022` (was es2017), so `Object.fromEntries`, `replaceAll` and `at()` typecheck
|
|
8
|
+
- fix: `tspci init` with the javascript template kept the preset's name, description and `typeIdentifier` ("myPci") in package.json, which the generated code does not register
|
|
9
|
+
- AUTHORING.md: the correct response from the settings, margins, tools, and a checklist item for settings the student must see; the readme warns that Preact needs `tabindex`, not `tabIndex`, on SVG elements
|
|
10
|
+
|
|
1
11
|
# 2.12.1
|
|
2
12
|
|
|
3
13
|
- `tspci init` now writes a tsconfig that is ready for TypeScript 6: an explicit `rootDir` of `./src`, `moduleResolution: "bundler"` instead of the deprecated node10 resolution, `module: "esnext"` and `lib: es2017`
|
package/src/authoring.js
CHANGED
|
@@ -17,6 +17,15 @@ export const DESCRIPTOR_FILE = "authoring.json";
|
|
|
17
17
|
*/
|
|
18
18
|
export const PROPERTY_EDITORS = ["text", "textarea", "integer", "number", "boolean", "enum", "color", "colorList", "hidden", "pci"];
|
|
19
19
|
|
|
20
|
+
/** How an authoring system may find the correct response itself: `settings` = the PCI has getCorrectResponse(properties). */
|
|
21
|
+
export const CORRECT_RESPONSE_SOURCES = ["settings"];
|
|
22
|
+
|
|
23
|
+
/** The margin a score rule may give a response or a record field: `range` for numbers, `area` for points. */
|
|
24
|
+
export const TOLERANCES = { range: ["integer", "float"], area: ["point"] };
|
|
25
|
+
|
|
26
|
+
/** `question` is scored; a `tool` (a ruler, a calculator) never counts towards the score. */
|
|
27
|
+
export const ROLES = ["question", "tool"];
|
|
28
|
+
|
|
20
29
|
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
30
|
|
|
22
31
|
/** config.json → the plain defaults the PCI receives (every value a string, as a data- attribute gives it). */
|
|
@@ -51,6 +60,17 @@ function toProperty(key, entry, problems) {
|
|
|
51
60
|
return { editor, default: String(value), ...rest };
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
/** A margin only fits a base type it can measure: a range on numbers, an area on points. */
|
|
64
|
+
function checkTolerance(where, tolerance, baseType, problems) {
|
|
65
|
+
if (tolerance === undefined) return;
|
|
66
|
+
const fits = TOLERANCES[tolerance];
|
|
67
|
+
if (!fits) {
|
|
68
|
+
problems.push(`${where}.tolerance: "${tolerance}" is not one of ${Object.keys(TOLERANCES).join(", ")}`);
|
|
69
|
+
} else if (baseType && !fits.includes(baseType)) {
|
|
70
|
+
problems.push(`${where}.tolerance: "${tolerance}" needs a base type ${fits.join(" or ")}, not ${baseType}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
function checkResponse(response, problems) {
|
|
55
75
|
if (!response) return;
|
|
56
76
|
const cardinality = response.cardinality ?? "single";
|
|
@@ -62,12 +82,39 @@ function checkResponse(response, problems) {
|
|
|
62
82
|
if (!fields.length) problems.push("config.tspci.response: a record needs fields");
|
|
63
83
|
for (const [name, field] of fields) {
|
|
64
84
|
if (!field?.baseType) problems.push(`config.tspci.response.fields.${name}: baseType is missing`);
|
|
85
|
+
checkTolerance(`config.tspci.response.fields.${name}`, field?.tolerance, field?.baseType, problems);
|
|
86
|
+
}
|
|
87
|
+
if (response.tolerance !== undefined) problems.push("config.tspci.response.tolerance: a record has a tolerance per field, not one for all");
|
|
88
|
+
} else {
|
|
89
|
+
if (!response.baseType) problems.push("config.tspci.response: baseType is missing");
|
|
90
|
+
// A margin on a list would mean a margin per value: no authoring system scores that, so only single responses.
|
|
91
|
+
if (response.tolerance !== undefined && cardinality !== "single") {
|
|
92
|
+
problems.push(`config.tspci.response.tolerance: only a single response can have one, not ${cardinality}`);
|
|
93
|
+
} else {
|
|
94
|
+
checkTolerance("config.tspci.response", response.tolerance, response.baseType, problems);
|
|
65
95
|
}
|
|
66
|
-
} else if (!response.baseType) {
|
|
67
|
-
problems.push("config.tspci.response: baseType is missing");
|
|
68
96
|
}
|
|
69
97
|
}
|
|
70
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Question or tool, and where the correct response can come from. A tool is never scored: config.tspci.role "tool",
|
|
101
|
+
* or a score list with only NONE. A tool has no correct response, so correctResponse makes no sense on it.
|
|
102
|
+
*/
|
|
103
|
+
function toRole(tspci, problems) {
|
|
104
|
+
const scoreOnlyNone = Array.isArray(tspci.score) && tspci.score.length > 0 && tspci.score.every((s) => s === "NONE");
|
|
105
|
+
const role = tspci.role ?? (scoreOnlyNone ? "tool" : "question");
|
|
106
|
+
if (!ROLES.includes(role)) problems.push(`config.tspci.role: "${role}" is not one of ${ROLES.join(", ")}`);
|
|
107
|
+
if (role === "tool" && Array.isArray(tspci.score) && !scoreOnlyNone) {
|
|
108
|
+
problems.push("config.tspci.score: a tool is never scored, so score can only be [\"NONE\"]");
|
|
109
|
+
}
|
|
110
|
+
const source = tspci.correctResponse;
|
|
111
|
+
if (source !== undefined && !CORRECT_RESPONSE_SOURCES.includes(source)) {
|
|
112
|
+
problems.push(`config.tspci.correctResponse: "${source}" is not one of ${CORRECT_RESPONSE_SOURCES.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
if (source !== undefined && role === "tool") problems.push("config.tspci.correctResponse: a tool has no correct response");
|
|
115
|
+
return { role, correctResponse: CORRECT_RESPONSE_SOURCES.includes(source) && role !== "tool" ? source : undefined };
|
|
116
|
+
}
|
|
117
|
+
|
|
71
118
|
/**
|
|
72
119
|
* The authoring mode of a PCI: present when a property has editor `pci`. config.tspci.authoring adds a label for the
|
|
73
120
|
* button that opens it and the size the PCI would like for it.
|
|
@@ -83,7 +130,8 @@ function toAuthoring(authoring, properties, problems) {
|
|
|
83
130
|
|
|
84
131
|
/**
|
|
85
132
|
* The descriptor of a PCI project. `problems` lists what an authoring system could not use (an unknown editor, an
|
|
86
|
-
* enum default that is not an option, a record field without a base type
|
|
133
|
+
* enum default that is not an option, a record field without a base type, a margin on a type it cannot measure, a
|
|
134
|
+
* correct response on a tool); the build reports them as warnings.
|
|
87
135
|
*/
|
|
88
136
|
export function buildDescriptor(packageJson, config) {
|
|
89
137
|
const tspci = packageJson?.config?.tspci ?? {};
|
|
@@ -96,6 +144,9 @@ export function buildDescriptor(packageJson, config) {
|
|
|
96
144
|
const response = tspci.response ?? { cardinality: "single", baseType: "string" };
|
|
97
145
|
checkResponse(response, problems);
|
|
98
146
|
const authoring = toAuthoring(tspci.authoring, properties, problems);
|
|
147
|
+
const { role, correctResponse } = toRole(tspci, problems);
|
|
148
|
+
const margins = [response.tolerance, ...Object.values(response.fields ?? {}).map((f) => f?.tolerance)].filter((t) => t !== undefined);
|
|
149
|
+
if (role === "tool" && margins.length) problems.push("config.tspci.response: a tool is never scored, so a tolerance makes no sense");
|
|
99
150
|
|
|
100
151
|
return {
|
|
101
152
|
descriptor: {
|
|
@@ -106,7 +157,9 @@ export function buildDescriptor(packageJson, config) {
|
|
|
106
157
|
properties,
|
|
107
158
|
markup: tspci.markup ?? {},
|
|
108
159
|
response,
|
|
109
|
-
|
|
160
|
+
role,
|
|
161
|
+
...(tspci.score ? { score: tspci.score } : role === "tool" ? { score: ["NONE"] } : {}),
|
|
162
|
+
...(correctResponse ? { correctResponse } : {}),
|
|
110
163
|
...(authoring ? { authoring } : {}),
|
|
111
164
|
},
|
|
112
165
|
problems,
|
package/src/cli.js
CHANGED
|
@@ -128,7 +128,10 @@ const watchBuild = (devServer = false, target = "IMS") => {
|
|
|
128
128
|
result.close();
|
|
129
129
|
}
|
|
130
130
|
});
|
|
131
|
-
|
|
131
|
+
// keep watching until the dev server stops (closing it here cancels the first build)
|
|
132
|
+
const closeWatcher = () => watcher.close();
|
|
133
|
+
process.on("SIGINT", closeWatcher);
|
|
134
|
+
process.on("SIGTERM", closeWatcher);
|
|
132
135
|
});
|
|
133
136
|
};
|
|
134
137
|
|
|
@@ -455,10 +458,8 @@ const run = async () => {
|
|
|
455
458
|
path.join(destinationFolder, "src"),
|
|
456
459
|
pciName
|
|
457
460
|
);
|
|
458
|
-
// point to the javascript file
|
|
459
|
-
|
|
460
|
-
const packageJsonContent = fs.readFileSync(packageJson, "utf8");
|
|
461
|
-
let packageJsonContentJson = JSON.parse(packageJsonContent);
|
|
461
|
+
// point to the javascript file. Keep the package.json built above (name, description, typeIdentifier): reading
|
|
462
|
+
// it from disk again gave every javascript project the preset's "myPci", which its code does not register.
|
|
462
463
|
packageJsonContentJson.source = "src/index.js";
|
|
463
464
|
const newPackageJsonContent = JSON.stringify(packageJsonContentJson, null, 2);
|
|
464
465
|
fs.writeFileSync(packageJson, newPackageJsonContent);
|
package/types/index.d.ts
CHANGED
|
@@ -69,10 +69,20 @@ export interface AuthoringResponseField {
|
|
|
69
69
|
editor?: string;
|
|
70
70
|
label?: AuthoringText;
|
|
71
71
|
/** The kind of margin a score rule may give it: "range" (numbers), "area" (points). */
|
|
72
|
-
tolerance?:
|
|
72
|
+
tolerance?: AuthoringTolerance;
|
|
73
73
|
[hint: string]: unknown;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The kind of margin a score rule may give a response: "range" (an integer or float between two values, or within a
|
|
78
|
+
* distance of the correct one), "area" (a point inside a shape, or within a distance of the correct point). The margin
|
|
79
|
+
* itself is response processing in the item, written by the authoring system; the PCI only says which kind fits.
|
|
80
|
+
*/
|
|
81
|
+
export type AuthoringTolerance = "range" | "area";
|
|
82
|
+
|
|
83
|
+
/** "question" is scored; a "tool" (a ruler, a protractor, a calculator) never counts towards the score. */
|
|
84
|
+
export type PciRole = "question" | "tool";
|
|
85
|
+
|
|
76
86
|
/**
|
|
77
87
|
* dist/authoring.json, generated from src/config.json and package.json config.tspci, and shipped next to the bundle.
|
|
78
88
|
* See AUTHORING.md.
|
|
@@ -86,10 +96,18 @@ export interface AuthoringDescriptor {
|
|
|
86
96
|
/** Elements the item puts in qti-interaction-markup, e.g. `{ "video": { "editor": "media", "accept": ["video/*"] } }`. */
|
|
87
97
|
markup: Record<string, { editor: string; element?: string; accept?: string[]; required?: boolean; label?: AuthoringText }>;
|
|
88
98
|
response:
|
|
89
|
-
| { cardinality: "single"
|
|
99
|
+
| { cardinality: "single"; baseType: string; tolerance?: AuthoringTolerance }
|
|
100
|
+
| { cardinality: "multiple" | "ordered"; baseType: string }
|
|
90
101
|
| { cardinality: "record"; fields: Record<string, AuthoringResponseField> };
|
|
102
|
+
/** config.tspci.role; a score of only ["NONE"] makes a tool. */
|
|
103
|
+
role: PciRole;
|
|
91
104
|
/** The response processing templates the PCI is meant for (TAO's score setting), e.g. ["MATCH_CORRECT", "NONE"]. */
|
|
92
105
|
score?: string[];
|
|
106
|
+
/**
|
|
107
|
+
* "settings": the registered object has getCorrectResponse(properties), so an authoring system can fill the correct
|
|
108
|
+
* response from the item's settings (and keep it in line when they change). Never on a tool.
|
|
109
|
+
*/
|
|
110
|
+
correctResponse?: "settings";
|
|
93
111
|
/** Present when the PCI has an authoring mode: properties with editor "pci", plus config.tspci.authoring. */
|
|
94
112
|
authoring?: AuthoringMode;
|
|
95
113
|
}
|
|
@@ -205,8 +223,18 @@ export interface IMSpci<T> {
|
|
|
205
223
|
export interface IMSpciFactory<T> {
|
|
206
224
|
typeIdentifier: string;
|
|
207
225
|
getInstance: (dom: HTMLElement, configuration: ConfigProperties<T>, state?: string) => IMSpci<T>;
|
|
226
|
+
/**
|
|
227
|
+
* Not part of QTI 3: the correct response for these settings, for an authoring system (config.tspci.correctResponse
|
|
228
|
+
* "settings"). Gets the item's data- properties as strings (a missing one means its default) and returns what
|
|
229
|
+
* getResponse() reports for the correct answer, or undefined when these settings have no single correct answer.
|
|
230
|
+
* Players never call it. It must not touch the DOM: it may run without an instance, in another frame.
|
|
231
|
+
*/
|
|
232
|
+
getCorrectResponse?: PciCorrectResponse<T>;
|
|
208
233
|
}
|
|
209
234
|
|
|
235
|
+
/** See IMSpciFactory.getCorrectResponse. */
|
|
236
|
+
export type PciCorrectResponse<T> = (properties: Partial<T>) => QtiVariableJSON | undefined;
|
|
237
|
+
|
|
210
238
|
/**
|
|
211
239
|
* The QTI 3 PCI (the object registered with qtiCustomInteractionContext) extended with an authoring mode:
|
|
212
240
|
* getAuthoringInstance next to getInstance. Delivery engines only know getInstance and never call it, so the PCI works
|