Tooltips in the Notebooks chooser

From the development repository at e819bc4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-11 18:05:49 +02:00
1 parent fcd6117618
commit 87ba7fc776
10 files changed
+220 -24

No files matched your search

+2 -1
View File
@@ -136,7 +136,8 @@ export const backend = {
},
async listPages(notebook) {
return transaction('readonly', async (pages) => (await pagesOf(pages, notebook)).map((page) => page.name));
return transaction('readonly', async (pages) =>
(await pagesOf(pages, notebook)).map((page) => ({ name: page.name, changed: page.changed })));
},
async readPage(notebook, pageName) {
+104 -11
View File
@@ -21,7 +21,13 @@
* so to anyone else.
*/
import { discardNotebook, discardPage, listKeptPages, listNotebooks, readPage } from './notebook.js';
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
@@ -49,6 +55,83 @@ 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.
@@ -116,9 +199,9 @@ export class NotebookChooser {
element('chooser_message').textContent = '';
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
try {
const { notebooks, trash } = await listNotebooks();
const { notebooks, trash, directory } = await listNotebooks();
this.trash = trash;
this.showNotebooks(notebooks);
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
@@ -134,8 +217,11 @@ export class NotebookChooser {
element('chooser_message').textContent = text;
}
/** @param {string[]} notebooks */
showNotebooks(notebooks) {
/**
* @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) {
@@ -145,6 +231,10 @@ export class NotebookChooser {
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.'
@@ -178,14 +268,14 @@ export class NotebookChooser {
return;
}
const opening = this.opening += 1;
const pages = await listKeptPages(notebook);
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, HTMLImageElement>} */
/** @type {Map<string, {picture: HTMLImageElement, card: HTMLElement, changed: number}>} */
const pictures = new Map();
for (const pageName of pages) {
for (const { name: pageName, changed } of pages) {
const slot = document.createElement('div');
slot.className = 'page_slot';
const card = document.createElement('button');
@@ -193,7 +283,8 @@ export class NotebookChooser {
card.className = 'page_card';
const picture = document.createElement('img');
picture.alt = '';
pictures.set(pageName, picture);
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);
@@ -219,14 +310,16 @@ export class NotebookChooser {
}
this.markedPages.set(notebook, marked);
// The pictures are the page files themselves, fetched one by one.
for (const [pageName, picture] of pictures) {
// 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);
}
}
+30 -3
View File
@@ -29,6 +29,13 @@ const LONGEST_WAIT_MS = 2000;
/** After a write fails, try again this much later. */
const RETRY_MS = 3000;
/**
* A page file in a notebook.
* @typedef {object} KeptPage
* @property {string} name
* @property {number} changed When it was last written, in milliseconds since the epoch.
*/
/**
* What keeps the notebooks: the local program's files, or the browser.
* @typedef {object} Backend
@@ -37,7 +44,7 @@ const RETRY_MS = 3000;
* @property {() => Promise<{directory: string, notebooks: string[], trash: boolean}>} listNotebooks
* The directory is '' when the notebooks are in the browser; trash
* says whether a deleted notebook or page goes to one, or is gone for good.
* @property {(notebook: string) => Promise<string[]>} listPages Every page file, Current.svg too.
* @property {(notebook: string) => Promise<KeptPage[]>} listPages Every page file, Current.svg too.
* @property {(notebook: string, pageName: string) => Promise<string | null>} readPage
* @property {(notebook: string, pageName: string, text: string) => Promise<void>} writePage
* @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage
@@ -101,7 +108,8 @@ const inFiles = /** @type {Backend} */ ({
async listPages(notebook) {
const response = await request('GET', [notebook]);
if (!response.ok) throw new Error(`The notebook “${notebook}” cannot be read.`);
return (await response.json()).pages;
const { pages, changed } = await response.json();
return pages.map((/** @type {string} */ name) => ({ name, changed: changed?.[name] ?? 0 }));
},
async readPage(notebook, pageName) {
const response = await request('GET', [notebook, pageName]);
@@ -189,8 +197,27 @@ export function discardPage(notebook, pageName) {
* notebook, in order; not the page being written.
*/
export async function listKeptPages(notebook) {
return (await listKeptPageDetails(notebook)).map((page) => page.name);
}
/**
* @param {string} notebook
* @returns {Promise<KeptPage[]>} The pages kept in the notebook, in
* order, with when each was last changed; not the page being written.
*/
export async function listKeptPageDetails(notebook) {
const pages = await backend.listPages(notebook);
return pages.filter((name) => name !== CURRENT_PAGE);
return pages.filter((page) => page.name !== CURRENT_PAGE);
}
/**
* @param {string} notebook
* @returns {Promise<number>} When the page being written in the notebook
* was last written, in milliseconds since the epoch, or 0 if there is none.
*/
export async function currentPageChanged(notebook) {
const pages = await backend.listPages(notebook);
return pages.find((page) => page.name === CURRENT_PAGE)?.changed ?? 0;
}
/**
+4
View File
@@ -50,6 +50,10 @@ function show(element) {
tooltip.setAttribute('role', 'tooltip');
document.body.append(tooltip);
}
// A modal dialog is above everything in the body, its tooltips included,
// so a tooltip for an element in one lives in the dialog.
const home = element.closest('dialog[open]') ?? document.body;
if (tooltip.parentElement !== home) home.append(tooltip);
tooltip.textContent = element.getAttribute('data-help');
tooltip.hidden = false;
shownFor = element;