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

316 lines
11 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.
*
* It also deletes (Andy, 2026-10-11): every notebook and every page card
* carries a mark, ×, that a tap turns into a tick, and one "Delete"
* button in the header, which appears as soon as something is marked,
* says how much is marked and asks for a second press before anything
* goes, as the file chooser does for replacing a file; no system dialog.
* One gesture serves one page and twenty. Where the local program has
* a Trash the entries are moved there, and the button says so;
* in the browser they are gone for good, and the button says that. The
* notebook being written in cannot be marked: its mark explains.
2026-10-06 15:58:02 +02:00
*/
import { discardNotebook, discardPage, listKeptPages, listNotebooks, readPage } from './notebook.js';
2026-10-06 15:58:02 +02:00
/**
* @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;
}
/**
* @param {number} count
* @param {string} noun
* @returns {string} "1 page", "3 pages".
*/
function counted(count, noun) {
return `${count} ${noun}${count === 1 ? '' : 's'}`;
}
/**
* A mark button: × until marked, then ✓.
* @param {string} help The tooltip.
* @returns {HTMLButtonElement}
*/
function markButton(help) {
const mark = document.createElement('button');
mark.type = 'button';
mark.className = 'mark';
mark.textContent = '×';
mark.setAttribute('aria-label', 'Mark for deletion');
mark.setAttribute('aria-pressed', 'false');
mark.dataset.help = help;
return mark;
}
2026-10-06 15:58:02 +02:00
export class NotebookChooser {
/**
* @param {(notebook: string, pageName: string, text: string) => void} onChosen
* Called with the chosen page file.
* @param {() => void} [onDeleted] Called after notebooks or pages were
* deleted, for lists elsewhere that name them.
2026-10-06 15:58:02 +02:00
*/
constructor(onChosen, onDeleted = () => {}) {
2026-10-06 15:58:02 +02:00
this.onChosen = onChosen;
this.onDeleted = onDeleted;
2026-10-06 15:58:02 +02:00
this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser'));
this.notebook = '';
/** The notebook being written in, which cannot be deleted while it is. */
this.writing = '';
/** Whether deletions go to a Trash (the local program's) or are for good. */
this.trash = false;
2026-10-06 15:58:02 +02:00
/** Counts openings, so that pictures fetched for an earlier one are dropped. */
this.opening = 0;
/** @type {string[]} */
this.pictureAddresses = [];
/** @type {Set<string>} Notebooks marked for deletion. */
this.markedNotebooks = new Set();
/** @type {Map<string, Set<string>>} Pages marked for deletion, by notebook. */
this.markedPages = new Map();
/** Whether the Delete button has been pressed once and awaits its second press. */
this.armed = false;
2026-10-06 15:58:02 +02:00
element('chooser_close').addEventListener('click', () => this.dialog.close());
element('chooser_delete').addEventListener('click', () => { this.deleteMarked().catch(() => {}); });
this.dialog.addEventListener('close', () => {
this.forgetPictures();
this.clearMarks();
});
2026-10-06 15:58:02 +02:00
}
/**
* Show the notebooks, or the pages of the only notebook.
* @param {string} [notebook] Open on this notebook: the one being written in.
2026-10-06 15:58:02 +02:00
*/
async open(notebook) {
this.opening += 1;
this.writing = notebook ?? '';
this.clearMarks();
2026-10-06 15:58:02 +02:00
this.dialog.showModal();
element('chooser_message').textContent = '';
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
try {
const { notebooks, trash } = await listNotebooks();
this.trash = trash;
2026-10-06 15:58:02 +02:00
this.showNotebooks(notebooks);
// The notebook being written in is chosen already, its pages shown;
// the list is always there to see (Andy, 2026-10-06).
2026-10-06 15:58:02 +02:00
const chosen = notebook && notebooks.includes(notebook) ? notebook
: notebooks.length === 1 ? notebooks[0] : '';
await this.showPages(chosen);
} catch (error) {
this.say(error instanceof Error ? error.message : String(error));
2026-10-06 15:58:02 +02:00
}
}
/** @param {string} text */
say(text) {
element('chooser_message').textContent = text;
}
2026-10-06 15:58:02 +02:00
/** @param {string[]} notebooks */
showNotebooks(notebooks) {
const list = element('chooser_notebooks');
list.replaceChildren();
for (const notebook of notebooks) {
const row = document.createElement('div');
row.className = 'notebook_row';
2026-10-06 15:58:02 +02:00
const button = document.createElement('button');
button.type = 'button';
button.className = 'notebook_pick';
2026-10-06 15:58:02 +02:00
button.textContent = notebook;
button.addEventListener('click', () => { this.showPages(notebook); });
const mark = markButton(notebook === this.writing
? 'This is the notebook you are writing in. To delete it, choose another notebook in the toolbar first.'
: 'Mark this notebook, with every page in it, for deletion. The Delete button above then deletes what is marked.');
if (notebook === this.writing) mark.classList.add('unavailable');
mark.addEventListener('click', () => {
if (notebook === this.writing) {
this.say('You are writing in this notebook. To delete it, choose another notebook in the toolbar first.');
return;
}
this.toggleMark(mark, this.markedNotebooks, notebook);
});
row.append(button, mark);
list.append(row);
2026-10-06 15:58:02 +02:00
}
this.showMarks();
2026-10-06 15:58:02 +02:00
}
/** @param {string} notebook */
async showPages(notebook) {
this.notebook = notebook;
this.disarm();
for (const button of element('chooser_notebooks').querySelectorAll('button.notebook_pick')) {
2026-10-06 15:58:02 +02:00
button.setAttribute('aria-pressed', String(button.textContent === notebook));
}
const grid = element('chooser_pages');
grid.replaceChildren();
this.forgetPictures();
if (!notebook) {
this.say('Choose a notebook.');
2026-10-06 15:58:02 +02:00
return;
}
const opening = this.opening += 1;
const pages = await listKeptPages(notebook);
if (opening !== this.opening) return;
this.say(pages.length ? '' : 'This notebook has no kept pages.');
2026-10-06 15:58:02 +02:00
const marked = this.markedPages.get(notebook) ?? new Set();
2026-10-06 15:58:02 +02:00
/** @type {Map<string, HTMLImageElement>} */
const pictures = new Map();
for (const pageName of pages) {
const slot = document.createElement('div');
slot.className = 'page_slot';
2026-10-06 15:58:02 +02:00
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);
});
const mark = markButton('Mark this page for deletion. The Delete button above then deletes what is marked.');
mark.addEventListener('click', () => { this.toggleMark(mark, marked, pageName); });
if (marked.has(pageName)) {
mark.textContent = '✓';
mark.setAttribute('aria-pressed', 'true');
slot.classList.add('marked');
}
slot.append(card, mark);
grid.append(slot);
2026-10-06 15:58:02 +02:00
}
this.markedPages.set(notebook, marked);
2026-10-06 15:58:02 +02:00
// 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;
}
}
/**
* Mark or unmark one entry.
* @param {HTMLButtonElement} mark
* @param {Set<string>} marked The set the entry belongs in.
* @param {string} name
*/
toggleMark(mark, marked, name) {
const now = !marked.has(name);
if (now) marked.add(name); else marked.delete(name);
mark.textContent = now ? '✓' : '×';
mark.setAttribute('aria-pressed', String(now));
/** @type {HTMLElement} */ (mark.parentElement).classList.toggle('marked', now);
this.disarm();
this.showMarks();
}
/** @returns {{notebooks: number, pages: number}} What is marked, a marked notebook's pages not counted. */
countMarks() {
let pages = 0;
for (const [notebook, names] of this.markedPages) {
if (!this.markedNotebooks.has(notebook)) pages += names.size;
}
return { notebooks: this.markedNotebooks.size, pages };
}
/** @returns {string} "1 notebook and 3 pages", or '' when nothing is marked. */
describeMarks() {
const { notebooks, pages } = this.countMarks();
const parts = [];
if (notebooks) parts.push(counted(notebooks, 'notebook'));
if (pages) parts.push(counted(pages, 'page'));
return parts.join(' and ');
}
/** The Delete button says what is marked, or is hidden when nothing is. */
showMarks() {
const button = element('chooser_delete');
const what = this.describeMarks();
button.hidden = !what;
if (!what) return;
button.textContent = this.armed
? (this.trash ? `Press again to move ${what} to the Trash`
: `Press again to delete ${what} for good`)
: `Delete ${what}?`;
}
/** The second press is no longer awaited: something else happened. */
disarm() {
if (!this.armed) return;
this.armed = false;
this.showMarks();
}
clearMarks() {
this.markedNotebooks.clear();
this.markedPages.clear();
this.armed = false;
element('chooser_delete').hidden = true;
}
/** The Delete button: arm on the first press, delete on the second. */
async deleteMarked() {
if (!this.armed) {
this.armed = true;
this.showMarks();
return;
}
const what = this.describeMarks();
const notebooks = [...this.markedNotebooks];
const pages = [...this.markedPages].filter(([notebook]) => !this.markedNotebooks.has(notebook));
this.clearMarks();
let failed = '';
try {
for (const notebook of notebooks) await discardNotebook(notebook);
for (const [notebook, names] of pages) {
for (const name of names) await discardPage(notebook, name);
}
} catch (error) {
failed = error instanceof Error ? error.message : String(error);
}
this.onDeleted();
try {
const listed = await listNotebooks();
this.showNotebooks(listed.notebooks);
await this.showPages(listed.notebooks.includes(this.notebook) ? this.notebook : '');
} catch (error) {
this.say(error instanceof Error ? error.message : String(error));
return;
}
const sentence = what.charAt(0).toUpperCase() + what.slice(1);
this.say(failed ? `Deleting stopped: ${failed}`
: this.trash ? `${sentence} moved to the Trash.` : `${sentence} deleted.`);
}
2026-10-06 15:58:02 +02:00
forgetPictures() {
for (const address of this.pictureAddresses) URL.revokeObjectURL(address);
this.pictureAddresses = [];
}
}