Kokkoja: first public assembly
Assembled from the development repository by tools/make_release.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
95c80d04c9
39 files changed
+9905
No files matched your search
@@ -0,0 +1,132 @@
|
||||
// @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 = [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user