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

132 lines
4.5 KiB
JavaScript
Raw Normal View History

2026-10-06 15:58:02 +02:00
// @ts-check
/**
* The notebook chooser: the "Notebook" button's dialog, which shows the
* notebooks and the pages kept in them, and loads the chosen page.
*/
import { listKeptPages, listNotebooks, readPage } from './notebook.js';
/**
* @param {string} id
* @returns {HTMLElement}
*/
function element(id) {
return /** @type {HTMLElement} */ (document.getElementById(id));
}
/**
* @param {string} pageName
* @returns {string} "2026_10_02_01.svg" shown as "2026-10-02 (1)".
*/
function pageTitle(pageName) {
const match = /^(\d{4})_(\d{2})_(\d{2})_(\d+)\.svg$/.exec(pageName);
return match ? `${match[1]}-${match[2]}-${match[3]} (${Number(match[4])})` : pageName;
}
export class NotebookChooser {
/**
* @param {(notebook: string, pageName: string, text: string) => void} onChosen
* Called with the chosen page file.
*/
constructor(onChosen) {
this.onChosen = onChosen;
this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser'));
this.notebook = '';
/** Counts openings, so that pictures fetched for an earlier one are dropped. */
this.opening = 0;
/** @type {string[]} */
this.pictureAddresses = [];
element('chooser_close').addEventListener('click', () => this.dialog.close());
this.dialog.addEventListener('close', () => this.forgetPictures());
}
/**
* Show the notebooks, or the pages of the only notebook.
* @param {string} [notebook] Open on this notebook.
*/
async open(notebook) {
this.opening += 1;
this.dialog.showModal();
element('chooser_message').textContent = '';
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
try {
const { notebooks } = await listNotebooks();
this.showNotebooks(notebooks);
const chosen = notebook && notebooks.includes(notebook) ? notebook
: notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen);
} catch (error) {
element('chooser_message').textContent = error instanceof Error ? error.message : String(error);
}
}
/** @param {string[]} notebooks */
showNotebooks(notebooks) {
const list = element('chooser_notebooks');
list.replaceChildren();
list.hidden = notebooks.length <= 1;
for (const notebook of notebooks) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = notebook;
button.addEventListener('click', () => { this.showPages(notebook); });
list.append(button);
}
}
/** @param {string} notebook */
async showPages(notebook) {
this.notebook = notebook;
for (const button of element('chooser_notebooks').querySelectorAll('button')) {
button.setAttribute('aria-pressed', String(button.textContent === notebook));
}
const grid = element('chooser_pages');
grid.replaceChildren();
this.forgetPictures();
if (!notebook) {
element('chooser_message').textContent = 'Choose a notebook.';
return;
}
const opening = this.opening += 1;
const pages = await listKeptPages(notebook);
if (opening !== this.opening) return;
element('chooser_message').textContent = pages.length ? '' : 'This notebook has no kept pages.';
/** @type {Map<string, HTMLImageElement>} */
const pictures = new Map();
for (const pageName of pages) {
const card = document.createElement('button');
card.type = 'button';
card.className = 'page_card';
const picture = document.createElement('img');
picture.alt = '';
pictures.set(pageName, picture);
const title = document.createElement('span');
title.textContent = pageTitle(pageName);
card.append(picture, title);
card.addEventListener('click', async () => {
const text = await readPage(notebook, pageName);
if (text === null) return;
this.dialog.close();
this.onChosen(notebook, pageName, text);
});
grid.append(card);
}
// The pictures are the page files themselves, fetched one by one.
for (const [pageName, picture] of pictures) {
const text = await readPage(notebook, pageName);
if (opening !== this.opening) return;
if (text === null) continue;
const address = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' }));
this.pictureAddresses.push(address);
picture.src = address;
}
}
forgetPictures() {
for (const address of this.pictureAddresses) URL.revokeObjectURL(address);
this.pictureAddresses = [];
}
}