From 35c4f434f3e354f32b6bef1e0aa044ab795b2ba3 Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Wed, 14 Oct 2026 01:07:53 +0200 Subject: [PATCH] The Page menu, Help, and the manual from the tooltips The page and view commands are in a menu under "Page", with grey titles over the display and size sections. Help, at the far right, shows a note for now; with Ctrl (Command on a Mac) held it shows the explanations of every command in one text, alphabetical, wrapped at 80 columns, a shared name prefixed by its context. Co-Authored-By: Claude Fable 5.1 --- client/index.html | 47 ++++++++++---- client/js/main.js | 113 ++++++++++++++++++++++++-------- client/js/manual.js | 135 +++++++++++++++++++++++++++++++++++++++ client/kokkoja.css | 76 ++++++++++++++++++---- tests/test_end_to_end.py | 103 +++++++++++++++++++++++++++++ 5 files changed, 419 insertions(+), 55 deletions(-) create mode 100644 client/js/manual.js diff --git a/client/index.html b/client/index.html index 955bf39..22ab89a 100644 --- a/client/index.html +++ b/client/index.html @@ -75,21 +75,28 @@ -
- - - - - -
+ + + +
@@ -134,7 +143,7 @@
-

@@ -151,6 +160,16 @@ +

+
+

Help

+ + +
+

+
+

Notebooks

diff --git a/client/js/main.js b/client/js/main.js index 1c45da1..ffe0732 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -35,6 +35,7 @@ import { TextEditor } from './text_editor.js'; import { enableTooltips } from './tooltip.js'; import { guardSelection, inField } from './selection_guard.js'; import { QUIET_ZONE, qrModules, qrPath } from './qr_code.js'; +import { manualText } from './manual.js'; /** * The colours in the toolbar: black, red, green, blue, white (Andy's @@ -629,14 +630,12 @@ async function enterSession(invitationText, name, notebook) { }; new ResizeObserver(watchToolbar).observe(toolbar); watchToolbar(); - // The drawing group keeps the width of its widest state, the pen's - // colours and widths, so that the page-and-view group, centred between - // it and the session group, stays put when the tool changes (Andy, - // 2026-10-11). The floor rises if the group ever grows, as when a - // colour is added. - // The settings (colours, widths, font and size) keep their widest - // room likewise, so that Eraser, Hide and Undo, which stand after them - // since 2026-10-11, do not jump when a tool shows fewer settings. + // The drawing group and the settings (colours, widths, font and size) + // keep the width of their widest state, so that Eraser, Hide and Undo, + // which stand after the settings since 2026-10-11, do not jump when a + // tool shows fewer settings (the drawing group's floor also kept the + // middle group still while there was one, until 2026-10-14). The + // floor rises if a group ever grows, as when a colour is added. for (const part of [element('drawing'), element('settings')]) { let widest = 0; new ResizeObserver(() => { @@ -1053,6 +1052,30 @@ async function enterSession(invitationText, name, notebook) { }; // The host's Fit and A4 reach everyone (Andy, 2026-10-08); a guest's // are the guest's own. + // The page and view commands live in the Page menu (Andy, 2026-10-14: + // seldom used, and the one row is precious); Help is a note for now. + attachPopup(element('page_menu_button'), element('page_menu')); + // Ctrl-click (Command on a Mac) on Help: the tooltips of every command, + // gathered as one text (Andy, 2026-10-14). Registered before the + // popup's handler, which it then stops. + const manualDialog = /** @type {HTMLDialogElement} */ (element('manual_dialog')); + element('help').addEventListener('click', (event) => { + if (!(event.ctrlKey || event.metaKey)) return; + event.stopImmediatePropagation(); + element('manual_text').textContent = manualText(document); + manualDialog.showModal(); + }); + element('manual_close').addEventListener('click', () => manualDialog.close()); + element('copy_manual').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(element('manual_text').textContent ?? ''); + element('copy_manual').textContent = 'Copied'; + setTimeout(() => { element('copy_manual').textContent = 'Copy'; }, 1500); + } catch (error) { + log(`The manual could not be copied: ${error}`); + } + }); + attachPopup(element('help'), element('help_popup')); element('zoom_fit').addEventListener('click', () => { renderer.fit(); session.sendViewCommand('fit'); @@ -1277,6 +1300,59 @@ function press(group, chosen) { } } +/** The closers of every popup, so that opening one closes the others. @type {Set<() => void>} */ +const popupClosers = new Set(); + +/** + * A popup of Kokkoja's own below a toolbar button (the colour palette, + * the Page menu, the Help note; no system menu or dialog, 2026-10-14). + * The button's press opens it below the button, left edges aligned, or + * right edges when it would run past the window; a press anywhere else, + * Escape, or a press on an item marked `.menu_item` closes it. + * @param {HTMLElement} button + * @param {HTMLElement} popup + * @param {() => void} [onOpen] Fill the popup before it shows. + */ +export function attachPopup(button, popup, onOpen) { + const close = () => { + popup.hidden = true; + button.setAttribute('aria-expanded', 'false'); + }; + popupClosers.add(close); + button.addEventListener('click', () => { + if (!popup.hidden) { + close(); + return; + } + // One popup at a time (a keyboard's Enter opens without the press + // that would close the other). + for (const other of popupClosers) other(); + onOpen?.(); + const box = button.getBoundingClientRect(); + // Measured at the window's left edge: placed near the right edge, an + // absolute box shrinks to the room left and its text wraps. + popup.style.left = '0px'; + popup.style.top = `${box.bottom + 6}px`; + popup.hidden = false; + button.setAttribute('aria-expanded', 'true'); + const width = popup.getBoundingClientRect().width; + const fits = box.left + width <= window.innerWidth - 8; + popup.style.left = `${fits ? box.left : Math.max(8, box.right - width)}px`; + }); + popup.addEventListener('click', (event) => { + const item = /** @type {Element} */ (event.target).closest('.menu_item'); + // A label's click reaches here twice, once as the input's; close on the second. + if (item && !(event.target instanceof HTMLLabelElement)) close(); + }); + document.addEventListener('pointerdown', (event) => { + const target = /** @type {Node} */ (event.target); + if (!popup.hidden && !popup.contains(target) && !button.contains(target)) close(); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !popup.hidden) close(); + }); +} + /** * @param {object} options * @param {string} options.color Initial color. @@ -1374,25 +1450,7 @@ function buildToolbar(options) { palette.append(button); } }; - more.addEventListener('click', () => { - if (!palette.hidden) { - palette.hidden = true; - return; - } - showPalette(); - const box = more.getBoundingClientRect(); - palette.style.left = `${box.left}px`; - palette.style.top = `${box.bottom + 6}px`; - palette.hidden = false; - }); - document.addEventListener('pointerdown', (event) => { - if (!palette.hidden && !palette.contains(/** @type {Node} */ (event.target)) && event.target !== more) { - palette.hidden = true; - } - }); - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') palette.hidden = true; - }); + attachPopup(more, palette, showPalette); const widths = element('widths'); for (const width of WIDTHS) { @@ -1466,6 +1524,7 @@ function showParticipants(session, renderer) { const visible = document.createElement('input'); visible.type = 'checkbox'; visible.checked = !renderer.hidden.has(participant.id); + visible.setAttribute('aria-label', 'Shown'); visible.setAttribute('data-help', 'Show or hide this participant’s writing on your screen only.'); visible.addEventListener('change', () => renderer.setLayerVisible(participant.id, visible.checked)); label.append(visible, ` ${participant.name}`); diff --git a/client/js/manual.js b/client/js/manual.js new file mode 100644 index 0000000..86ea544 --- /dev/null +++ b/client/js/manual.js @@ -0,0 +1,135 @@ +// @ts-check +/** + * The manual that the interface already carries: every control's tooltip + * (`data-help`), gathered from the page and written as plain text under + * the title "User interface actions": the controls in alphabetical + * order, each one's name on a line, its explanation on the next lines, + * wrapped at WIDTH columns, and a blank line after (Andy, 2026-10-14: + * Ctrl-click on Help shows it; a first form as Klammertext was replaced + * by this the same day). + */ + +export const TITLE = 'User interface actions'; +export const WIDTH = 80; + +/** + * What a control is called: a button's label; a label's own text, without + * its checkbox; else the name given for assistive technology, the text of + * the label that points at the control, or its id. + * @param {Element} el + * @returns {string} + */ +function nameOf(el) { + if (el instanceof HTMLLabelElement) { + return [...el.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent?.trim() ?? '').join(' ').trim(); + } + const text = el instanceof HTMLButtonElement ? el.textContent?.trim() ?? '' : ''; + const labelFor = el.id ? el.ownerDocument.querySelector(`label[for="${el.id}"]`) : null; + return text || el.getAttribute('aria-label') || labelFor?.textContent?.trim() || el.id.replace(/_/g, ' '); +} + +/** + * Where a control is, for telling two of the same name apart ("Copy" in + * the Session window's Invitation and Log parts): the nearest heading + * above it within its window, else the window's title, else the + * toolbar. + * @param {Element} el + * @returns {string} + */ +function contextOf(el) { + const scope = el.closest('dialog, main, .toolbar'); + if (!scope) return ''; + let context = ''; + for (const heading of scope.querySelectorAll('h1, h2, h3')) { + if (heading.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) { + context = heading.textContent?.trim() ?? ''; + } + } + return context || (scope.classList.contains('toolbar') ? 'Toolbar' : ''); +} + +/** + * The entries: every control with an explanation, except the manual's + * own window; controls that differ only by a number or a colour (the + * line widths, the swatches) are one entry, named by what their names + * share; a name that two controls share gets each one's context before + * it, "Invitation: Copy" (Andy, 2026-10-14). In alphabetical order of + * the names. + * @param {Document | Element} doc + * @returns {[string, string][]} Name and explanation. + */ +export function manualEntries(doc) { + /** @type {Map} */ + const byKey = new Map(); + for (const el of doc.querySelectorAll('[data-help]')) { + if (el.closest('#manual_dialog')) continue; + const help = el.getAttribute('data-help') ?? ''; + const key = help.replace(/\d+(?:\.\d+)?/g, '#'); + const entry = byKey.get(key); + if (entry) { + entry.names.push(nameOf(el)); + entry.contexts.push(contextOf(el)); + } else { + byKey.set(key, { names: [nameOf(el)], contexts: [contextOf(el)], help }); + } + } + const named = [...byKey.values()].map(({ names, contexts, help }) => + ({ name: names.length > 1 ? commonStart(names) : names[0], context: contexts[0], help })); + const counts = new Map(); + for (const { name } of named) counts.set(name, (counts.get(name) ?? 0) + 1); + /** @type {[string, string][]} */ + const entries = named.map(({ name, context, help }) => + [(counts.get(name) ?? 0) > 1 && context ? `${context}: ${name}` : name, help]); + return entries.sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }) || a[1].localeCompare(b[1])); +} + +/** + * The words that names share at their start ("Color #000000", "Color + * #d32f2f" share "Color"); the first name when they share nothing. + * @param {string[]} names + * @returns {string} + */ +function commonStart(names) { + const words = names[0].split(' '); + let n = words.length; + for (const name of names.slice(1)) { + const other = name.split(' '); + let i = 0; + while (i < n && i < other.length && other[i] === words[i]) i += 1; + n = i; + } + return n > 0 ? words.slice(0, n).join(' ') : names[0]; +} + +/** + * The text filled into lines of at most `width` characters, broken at + * spaces; a word longer than a line stands alone on one. + * @param {string} text + * @param {number} width + * @returns {string} + */ +export function wrap(text, width) { + const lines = []; + let line = ''; + for (const word of text.split(/\s+/).filter(Boolean)) { + if (line && line.length + 1 + word.length > width) { + lines.push(line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + if (line) lines.push(line); + return lines.join('\n'); +} + +/** + * The manual as plain text. + * @param {Document | Element} doc + * @returns {string} + */ +export function manualText(doc) { + const blocks = manualEntries(doc).map(([name, help]) => `${name}\n${wrap(help, WIDTH)}\n`); + return `${TITLE}\n\n${blocks.join('\n')}`; +} diff --git a/client/kokkoja.css b/client/kokkoja.css index ad96501..ee82387 100644 --- a/client/kokkoja.css +++ b/client/kokkoja.css @@ -108,23 +108,22 @@ h2 { background: var(--panel); } -/* Three groups (Andy, 2026-10-11): the drawing tools with their settings - and Undo on the left; the page and the view in the middle, centred - between the other two; the session's buttons flush right. The - drawing group keeps the width of its widest state (main.js sets its - min-width by measuring), so that the middle group stays put when the - tool changes. */ +/* Two groups since 2026-10-14 (three from 2026-10-11, when the page and + the view stood centred between them; those commands are in the Page + menu now): the drawing tools with their settings and Undo on the + left; the page and session buttons flush right, Help last. The + drawing group and the settings keep the width of their widest state + (main.js sets their min-width by measuring), so that nothing after + them moves when the tool changes. */ .drawing, .settings { display: flex; align-items: center; gap: 0.5rem 0.55rem; } /* Twice the gap before Eraser, Hide and Undo, so that with the pen the widest line and the Eraser read as two groups (Andy, 2026-10-11). */ .drawing .after_settings { margin-left: 0.55rem; } -.toolbar .page_view { margin-left: auto; margin-right: auto; } /* When the window is too narrow for one row, the tools are centred instead of standing at the two ends (Andy, 2026-10-09; main.js sets the class by measuring). */ .toolbar.wrapped { justify-content: center; } -.toolbar.wrapped .page_view { margin-left: 0; margin-right: 0; } .toolbar.wrapped .toolbar_right { margin-left: 0; justify-content: center; } /* 9rem wide until 2026-10-11: room for Undo. The buttons' height, else @@ -179,12 +178,12 @@ h2 { /* The palette that "+" opens: no system dialog, which crashes Firefox on Linux (Hollis, 2026-10-04). */ -.palette { +/* A popup of Kokkoja's own below a toolbar button (no system menu or + dialog): the colour palette, the Page menu, the Help note. main.js + places it, and closes it on a press elsewhere or Escape. */ +.popup { position: absolute; z-index: 5; - display: grid; - grid-template-columns: repeat(8, 1.5rem); - gap: 0.3rem; padding: 0.5rem; border: 1px solid var(--line); border-radius: 8px; @@ -192,6 +191,55 @@ h2 { box-shadow: 0 4px 16px rgb(0 0 0 / 0.2); } +.palette { + display: grid; + grid-template-columns: repeat(8, 1.5rem); + gap: 0.3rem; +} + +/* The Page menu: commands one below the other, read as a list, with a + line between the view, the page's size and the writing mode, and a + title over the first two in the muted grey of what cannot be pressed + (Andy, 2026-10-14: grey, not italics). */ +.menu { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.35rem; min-width: 14rem; } +.menu .menu_item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + height: auto; + padding: 0.35rem 0.6rem; + border: 1px solid transparent; + border-radius: 6px; + background: none; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; +} +.menu .menu_item:hover { background: var(--panel); border-color: var(--line); } +.menu .menu_title { padding: 0.2rem 0.6rem 0.1rem; color: var(--muted); cursor: default; } +.menu hr { width: 100%; margin: 0.2rem 0; border: 0; border-top: 1px solid var(--line); } +.menu .toggle input { margin: 0; } + +.help_popup { padding: 0.8rem 1rem; } + +/* The generated manual (Ctrl-click on Help): the tooltips as Klammertext. + Its Copy button has a button's ordinary shape, not the close mark's + 2rem; the text takes the focus when the window opens, so that the + keyboard scrolls it and no button's tooltip opens by itself. */ +.manual header h2 { flex: 1; min-width: 0; } +.chooser header button.action { width: auto; padding: 0.35rem 0.8rem; margin-right: 0.5rem; font-size: inherit; line-height: normal; } +.manual_text:focus { outline: 2px solid var(--line); outline-offset: 2px; } +.manual_text { + margin: 0; + max-height: 70vh; + overflow: auto; + white-space: pre-wrap; + font-size: 0.85rem; + line-height: 1.4; +} + .palette .swatch { width: 1.5rem; height: 1.5rem; } /* A colour already in the toolbar leaves its place in the grid empty. */ .palette .swatch.missing { visibility: hidden; } @@ -226,7 +274,7 @@ h2 { flex-wrap: wrap; align-items: center; gap: 0.5rem 0.55rem; - margin-left: 0; /* auto until 2026-10-11; the middle group's margins push it right now. */ + margin-left: auto; /* 0 from 2026-10-11 to 2026-10-14, when the middle group's margins pushed it right. */ min-width: 0; } @@ -599,7 +647,7 @@ button.mark.unavailable { opacity: 0.4; } .tooltip { position: fixed; - z-index: 10; + z-index: 6; max-width: 20rem; padding: 0.4rem 0.6rem; border-radius: 6px; diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index e2d62e3..29079d3 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -1484,6 +1484,109 @@ class EndToEndTest(unittest.TestCase): choose("arrange") self.assertEqual(cues(), ["text"]) + def test_page_menu_and_help(self): + """Andy, 2026-10-14: the page and view commands are in a Page menu, + a popup of Kokkoja's own under the button, closed by a press on an + item, elsewhere, or Escape; Help shows a note for now; one popup + at a time.""" + firefox = self.firefox + firefox.go(self.address("Late")) + self.enter_name("Menuist", "start_session") + self.wait_until(lambda s: s["page"]["number"] == 1, "the page") + hidden = lambda id: firefox.run(f"return document.getElementById('{id}').hidden;") + click = lambda id: firefox.run(f"document.getElementById('{id}').click();") + box = lambda id: firefox.run(f"const b = document.getElementById('{id}').getBoundingClientRect(); return [b.left, b.top, b.right, b.bottom];") + self.assertTrue(hidden("page_menu")) + self.assertIsNone(firefox.run("return document.querySelector('.page_view');"), "the middle group is gone") + # The Page button is the first of the right-hand group; Help the last of all. + self.assertEqual(firefox.run("return [...document.querySelectorAll('.toolbar button')].at(-1).id;"), "help") + self.assertEqual(firefox.run("return document.querySelector('.toolbar_right button').id;"), "page_menu_button") + + # Open: below the button, left edges aligned, within the window. + click("page_menu_button") + self.assertFalse(hidden("page_menu")) + self.assertEqual(firefox.run("return document.getElementById('page_menu_button').getAttribute('aria-expanded');"), "true") + button, menu = box("page_menu_button"), box("page_menu") + self.assertAlmostEqual(menu[0], button[0], delta=1) + self.assertGreater(menu[1], button[3]) + self.assertLess(menu[2], firefox.run("return window.innerWidth;")) + items = firefox.run("return [...document.querySelectorAll('#page_menu .menu_item')].filter((e) => !e.hidden).map((e) => e.textContent.trim());") + self.assertEqual(items, ["Fit to the window", "A4 scale", "Expand to the window", + "Shrink to the contents", "Extend the page while writing"]) + # Two grey titles, over the first two sections, in the muted colour. + self.assertEqual(firefox.run("return [...document.querySelectorAll('#page_menu .menu_title')].map((e) => e.textContent);"), + ["Change the display of the page", "Change the size of the page"]) + self.assertEqual(firefox.run("const t = document.querySelector('#page_menu .menu_title'); return [getComputedStyle(t).color, getComputedStyle(t).fontStyle];"), + firefox.run("return [getComputedStyle(document.getElementById('undo')).color, 'normal'];")) + # An item's press runs it and closes the menu. + click("zoom_sheet") + self.assertTrue(hidden("page_menu")) + # The checkbox item toggles Extend and closes too. + click("page_menu_button") + self.assertTrue(firefox.run("return document.getElementById('extend').checked;")) + click("extend") + self.assertFalse(firefox.run("return document.getElementById('extend').checked;")) + self.assertTrue(hidden("page_menu")) + click("extend") # back on, for the tests that follow + # Escape closes; so does a press elsewhere. + click("page_menu_button") + self.assertFalse(hidden("page_menu")) + firefox.chord("\ue00c") + self.assertTrue(hidden("page_menu")) + click("page_menu_button") + firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));") + self.assertTrue(hidden("page_menu")) + + # Help: a note for now; opening it closes the menu, and the reverse. + click("page_menu_button") + click("help") + self.assertFalse(hidden("help_popup")) + self.assertTrue(hidden("page_menu")) + # At the far right, the note's right edge aligns with the button's, inside the window. + help_button, note = box("help"), box("help_popup") + self.assertAlmostEqual(note[2], help_button[2], delta=1) + self.assertLess(note[2], firefox.run("return window.innerWidth;")) + self.assertEqual(firefox.run("return document.getElementById('help_popup').textContent.trim();"), "Help is coming!") + click("page_menu_button") + self.assertTrue(hidden("help_popup")) + self.assertFalse(hidden("page_menu")) + firefox.chord("\ue00c") + + # Ctrl-click on Help: the tooltips of every command as one text, in + # a window: the title, then each control in alphabetical order, + # its name on a line, its explanation wrapped at 80 columns, and + # a blank line after. + firefox.run("document.getElementById('help').dispatchEvent(new MouseEvent('click', {ctrlKey: true, bubbles: true}));") + self.assertTrue(firefox.run("return document.getElementById('manual_dialog').open;")) + self.assertTrue(hidden("help_popup"), "the note did not open as well") + manual = firefox.run("return document.getElementById('manual_text').textContent;") + self.assertTrue(manual.startswith("User interface actions\n\n")) + blocks = manual.split("\n\n")[1:] + names = [block.split("\n")[0] for block in blocks if block] + self.assertEqual(names, sorted(names, key=str.casefold), "alphabetical") + self.assertIn("Pen", names) + self.assertIn("Color", names) # the swatches as one entry + self.assertIn("Line width", names) # the widths as one entry + self.assertIn("Notebook for the pages of this session", names) + self.assertIn("QR code", names) + # A name two controls share gets each one's context: the nearest heading above it. + self.assertIn("Invitation: Copy", names) + self.assertIn("Log: Copy", names) + self.assertNotIn("Copy", names) + self.assertEqual(len(names), len(set(names)), "no two entries of one name") + self.assertIn("\nPen\nWrite on the page. Only your own writing can be erased.\n", manual) + self.assertIn("\nFit to the window\nShow the whole page in the window.", manual) + self.assertTrue(all(len(line) <= 80 for line in manual.split("\n")), "wrapped at 80") + self.assertTrue(any(len(line) > 60 for line in manual.split("\n")), "filled, not one word a line") + # Every tooltip is in it; those that differ only by a number (the + # line widths) as one entry, so the numbers are set aside. + helps = firefox.run("return [...document.querySelectorAll('[data-help]')].filter((e) => !e.closest('#manual_dialog')).map((e) => e.getAttribute('data-help'));") + unwrapped = re.sub(r"\d+(?:\.\d+)?", "#", manual.replace("\n", " ")) + for help in set(helps): + self.assertIn(re.sub(r"\d+(?:\.\d+)?", "#", help), unwrapped, help[:40]) + firefox.run("document.getElementById('manual_close').click();") + self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;")) + def test_text_being_typed_stays_on_the_page_left(self): """Andy, 2026-10-06: a guest was typing when the host pressed New, and the text reappeared on the new page."""