// @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 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" * 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. * 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. */ 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; /** * @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'}`; } /** * @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} */ 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'); } /** * 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; } 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. */ constructor(onChosen, onDeleted = () => {}) { this.onChosen = onChosen; this.onDeleted = onDeleted; this.dialog = /** @type {HTMLDialogElement} */ (element('notebook_chooser')); this.notebook = ''; /** The notebook being written in, which cannot be deleted while it is. */ this.writing = ''; /** Whether this participant may load a page into the session. */ this.mayLoad = true; /** Whether deletions go to a Trash (the local program's) or are for good. */ this.trash = false; /** Counts openings, so that pictures fetched for an earlier one are dropped. */ this.opening = 0; /** @type {string[]} */ this.pictureAddresses = []; /** @type {Set} Notebooks marked for deletion. */ this.markedNotebooks = new Set(); /** @type {Map>} 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; element('chooser_close').addEventListener('click', () => this.dialog.close()); element('chooser_delete').addEventListener('click', () => { this.deleteMarked().catch(() => {}); }); this.dialog.addEventListener('close', () => { this.forgetPictures(); this.clearMarks(); }); } /** * Show the notebooks, or the pages of the only notebook. * @param {string} [notebook] Open on this notebook: the one being written in. * @param {boolean} [mayLoad] Whether a page may be loaded into the session. */ async open(notebook, mayLoad = true) { this.opening += 1; this.writing = notebook ?? ''; this.mayLoad = mayLoad; this.clearMarks(); this.dialog.showModal(); element('chooser_message').textContent = ''; element('chooser_pages').replaceChildren(); // Nothing stale while the lists load. try { const { notebooks, trash, directory } = await listNotebooks(); this.trash = trash; this.showNotebooks(notebooks, directory); // The notebook being written in is chosen already, its pages shown; // the list is always there to see (Andy, 2026-10-06). 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)); } } /** @param {string} text */ say(text) { element('chooser_message').textContent = text; } /** * @param {string[]} notebooks * @param {string} [directory] Where the notebooks are, for their tooltips. */ showNotebooks(notebooks, directory = '') { const list = element('chooser_notebooks'); list.replaceChildren(); for (const notebook of notebooks) { const row = document.createElement('div'); row.className = 'notebook_row'; const button = document.createElement('button'); button.type = 'button'; button.className = 'notebook_pick'; button.textContent = notebook; button.dataset.help = `The notebook “${notebook}”.`; describeNotebook(notebook, directory).then((facts) => { if (button.isConnected) button.dataset.help = facts; // Not a list since replaced. }).catch(() => {}); 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(mark, button); list.append(row); } this.showMarks(); } /** @param {string} notebook */ async showPages(notebook) { this.notebook = notebook; this.disarm(); for (const button of element('chooser_notebooks').querySelectorAll('button.notebook_pick')) { button.setAttribute('aria-pressed', String(button.textContent === notebook)); } const grid = element('chooser_pages'); grid.replaceChildren(); this.forgetPictures(); if (!notebook) { this.say('Choose a notebook.'); return; } const opening = this.opening += 1; const pages = await listKeptPageDetails(notebook); if (opening !== this.opening) return; this.say(pages.length ? '' : 'This notebook has no kept pages.'); const marked = this.markedPages.get(notebook) ?? new Set(); /** @type {Map} */ const pictures = new Map(); for (const { name: pageName, changed } of pages) { const slot = document.createElement('div'); slot.className = 'page_slot'; const card = document.createElement('button'); card.type = 'button'; card.className = 'page_card'; const picture = document.createElement('img'); picture.alt = ''; pictures.set(pageName, { picture, card, changed }); card.dataset.help = `Kept as ${pageName}.`; const title = document.createElement('span'); title.textContent = pageTitle(pageName); card.append(picture, title); card.addEventListener('click', async () => { if (!this.mayLoad) { this.say('Only the host and co-hosts can start a page for everyone from a kept page.'); return; } 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(mark, card); grid.append(slot); } this.markedPages.set(notebook, marked); // 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) { 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; card.dataset.help = describePage(text, changed); } } /** * Mark or unmark one entry. * @param {HTMLButtonElement} mark * @param {Set} 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.`); } forgetPictures() { for (const address of this.pictureAddresses) URL.revokeObjectURL(address); this.pictureAddresses = []; } }