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:
1 parent
fcd6117618
commit
87ba7fc776
10 files changed
+220
-24
No files matched your search
@@ -136,7 +136,8 @@ export const backend = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async listPages(notebook) {
|
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) {
|
async readPage(notebook, pageName) {
|
||||||
|
|||||||
+104
-11
@@ -21,7 +21,13 @@
|
|||||||
* so to anyone else.
|
* 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
|
* @param {string} id
|
||||||
@@ -49,6 +55,83 @@ function counted(count, noun) {
|
|||||||
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
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 ✓.
|
* A mark button: × until marked, then ✓.
|
||||||
* @param {string} help The tooltip.
|
* @param {string} help The tooltip.
|
||||||
@@ -116,9 +199,9 @@ export class NotebookChooser {
|
|||||||
element('chooser_message').textContent = '';
|
element('chooser_message').textContent = '';
|
||||||
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
|
element('chooser_pages').replaceChildren(); // Nothing stale while the lists load.
|
||||||
try {
|
try {
|
||||||
const { notebooks, trash } = await listNotebooks();
|
const { notebooks, trash, directory } = await listNotebooks();
|
||||||
this.trash = trash;
|
this.trash = trash;
|
||||||
this.showNotebooks(notebooks);
|
this.showNotebooks(notebooks, directory);
|
||||||
// The notebook being written in is chosen already, its pages shown;
|
// The notebook being written in is chosen already, its pages shown;
|
||||||
// the list is always there to see (Andy, 2026-10-06).
|
// the list is always there to see (Andy, 2026-10-06).
|
||||||
const chosen = notebook && notebooks.includes(notebook) ? notebook
|
const chosen = notebook && notebooks.includes(notebook) ? notebook
|
||||||
@@ -134,8 +217,11 @@ export class NotebookChooser {
|
|||||||
element('chooser_message').textContent = text;
|
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');
|
const list = element('chooser_notebooks');
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
for (const notebook of notebooks) {
|
for (const notebook of notebooks) {
|
||||||
@@ -145,6 +231,10 @@ export class NotebookChooser {
|
|||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'notebook_pick';
|
button.className = 'notebook_pick';
|
||||||
button.textContent = notebook;
|
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); });
|
button.addEventListener('click', () => { this.showPages(notebook); });
|
||||||
const mark = markButton(notebook === this.writing
|
const mark = markButton(notebook === this.writing
|
||||||
? 'This is the notebook you are writing in. To delete it, choose another notebook in the toolbar first.'
|
? '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;
|
return;
|
||||||
}
|
}
|
||||||
const opening = this.opening += 1;
|
const opening = this.opening += 1;
|
||||||
const pages = await listKeptPages(notebook);
|
const pages = await listKeptPageDetails(notebook);
|
||||||
if (opening !== this.opening) return;
|
if (opening !== this.opening) return;
|
||||||
this.say(pages.length ? '' : 'This notebook has no kept pages.');
|
this.say(pages.length ? '' : 'This notebook has no kept pages.');
|
||||||
|
|
||||||
const marked = this.markedPages.get(notebook) ?? new Set();
|
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();
|
const pictures = new Map();
|
||||||
for (const pageName of pages) {
|
for (const { name: pageName, changed } of pages) {
|
||||||
const slot = document.createElement('div');
|
const slot = document.createElement('div');
|
||||||
slot.className = 'page_slot';
|
slot.className = 'page_slot';
|
||||||
const card = document.createElement('button');
|
const card = document.createElement('button');
|
||||||
@@ -193,7 +283,8 @@ export class NotebookChooser {
|
|||||||
card.className = 'page_card';
|
card.className = 'page_card';
|
||||||
const picture = document.createElement('img');
|
const picture = document.createElement('img');
|
||||||
picture.alt = '';
|
picture.alt = '';
|
||||||
pictures.set(pageName, picture);
|
pictures.set(pageName, { picture, card, changed });
|
||||||
|
card.dataset.help = `Kept as ${pageName}.`;
|
||||||
const title = document.createElement('span');
|
const title = document.createElement('span');
|
||||||
title.textContent = pageTitle(pageName);
|
title.textContent = pageTitle(pageName);
|
||||||
card.append(picture, title);
|
card.append(picture, title);
|
||||||
@@ -219,14 +310,16 @@ export class NotebookChooser {
|
|||||||
}
|
}
|
||||||
this.markedPages.set(notebook, marked);
|
this.markedPages.set(notebook, marked);
|
||||||
|
|
||||||
// The pictures are the page files themselves, fetched one by one.
|
// The pictures are the page files themselves, fetched one by one; the
|
||||||
for (const [pageName, picture] of pictures) {
|
// same text gives the card's tooltip.
|
||||||
|
for (const [pageName, { picture, card, changed }] of pictures) {
|
||||||
const text = await readPage(notebook, pageName);
|
const text = await readPage(notebook, pageName);
|
||||||
if (opening !== this.opening) return;
|
if (opening !== this.opening) return;
|
||||||
if (text === null) continue;
|
if (text === null) continue;
|
||||||
const address = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' }));
|
const address = URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' }));
|
||||||
this.pictureAddresses.push(address);
|
this.pictureAddresses.push(address);
|
||||||
picture.src = address;
|
picture.src = address;
|
||||||
|
card.dataset.help = describePage(text, changed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-3
@@ -29,6 +29,13 @@ const LONGEST_WAIT_MS = 2000;
|
|||||||
/** After a write fails, try again this much later. */
|
/** After a write fails, try again this much later. */
|
||||||
const RETRY_MS = 3000;
|
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.
|
* What keeps the notebooks: the local program's files, or the browser.
|
||||||
* @typedef {object} Backend
|
* @typedef {object} Backend
|
||||||
@@ -37,7 +44,7 @@ const RETRY_MS = 3000;
|
|||||||
* @property {() => Promise<{directory: string, notebooks: string[], trash: boolean}>} listNotebooks
|
* @property {() => Promise<{directory: string, notebooks: string[], trash: boolean}>} listNotebooks
|
||||||
* The directory is '' when the notebooks are in the browser; trash
|
* 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.
|
* 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) => Promise<string | null>} readPage
|
||||||
* @property {(notebook: string, pageName: string, text: string) => Promise<void>} writePage
|
* @property {(notebook: string, pageName: string, text: string) => Promise<void>} writePage
|
||||||
* @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage
|
* @property {(notebook: string) => Promise<{kept: string | null, new: boolean}>} keepPage
|
||||||
@@ -101,7 +108,8 @@ const inFiles = /** @type {Backend} */ ({
|
|||||||
async listPages(notebook) {
|
async listPages(notebook) {
|
||||||
const response = await request('GET', [notebook]);
|
const response = await request('GET', [notebook]);
|
||||||
if (!response.ok) throw new Error(`The notebook “${notebook}” cannot be read.`);
|
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) {
|
async readPage(notebook, pageName) {
|
||||||
const response = await request('GET', [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.
|
* notebook, in order; not the page being written.
|
||||||
*/
|
*/
|
||||||
export async function listKeptPages(notebook) {
|
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);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ function show(element) {
|
|||||||
tooltip.setAttribute('role', 'tooltip');
|
tooltip.setAttribute('role', 'tooltip');
|
||||||
document.body.append(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.textContent = element.getAttribute('data-help');
|
||||||
tooltip.hidden = false;
|
tooltip.hidden = false;
|
||||||
shownFor = element;
|
shownFor = element;
|
||||||
|
|||||||
@@ -571,6 +571,7 @@ button.mark.unavailable { opacity: 0.4; }
|
|||||||
color: white;
|
color: white;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.35;
|
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);
|
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
**Notebooks** in the toolbar opens a chooser that shows your notebooks
|
||||||
and small pictures of the pages kept in them, wherever they are kept.
|
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
|
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
|
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
|
a deleted page is gone for good; with the Kokkoja program it goes to the
|
||||||
|
|||||||
@@ -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 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
|
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
|
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
|
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;
|
Linux, with the info file the file manager wants for putting it back;
|
||||||
`~/.Trash` on macOS) unless `--trash` says otherwise, and the
|
`~/.Trash` on macOS) unless `--trash` says otherwise, and the
|
||||||
|
|||||||
+5
-2
@@ -403,7 +403,8 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
|||||||
def notebook_request(self, method, path):
|
def notebook_request(self, method, path):
|
||||||
"""
|
"""
|
||||||
GET /api/notebooks the notebooks
|
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
|
GET /api/notebooks/<notebook>/<page> a page
|
||||||
PUT /api/notebooks/<notebook>/<page> write a page
|
PUT /api/notebooks/<notebook>/<page> write a page
|
||||||
POST /api/notebooks/<notebook>/keep keep a copy of Current.svg
|
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),
|
"notebooks": self.listing(self.notebooks, directories=True),
|
||||||
"trash": self.trash is not None})
|
"trash": self.trash is not None})
|
||||||
elif len(names) == 1:
|
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:
|
else:
|
||||||
self.send_body(target.read_bytes(), "image/svg+xml")
|
self.send_body(target.read_bytes(), "image/svg+xml")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
|||||||
@@ -916,6 +916,64 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
time.sleep(0.8)
|
time.sleep(0.8)
|
||||||
self.assertEqual(showing(), "Write on the page. O")
|
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):
|
def test_hide_rectangles(self):
|
||||||
"""The teacher's requests of 2026-10-08: a Hide rectangle covers part
|
"""The teacher's requests of 2026-10-08: a Hide rectangle covers part
|
||||||
of the page in white for everyone, marked by blue corners."""
|
of the page in white for everyone, marked by blue corners."""
|
||||||
@@ -1656,11 +1714,12 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
const box = tip.getBoundingClientRect();
|
const box = tip.getBoundingClientRect();
|
||||||
return {text: tip.textContent, below: box.top > button.bottom, left: box.left, buttonLeft: button.left};
|
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")
|
firefox.hover("#new_page")
|
||||||
time.sleep(1.2)
|
time.sleep(0.5)
|
||||||
self.assertIsNone(tooltip())
|
self.assertIsNone(tooltip())
|
||||||
time.sleep(1.3)
|
time.sleep(0.8)
|
||||||
shown = tooltip()
|
shown = tooltip()
|
||||||
self.assertEqual(shown["text"], firefox.run(
|
self.assertEqual(shown["text"], firefox.run(
|
||||||
"return document.getElementById('new_page').getAttribute('data-help');"))
|
"return document.getElementById('new_page').getAttribute('data-help');"))
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ class LocalProgramTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.request("GET", page), (200, PAGE))
|
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.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")[1]["notebooks"], ["Übungen A1"])
|
||||||
self.assertEqual(self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1")),
|
listed = self.json("GET", "/api/notebooks/" + urllib.parse.quote("Übungen A1"))
|
||||||
(200, {"pages": ["2026_10_02_page.svg"]}))
|
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):
|
def test_writing_again_replaces_the_page_and_leaves_nothing_else(self):
|
||||||
page = "/api/notebooks/Andy/page.svg"
|
page = "/api/notebooks/Andy/page.svg"
|
||||||
@@ -283,7 +284,7 @@ class LocalProgramTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_missing_page(self):
|
def test_missing_page(self):
|
||||||
self.assertEqual(self.request("GET", "/api/notebooks/Andy/none.svg")[0], 404)
|
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.
|
# The file chooser and exporting.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user