@citolab/tspci 2.8.4 → 2.9.7

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/public/index.html CHANGED
@@ -1,378 +1,853 @@
1
- <!DOCTYPE html>
1
+ <!doctype html>
2
2
  <html lang="en">
3
+ <head>
4
+ <title>Hello RequireJS</title>
5
+ <script
6
+ src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
7
+ integrity="sha512-c3Nl8+7g4LMSTdrm621y7kf9v3SDPnhxLNhcjFJbKECVnmZHTdo+IRO05sNLTH/D3vA6u1X32ehoLC7WFVdheg=="
8
+ crossorigin="anonymous"
9
+ referrerpolicy="no-referrer"
10
+ ></script>
11
+ <!-- <link rel="stylesheet" class="bootswatcher" href="//bootswatch.com/5/lumen/bootstrap.min.css" /> -->
12
+
13
+ <link
14
+ rel="stylesheet"
15
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
16
+ />
17
+
18
+ <style>
19
+ html {
20
+ font-size: 100%;
21
+ }
3
22
 
4
- <head>
5
- <title>Hello RequireJS</title>
6
- <script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
7
- integrity="sha512-c3Nl8+7g4LMSTdrm621y7kf9v3SDPnhxLNhcjFJbKECVnmZHTdo+IRO05sNLTH/D3vA6u1X32ehoLC7WFVdheg=="
8
- crossorigin="anonymous" referrerpolicy="no-referrer"></script>
9
- <!-- <link rel="stylesheet" class="bootswatcher" href="//bootswatch.com/5/lumen/bootstrap.min.css" /> -->
10
-
11
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" />
12
-
13
- <style>
14
- html {
15
- font-size: 100%;
16
- }
17
-
18
- #pciContainer {
19
- width: 100%;
20
- /* min-width: 800px; */
21
-
22
- max-width: 1024px;
23
- height: 600px;
24
- min-height: 600px;
25
- /* max-height: 600px; */
26
- border: 1px solid #eeeeee;
27
- overflow: hidden;
28
- margin: 0 auto;
29
- background-color: white;
30
- border-radius: 10px;
31
- margin-top: 1rem;
32
- }
33
-
34
- #devtools {
35
- max-width: 1024px;
36
- margin: 0 auto;
37
- }
38
- </style>
39
- </head>
40
-
41
- <body class="bg-light">
42
- <div class="container">
43
- <div class="alert alert-danger position-fixed mx-auto left-2 top-2" style="display: none" role="alert"
44
- id="noresponse">
45
- user has not yet interacted yet<br />getResponse should return undefined.
46
- </div>
23
+ #pciContainer {
24
+ width: 100%;
25
+ /* min-width: 800px; */
26
+
27
+ max-width: 1024px;
28
+ height: 600px;
29
+ min-height: 600px;
30
+ /* max-height: 600px; */
31
+ border: 1px solid #dbe3ec;
32
+ overflow: hidden;
33
+ margin: 1.25rem auto 0;
34
+ background-color: #ffffff;
35
+ border-radius: 16px;
36
+ padding: 1rem;
37
+ box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08);
38
+ }
47
39
 
48
- <div id="pciContainer" class="shadow-sm">
49
- <div id="loading" class="text-muted mx-auto p-4">Loading...</div>
50
- </div>
51
- <div id="devtools"></div>
52
- </div>
53
-
54
- <script type="module">
55
- import { h, Component, render } from "https://unpkg.com/preact@latest?module";
56
- import { useState, useEffect, useRef } from "https://unpkg.com/preact@latest/hooks/dist/hooks.module.js?module";
57
- import htm from "https://unpkg.com/htm?module";
58
- // Initialize htm with Preact
59
- const html = htm.bind(h);
60
-
61
- /**
62
- * Minified useInterval hook. A custom hook that calls the given `callback` function at the given `delay` interval.
63
- * useInterval(() => {
64
- * console.log('interval')
65
- * }, 1000);
66
- * The callback is not called immediately on mount but only after the first delay.
67
- */
68
- function useInterval(c, d) { const s = useRef(); useEffect(() => { s.current = c }, [c]), useEffect(() => { if (d !== null) { const i = setInterval(() => { s.current() }, d); return () => clearInterval(i) } }, [d]) }
69
-
70
- function App({ item }) {
71
- const [show, setShow] = useState(false);
72
-
73
- const [range, setRange] = useState(0);
74
- const [currentRangeValue, setCurrentRangeValue] = useState(0);
75
- const [maxRange, setMaxRange] = useState(0);
76
- const [actions, setActions] = useState([]);
77
-
78
- const isReplaying = useRef(false);
79
- const replayedIndex = useRef(0);
80
- const store = item.store;
81
-
82
- useEffect(() => {
83
- if (store) {
84
- store.subscribeActions(action => {
85
- if (!isReplaying.current) {
86
- const allActions = store.actions;
87
- setRange(allActions.length);
88
- setMaxRange(allActions.length);
89
- setActions(allActions);
90
- setCurrentRangeValue(allActions.findIndex(a => a === action) + 1);
91
- } else {
92
- replayedIndex.current++;
93
- setCurrentRangeValue(replayedIndex.current);
94
- }
95
- });
40
+ qti-portable-custom-interaction {
41
+ display: block;
42
+ width: 100%;
43
+ height: 100%;
44
+ }
45
+
46
+ #devtools {
47
+ max-width: 1024px;
48
+ margin: 0 auto;
49
+ }
50
+
51
+ .container {
52
+ max-width: 1360px;
53
+ margin-left: auto;
54
+ margin-right: auto;
55
+ }
56
+
57
+ .app-shell {
58
+ display: flex;
59
+ align-items: flex-start;
60
+ gap: 1rem;
61
+ }
62
+
63
+ #main {
64
+ flex: 1 1 auto;
65
+ min-width: 0;
66
+ }
67
+
68
+ #right-pane {
69
+ flex: 0 0 320px;
70
+ width: 320px;
71
+ padding-top: 1.25rem;
72
+ }
73
+
74
+ .dev-layout {
75
+ margin-top: 1rem;
76
+ }
77
+
78
+ .dev-main {
79
+ min-width: 0;
80
+ }
81
+
82
+ .dev-sidepane {
83
+ position: sticky;
84
+ top: 1rem;
85
+ width: 100%;
86
+ max-height: calc(100vh - 2rem);
87
+ overflow: auto;
88
+ background: #fff;
89
+ border: 1px solid #dee2e6;
90
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
91
+ padding: 1rem;
92
+ border-radius: 0.5rem;
93
+ }
94
+
95
+ @media (max-width: 1100px) {
96
+ .app-shell {
97
+ display: block;
96
98
  }
97
- }, [store]);
98
-
99
- async function scrubRange(value) {
100
- if (!isReplaying.current) {
101
- setCurrentRangeValue(value);
102
- if (store) {
103
- store.reset();
104
- isReplaying.current = true;
105
- replayedIndex.current = 0;
106
- setCurrentRangeValue(value);
107
- await store.replay([...actions], { until: value, animate: false });
108
- isReplaying.current = false;
109
- }
99
+
100
+ #right-pane {
101
+ width: 100%;
102
+ max-width: 100%;
103
+ padding-top: 0;
104
+ }
105
+
106
+ .dev-sidepane {
107
+ position: static;
108
+ max-height: none;
110
109
  }
111
110
  }
112
111
 
113
- let keyArray = [];
114
- if (item.config && item.config.properties) {
115
- keyArray = Object.keys(item.config.properties).map((keyName, keyIndex) => ({
116
- key: keyName,
117
- value: item.config.properties[keyName],
118
- }));
112
+ .json-block {
113
+ min-height: 180px;
114
+ max-height: 280px;
115
+ overflow: auto;
116
+ background: #111827;
117
+ color: #e5e7eb;
118
+ border-radius: 0.5rem;
119
+ padding: 0.75rem;
120
+ font-size: 12px;
121
+ line-height: 1.4;
122
+ margin: 0;
123
+ border: 1px solid #374151;
119
124
  }
120
125
 
121
- function resetlocalstorage() {
122
- localStorage.removeItem(item.typeIdentifier + "_state");
123
- localStorage.removeItem(item.typeIdentifier + "_props");
124
- localStorage.removeItem(item.typeIdentifier + "_response");
126
+ .json-key {
127
+ color: #93c5fd;
125
128
  }
126
129
 
127
- function reset() {
128
- resetlocalstorage();
129
- location.reload();
130
+ .json-string {
131
+ color: #86efac;
130
132
  }
131
133
 
132
- function resetResponse() {
133
- localStorage.removeItem(item.typeIdentifier + "_response");
134
- item.resetResponse();
134
+ .json-number {
135
+ color: #fca5a5;
135
136
  }
136
137
 
137
- function setResponse() {
138
- const response = localStorage.getItem(item.typeIdentifier + "_response");
139
- if (response) {
140
- if (item.setResponse) {
141
- item.setResponse({
142
- base: {
143
- string: response,
144
- },
145
- });
138
+ .json-boolean {
139
+ color: #fcd34d;
140
+ }
141
+
142
+ .json-null {
143
+ color: #c4b5fd;
144
+ }
145
+
146
+ .icon-btn {
147
+ border: 1px solid #ced4da;
148
+ background: #fff;
149
+ border-radius: 999px;
150
+ width: 26px;
151
+ height: 26px;
152
+ display: inline-flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ font-size: 13px;
156
+ cursor: pointer;
157
+ }
158
+
159
+ .spin {
160
+ animation: spin 1s linear infinite;
161
+ }
162
+
163
+ @keyframes spin {
164
+ from {
165
+ transform: rotate(0deg);
166
+ }
167
+
168
+ to {
169
+ transform: rotate(360deg);
170
+ }
171
+ }
172
+
173
+ .tiny-note {
174
+ font-size: 12px;
175
+ color: #6c757d;
176
+ margin-top: 0.35rem;
177
+ }
178
+ </style>
179
+ </head>
180
+
181
+ <body class="bg-light">
182
+ <div class="container app-shell">
183
+ <div id="main">
184
+ <div
185
+ class="alert alert-danger position-fixed mx-auto left-2 top-2"
186
+ style="display: none"
187
+ role="alert"
188
+ id="noresponse"
189
+ >
190
+ user has not yet interacted yet<br />getResponse should return
191
+ undefined.
192
+ </div>
193
+
194
+ <div id="pciContainer" class="shadow-sm">
195
+ <div id="loading" class="text-muted mx-auto p-4">Loading...</div>
196
+ </div>
197
+ <div id="devtools"></div>
198
+ </div>
199
+ <div id="right-pane"></div>
200
+ </div>
201
+
202
+ <script type="module">
203
+ import {
204
+ h,
205
+ render,
206
+ } from "https://unpkg.com/preact@latest?module";
207
+ import {
208
+ useState,
209
+ useEffect,
210
+ useRef,
211
+ } from "https://unpkg.com/preact@latest/hooks/dist/hooks.module.js?module";
212
+ import htm from "https://unpkg.com/htm?module";
213
+ // Initialize htm with Preact
214
+ const html = htm.bind(h);
215
+
216
+ const CONFIG_TO_DATA_PROPERTY_KEYS = [
217
+ "title",
218
+ "prompt",
219
+ "width",
220
+ "height",
221
+ ];
222
+ const CONFIG_CHANGE_DEBOUNCE_MS = 1000;
223
+ const INTERACTION_REFRESH_DELAY_MS = 120;
224
+
225
+ function mergeConfigPropertiesIntoDataProperties(
226
+ properties,
227
+ dataProperties = {},
228
+ ) {
229
+ const merged = { ...(dataProperties || {}) };
230
+ if (!properties) {
231
+ return merged;
232
+ }
233
+ CONFIG_TO_DATA_PROPERTY_KEYS.forEach((key) => {
234
+ const value = properties[key];
235
+ if (value !== null && typeof value !== "undefined") {
236
+ merged[key] = String(value);
146
237
  }
147
- else {
148
- alert("load response only when TAO interface is implemented");
238
+ });
239
+ return merged;
240
+ }
241
+
242
+ function getQtiPortableCustomInteractionElement() {
243
+ return document.querySelector(
244
+ "#pciContainer qti-portable-custom-interaction",
245
+ );
246
+ }
247
+
248
+ function getQtiAssessmentItemElement() {
249
+ return document.querySelector("#pciContainer qti-assessment-item");
250
+ }
251
+
252
+ function applyDataPropertiesToQtiHost(dataProperties = {}) {
253
+ const host = getQtiPortableCustomInteractionElement();
254
+ if (!host) {
255
+ return;
256
+ }
257
+ Object.keys(dataProperties || {}).forEach((key) => {
258
+ const normalizedKey = String(key || "")
259
+ .trim()
260
+ .replace(/^data-/, "");
261
+ if (!normalizedKey) {
262
+ return;
263
+ }
264
+ const value = dataProperties[key];
265
+ if (value === null || typeof value === "undefined") {
266
+ return;
149
267
  }
150
- } else {
151
- alert("no response stored");
268
+ host.setAttribute(`data-${normalizedKey}`, String(value));
269
+ });
270
+ }
271
+
272
+ function syncConfigPropertiesToHostDataProperties(
273
+ config,
274
+ dataProperties,
275
+ ) {
276
+ if (!config || !config.properties) {
277
+ return;
278
+ }
279
+ currentPciHostDataProperties = mergeConfigPropertiesIntoDataProperties(
280
+ config.properties,
281
+ dataProperties,
282
+ );
283
+ applyDataPropertiesToQtiHost(currentPciHostDataProperties);
284
+ }
285
+
286
+ let currentPciFactory = null;
287
+ let currentPciConfig = null;
288
+ let currentPciHostDataProperties = {};
289
+ let currentPciInteraction = null;
290
+ let currentAssessmentItem = null;
291
+ let configUpdateTimer = null;
292
+
293
+ function ensureOptionalTaoMethods(target) {
294
+ if (!target || typeof target !== "object") {
295
+ return;
296
+ }
297
+ if (typeof target.off !== "function") {
298
+ target.off = () => {};
299
+ }
300
+ if (typeof target.on !== "function") {
301
+ target.on = () => {};
302
+ }
303
+ if (typeof target.trigger !== "function") {
304
+ target.trigger = () => {};
305
+ }
306
+ if (typeof target.resetResponse !== "function") {
307
+ target.resetResponse = () => {};
152
308
  }
153
309
  }
154
310
 
155
- function saveResponse() {
156
- const saveResponse = item.getResponse().base.string;
157
- localStorage.setItem(item.typeIdentifier + "_response", saveResponse);
311
+ function setCurrentRuntime(factory, interaction, config, dataProperties) {
312
+ const runtimeItem =
313
+ interaction && typeof interaction === "object"
314
+ ? interaction
315
+ : factory;
316
+ ensureOptionalTaoMethods(runtimeItem);
317
+ currentPciFactory = factory;
318
+ currentPciInteraction =
319
+ interaction && typeof interaction === "object" ? interaction : null;
320
+ currentPciConfig = config;
321
+ currentPciHostDataProperties = dataProperties || {};
322
+ renderDevtools();
323
+ renderConfigPanel();
158
324
  }
159
325
 
160
- function compareResponse() {
161
- const response = localStorage.getItem(item.typeIdentifier + "_response");
162
- if (!response) {
163
- alert("no response stored");
326
+ function refreshCurrentPci(options = {}) {
327
+ if (
328
+ !currentPciFactory ||
329
+ typeof currentPciFactory.getInstance !== "function"
330
+ ) {
164
331
  return;
165
332
  }
166
- if (response === item.getResponse().base.string) {
167
- alert("same");
168
- } else {
169
- alert("different");
333
+ const config = currentPciConfig || { properties: {} };
334
+ const state = Object.prototype.hasOwnProperty.call(
335
+ options,
336
+ "stateOverride",
337
+ )
338
+ ? options.stateOverride
339
+ : currentAssessmentItem?.state;
340
+ if (typeof currentPciInteraction?.oncompleted === "function") {
341
+ currentPciInteraction.oncompleted();
342
+ }
343
+ if (typeof currentPciInteraction?.destroy === "function") {
344
+ currentPciInteraction.destroy();
170
345
  }
346
+ const dataProperties = mergeConfigPropertiesIntoDataProperties(
347
+ config.properties,
348
+ currentPciHostDataProperties || {},
349
+ );
350
+ const dom = createQtiPciHost(currentPciFactory, dataProperties);
351
+ const originalOnReady = config.onready;
352
+ const configWithOnReady = {
353
+ ...config,
354
+ onready: (...args) => {
355
+ if (typeof originalOnReady === "function") {
356
+ originalOnReady(...args);
357
+ }
358
+ if (typeof options.onready === "function") {
359
+ options.onready(...args);
360
+ }
361
+ },
362
+ };
363
+ const interaction = currentPciFactory.getInstance(
364
+ dom,
365
+ configWithOnReady,
366
+ state,
367
+ );
368
+ setCurrentRuntime(
369
+ currentPciFactory,
370
+ interaction,
371
+ config,
372
+ dataProperties,
373
+ );
171
374
  }
172
375
 
173
- function reload() {
174
- const state = item.getState();
175
- localStorage.setItem(item.typeIdentifier + "_state", JSON.stringify(state));
176
- location.reload();
376
+ function renderDevtools() {
377
+ const devtools = document.getElementById("devtools");
378
+ if (!devtools) {
379
+ return;
380
+ }
381
+ render(html`<${App} />`, devtools);
177
382
  }
178
383
 
179
- async function playState() {
180
- if (store && !isReplaying.current) {
181
- store.reset();
182
- isReplaying.current = true;
183
- replayedIndex.current = 0;
184
- await store.replay([...actions]);
185
- isReplaying.current = false;
384
+ function applyConfigChangesDebounced() {
385
+ if (configUpdateTimer) {
386
+ clearTimeout(configUpdateTimer);
186
387
  }
388
+ configUpdateTimer = setTimeout(() => {
389
+ const current = currentPciConfig;
390
+ if (!currentPciFactory || !current || !current.properties) {
391
+ return;
392
+ }
393
+ syncConfigPropertiesToHostDataProperties(
394
+ current,
395
+ currentPciHostDataProperties,
396
+ );
397
+ localStorage.setItem(
398
+ currentPciFactory.typeIdentifier + "_props",
399
+ JSON.stringify(current.properties || {}),
400
+ );
401
+ refreshCurrentPci();
402
+ renderConfigPanel();
403
+ }, CONFIG_CHANGE_DEBOUNCE_MS);
187
404
  }
188
405
 
189
- function changeConfig(key, value) {
190
- item.config.properties[key] = value;
191
- item.trigger && item.trigger(`change${key}`, value);
406
+ function changeConfigProperty(key, value) {
407
+ const current = currentPciConfig;
408
+ if (!current || !current.properties) {
409
+ return;
410
+ }
411
+ current.properties[key] = value;
412
+ renderConfigPanel();
413
+ applyConfigChangesDebounced();
192
414
  }
193
415
 
194
- function reConfig(key, value) {
195
- localStorage.setItem(item.typeIdentifier + "_props", JSON.stringify(item.config.properties));
196
- location.reload();
416
+ function ConfigPanel() {
417
+ const configSource = currentPciConfig;
418
+ const keyArray = configSource?.properties
419
+ ? Object.keys(configSource.properties).map((keyName) => ({
420
+ key: keyName,
421
+ value: configSource.properties[keyName],
422
+ }))
423
+ : [];
424
+
425
+ return html`
426
+ <aside class="dev-sidepane">
427
+ <h6 class="mb-3">Config</h6>
428
+ <form onSubmit="${(e) => e.preventDefault()}">
429
+ ${keyArray.map(
430
+ ({ key, value }) =>
431
+ html`<div class="mb-2">
432
+ <label class="form-label text-muted mb-1">${key}</label>
433
+ <input
434
+ type="text"
435
+ class="form-control"
436
+ value="${value}"
437
+ onInput="${(e) =>
438
+ changeConfigProperty(key, e.target.value)}"
439
+ />
440
+ </div>`,
441
+ )}
442
+ </form>
443
+ </aside>
444
+ `;
197
445
  }
198
446
 
199
- function unload() {
200
- item.destroy && item.destroy();
201
- item.oncompleted && item.oncompleted();
202
- var name = require.undef("index.js");
203
- loadItem();
447
+ function renderConfigPanel() {
448
+ const pane = document.getElementById("right-pane");
449
+ if (!pane) {
450
+ return;
451
+ }
452
+ render(html`<${ConfigPanel} />`, pane);
204
453
  }
205
454
 
206
- return html`
207
- <div>
208
- <div style="width:300px" class="shadow offcanvas offcanvas-end ${show ? "show visible" : ""}">
209
- <div class="offcanvas-header">
210
- <h5 class="offcanvas-title" id="offcanvasExampleLabel">properties</h5>
211
- <button
212
- type="button"
213
- onClick="${() => setShow(false)}"
214
- disabled="${!item.config}"
215
- class="btn-close text-reset"
216
- ></button>
217
- </div>
218
- <div class="offcanvas-body">
219
- <ul class="card-body">
220
- ${keyArray.map(
221
- ({ key, value }) => html`<div>
222
- <div>${key}</div>
223
- <input
224
- type="text"
225
- class="form-control"
226
- value="${value}"
227
- onkeyup="${(e) => changeConfig(key, e.target.value)}"
228
- />
229
- </div>`
230
- )}
231
- </ul>
232
- <button class="btn btn-primary" onClick="${() => reConfig()}">save and reload</button>
233
- </div>
234
- </div>
455
+ function toPrettyJson(value) {
456
+ if (typeof value === "undefined") {
457
+ return "undefined";
458
+ }
459
+ try {
460
+ return JSON.stringify(value, null, 2);
461
+ } catch (error) {
462
+ return `[unserializable] ${String(error?.message || error)}`;
463
+ }
464
+ }
235
465
 
236
- <div class="d-flex justify-content-between mt-3" role="toolbar" aria-label="Toolbar with button groups">
237
- <div class="d-flex flex-column">
238
- <small class="text-muted">configuration</small>
239
- <div class="btn-group me-2 shadow-sm" role="group" aria-label="Basic checkbox toggle button group">
240
- <button class="btn btn-outline-secondary" onClick="${() => setShow(true)}">config</button>
241
- </div>
242
- </div>
466
+ function escapeHtml(value) {
467
+ return String(value)
468
+ .replace(/&/g, "&amp;")
469
+ .replace(/</g, "&lt;")
470
+ .replace(/>/g, "&gt;");
471
+ }
243
472
 
244
- <div class="d-flex flex-column">
245
- <small class="text-muted">data</small>
246
- <div class="btn-group me-2 shadow-sm" role="group" aria-label="Basic checkbox toggle button group">
247
- <button
248
- class="btn btn-outline-secondary"
249
- onClick="${() => alert(JSON.stringify(item.getResponse(), null, 4))}"
250
- >
251
- getResponse
252
- </button>
253
- <button class="btn btn-outline-secondary" onClick="${() => alert(item.getState())}">getStates</button>
254
- </div>
255
- </div>
473
+ function escapeHtmlAttribute(value) {
474
+ return String(value)
475
+ .replace(/&/g, "&amp;")
476
+ .replace(/"/g, "&quot;")
477
+ .replace(/</g, "&lt;")
478
+ .replace(/>/g, "&gt;");
479
+ }
256
480
 
257
- <div class="d-flex flex-column">
258
- <small class="text-muted">state</small>
259
- <div class="btn-group me-2 shadow-sm" role="group" aria-label="Basic checkbox toggle button group">
260
- <button class="btn btn-outline-secondary" onClick="${() => reload()}">reload</button>
261
- </div>
262
- </div>
481
+ function highlightJson(jsonText) {
482
+ const escaped = escapeHtml(jsonText);
483
+ return escaped.replace(
484
+ /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
485
+ (match) => {
486
+ if (match.startsWith('"') && match.endsWith(":")) {
487
+ return `<span class="json-key">${match}</span>`;
488
+ }
489
+ if (match.startsWith('"')) {
490
+ return `<span class="json-string">${match}</span>`;
491
+ }
492
+ if (match === "true" || match === "false") {
493
+ return `<span class="json-boolean">${match}</span>`;
494
+ }
495
+ if (match === "null") {
496
+ return `<span class="json-null">${match}</span>`;
497
+ }
498
+ return `<span class="json-number">${match}</span>`;
499
+ },
500
+ );
501
+ }
263
502
 
264
- <div class="d-flex flex-column">
265
- <small class="text-muted">scoring TAO (match correct string)</small>
266
- <div class="btn-group me-2 shadow-sm" role="group" aria-label="Basic checkbox toggle button group">
267
- <button class="btn btn-outline-secondary position-relative" onClick="${() => saveResponse()}">
268
- save
269
- </button>
270
- <button class="btn btn-outline-secondary" onClick="${() => compareResponse()}">compare</button>
271
- <button class="btn btn-outline-secondary" onClick="${() => setResponse()}">load</button>
272
- <button
273
- class="btn btn-outline-secondary"
274
- disabled="${!item.resetResponse}"
275
- onClick="${() => resetResponse()}"
276
- >
277
- clear
278
- </button>
279
- </div>
280
- </div>
503
+ function App() {
504
+ const [responseSnapshot, setResponseSnapshot] = useState("undefined");
505
+ const [stateSnapshot, setStateSnapshot] = useState("undefined");
506
+ const [isRestoringState, setIsRestoringState] = useState(false);
507
+ const interactionRefreshTimer = useRef(null);
508
+ const getCurrentAssessmentItem = () =>
509
+ currentAssessmentItem || getQtiAssessmentItemElement();
510
+
511
+ function refreshLiveData() {
512
+ const assessmentItem = getCurrentAssessmentItem();
513
+ let responseValue = undefined;
514
+ let stateValue = undefined;
515
+
516
+ if (assessmentItem) {
517
+ try {
518
+ stateValue = assessmentItem.state;
519
+ } catch (error) {
520
+ stateValue = `[assessment state threw] ${String(error?.message || error)}`;
521
+ }
522
+ try {
523
+ const variables = assessmentItem.variables;
524
+ const responseVariable = Array.isArray(variables)
525
+ ? variables.find((v) => v?.identifier === "RESPONSE")
526
+ : undefined;
527
+ responseValue = responseVariable?.value;
528
+ } catch (error) {
529
+ responseValue = `[assessment variables threw] ${String(error?.message || error)}`;
530
+ }
531
+ }
532
+
533
+ setResponseSnapshot(toPrettyJson(responseValue));
534
+ setStateSnapshot(toPrettyJson(stateValue));
535
+ }
536
+
537
+ useEffect(() => {
538
+ refreshLiveData();
539
+ }, []);
540
+
541
+ useEffect(() => {
542
+ const container = document.getElementById("pciContainer");
543
+ if (!container) return;
544
+ const onAssessmentItemConnected = (event) => {
545
+ const detail = event?.detail;
546
+ const candidate =
547
+ detail?.assessmentItem ||
548
+ detail?.["qti-assessment-item"] ||
549
+ (detail?.tagName === "QTI-ASSESSMENT-ITEM" ? detail : null);
550
+ if (candidate && candidate.tagName === "QTI-ASSESSMENT-ITEM") {
551
+ currentAssessmentItem = candidate;
552
+ refreshLiveData();
553
+ }
554
+ };
555
+ const onInteractionChanged = (event) => {
556
+ if (interactionRefreshTimer.current) {
557
+ clearTimeout(interactionRefreshTimer.current);
558
+ }
559
+ interactionRefreshTimer.current = setTimeout(() => {
560
+ refreshLiveData();
561
+ interactionRefreshTimer.current = null;
562
+ }, INTERACTION_REFRESH_DELAY_MS);
563
+ };
564
+ container.addEventListener(
565
+ "qti-assessment-item-connected",
566
+ onAssessmentItemConnected,
567
+ );
568
+ container.addEventListener(
569
+ "qti-interaction-changed",
570
+ onInteractionChanged,
571
+ );
572
+ return () => {
573
+ if (interactionRefreshTimer.current) {
574
+ clearTimeout(interactionRefreshTimer.current);
575
+ interactionRefreshTimer.current = null;
576
+ }
577
+ container.removeEventListener(
578
+ "qti-assessment-item-connected",
579
+ onAssessmentItemConnected,
580
+ );
581
+ container.removeEventListener(
582
+ "qti-interaction-changed",
583
+ onInteractionChanged,
584
+ );
585
+ };
586
+ }, []);
587
+
588
+ useEffect(() => {
589
+ const onGlobalInteractionChanged = (event) => {
590
+ if (interactionRefreshTimer.current) {
591
+ clearTimeout(interactionRefreshTimer.current);
592
+ }
593
+ interactionRefreshTimer.current = setTimeout(() => {
594
+ refreshLiveData();
595
+ interactionRefreshTimer.current = null;
596
+ }, INTERACTION_REFRESH_DELAY_MS);
597
+ };
598
+ document.addEventListener(
599
+ "qti-interaction-changed",
600
+ onGlobalInteractionChanged,
601
+ true,
602
+ );
603
+ return () => {
604
+ document.removeEventListener(
605
+ "qti-interaction-changed",
606
+ onGlobalInteractionChanged,
607
+ true,
608
+ );
609
+ };
610
+ }, []);
611
+
612
+ useEffect(() => {
613
+ return () => {
614
+ if (configUpdateTimer) {
615
+ clearTimeout(configUpdateTimer);
616
+ configUpdateTimer = null;
617
+ }
618
+ if (interactionRefreshTimer.current) {
619
+ clearTimeout(interactionRefreshTimer.current);
620
+ interactionRefreshTimer.current = null;
621
+ }
622
+ };
623
+ }, []);
624
+
625
+ function emptyAndRestoreState() {
626
+ if (isRestoringState) return;
627
+ const storedState = currentAssessmentItem?.state;
628
+ const storageKey = currentPciFactory
629
+ ? `${currentPciFactory.typeIdentifier}_state_restore`
630
+ : "pci_state_restore";
631
+ try {
632
+ sessionStorage.setItem(storageKey, JSON.stringify(storedState));
633
+ } catch (error) {
634
+ // Ignore storage failures; fallback to in-memory state.
635
+ }
636
+
637
+ let reinitState = storedState;
638
+ try {
639
+ const fromStorage = sessionStorage.getItem(storageKey);
640
+ if (fromStorage !== null) {
641
+ reinitState = JSON.parse(fromStorage);
642
+ }
643
+ } catch (error) {
644
+ reinitState = storedState;
645
+ }
281
646
 
282
- <div class="d-flex flex-column">
283
- <small class="text-muted">ㅤ</small>
284
- <div class="d-flex">
285
- <button
286
- class="btn bg-light shadow-sm btn-outline-danger me-2"
287
- disabled="${!(item.destroy || item.oncompleted)}"
288
- onClick="${() => unload()}"
647
+ setIsRestoringState(true);
648
+ refreshCurrentPci({
649
+ stateOverride: reinitState,
650
+ onready: () => {
651
+ setIsRestoringState(false);
652
+ refreshLiveData();
653
+ },
654
+ });
655
+ }
656
+
657
+ return html`
658
+ <div class="dev-layout">
659
+ <div class="dev-main">
660
+ <div class="row g-3 mt-1">
661
+ <div class="col-md-6">
662
+ <h6 class="mb-1">Response</h6>
663
+ <pre class="json-block"><code dangerouslySetInnerHTML=${{
664
+ __html: highlightJson(responseSnapshot),
665
+ }}></code></pre>
666
+ </div>
667
+ <div class="col-md-6">
668
+ <div
669
+ class="d-flex justify-content-between align-items-center"
289
670
  >
290
- unload
291
- </button>
292
- <button class="btn bg-light shadow-sm btn-outline-danger" onClick="${() => reset()}">reset</button>
671
+ <h6 class="mb-1">State</h6>
672
+ <button
673
+ class="icon-btn ${isRestoringState ? "spin" : ""}"
674
+ title="empty the state and restore"
675
+ onClick="${() => emptyAndRestoreState()}"
676
+ >
677
+ ↻
678
+ </button>
679
+ </div>
680
+ <pre class="json-block"><code dangerouslySetInnerHTML=${{
681
+ __html: highlightJson(stateSnapshot),
682
+ }}></code></pre>
293
683
  </div>
294
684
  </div>
295
685
  </div>
296
- ${store && html`<div class="mt-3 d-flex flex-column gap-2">
297
- <div class="d-flex gap-2 align-items-center">
298
- <button class="btn btn-outline-secondary" onClick="${() => playState()}">
299
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play" viewBox="0 0 16 16">
300
- <path d="M3.905 14.21a1 1 0 0 1-1.555-.832V2.622a1 1 0 0 1 1.555-.832l9.066 5.293a1 1 0 0 1 0 1.664l-9.066 5.293zm-.57-1.664L11.4 8 3.335 2.454v10.092z"/>
301
- </svg>
302
- </button>
303
- <div class="w-100 d-flex"><input
304
- class="form-control-range w-100"
305
- type="range"
306
- min="0"
307
- max="${maxRange}"
308
- value=${currentRangeValue}
309
- onInput=${(e) => scrubRange(e.target.value)}
310
- step="1"
311
- /><div>${maxRange}</div></div>
312
- </div>
313
- </div>` }
314
-
315
686
  </div>
316
687
  `;
317
- }
318
-
319
- function register(item) {
320
- let state = undefined;
321
- const localStorageState = localStorage.getItem(item.typeIdentifier + "_state");
322
- if (localStorageState) {
323
- state = JSON.parse(localStorageState);
324
- }
325
- let props = {};
326
- const localStorageProps = localStorage.getItem(item.typeIdentifier + "_props");
327
- if (localStorageProps) {
328
- props = JSON.parse(localStorageProps);
329
- }
330
- const dom = document.getElementById("pciContainer");
331
- const config = {
332
- onready: () => {
333
- document.getElementById("loading").innerHTML = "";
334
- },
335
- properties: props,
336
- };
688
+ }
689
+ function register(item) {
690
+ let state = undefined;
691
+ const localStorageState = localStorage.getItem(
692
+ item.typeIdentifier + "_state",
693
+ );
694
+ if (localStorageState) {
695
+ state = JSON.parse(localStorageState);
696
+ }
697
+ let props = {};
698
+ const localStorageProps = localStorage.getItem(
699
+ item.typeIdentifier + "_props",
700
+ );
701
+ if (localStorageProps) {
702
+ props = JSON.parse(localStorageProps);
703
+ }
704
+ const dataProperties = mergeConfigPropertiesIntoDataProperties(props);
705
+ item.qtiHostDataProperties = dataProperties;
706
+ const dom = createQtiPciHost(item, dataProperties);
707
+ const config = {
708
+ onready: () => {
709
+ const loading = document.getElementById("loading");
710
+ if (loading) {
711
+ loading.innerHTML = "";
712
+ }
713
+ },
714
+ properties: props,
715
+ };
337
716
 
338
- item.getInstance(dom, config, state);
717
+ const interaction = item.getInstance(dom, config, state);
718
+ setCurrentRuntime(item, interaction, config, dataProperties);
719
+ syncConfigPropertiesToHostDataProperties(config, dataProperties);
720
+ localStorage.removeItem(item.typeIdentifier + "_state");
721
+ }
339
722
 
340
- if (item.getResponse() !== undefined) {
341
- // document.getElementById("noresponse").style.display = "block";
723
+ async function loadQtiComponents() {
724
+ if (customElements.get("qti-portable-custom-interaction")) {
725
+ return true;
726
+ }
727
+ const moduleCandidates = [
728
+ "https://cdn.jsdelivr.net/npm/@citolab/qti-components/+esm",
729
+ "https://cdn.jsdelivr.net/npm/@citolab/qti-components/dist/index.js",
730
+ "https://unpkg.com/@citolab/qti-components/dist/index.js?module",
731
+ ];
732
+
733
+ for (const modulePath of moduleCandidates) {
734
+ try {
735
+ await import(modulePath);
736
+ if (customElements.get("qti-portable-custom-interaction")) {
737
+ return true;
738
+ }
739
+ } catch (error) {
740
+ // Try the next candidate.
741
+ }
742
+ }
743
+
744
+ console.warn(
745
+ "Could not load qti-components; using default PCI container rendering.",
746
+ );
747
+ return false;
342
748
  }
343
- localStorage.removeItem(item.typeIdentifier + "_state");
344
- }
345
749
 
346
- define("qtiCustomInteractionContext", (qtiCustomInteractionContext) => {
347
- return { register: (ctxA) => register(ctxA) };
348
- });
750
+ function createQtiPciHost(item, dataProperties = {}) {
751
+ const container = document.getElementById("pciContainer");
752
+ container.innerHTML = "";
753
+ currentAssessmentItem = null;
754
+
755
+ if (!customElements.get("qti-portable-custom-interaction")) {
756
+ return container;
757
+ }
758
+
759
+ const typeIdentifier = item.typeIdentifier || "pci";
760
+ const escapedTypeIdentifier = escapeHtmlAttribute(typeIdentifier);
761
+ const dataAttributes = Object.keys(dataProperties || {})
762
+ .map((key) => {
763
+ const normalizedKey = String(key || "")
764
+ .trim()
765
+ .replace(/^data-/, "");
766
+ if (!normalizedKey) {
767
+ return "";
768
+ }
769
+ const value = dataProperties[key];
770
+ if (value === null || typeof value === "undefined") {
771
+ return "";
772
+ }
773
+ return ` data-${normalizedKey}="${escapeHtmlAttribute(value)}"`;
774
+ })
775
+ .join("");
776
+
777
+ const qtiItem = document.createElement("qti-item");
778
+ qtiItem.innerHTML = `
779
+ <qti-assessment-item identifier="PREVIEW-ITEM" title="${escapedTypeIdentifier}" adaptive="false" time-dependent="false">
780
+ <qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string"></qti-response-declaration>
781
+ <qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float"></qti-outcome-declaration>
782
+ <qti-item-body>
783
+ <qti-portable-custom-interaction response-identifier="RESPONSE" module="pci" custom-interaction-type-identifier="${escapedTypeIdentifier}"${dataAttributes}>
784
+ <qti-interaction-modules>
785
+ <qti-interaction-module id="pci" primary-path="./index"></qti-interaction-module>
786
+ </qti-interaction-modules>
787
+ <qti-interaction-markup>
788
+ <div data-qti-pci-runtime-host style="height:100%"></div>
789
+ </qti-interaction-markup>
790
+ </qti-portable-custom-interaction>
791
+ </qti-item-body>
792
+ </qti-assessment-item>
793
+ `;
794
+ container.appendChild(qtiItem);
795
+
796
+ return (
797
+ qtiItem.querySelector(
798
+ "[data-qti-pci-runtime-host]",
799
+ ) || qtiItem.querySelector("qti-portable-custom-interaction")
800
+ );
801
+ }
349
802
 
350
- function loadItem() {
351
- requirejs(["index.js"], (itemInstance) => {
352
- render(html`<${App} item="${itemInstance}" />`, document.getElementById("devtools"));
803
+ define("qtiCustomInteractionContext", (qtiCustomInteractionContext) => {
804
+ return { register: (ctxA) => register(ctxA) };
353
805
  });
354
- }
355
- window.onload = () => loadItem();
356
- </script>
357
-
358
- <h3 style="margin-top: 12rem">checklist</h3>
359
- <ul style="margin-bottom: 10rem">
360
- <li>Is the css scoped to the item?</li>
361
- <li>Is the css/item somewhat responsive on smaller screens</li>
362
- <li>Can you reset the item to its initial state</li>
363
- <li>Can it restore its state ( reload )</li>
364
- <li>Can you change the config</li>
365
- <li>Colorblind</li>
366
- <li>Touch/Mouse friendly</li>
367
- <li>Works in older browser like IE11</li>
368
- <li>Destroys itself correctly, removing event listeners</li>
369
- <li>No objects or variables on global scope</li>
370
- <li>Triggers correctly on changed config properties</li>
371
- <li>Implements on off() methods of tao?</li>
372
- <li>Response type and response correct</li>
373
- <li>getResponse() should give undefined when user has not done anything</li>
374
- <li>For TAO to enter response is the entered comparable by the response given</li>
375
- </ul>
376
- </body>
377
-
378
- </html>
806
+
807
+ function loadItem() {
808
+ requirejs(["index.js"], (itemInstance) => {
809
+ currentAssessmentItem = null;
810
+ if (!currentPciFactory) {
811
+ currentPciFactory = itemInstance;
812
+ }
813
+ renderDevtools();
814
+ });
815
+ }
816
+ window.onload = async () => {
817
+ // Some runtimes assume a global `off()` exists (TAO compatibility).
818
+ // In the live IMS preview this should be optional, so provide a safe fallback.
819
+ if (typeof globalThis.off !== "function") {
820
+ globalThis.off = () => {};
821
+ }
822
+ await loadQtiComponents();
823
+ loadItem();
824
+ };
825
+ </script>
826
+
827
+ <!--
828
+ <h3 style="margin-top: 12rem">checklist</h3>
829
+ <ul style="margin-bottom: 10rem">
830
+ <li>Is the css scoped to the item?</li>
831
+ <li>Is the css/item somewhat responsive on smaller screens</li>
832
+ <li>Can you reset the item to its initial state</li>
833
+ <li>Can it restore its state ( reload )</li>
834
+ <li>Can you change the config</li>
835
+ <li>Colorblind</li>
836
+ <li>Touch/Mouse friendly</li>
837
+ <li>Works in older browser like IE11</li>
838
+ <li>Destroys itself correctly, removing event listeners</li>
839
+ <li>No objects or variables on global scope</li>
840
+ <li>Triggers correctly on changed config properties</li>
841
+ <li>Implements on off() methods of tao?</li>
842
+ <li>Response type and response correct</li>
843
+ <li>
844
+ getResponse() should give undefined when user has not done anything
845
+ </li>
846
+ <li>
847
+ For TAO to enter response is the entered comparable by the response
848
+ given
849
+ </li>
850
+ </ul>
851
+ -->
852
+ </body>
853
+ </html>