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.
|
2026-10-11 16:59:03 +02:00
|
|
|
|
*
|
|
|
|
|
|
* It also deletes (Andy, 2026-10-11): every notebook and every page card
|
2026-10-11 17:10:08 +02:00
|
|
|
|
* carries a mark at its left, a × that a tap turns into a tick (at the
|
|
|
|
|
|
* left, since a × at the top right means "delete" itself in many
|
|
|
|
|
|
* interfaces; a dot was tried and implied a generality that does not
|
|
|
|
|
|
* exist), and one "Delete"
|
2026-10-11 16:59:03 +02:00
|
|
|
|
* 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-11 17:34:24 +02:00
|
|
|
|
* Everyone has the chooser (Andy, 2026-10-11), so that a guest can see
|
|
|
|
|
|
* and clear what their notebook holds, in a browser above all; loading
|
|
|
|
|
|
* a page into the session is for the host and co-hosts, and a card says
|
|
|
|
|
|
* so to anyone else.
|
2026-10-06 15:58:02 +02:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-11 18:05:49 +02:00
|
|
|
|
import {
|
|
|
|
|
|
currentPageChanged, discardNotebook, discardPage, listKeptPageDetails, listNotebooks, readPage,
|
|
|
|
|
|
} from './notebook.js';
|
|
|
|
|
|
import { pageFromSvg } from './page_file.js';
|
|
|
|
|
|
|
|
|
|
|
|
/** How much of a page's first text the card's tooltip shows. */
|
|
|
|
|
|
const FIRST_TEXT_CHARS = 60;
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @param {number} count
|
|
|
|
|
|
* @param {string} noun
|
|
|
|
|
|
* @returns {string} "1 page", "3 pages".
|
|
|
|
|
|
*/
|
|
|
|
|
|
function counted(count, noun) {
|
|
|
|
|
|
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 18:05:49 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @param {number} time Milliseconds since the epoch.
|
|
|
|
|
|
* @returns {string} The day and the time of day, in the user's notation.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function when(time) {
|
|
|
|
|
|
const date = new Date(time);
|
|
|
|
|
|
return `${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The facts about a kept page for its card's tooltip (Andy, 2026-10-11):
|
|
|
|
|
|
* the first text on it, if any, as a heading (the block nearest the
|
|
|
|
|
|
* top, then nearest the left: reading order), then when it was last
|
|
|
|
|
|
* changed, who wrote on it, what is on it, and the sheet's size.
|
|
|
|
|
|
* @param {string} text The page file.
|
|
|
|
|
|
* @param {number} changed When it was last written, in milliseconds.
|
|
|
|
|
|
* @returns {string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function describePage(text, changed) {
|
|
|
|
|
|
const read = pageFromSvg(text);
|
|
|
|
|
|
if (!read) return 'Not a Kokkoja page.';
|
|
|
|
|
|
const { page } = read;
|
|
|
|
|
|
const lines = [];
|
|
|
|
|
|
/** @type {{x: number, y: number, text: string} | null} */
|
|
|
|
|
|
let first = null;
|
|
|
|
|
|
let strokes = 0;
|
|
|
|
|
|
let blocks = 0;
|
|
|
|
|
|
const writers = [];
|
|
|
|
|
|
for (const layer of page.layers.values()) {
|
|
|
|
|
|
if (layer.isEmpty()) continue;
|
|
|
|
|
|
writers.push(layer.name || 'Unnamed');
|
|
|
|
|
|
strokes += layer.strokes.size;
|
|
|
|
|
|
blocks += layer.texts.size;
|
|
|
|
|
|
for (const block of layer.texts.values()) {
|
|
|
|
|
|
if (!block.text.trim()) continue;
|
|
|
|
|
|
if (!first || block.y < first.y || (block.y === first.y && block.x < first.x)) first = block;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (first) {
|
|
|
|
|
|
const heading = first.text.replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
lines.push(heading.length > FIRST_TEXT_CHARS ? `${heading.slice(0, FIRST_TEXT_CHARS - 1)}…` : heading);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (changed) lines.push(`Last changed ${when(changed)}`);
|
|
|
|
|
|
if (writers.length) lines.push(`Written by ${writers.join(', ')}`);
|
|
|
|
|
|
const contents = [];
|
|
|
|
|
|
if (page.images.length) contents.push(counted(page.images.length, 'image'));
|
|
|
|
|
|
if (strokes) contents.push(counted(strokes, 'stroke'));
|
|
|
|
|
|
if (blocks) contents.push(counted(blocks, 'text block'));
|
|
|
|
|
|
lines.push(contents.length ? contents.join(', ') : 'Nothing on it');
|
|
|
|
|
|
lines.push(`Sheet ${(page.baseWidth / 100).toFixed(1)} × ${(page.baseHeight / 100).toFixed(1)} cm`);
|
|
|
|
|
|
return lines.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* The facts about a notebook for its button's tooltip: how many pages
|
|
|
|
|
|
* are kept and from when to when, when it was last written in, and
|
|
|
|
|
|
* where it is.
|
|
|
|
|
|
* @param {string} notebook
|
|
|
|
|
|
* @param {string} directory Where the notebooks are, or '' in the browser.
|
|
|
|
|
|
* @returns {Promise<string>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function describeNotebook(notebook, directory) {
|
|
|
|
|
|
const kept = await listKeptPageDetails(notebook);
|
|
|
|
|
|
const written = await currentPageChanged(notebook);
|
|
|
|
|
|
const lines = [];
|
|
|
|
|
|
if (kept.length) {
|
|
|
|
|
|
const days = kept.map((page) => pageTitle(page.name).slice(0, 10));
|
|
|
|
|
|
const span = days[0] === days[days.length - 1] ? `on ${days[0]}` : `from ${days[0]} to ${days[days.length - 1]}`;
|
|
|
|
|
|
lines.push(`${counted(kept.length, 'kept page')}, ${span}`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lines.push('No kept pages');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (written) lines.push(`Last written in ${when(written)}`);
|
|
|
|
|
|
lines.push(directory ? `In ${directory}/${notebook}` : 'Kept in this browser');
|
|
|
|
|
|
return lines.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/**
|
2026-10-11 17:10:08 +02:00
|
|
|
|
* A mark button: × until marked, then ✓.
|
2026-10-11 16:59:03 +02:00
|
|
|
|
* @param {string} help The tooltip.
|
|
|
|
|
|
* @returns {HTMLButtonElement}
|
|
|
|
|
|
*/
|
|
|
|
|
|
function markButton(help) {
|
|
|
|
|
|
const mark = document.createElement('button');
|
|
|
|
|
|
mark.type = 'button';
|
|
|
|
|
|
mark.className = 'mark';
|
2026-10-11 17:10:08 +02:00
|
|
|
|
mark.textContent = '×';
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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.
|
2026-10-11 16:59:03 +02:00
|
|
|
|
* @param {() => void} [onDeleted] Called after notebooks or pages were
|
|
|
|
|
|
* deleted, for lists elsewhere that name them.
|
2026-10-06 15:58:02 +02:00
|
|
|
|
*/
|
2026-10-11 16:59:03 +02:00
|
|
|
|
constructor(onChosen, onDeleted = () => {}) {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
this.onChosen = onChosen;
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.onDeleted = onDeleted;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser'));
|
|
|
|
|
|
this.notebook = '';
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/** The notebook being written in, which cannot be deleted while it is. */
|
|
|
|
|
|
this.writing = '';
|
2026-10-11 17:34:24 +02:00
|
|
|
|
/** Whether this participant may load a page into the session. */
|
|
|
|
|
|
this.mayLoad = true;
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/** 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 = [];
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/** @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());
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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.
|
2026-10-11 16:59:03 +02:00
|
|
|
|
* @param {string} [notebook] Open on this notebook: the one being written in.
|
2026-10-11 17:34:24 +02:00
|
|
|
|
* @param {boolean} [mayLoad] Whether a page may be loaded into the session.
|
2026-10-06 15:58:02 +02:00
|
|
|
|
*/
|
2026-10-11 17:34:24 +02:00
|
|
|
|
async open(notebook, mayLoad = true) {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
this.opening += 1;
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.writing = notebook ?? '';
|
2026-10-11 17:34:24 +02:00
|
|
|
|
this.mayLoad = mayLoad;
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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 {
|
2026-10-11 18:05:49 +02:00
|
|
|
|
const { notebooks, trash, directory } = await listNotebooks();
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.trash = trash;
|
2026-10-11 18:05:49 +02:00
|
|
|
|
this.showNotebooks(notebooks, directory);
|
2026-10-10 19:01:20 +02:00
|
|
|
|
// 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) {
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.say(error instanceof Error ? error.message : String(error));
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/** @param {string} text */
|
|
|
|
|
|
say(text) {
|
|
|
|
|
|
element('chooser_message').textContent = text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 18:05:49 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @param {string[]} notebooks
|
|
|
|
|
|
* @param {string} [directory] Where the notebooks are, for their tooltips.
|
|
|
|
|
|
*/
|
|
|
|
|
|
showNotebooks(notebooks, directory = '') {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const list = element('chooser_notebooks');
|
|
|
|
|
|
list.replaceChildren();
|
|
|
|
|
|
for (const notebook of notebooks) {
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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';
|
2026-10-11 16:59:03 +02:00
|
|
|
|
button.className = 'notebook_pick';
|
2026-10-06 15:58:02 +02:00
|
|
|
|
button.textContent = notebook;
|
2026-10-11 18:05:49 +02:00
|
|
|
|
button.dataset.help = `The notebook “${notebook}”.`;
|
|
|
|
|
|
describeNotebook(notebook, directory).then((facts) => {
|
|
|
|
|
|
if (button.isConnected) button.dataset.help = facts; // Not a list since replaced.
|
|
|
|
|
|
}).catch(() => {});
|
2026-10-06 15:58:02 +02:00
|
|
|
|
button.addEventListener('click', () => { this.showPages(notebook); });
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-10-11 17:04:08 +02:00
|
|
|
|
row.append(mark, button);
|
2026-10-11 16:59:03 +02:00
|
|
|
|
list.append(row);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.showMarks();
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @param {string} notebook */
|
|
|
|
|
|
async showPages(notebook) {
|
|
|
|
|
|
this.notebook = notebook;
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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) {
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.say('Choose a notebook.');
|
2026-10-06 15:58:02 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const opening = this.opening += 1;
|
2026-10-11 18:05:49 +02:00
|
|
|
|
const pages = await listKeptPageDetails(notebook);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
if (opening !== this.opening) return;
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.say(pages.length ? '' : 'This notebook has no kept pages.');
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
2026-10-11 16:59:03 +02:00
|
|
|
|
const marked = this.markedPages.get(notebook) ?? new Set();
|
2026-10-11 18:05:49 +02:00
|
|
|
|
/** @type {Map<string, {picture: HTMLImageElement, card: HTMLElement, changed: number}>} */
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const pictures = new Map();
|
2026-10-11 18:05:49 +02:00
|
|
|
|
for (const { name: pageName, changed } of pages) {
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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 = '';
|
2026-10-11 18:05:49 +02:00
|
|
|
|
pictures.set(pageName, { picture, card, changed });
|
|
|
|
|
|
card.dataset.help = `Kept as ${pageName}.`;
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const title = document.createElement('span');
|
|
|
|
|
|
title.textContent = pageTitle(pageName);
|
|
|
|
|
|
card.append(picture, title);
|
|
|
|
|
|
card.addEventListener('click', async () => {
|
2026-10-11 17:34:24 +02:00
|
|
|
|
if (!this.mayLoad) {
|
|
|
|
|
|
this.say('Only the host and co-hosts can start a page for everyone from a kept page.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-10-06 15:58:02 +02:00
|
|
|
|
const text = await readPage(notebook, pageName);
|
|
|
|
|
|
if (text === null) return;
|
|
|
|
|
|
this.dialog.close();
|
|
|
|
|
|
this.onChosen(notebook, pageName, text);
|
|
|
|
|
|
});
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-10-11 17:04:08 +02:00
|
|
|
|
slot.append(mark, card);
|
2026-10-11 16:59:03 +02:00
|
|
|
|
grid.append(slot);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
2026-10-11 16:59:03 +02:00
|
|
|
|
this.markedPages.set(notebook, marked);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
|
2026-10-11 18:05:49 +02:00
|
|
|
|
// The pictures are the page files themselves, fetched one by one; the
|
|
|
|
|
|
// same text gives the card's tooltip.
|
|
|
|
|
|
for (const [pageName, { picture, card, changed }] of pictures) {
|
2026-10-06 15:58:02 +02:00
|
|
|
|
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;
|
2026-10-11 18:05:49 +02:00
|
|
|
|
card.dataset.help = describePage(text, changed);
|
2026-10-06 15:58:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 16:59:03 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
2026-10-11 17:10:08 +02:00
|
|
|
|
mark.textContent = now ? '✓' : '×';
|
2026-10-11 16:59:03 +02:00
|
|
|
|
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 = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|