@citolab/tspci-tao 2.12.1 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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.14.0](https://github.com/Citolab/tspci/compare/v2.13.0...v2.14.0) (2026-10-14)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @citolab/tspci-tao
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# [2.13.0](https://github.com/Citolab/tspci/compare/v2.12.1...v2.13.0) (2026-10-14)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **tspci-tao:** typed properties form and a Configure button in TAO ([406a940](https://github.com/Citolab/tspci/commit/406a940fde13990c14bda3984a8a4451c49617d1))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
6
25
|
## [2.12.1](https://github.com/Citolab/tspci/compare/v2.12.0...v2.12.1) (2026-08-19)
|
|
7
26
|
|
|
8
27
|
**Note:** Version bump only for package @citolab/tspci-tao
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@citolab/tspci-tao",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "create a zip file that can be imported into TAO",
|
|
5
5
|
"author": "patrick <patrick.deklein@cito.nl>, Marcel Hoekstra <marcel.hoekstra@cito.nl>",
|
|
6
|
-
"repository":
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Citolab/tspci.git",
|
|
9
|
+
"directory": "lib/tspci-tao"
|
|
10
|
+
},
|
|
7
11
|
"type": "module",
|
|
8
12
|
"types": "types/index.d.ts",
|
|
9
13
|
"dependencies": {
|
|
@@ -16,5 +20,5 @@
|
|
|
16
20
|
"access": "public",
|
|
17
21
|
"registry": "https://registry.npmjs.org/"
|
|
18
22
|
},
|
|
19
|
-
"gitHead": "
|
|
23
|
+
"gitHead": "d72febca7cea937f77be4cef910a7f2d72fd524f"
|
|
20
24
|
}
|
package/readme.md
CHANGED
|
@@ -13,6 +13,53 @@ This package allows the @citolab/tspci-bundler to create a package which can be
|
|
|
13
13
|
|
|
14
14
|
PCI's can be imported in TAO as a ZIP file. The ZIP generated contains the PCI itself and metadata enabling the PCI to be shown in the TAO creator platform.
|
|
15
15
|
Configuration of a PCI can be done by the author because this packager reads the config.json file in the src folder
|
|
16
|
+
A `config.json` value may also be an object with a `default` and editor hints (the authoring descriptor, see
|
|
17
|
+
[@citolab/tspci](https://github.com/Citolab/tspci/tree/main/lib/tspci#authoring-systems)). TAO's properties form then
|
|
18
|
+
shows the setting's `label` (the English one when it is given per language), with its `hint` (else the label) in the
|
|
19
|
+
tooltip, and a control for its `editor`:
|
|
20
|
+
|
|
21
|
+
| editor | TAO's form shows | the PCI gets |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `text` | a text field | the text |
|
|
24
|
+
| `textarea` | a larger text field | the text |
|
|
25
|
+
| `integer`, `number` | a number field with `min`, `max` and `step` (1 for an integer) | `"200"` |
|
|
26
|
+
| `boolean` | a checkbox | `"true"` / `"false"` |
|
|
27
|
+
| `enum` | a dropdown with the labels of the `options` | the option's value |
|
|
28
|
+
| `color` | a colour picker and a hex field, kept in sync | `"#4b9f4a"` |
|
|
29
|
+
| `colorList` | a colour picker per colour, with a button to add one and one to remove each (at least one stays) | `"#c91a09, #0055bf"` |
|
|
30
|
+
| `hidden` | nothing; the interaction keeps the default | the default |
|
|
31
|
+
| `pci` | a text field below the "Configure…" button (see below) | what the PCI's editor writes |
|
|
32
|
+
| another | a text field | the text |
|
|
33
|
+
|
|
34
|
+
A setting given as a plain default (`"width": "200"`) keeps the field it always had: its name as the label, and a
|
|
35
|
+
number field for a whole number, else a text field. Every change is written to the interaction and passed to the
|
|
36
|
+
PCI's `trigger`, as text, the way a `data-` attribute gives it.
|
|
37
|
+
|
|
38
|
+
The editor hints are read by `buildDescriptor` of the project's `@citolab/tspci` (as for `dist/authoring.json`). With
|
|
39
|
+
an older `@citolab/tspci` without it, every setting gets a text field.
|
|
40
|
+
|
|
41
|
+
### Authoring mode: "Configure…"
|
|
42
|
+
|
|
43
|
+
Settings with `editor: "pci"` are filled by the PCI itself, in its authoring mode (`getAuthoringInstance`, see
|
|
44
|
+
[@citolab/tspci](https://github.com/Citolab/tspci/tree/main/lib/tspci) AUTHORING.md, "Authoring mode"). For such a PCI
|
|
45
|
+
the properties form gets a button above the first of those settings. Its text is `config.tspci.authoring.label` in
|
|
46
|
+
package.json (the English one when it is given per language, through TAO's translations), or "Configure…".
|
|
47
|
+
|
|
48
|
+
The button opens TAO's modal with the PCI's authoring UI in it, sized after `config.tspci.authoring.width/height` (at
|
|
49
|
+
most 70% of the window height). The PCI gets all current settings of the interaction and TAO's UI language.
|
|
50
|
+
|
|
51
|
+
- **Done** asks the PCI to `validate()`. Problems are shown, and the dialog stays open. Otherwise the settings that
|
|
52
|
+
`getConfig()` returns (only the `pci` ones) are written to the interaction and passed to the PCI's `trigger`,
|
|
53
|
+
like a change in the form, and the dialog closes.
|
|
54
|
+
- **Cancel**, the close button or Escape close it without changes.
|
|
55
|
+
|
|
56
|
+
The text field of a `pci` setting stays below the button: it is the way to clear the value (back to the PCI's
|
|
57
|
+
default), to copy it from another item, and the only way to change it when the module has no authoring mode. In
|
|
58
|
+
that case the dialog says so instead of showing the editor.
|
|
59
|
+
|
|
60
|
+
TAO runs the creator in its own page with RequireJS, so the button calls the runtime module directly; there is no
|
|
61
|
+
iframe and no message protocol, unlike authoring systems that sandbox the PCI. A PCI without `pci` settings gets
|
|
62
|
+
exactly the package it got before.
|
|
16
63
|
|
|
17
64
|
If scoring can be done with a MATCH_CORRECT (single string comparison) the item author can provide the correct answer in TAO by interaction with the PCI.
|
|
18
65
|
This way one PCI can be re-used in different configurations supporting multiple items.
|
package/src/createPackage.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs, { mkdirSync } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
3
5
|
import yazl from "yazl";
|
|
4
6
|
|
|
5
|
-
export const bundle = () => {
|
|
7
|
+
export const bundle = async () => {
|
|
6
8
|
const PACKAGE_ROOT_PATH = process.cwd();
|
|
7
9
|
|
|
8
10
|
const PACKAGER_PATH = path.join(
|
|
@@ -107,27 +109,43 @@ export const bundle = () => {
|
|
|
107
109
|
// ---------------------------------------------------------------------
|
|
108
110
|
|
|
109
111
|
// add variables from taoConfigValues, to be able to configurate PCI's
|
|
112
|
+
let authoringOptions = null;
|
|
113
|
+
let controlsOptions = null;
|
|
110
114
|
const configPath = path.join(PACKAGE_ROOT_PATH, "src/config.json");
|
|
111
115
|
|
|
112
116
|
if (fs.existsSync(configPath)) {
|
|
113
117
|
let configValues;
|
|
114
118
|
let configJson = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
119
|
+
// the editor hints, read the way the tspci build reads them for dist/authoring.json
|
|
120
|
+
const properties = await describedProperties(PACKAGE_ROOT_PATH, packageData, configJson);
|
|
115
121
|
configValues = Object.keys(configJson).reduce((acc, elem) => {
|
|
116
|
-
|
|
122
|
+
// A config.json value is a default, or { default, editor, label, ... } (the authoring descriptor, see
|
|
123
|
+
// AUTHORING.md): TAO's form gets the default, and the editor decides the field when there is one.
|
|
124
|
+
const entry = configJson[elem];
|
|
125
|
+
const described = entry !== null && typeof entry === "object" && !Array.isArray(entry);
|
|
126
|
+
const defaultValue = described ? String(entry.default ?? "") : entry;
|
|
117
127
|
|
|
118
128
|
let type = "string";
|
|
119
|
-
if (
|
|
129
|
+
if (described && (entry.editor === "integer" || entry.editor === "number")) {
|
|
130
|
+
type = "number";
|
|
131
|
+
} else if (described && entry.editor === "boolean") {
|
|
132
|
+
type = "boolean";
|
|
133
|
+
} else if (!described && /^-?\d+$/.test(String(defaultValue))) {
|
|
120
134
|
type = "number";
|
|
121
135
|
}
|
|
122
|
-
if (isBoolean(defaultValue)) {
|
|
136
|
+
if (!described && isBoolean(defaultValue)) {
|
|
123
137
|
type = "boolean";
|
|
124
138
|
}
|
|
125
139
|
|
|
126
140
|
acc.push({
|
|
127
141
|
name: `${elem}`,
|
|
128
142
|
type,
|
|
129
|
-
|
|
143
|
+
// a described default goes into the creator's code as a JS string literal, so quotes and newlines are safe
|
|
144
|
+
defaultValue: described ? JSON.stringify(defaultValue) : `"${defaultValue}"`, // type === "number" ? `${defaultValue}` : `"${defaultValue}"`,
|
|
130
145
|
optional: true,
|
|
146
|
+
managed: described && entry.editor === "pci",
|
|
147
|
+
// the typed control (formControl), only for a described setting: a plain default keeps the text field
|
|
148
|
+
property: described ? properties?.[elem] : undefined,
|
|
131
149
|
});
|
|
132
150
|
return acc;
|
|
133
151
|
}, []);
|
|
@@ -187,7 +205,10 @@ export const bundle = () => {
|
|
|
187
205
|
|
|
188
206
|
const callbacks = `${configValues
|
|
189
207
|
.map((cv) => {
|
|
190
|
-
|
|
208
|
+
// a checkbox gives true/false; the PCI gets "true"/"false", as from a data- attribute
|
|
209
|
+
const toText = cv.property?.editor === "boolean" ? `
|
|
210
|
+
value = String(value === true || value === 'true');` : "";
|
|
211
|
+
return `${cv.name} : function(interaction, value){${toText}
|
|
191
212
|
//update the pci property value:
|
|
192
213
|
interaction.prop('${cv.name}', value);
|
|
193
214
|
|
|
@@ -235,25 +256,12 @@ export const bundle = () => {
|
|
|
235
256
|
outputDirectoryTemp,
|
|
236
257
|
"interaction/creator/tpl/propertiesForm.tpl"
|
|
237
258
|
);
|
|
259
|
+
const firstManaged = configValues.find((cv) => cv.managed);
|
|
238
260
|
const template = `${configValues
|
|
239
261
|
.map((cv) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
243
|
-
<span class="icon-help tooltipstered" data-tooltip="~ .tooltip-content:first" data-tooltip-theme="info"></span>
|
|
244
|
-
<span class="tooltip-content">{{__ "${capitalizedName}"}}</span>
|
|
245
|
-
<input type="text"
|
|
246
|
-
name="${cv.name}"
|
|
247
|
-
type="${
|
|
248
|
-
cv.type === "string"
|
|
249
|
-
? "text"
|
|
250
|
-
: cv.type === "number"
|
|
251
|
-
? "number"
|
|
252
|
-
: "text"
|
|
253
|
-
}"
|
|
254
|
-
value="{{${cv.name}}}"
|
|
255
|
-
placeholder="e.g. ${replaceAll(cv.defaultValue, '"', "")}"
|
|
256
|
-
data-validate="${cv.optional ? "" : "$notEmpty"};">`;
|
|
262
|
+
// the authoring mode's button goes above the first setting the PCI fills itself
|
|
263
|
+
const button = cv === firstManaged ? authoringButton(packageData) : "";
|
|
264
|
+
return `${button}${cv.property ? formControl(cv) : plainControl(cv)}`;
|
|
257
265
|
})
|
|
258
266
|
.join("\r\n")}
|
|
259
267
|
`;
|
|
@@ -262,12 +270,50 @@ export const bundle = () => {
|
|
|
262
270
|
`<!-- ##_HTML_CONTROLS_## -->`,
|
|
263
271
|
template
|
|
264
272
|
);
|
|
273
|
+
|
|
274
|
+
// name → editor of the typed controls that start from the property (see creatorControls.fragment.js)
|
|
275
|
+
const typed = configValues.filter((cv) => cv.property && !["hidden", "pci"].includes(cv.property.editor));
|
|
276
|
+
if (typed.length) {
|
|
277
|
+
controlsOptions = Object.fromEntries(typed.map((cv) => [cv.name, controlEditor(cv.property.editor)]));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (firstManaged) {
|
|
281
|
+
authoringOptions = {
|
|
282
|
+
properties: configValues.filter((cv) => cv.managed).map((cv) => cv.name),
|
|
283
|
+
settings: configValues.map((cv) => cv.name),
|
|
284
|
+
width: packageData.config.tspci.authoring?.width,
|
|
285
|
+
height: packageData.config.tspci.authoring?.height,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
265
288
|
} else {
|
|
266
289
|
console.warn(
|
|
267
290
|
"Create a config.json with properties to generate a TAO config"
|
|
268
291
|
);
|
|
269
292
|
}
|
|
270
293
|
|
|
294
|
+
// Typed controls and authoring mode: Question.js gets their code only when config.json asks for them. Without it the
|
|
295
|
+
// marker lines go, so the file is exactly what it was before either existed.
|
|
296
|
+
const questionFile = path.join(outputDirectoryTemp, "interaction/creator/widget/states/Question.js");
|
|
297
|
+
const fragment = (file, marker, options) =>
|
|
298
|
+
options
|
|
299
|
+
? fs
|
|
300
|
+
.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file), "utf8")
|
|
301
|
+
.replace(`'${marker}'`, () => JSON.stringify(options))
|
|
302
|
+
.replace(/\s+$/, "")
|
|
303
|
+
: "";
|
|
304
|
+
fs.writeFileSync(
|
|
305
|
+
questionFile,
|
|
306
|
+
fs
|
|
307
|
+
.readFileSync(questionFile, "utf8")
|
|
308
|
+
.replace(/\r?\n[ \t]*\/\/ ##__CONTROLS__##/, () =>
|
|
309
|
+
fragment("creatorControls.fragment.js", "##__CONTROLS_OPTIONS__##", controlsOptions)
|
|
310
|
+
)
|
|
311
|
+
.replace(/\r?\n[ \t]*\/\/ ##__AUTHORING__##/, () =>
|
|
312
|
+
fragment("creatorAuthoring.fragment.js", "##__AUTHORING_OPTIONS__##", authoringOptions)
|
|
313
|
+
),
|
|
314
|
+
"utf8"
|
|
315
|
+
);
|
|
316
|
+
|
|
271
317
|
// copy specific files
|
|
272
318
|
if (fs.existsSync(path.join(PACKAGE_ROOT_PATH, "tao_pci"))) {
|
|
273
319
|
copyDirectoryRecursiveSync(
|
|
@@ -353,7 +399,8 @@ export const bundle = () => {
|
|
|
353
399
|
function replace_string_in_file(filename, to_replace, replace_with) {
|
|
354
400
|
var data = fs.readFileSync(filename, "utf8");
|
|
355
401
|
|
|
356
|
-
|
|
402
|
+
// a function, so a $ in a label or default ("$&", "$'") is not a replacement pattern
|
|
403
|
+
var result = data.replace(new RegExp(to_replace, "g"), () => replace_with);
|
|
357
404
|
fs.writeFileSync(filename, result, "utf8");
|
|
358
405
|
}
|
|
359
406
|
|
|
@@ -411,6 +458,132 @@ function addDirectoryToZip(zipFile, rootDir, currentDir) {
|
|
|
411
458
|
}
|
|
412
459
|
}
|
|
413
460
|
|
|
461
|
+
// The button that opens the authoring mode: config.tspci.authoring.label (a text, or one per language, of which
|
|
462
|
+
// TAO's {{__}} gets the English one), else "Configure…".
|
|
463
|
+
function authoringButton(packageData) {
|
|
464
|
+
const text = readable(packageData.config.tspci.authoring?.label) || "Configure…";
|
|
465
|
+
return `<div><button type="button" class="btn-info small" data-pci-authoring>{{__ "${String(text).replace(/["\\{}]/g, "")}"}}</button></div>
|
|
466
|
+
`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// A text given per language (a label, a hint): the English one, as TAO's own strings are, else the first.
|
|
470
|
+
function readable(text) {
|
|
471
|
+
return typeof text === "string" ? text : text?.en ?? Object.values(text ?? {})[0];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// The properties of the authoring descriptor, from buildDescriptor in the project's @citolab/tspci, so TAO reads the
|
|
475
|
+
// editor hints exactly as the build does. An older tspci without it: no typed controls, every setting a text field.
|
|
476
|
+
async function describedProperties(projectRoot, packageData, config) {
|
|
477
|
+
let authoring;
|
|
478
|
+
try {
|
|
479
|
+
const file = createRequire(path.join(projectRoot, "package.json")).resolve("@citolab/tspci/src/authoring.js");
|
|
480
|
+
authoring = await import(pathToFileURL(file).href);
|
|
481
|
+
} catch {
|
|
482
|
+
console.warn("@citolab/tspci has no authoring descriptor (src/authoring.js): TAO's form gets text fields only");
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
return authoring.buildDescriptor(packageData, config).descriptor.properties;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Text in the form template: as HTML, in an element or a quoted attribute, and without anything Handlebars reads
|
|
489
|
+
// ({{ }} and the backslash that escapes them), since a label or a default is the PCI developer's text, not template.
|
|
490
|
+
function escapeHtml(value) {
|
|
491
|
+
const entities = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", "{": "{", "}": "}", "\\": "\" };
|
|
492
|
+
return String(value).replace(/[&<>"'{}\\]/g, (c) => entities[c]);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// The editors creatorControls.fragment.js treats differently; the rest start from the property as text.
|
|
496
|
+
function controlEditor(editor) {
|
|
497
|
+
return ["boolean", "enum", "color", "colorList"].includes(editor) ? editor : "text";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A setting given as a plain default: the text field it always had (now with one type attribute: number for a whole
|
|
501
|
+
// number, which TAO's validation passes to the change callback like a text field).
|
|
502
|
+
function plainControl(cv) {
|
|
503
|
+
const capitalizedName = cv.name.charAt(0).toUpperCase() + cv.name.slice(1);
|
|
504
|
+
return `<label for="${cv.name}">{{__ "${capitalizedName}"}}</label>
|
|
505
|
+
<span class="icon-help tooltipstered" data-tooltip="~ .tooltip-content:first" data-tooltip-theme="info"></span>
|
|
506
|
+
<span class="tooltip-content">{{__ "${capitalizedName}"}}</span>
|
|
507
|
+
<input type="${cv.type === "number" ? "number" : "text"}"
|
|
508
|
+
name="${cv.name}"
|
|
509
|
+
value="{{${cv.name}}}"
|
|
510
|
+
placeholder="e.g. ${replaceAll(cv.defaultValue, '"', "")}"
|
|
511
|
+
data-validate="${cv.optional ? "" : "$notEmpty"};">`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// A described setting: the control of its editor, with its label and its hint as the tooltip. TAO's formElement
|
|
515
|
+
// passes changes of text fields (through its validation), selects, checkboxes and hidden fields to the change
|
|
516
|
+
// callbacks; the colour pickers write the setting's hidden field (creatorControls.fragment.js).
|
|
517
|
+
function formControl(cv) {
|
|
518
|
+
const property = cv.property;
|
|
519
|
+
const name = escapeHtml(cv.name);
|
|
520
|
+
const label = escapeHtml(readable(property.label) || cv.name.charAt(0).toUpperCase() + cv.name.slice(1));
|
|
521
|
+
const tooltip = `<span class="icon-help tooltipstered" data-tooltip="~ .tooltip-content:first" data-tooltip-theme="info"></span>
|
|
522
|
+
<span class="tooltip-content">${escapeHtml(readable(property.hint) || readable(property.label) || label)}</span>`;
|
|
523
|
+
const header = `<label for="${name}">${label}</label>
|
|
524
|
+
${tooltip}`;
|
|
525
|
+
const value = `{{${cv.name}}}`;
|
|
526
|
+
const placeholder = property.default === "" ? "" : ` placeholder="e.g. ${escapeHtml(property.default)}"`;
|
|
527
|
+
const range = ["min", "max", "step"]
|
|
528
|
+
.filter((key) => property[key] !== undefined && property[key] !== null && property[key] !== "")
|
|
529
|
+
.map((key) => ` ${key}="${escapeHtml(property[key])}"`)
|
|
530
|
+
.join("");
|
|
531
|
+
switch (property.editor) {
|
|
532
|
+
case "hidden":
|
|
533
|
+
return "";
|
|
534
|
+
case "boolean":
|
|
535
|
+
return `<label>
|
|
536
|
+
<input type="checkbox" name="${name}" value="true">
|
|
537
|
+
<span class="icon-checkbox"></span>
|
|
538
|
+
${label}
|
|
539
|
+
</label>
|
|
540
|
+
${tooltip}`;
|
|
541
|
+
case "enum":
|
|
542
|
+
if (!property.options?.length) return formControl({ ...cv, property: { ...property, editor: "text" } });
|
|
543
|
+
return `${header}
|
|
544
|
+
<select name="${name}" class="select2" data-has-search="false">${(property.options ?? [])
|
|
545
|
+
.map((option) => {
|
|
546
|
+
const text = readable(option.label) || option.value;
|
|
547
|
+
return `
|
|
548
|
+
<option value="${escapeHtml(option.value)}">${escapeHtml(text)}</option>`;
|
|
549
|
+
})
|
|
550
|
+
.join("")}
|
|
551
|
+
</select>`;
|
|
552
|
+
case "integer":
|
|
553
|
+
case "number":
|
|
554
|
+
return `${header}
|
|
555
|
+
<input type="number" name="${name}" value="${value}"${range}${range.includes(" step=") ? "" : property.editor === "integer" ? ' step="1"' : ' step="any"'}${placeholder} data-validate=";">`;
|
|
556
|
+
case "textarea":
|
|
557
|
+
return `${header}
|
|
558
|
+
<textarea name="${name}" rows="4"${placeholder}>${value}</textarea>`;
|
|
559
|
+
// min-width:0, as TAO's creator gives a colour input a min-inline-size of 150px
|
|
560
|
+
case "color":
|
|
561
|
+
return `${header}
|
|
562
|
+
<span data-tspci-color="${name}" style="display:flex;align-items:center;gap:6px">
|
|
563
|
+
<input type="color" aria-label="${label}" style="width:44px;min-width:0;height:28px;padding:0;flex:none">
|
|
564
|
+
<input type="text" aria-label="${label} (hex)" placeholder="#rrggbb" maxlength="7" style="flex:1;min-width:0">
|
|
565
|
+
</span>
|
|
566
|
+
<input type="hidden" name="${name}" value="${value}">`;
|
|
567
|
+
case "colorList":
|
|
568
|
+
return `${header}
|
|
569
|
+
<span data-tspci-color-list="${name}" style="display:block">
|
|
570
|
+
<span data-tspci-items style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px"></span>
|
|
571
|
+
<span data-tspci-item-template hidden>
|
|
572
|
+
<span style="display:inline-flex;align-items:center;gap:2px">
|
|
573
|
+
<input type="color" style="width:36px;min-width:0;height:28px;padding:0">
|
|
574
|
+
<button type="button" class="btn-default small" title="{{__ "Remove colour"}}" style="padding:0 6px"><span class="icon-bin"></span></button>
|
|
575
|
+
</span>
|
|
576
|
+
</span>
|
|
577
|
+
<button type="button" class="btn-info small" data-tspci-add><span class="icon-add"></span>{{__ "Add a colour"}}</button>
|
|
578
|
+
</span>
|
|
579
|
+
<input type="hidden" name="${name}" value="${value}">`;
|
|
580
|
+
default:
|
|
581
|
+
// text, pci (the "Configure…" button above it fills it) and an editor this packager does not know
|
|
582
|
+
return `${header}
|
|
583
|
+
<input type="text" name="${name}" value="${value}"${placeholder} data-validate=";">`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
414
587
|
function replaceAll(str, find, replace) {
|
|
415
588
|
return str.replace(new RegExp(find, "g"), replace);
|
|
416
589
|
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
|
|
2
|
+
// Authoring mode (@citolab/tspci AUTHORING.md): the "Configure…" button opens the PCI's own editor for the
|
|
3
|
+
// settings it manages (editor "pci"). The creator runs in TAO's page with RequireJS, so the runtime module is
|
|
4
|
+
// required and getAuthoringInstance is called directly; no iframe and no messages.
|
|
5
|
+
var authoring = '##__AUTHORING_OPTIONS__##';
|
|
6
|
+
// A managed setting is usually JSON. TAO turns a JSON property into an object when it loads a saved item, so
|
|
7
|
+
// read it back as text: for the PCI, and for the setting's text field (which would show "[object Object]").
|
|
8
|
+
var settingText = function(value){
|
|
9
|
+
return value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
10
|
+
};
|
|
11
|
+
authoring.settings.forEach(function(name){
|
|
12
|
+
var value = interaction.prop(name);
|
|
13
|
+
if (value !== undefined && value !== null) {
|
|
14
|
+
$form.find('[name="' + name + '"]').val(settingText(value));
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
$form.find('[data-pci-authoring]').on('click', function(){
|
|
18
|
+
require(['jquery', 'i18n'], function($, __){
|
|
19
|
+
openAuthoring($, __);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function openAuthoring($, __){
|
|
24
|
+
if ($('.tspci-authoring').length) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
var instance;
|
|
28
|
+
var closed = false;
|
|
29
|
+
var $dialog = $('<div class="tspci-authoring">' +
|
|
30
|
+
'<div class="tspci-authoring-pci"></div>' +
|
|
31
|
+
'<div class="feedback-error tspci-authoring-problems"></div>' +
|
|
32
|
+
'<div class="tspci-authoring-actions">' +
|
|
33
|
+
'<button type="button" class="btn-default small" data-action="cancel"></button> ' +
|
|
34
|
+
'<button type="button" class="btn-info small" data-action="done" disabled></button>' +
|
|
35
|
+
'</div></div>');
|
|
36
|
+
var $pci = $dialog.find('.tspci-authoring-pci').css({
|
|
37
|
+
width: authoring.width ? authoring.width + 'px' : '100%',
|
|
38
|
+
height: authoring.height ? authoring.height + 'px' : 'auto',
|
|
39
|
+
maxWidth: '100%',
|
|
40
|
+
maxHeight: '70vh',
|
|
41
|
+
overflow: 'auto'
|
|
42
|
+
});
|
|
43
|
+
var $problems = $dialog.find('.tspci-authoring-problems').hide();
|
|
44
|
+
var $done = $dialog.find('[data-action="done"]').text(__('Done'));
|
|
45
|
+
$dialog.find('[data-action="cancel"]').text(__('Cancel'));
|
|
46
|
+
$dialog.find('.tspci-authoring-actions').css({ marginTop: '12px', textAlign: 'right' });
|
|
47
|
+
|
|
48
|
+
var showProblems = function(problems){
|
|
49
|
+
$problems.empty().append(problems.map(function(problem){
|
|
50
|
+
return $('<div>').text(problem);
|
|
51
|
+
})).show();
|
|
52
|
+
};
|
|
53
|
+
// every way out (Cancel, Done, the close button, Escape) ends here
|
|
54
|
+
var cleanUp = function(){
|
|
55
|
+
closed = true;
|
|
56
|
+
if (instance && typeof instance.destroy === 'function') {
|
|
57
|
+
instance.destroy();
|
|
58
|
+
}
|
|
59
|
+
instance = null;
|
|
60
|
+
$dialog.remove();
|
|
61
|
+
};
|
|
62
|
+
var close = openDialog($, $dialog, startPci, cleanUp);
|
|
63
|
+
|
|
64
|
+
$dialog.find('[data-action="cancel"]').on('click', close);
|
|
65
|
+
$done.on('click', function(){
|
|
66
|
+
if (!instance) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
var problems = typeof instance.validate === 'function' ? instance.validate() || [] : [];
|
|
70
|
+
if (problems.length) {
|
|
71
|
+
showProblems(problems);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
var config = instance.getConfig() || {};
|
|
75
|
+
// only the managed properties are written, as strings, the same way the form fields write them
|
|
76
|
+
authoring.properties.forEach(function(name){
|
|
77
|
+
if (Object.prototype.hasOwnProperty.call(config, name)) {
|
|
78
|
+
var value = String(config[name]);
|
|
79
|
+
interaction.prop(name, value);
|
|
80
|
+
interaction.triggerPci(name, value);
|
|
81
|
+
$form.find('[name="' + name + '"]').val(value);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// the PCI renders once the dialog is in the page, so it can measure itself
|
|
88
|
+
function startPci(){
|
|
89
|
+
require(['##typeIdentifier##/interaction/runtime/js/index'], function(pciModule){
|
|
90
|
+
if (closed) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// the tspci bundle returns the registered PCI; else take what it registered with TAO's context
|
|
94
|
+
var pci = pciModule && typeof pciModule.getAuthoringInstance === 'function'
|
|
95
|
+
? pciModule
|
|
96
|
+
: (window._pciHooks || {})['##typeIdentifier##'];
|
|
97
|
+
if (!pci || typeof pci.getAuthoringInstance !== 'function') {
|
|
98
|
+
showProblems([__('This version of the PCI has no authoring mode. Use the fields below the button instead.')]);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
var properties = {};
|
|
102
|
+
authoring.settings.forEach(function(name){
|
|
103
|
+
var value = interaction.prop(name);
|
|
104
|
+
if (value !== undefined && value !== null) {
|
|
105
|
+
properties[name] = settingText(value);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
var returned = pci.getAuthoringInstance($pci[0], {
|
|
110
|
+
properties: properties,
|
|
111
|
+
language: document.documentElement.getAttribute('lang') || navigator.language,
|
|
112
|
+
onready: function(ready){
|
|
113
|
+
instance = ready;
|
|
114
|
+
$done.prop('disabled', false);
|
|
115
|
+
},
|
|
116
|
+
// what Done said was missing may be fixed now; Done checks again
|
|
117
|
+
onchange: function(){
|
|
118
|
+
$problems.hide().empty();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (returned) {
|
|
122
|
+
instance = returned;
|
|
123
|
+
$done.prop('disabled', false);
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
showProblems([String(e && e.message || e)]);
|
|
127
|
+
}
|
|
128
|
+
}, function(e){
|
|
129
|
+
showProblems([String(e && e.message || e)]);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// TAO's modal (ui/modal) when the creator has it, else a plain overlay. Calls onOpened once the dialog is in
|
|
135
|
+
// the page, and returns the function that closes it.
|
|
136
|
+
function openDialog($, $dialog, onOpened, onClosed){
|
|
137
|
+
var closed = false;
|
|
138
|
+
var whenClosed = function(){
|
|
139
|
+
if (!closed) {
|
|
140
|
+
closed = true;
|
|
141
|
+
onClosed();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
var close = function(){
|
|
145
|
+
whenClosed();
|
|
146
|
+
};
|
|
147
|
+
require(['ui/modal'], function(){
|
|
148
|
+
if (closed) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
$dialog.addClass('modal').appendTo('body').modal({
|
|
152
|
+
width: Math.min((authoring.width || 640) + 48, Math.round(window.innerWidth * 0.96))
|
|
153
|
+
});
|
|
154
|
+
$dialog.on('closed.modal', function(){
|
|
155
|
+
$dialog.modal('destroy');
|
|
156
|
+
whenClosed();
|
|
157
|
+
});
|
|
158
|
+
close = function(){
|
|
159
|
+
$dialog.modal('close');
|
|
160
|
+
};
|
|
161
|
+
onOpened();
|
|
162
|
+
}, function(){
|
|
163
|
+
var $overlay = $('<div class="tspci-authoring-overlay">').css({
|
|
164
|
+
position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, zIndex: 100000,
|
|
165
|
+
background: 'rgba(0,0,0,.5)', display: 'flex', alignItems: 'center', justifyContent: 'center'
|
|
166
|
+
});
|
|
167
|
+
$dialog.css({ background: '#fff', padding: '24px', maxWidth: '96vw', maxHeight: '96vh', overflow: 'auto' });
|
|
168
|
+
$overlay.append($dialog).appendTo('body');
|
|
169
|
+
var onKey = function(e){
|
|
170
|
+
if (e.key === 'Escape') {
|
|
171
|
+
close();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
$(document).on('keydown', onKey);
|
|
175
|
+
close = function(){
|
|
176
|
+
$(document).off('keydown', onKey);
|
|
177
|
+
$overlay.remove();
|
|
178
|
+
whenClosed();
|
|
179
|
+
};
|
|
180
|
+
onOpened();
|
|
181
|
+
});
|
|
182
|
+
return function(){
|
|
183
|
+
close();
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
|
|
2
|
+
// Typed settings (the editor hints in config.json, see @citolab/tspci AUTHORING.md). Runs before initWidget, so
|
|
3
|
+
// TAO's select2 starts from the right option. TAO turns a property that looks like JSON (400, true) into a number
|
|
4
|
+
// or boolean when it loads a saved item, and the `||` above drops 0 and false, so every control starts from the
|
|
5
|
+
// property itself, as text.
|
|
6
|
+
var controls = '##__CONTROLS_OPTIONS__##';
|
|
7
|
+
var propertyText = function(value){
|
|
8
|
+
return value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
9
|
+
};
|
|
10
|
+
var $setting = function(name){
|
|
11
|
+
return $form.find(':input').filter(function(){
|
|
12
|
+
return this.name === name;
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
Object.keys(controls).forEach(function(name){
|
|
16
|
+
var value = interaction.prop(name);
|
|
17
|
+
if (value === undefined || value === null) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
value = propertyText(value);
|
|
21
|
+
var $input = $setting(name);
|
|
22
|
+
if (controls[name] === 'boolean') {
|
|
23
|
+
$input.prop('checked', value === 'true');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// a value that is not one of the options (set elsewhere) is shown as it is, not as the first option
|
|
27
|
+
if ($input.is('select') && !$input.find('option').filter(function(){ return this.value === value; }).length) {
|
|
28
|
+
$input.append($input.find('option').first().clone().val(value).text(value));
|
|
29
|
+
}
|
|
30
|
+
$input.val(value);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Colour pickers write through the setting's hidden field: its change event reaches the callbacks below,
|
|
34
|
+
// which set the property and tell the PCI, as for any other field.
|
|
35
|
+
var hexColor = function(value){
|
|
36
|
+
var hex = String(value).trim();
|
|
37
|
+
if (/^#[0-9a-f]{3}$/i.test(hex)) {
|
|
38
|
+
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
|
|
39
|
+
}
|
|
40
|
+
// <input type="color"> only takes #rrggbb; another colour ("red") shows as black until it is picked
|
|
41
|
+
return /^#[0-9a-f]{6}$/i.test(hex) ? hex.toLowerCase() : '#000000';
|
|
42
|
+
};
|
|
43
|
+
var writeSetting = function($hidden, value){
|
|
44
|
+
if ($hidden.val() !== value) {
|
|
45
|
+
$hidden.val(value).trigger('change');
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var $colors = $form.find('[data-tspci-color]');
|
|
49
|
+
$colors.each(function(i){
|
|
50
|
+
var $color = $colors.eq(i);
|
|
51
|
+
var $hidden = $setting($color.attr('data-tspci-color'));
|
|
52
|
+
var $picker = $color.find('input[type="color"]').val(hexColor($hidden.val()));
|
|
53
|
+
var $hex = $color.find('input[type="text"]').val($hidden.val());
|
|
54
|
+
$picker.on('input', function(){
|
|
55
|
+
$hex.val($picker.val());
|
|
56
|
+
});
|
|
57
|
+
$picker.on('change', function(){
|
|
58
|
+
$hex.val($picker.val());
|
|
59
|
+
writeSetting($hidden, $picker.val());
|
|
60
|
+
});
|
|
61
|
+
$hex.on('input change', function(){
|
|
62
|
+
var value = $hex.val().trim();
|
|
63
|
+
if (/^#([0-9a-f]{3}){1,2}$/i.test(value)) {
|
|
64
|
+
$picker.val(hexColor(value));
|
|
65
|
+
writeSetting($hidden, value);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
var $colorLists = $form.find('[data-tspci-color-list]');
|
|
70
|
+
$colorLists.each(function(i){
|
|
71
|
+
var $list = $colorLists.eq(i);
|
|
72
|
+
var $hidden = $setting($list.attr('data-tspci-color-list'));
|
|
73
|
+
var $items = $list.find('[data-tspci-items]');
|
|
74
|
+
var $template = $list.find('[data-tspci-item-template]');
|
|
75
|
+
var $prototype = $template.children().first().detach();
|
|
76
|
+
$template.remove();
|
|
77
|
+
var colors = $hidden.val().split(',').map(function(color){ return color.trim(); }).filter(Boolean);
|
|
78
|
+
var write = function(){
|
|
79
|
+
writeSetting($hidden, colors.join(', '));
|
|
80
|
+
};
|
|
81
|
+
var render = function(){
|
|
82
|
+
$items.empty();
|
|
83
|
+
colors.forEach(function(color, index){
|
|
84
|
+
var $item = $prototype.clone();
|
|
85
|
+
$item.find('input[type="color"]').val(hexColor(color)).attr('title', color).on('change', function(){
|
|
86
|
+
colors[index] = this.value;
|
|
87
|
+
this.title = this.value;
|
|
88
|
+
write();
|
|
89
|
+
});
|
|
90
|
+
// the PCI needs at least one colour
|
|
91
|
+
$item.find('button').prop('disabled', colors.length < 2).on('click', function(){
|
|
92
|
+
colors.splice(index, 1);
|
|
93
|
+
render();
|
|
94
|
+
write();
|
|
95
|
+
});
|
|
96
|
+
$items.append($item);
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
$list.find('[data-tspci-add]').on('click', function(){
|
|
100
|
+
colors.push(colors.length ? hexColor(colors[colors.length - 1]) : '#000000');
|
|
101
|
+
render();
|
|
102
|
+
write();
|
|
103
|
+
});
|
|
104
|
+
render();
|
|
105
|
+
});
|
|
@@ -32,6 +32,7 @@ define([
|
|
|
32
32
|
identifier : interaction.attr('responseIdentifier'),
|
|
33
33
|
// ##__SET_VARIABLES__##
|
|
34
34
|
}));
|
|
35
|
+
// ##__CONTROLS__##
|
|
35
36
|
|
|
36
37
|
//init form javascript
|
|
37
38
|
formElement.initWidget($form);
|
|
@@ -44,6 +45,7 @@ define([
|
|
|
44
45
|
},
|
|
45
46
|
// ##__CALLBACK__##
|
|
46
47
|
});
|
|
48
|
+
// ##__AUTHORING__##
|
|
47
49
|
|
|
48
50
|
};
|
|
49
51
|
|