// @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; } }