Files
kokkoja-rel/client/js/chooser.js
T
ackandClaude Fable 5.1 87ba7fc776 Tooltips in the Notebooks chooser
From the development repository at e819bc4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 18:05:49 +02:00

425 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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<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');
}
/**
* 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<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;
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<string, {picture: HTMLImageElement, card: HTMLElement, changed: number}>} */
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<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.`);
}
forgetPictures() {
for (const address of this.pictureAddresses) URL.revokeObjectURL(address);
this.pictureAddresses = [];
}
}