Kokkoja as of 2026-10-10: the teacher's requests, the Session window, notices, the QR code, the license
This commit is contained in:
1 parent
95c80d04c9
commit
1d087af633
32 files changed
+4917
-890
No files matched your search
@@ -0,0 +1,151 @@
|
||||
// @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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user