Files
kokkoja-rel/client/js/notebook_picker.js
T

151 lines
4.4 KiB
JavaScript
Raw Normal View History

// @ts-check
/**
* A notebook picker: a list of the notebooks with "New notebook…" as its
* last entry, which asks for the name in a field shown in the list's
* place (not in a browser dialog). Used on the start screen and in the
* session's panel. A notebook exists once a page is written into it,
* so making one is naming it.
*/
import { listNotebooks } from './notebook.js';
/** The notebook used when none is chosen: the one for whatever has no notebook of its own. */
export const DEFAULT_NOTEBOOK = 'General';
/** The value of the "New notebook…" entry; no notebook can be named so. */
const NEW = '/new';
/** What the local program accepts as a notebook name (NAME in kokkoja.py). */
const NAME_ALLOWED = /^[^\x00-\x1f/\\:*?"<>|.][^\x00-\x1f/\\:*?"<>|]{0,119}$/;
const NAME_REFUSED = 'A notebook name cannot contain / \\ : * ? " < > | and cannot begin with a dot.';
export class NotebookPicker {
/**
* @param {HTMLSelectElement} select
* @param {HTMLInputElement} entry The field for a new name.
* @param {(message: string) => void} say Explains a refused name; '' clears.
* @param {(notebook: string) => void} onChosen The choice changed.
*/
constructor(select, entry, say, onChosen) {
this.select = select;
this.entry = entry;
this.say = say;
this.onChosen = onChosen;
/** The choice before "New notebook…" was picked, to return to. */
this.previous = '';
select.addEventListener('change', () => {
if (select.value === NEW) this.beginNew();
else onChosen(select.value);
});
entry.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.confirm();
} else if (event.key === 'Escape') {
event.preventDefault();
this.cancel();
}
});
// Leaving the field with a usable name confirms it; with none, gives up.
entry.addEventListener('blur', () => {
if (entry.hidden) return;
if (NAME_ALLOWED.test(entry.value.trim())) this.confirm();
else this.cancel();
});
}
/** @returns {string} The chosen notebook. */
get value() {
return this.select.value === NEW ? this.previous : this.select.value;
}
/**
* List the notebooks, with the given one chosen (added to the list if
* it is not there yet, since a notebook exists once a page is written).
* @param {string} current
*/
async show(current) {
let names = /** @type {string[]} */ ([]);
try {
names = (await listNotebooks()).notebooks;
} catch {
// Without the list, the current notebook alone is offered.
}
this.fill(names, current);
}
/**
* @param {string[]} names
* @param {string} current
*/
fill(names, current) {
const all = [...new Set([...names, current])].filter(Boolean).sort((a, b) => a.localeCompare(b));
this.select.replaceChildren();
for (const name of all) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.append(option);
}
const make = document.createElement('option');
make.value = NEW;
make.textContent = 'New notebook…';
this.select.append(make);
this.select.value = current;
}
beginNew() {
this.previous = this.previous || this.select.options[0]?.value || '';
this.say('');
this.select.hidden = true;
this.entry.value = '';
this.entry.hidden = false;
this.entry.focus();
}
confirm() {
const name = this.entry.value.trim();
if (!NAME_ALLOWED.test(name)) {
this.say(name ? NAME_REFUSED : '');
if (!name) this.cancel();
return;
}
this.say('');
if (![...this.select.options].some((option) => option.value === name)) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
this.select.insertBefore(option, this.select.options[this.select.options.length - 1]);
}
this.select.value = name;
this.previous = name;
this.endEntry();
this.onChosen(name);
}
cancel() {
this.select.value = this.previous;
this.endEntry();
}
endEntry() {
this.entry.hidden = true;
this.select.hidden = false;
}
/**
* The choice, made from outside; it is also the one to return to if
* "New notebook…" is given up.
* @param {string} name
*/
set current(name) {
this.previous = name;
this.select.value = name;
}
/** @returns {string} */
get current() {
return this.value;
}
}