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;
+1
View File
@@ -571,6 +571,7 @@ button.mark.unavailable { opacity: 0.4; }
color: white;
font-size: 0.85rem;
line-height: 1.35;
white-space: pre-line; /* A page card's tooltip has several lines. */
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
pointer-events: none;
}
+4
View File
@@ -218,6 +218,10 @@ doing anything. Where it is saved depends on how you joined.
**Notebooks** in the toolbar opens a chooser that shows your notebooks
and small pictures of the pages kept in them, wherever they are kept.
Rest the pointer on a page for a second and its facts appear: the first
text on it, when it was last changed, who wrote on it, what is on it
and the size of the sheet; on a notebook, how many pages it keeps and
from when, when it was last written in, and where it is.
There you can delete pages and notebooks you no longer want: mark them
with the × at their left, then press **Delete** twice. In the browser
a deleted page is gone for good; with the Kokkoja program it goes to the
+4 -1
View File
@@ -177,7 +177,10 @@ a page of another session in the notebook. Nothing is copied if the
page is blank or the same page is already kept in that notebook; a
page loaded from a notebook and left unchanged is therefore not kept
again, although the file names it as a new page. A kept page or a
whole notebook can be deleted from the Notebooks chooser; the local
whole notebook can be deleted from the Notebooks chooser, whose
tooltips describe each page (from the page file itself, which the
chooser fetches for the picture) and each notebook (from the listing,
which names the pages and says when each was last written); the local
program moves it to the system's Trash (the freedesktop Trash on
Linux, with the info file the file manager wants for putting it back;
`~/.Trash` on macOS) unless `--trash` says otherwise, and the
+5 -2
View File
@@ -403,7 +403,8 @@ class Handler(http.server.SimpleHTTPRequestHandler):
def notebook_request(self, method, path):
"""
GET /api/notebooks the notebooks
GET /api/notebooks/<notebook> the pages of a notebook
GET /api/notebooks/<notebook> the pages of a notebook, and
when each was last changed
GET /api/notebooks/<notebook>/<page> a page
PUT /api/notebooks/<notebook>/<page> write a page
POST /api/notebooks/<notebook>/keep keep a copy of Current.svg
@@ -460,7 +461,9 @@ class Handler(http.server.SimpleHTTPRequestHandler):
"notebooks": self.listing(self.notebooks, directories=True),
"trash": self.trash is not None})
elif len(names) == 1:
self.send_json({"pages": self.listing(target, directories=False)})
pages = self.listing(target, directories=False)
self.send_json({"pages": pages,
"changed": {name: int((target / name).stat().st_mtime * 1000) for name in pages}})
else:
self.send_body(target.read_bytes(), "image/svg+xml")
except FileNotFoundError:
+62 -3
View File
@@ -916,6 +916,64 @@ class EndToEndTest(unittest.TestCase):
time.sleep(0.8)
self.assertEqual(showing(), "Write on the page. O")
# In the Notebooks chooser, a page card's tooltip gives the facts
# about the page, headed by its first text (nearest the top, then
# the left), and a notebook's gives its facts; both shown inside
# the dialog, which is above everything else.
notebook = self.notebooks / "Reader notebook"
notebook.mkdir(parents=True, exist_ok=True)
(notebook / "2026_10_01_01.svg").write_text(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-page="abcdefghijkl" data-number="1" data-base="2970 2100" '
'viewBox="0 0 2970 2100"><rect width="2970" height="2100" fill="#fff"/>'
'<g data-layer="a" data-name="Anna"><g data-stroke="s" data-width="7" data-points="10 10 0.5 20 20 0.5" '
'stroke="#000000" fill="none"><path d="M10 10 L20 20"/></g>'
'<g data-text="t2" data-x="100" data-y="900" data-width="" data-height="" data-size="35" '
'data-font="sans-serif" data-box="300 50"><desc>Lower text</desc>'
'<text fill="#000000"><tspan>Lower text</tspan></text></g>'
'<g data-text="t1" data-x="500" data-y="200" data-width="" data-height="" data-size="35" '
'data-font="sans-serif" data-box="500 50"><desc>Übung 12: der Dativ</desc>'
'<text fill="#000000"><tspan>Übung 12: der Dativ</tspan></text></g></g></svg>')
(notebook / "2026_10_03_01.svg").write_text(
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" data-kokkoja="1" data-page="abcdefghijkl" data-number="1" data-base="2970 2100" '
'viewBox="0 0 2970 2100"><rect width="2970" height="2100" fill="#fff"/></svg>')
firefox.run("document.getElementById('load_notebook_page').click();")
self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')].map((b) => b.textContent);
"""), lambda names: "Reader notebook" in names, "the chooser to list the notebooks")
firefox.run("""
[...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Reader notebook').click();
""")
self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help);
"""), lambda helps: len(helps) == 2 and all("Sheet" in h for h in helps), "the cards' tooltips to be made")
helps = firefox.run("return [...document.querySelectorAll('#chooser_pages .page_card')].map((c) => c.dataset.help);")
self.assertEqual(helps[0].split("\n")[0], "Übung 12: der Dativ")
self.assertIn("Written by Anna", helps[0])
self.assertIn("1 stroke, 2 text blocks", helps[0])
self.assertIn("Sheet 29.7 × 21.0 cm", helps[0])
self.assertRegex(helps[0], r"Last changed \S+ at \d")
self.assertEqual(helps[1].split("\n")[0], "Last changed " + helps[1].split("\n")[0][13:])
self.assertIn("Nothing on it", helps[1])
self.wait_until_true(lambda: firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Reader notebook').dataset.help;
"""), lambda help: "kept pages" in help, "the notebook's tooltip to be made")
self.assertIn("2 kept pages, from 2026-10-01 to 2026-10-03", firefox.run("""
return [...document.querySelectorAll('#chooser_notebooks button.notebook_pick')]
.find((b) => b.textContent === 'Reader notebook').dataset.help;
"""))
firefox.run("""
document.querySelector('#chooser_pages .page_card')
.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, pointerType: 'mouse'}));
""")
time.sleep(1.3)
self.assertEqual(firefox.run("""
const t = document.querySelector('dialog[open] .tooltip');
return t && !t.hidden ? t.textContent.split('\\n')[0] : null;
"""), "Übung 12: der Dativ")
firefox.run("document.getElementById('chooser_close').click();")
def test_hide_rectangles(self):
"""The teacher's requests of 2026-10-08: a Hide rectangle covers part
of the page in white for everyone, marked by blue corners."""
@@ -1656,11 +1714,12 @@ class EndToEndTest(unittest.TestCase):
const box = tip.getBoundingClientRect();
return {text: tip.textContent, below: box.top > button.bottom, left: box.left, buttonLeft: button.left};
""")
# Two seconds of rest before the tooltip shows (one until 2026-10-06).
# One second in the button before the tooltip shows (two until
# 2026-10-11, one until 2026-10-06).
firefox.hover("#new_page")
time.sleep(1.2)
time.sleep(0.5)
self.assertIsNone(tooltip())
time.sleep(1.3)
time.sleep(0.8)
shown = tooltip()
self.assertEqual(shown["text"], firefox.run(
"return document.getElementById('new_page').getAttribute('data-help');"))
+4 -3
View File
@@ -107,8 +107,9 @@ class LocalProgramTest(unittest.TestCase):
self.assertEqual(self.request("GET", page), (200, PAGE))
self.assertEqual((self.notebooks / "Übungen A1" / "2026_10_02_page.svg").read_bytes(), PAGE)
self.assertEqual(self.json("GET", "/api/notebooks")[1]["notebooks"], ["Übungen A1"])
self.assertEqual(self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1")),
(200, {"pages": ["2026_10_02_page.svg"]}))
listed = self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1"))
self.assertEqual(listed[1]["pages"], ["2026_10_02_page.svg"])
self.assertAlmostEqual(listed[1]["changed"]["2026_10_02_page.svg"] / 1000, time.time(), delta=10)
def test_writing_again_replaces_the_page_and_leaves_nothing_else(self):
page = "/api/notebooks/Andy/page.svg"
@@ -283,7 +284,7 @@ class LocalProgramTest(unittest.TestCase):
def test_missing_page(self):
self.assertEqual(self.request("GET", "/api/notebooks/Andy/none.svg")[0], 404)
self.assertEqual(self.json("GET", "/api/notebooks/Andy"), (200, {"pages": []}))
self.assertEqual(self.json("GET", "/api/notebooks/Andy"), (200, {"pages": [], "changed": {}}))
# The file chooser and exporting.