@citolab/tspci-tao 2.12.1 → 2.13.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,17 @@
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.13.0](https://github.com/Citolab/tspci/compare/v2.12.1...v2.13.0) (2026-10-14)
7
+
8
+
9
+ ### Features
10
+
11
+ * **tspci-tao:** typed properties form and a Configure button in TAO ([406a940](https://github.com/Citolab/tspci/commit/406a940fde13990c14bda3984a8a4451c49617d1))
12
+
13
+
14
+
15
+
16
+
6
17
  ## [2.12.1](https://github.com/Citolab/tspci/compare/v2.12.0...v2.12.1) (2026-08-19)
7
18
 
8
19
  **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.12.1",
3
+ "version": "2.13.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": "525a89e24eb69a03fbf3f0b76c33f865f38f44ad"
23
+ "gitHead": "b62ff736df6ebd0ec8c86e35338a6bb705e0820c"
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.
@@ -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
- const defaultValue = configJson[elem];
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 (Number.isInteger(parseInt(defaultValue))) {
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
- defaultValue: `"${defaultValue}"`, // type === "number" ? `${defaultValue}` : `"${defaultValue}"`,
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
- return `${cv.name} : function(interaction, value){
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
- const capitalizedName =
241
- cv.name.charAt(0).toUpperCase() + cv.name.slice(1);
242
- return `<label for="${cv.name}">{{__ "${capitalizedName}"}}</label>
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
- var result = data.replace(new RegExp(to_replace, "g"), replace_with);
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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;", "{": "&#123;", "}": "&#125;", "\\": "&#92;" };
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