132 lines
4.5 KiB
JavaScript
132 lines
4.5 KiB
JavaScript
// @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 = [];
|
||
|
|
}
|
||
|
|
}
|