diff --git a/client/index.html b/client/index.html index 9959ae6..3df62de 100644 --- a/client/index.html +++ b/client/index.html @@ -133,8 +133,8 @@ - - + + @@ -184,7 +184,7 @@ data-help="Copy this text.">Copy -

+  
diff --git a/client/js/main.js b/client/js/main.js index a66653f..06afbdf 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -40,7 +40,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'; +import { manualList, manualText } from './manual.js'; /** * The colours in the toolbar: black, red, green, blue, white (Andy's @@ -1461,29 +1461,50 @@ async function enterSession(invitationText, name, notebook) { // The host's Fit and Normal scale ("A4" until 2026-10-14) 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. + // seldom used, and the one row is precious). 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. + // Help (Andy, 2026-10-15): one click shows the manual, the tooltips of + // every command gathered into one window (Ctrl-click until then); two + // clicks within DOUBLE_TAP_MS show the note about the documentation + // site. Since one click opens a modal window, it must wait the + // interval out to be told from the first of two; the shape button's + // double click needs no such wait, its single click having chosen the + // tool already. Two clicks are counted here rather than by the + // browser's dblclick, which a pen or a touch screen does not always + // send. 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); + const helpNote = attachPopup(element('help'), element('help_popup'), undefined, false); + const showManual = () => { + helpNote.close(); + const body = element('manual_text'); + body.replaceChildren(manualList(document)); manualDialog.showModal(); + body.focus(); + }; + /** @type {ReturnType | null} */ + let helpClick = null; + element('help').addEventListener('click', () => { + if (helpClick !== null) { + clearTimeout(helpClick); + helpClick = null; + helpNote.open(); + } else { + helpClick = setTimeout(() => { + helpClick = null; + showManual(); + }, DOUBLE_TAP_MS); + } }); element('manual_close').addEventListener('click', () => manualDialog.close()); element('copy_manual').addEventListener('click', async () => { try { - await navigator.clipboard.writeText(element('manual_text').textContent ?? ''); + await navigator.clipboard.writeText(manualText(document)); 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'); @@ -1693,6 +1714,7 @@ async function enterSession(invitationText, name, notebook) { // For looking at the session from the browser's console during development. Object.assign(window, { kokkoja: { session, renderer, writer, loadImage, textEditor, logText, + manualText: () => manualText(document), notebook: { listNotebooks, listKeptPages, readPage, readCurrentPage, notebooksInBrowser, discardNotebook, discardPage }, } }); } diff --git a/client/js/manual.js b/client/js/manual.js index 12eb797..06f7823 100644 --- a/client/js/manual.js +++ b/client/js/manual.js @@ -4,9 +4,12 @@ * (`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). + * wrapped at WIDTH columns, and a blank line after (Andy, 2026-10-14; a + * first form as Klammertext was replaced by this the same day); and the + * same entries as elements for the Help window (`manualList`), which + * the stylesheet sets, the names in a sans-serif and the explanations in + * a serif (Andy, 2026-10-15: one click on Help shows it, Ctrl-click + * until then). The Copy button copies the text. */ export const TITLE = 'User interface actions'; @@ -137,3 +140,27 @@ export function manualText(doc) { const blocks = manualEntries(doc).map(([name, help]) => `${name}\n${wrap(help, WIDTH)}\n`); return `${TITLE}\n\n${blocks.join('\n')}`; } + +/** + * The manual as elements: the title as a heading, then a description + * list, each control's name a term and its explanation the description. + * @param {Document | Element} doc + * @returns {DocumentFragment} + */ +export function manualList(doc) { + const owner = doc instanceof Document ? doc : doc.ownerDocument; + const fragment = owner.createDocumentFragment(); + const title = owner.createElement('h3'); + title.textContent = TITLE; + fragment.append(title); + const list = owner.createElement('dl'); + for (const [name, help] of manualEntries(doc)) { + const term = owner.createElement('dt'); + term.textContent = name; + const description = owner.createElement('dd'); + description.textContent = help; + list.append(term, description); + } + fragment.append(list); + return fragment; +} diff --git a/client/kokkoja.css b/client/kokkoja.css index 9cdf58e..1d93ce2 100644 --- a/client/kokkoja.css +++ b/client/kokkoja.css @@ -257,21 +257,43 @@ h2 { .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. */ +/* The manual (one click on Help): every control's tooltip, the names in + the interface's sans-serif and the explanations in a serif, a 2em + margin on all sides of the window (Andy, 2026-10-15); as tall as its + contents up to most of the window, the list then scrolling. Its Copy + button has a button's ordinary shape, not the close mark's 2rem; the + list takes the focus when the window opens, so that the keyboard + scrolls it and no button's tooltip opens by itself. (`.chooser`'s own + box comes later in this file, hence the two classes.) The flex layout + is set ONLY WHILE OPEN: on the dialog itself it would override the + browser's `display: none` for a closed dialog, and the empty window + stood at the end of the page (Andy saw it on the start screen, + 2026-10-15; in a session it lay below the desk, out of view). */ +.chooser.manual[open] { display: flex; } +.chooser.manual { + flex-direction: column; + width: min(46rem, 90vw); + height: fit-content; + max-height: 85vh; + padding: 2em; +} .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; } +/* The focus ring only when the keyboard put the focus there (Enter on + Help); after a click it would frame the list for no reason. */ +.manual_text:focus-visible { outline: 2px solid var(--line); outline-offset: 2px; } .manual_text { - margin: 0; - max-height: 70vh; + flex: 1; + min-height: 0; + margin-top: 1em; overflow: auto; - white-space: pre-wrap; - font-size: 0.85rem; line-height: 1.4; } +.manual_text h3 { margin: 0 0 1em; font-size: 1.1rem; } +.manual_text dl { margin: 0; } +.manual_text dt { font-family: system-ui, sans-serif; font-weight: 600; } +.manual_text dd { margin: 0.15em 0 1em; font-family: serif; font-size: 1.05rem; } +.manual_text dd:last-child { margin-bottom: 0; } .palette .swatch { width: 1.5rem; height: 1.5rem; } /* A colour already in the toolbar leaves its place in the grid empty. */ diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 1899b2a..d959d10 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -2107,12 +2107,19 @@ class EndToEndTest(unittest.TestCase): 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.""" + item, elsewhere, or Escape; one popup at a time. Help (Andy, + 2026-10-15): one click shows the manual, two quick clicks the note + about the documentation site.""" firefox = self.firefox firefox.go(self.address("Late")) + # The closed manual window is not displayed, on the start screen + # or in the session (a layout rule on the dialog itself had + # overridden the browser's display: none, 2026-10-15). + displayed = lambda: firefox.run("return getComputedStyle(document.getElementById('manual_dialog')).display;") + self.assertEqual(displayed(), "none") self.enter_name("Menuist", "start_session") self.wait_until(lambda s: s["page"]["number"] == 1, "the page") + self.assertEqual(displayed(), "none") 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];") @@ -2157,32 +2164,54 @@ class EndToEndTest(unittest.TestCase): 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. + # Help, two quick clicks: the note about the documentation site; + # opening it closes the menu, and the reverse. click("page_menu_button") - click("help") + double_click = lambda id: firefox.run(f"const b = document.getElementById('{id}'); b.click(); b.click();") + double_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!") + self.assertEqual(firefox.run("return document.getElementById('help_popup').textContent.trim();"), "Documentation website coming soon!") + time.sleep(0.6) + self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"), "two clicks do not open the manual") 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;")) + # One click on Help: the manual, the tooltips of every command in a + # window, once the interval for a second click has passed. The + # title, then each control in alphabetical order, its name in the + # interface's sans-serif and its explanation in a serif below; the + # window's contents 2em from its edges. + click("help") + self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"), "not before the interval") + self.wait_until_true(lambda: firefox.run("return document.getElementById('manual_dialog').open;"), bool, "the manual", seconds=3) + self.assertEqual(displayed(), "flex") self.assertTrue(hidden("help_popup"), "the note did not open as well") - manual = firefox.run("return document.getElementById('manual_text').textContent;") + self.assertEqual(firefox.run("return document.getElementById('manual_text').querySelector('h3').textContent;"), "User interface actions") + shown = firefox.run(""" + const list = document.querySelector('#manual_text dl'); + return [...list.children].map((e) => [e.tagName, e.textContent, getComputedStyle(e).fontFamily]); + """) + self.assertEqual([tag for tag, _, _ in shown[::2]], ["DT"] * (len(shown) // 2)) + self.assertEqual([tag for tag, _, _ in shown[1::2]], ["DD"] * (len(shown) // 2)) + self.assertTrue(all("sans-serif" in font for _, _, font in shown[::2]), "the names in a sans-serif") + self.assertTrue(all(font == "serif" for _, _, font in shown[1::2]), "the explanations in a serif") + self.assertEqual(firefox.run("const s = getComputedStyle(document.getElementById('manual_dialog')); return [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft];"), ["30px"] * 4, "2em at 15px") + self.assertEqual(firefox.run("return document.activeElement.id;"), "manual_text") + # The text the Copy button copies: the title, then each control in + # alphabetical order, its name on a line, its explanation wrapped + # at 80 columns, and a blank line after; the same entries as shown. + manual = firefox.run("return window.kokkoja.manualText();") 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, [text for _, text, _ in shown[::2]], "the text and the window agree") self.assertEqual(names, sorted(names, key=str.casefold), "alphabetical") self.assertIn("Pen", names) self.assertIn("Color", names) # the swatches as one entry @@ -2204,8 +2233,26 @@ class EndToEndTest(unittest.TestCase): 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]) + self.assertIn("Two quick clicks show where the documentation is.", unwrapped) firefox.run("document.getElementById('manual_close').click();") self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;")) + self.assertEqual(displayed(), "none") + # The same with a real mouse: two clicks within the interval show + # the note, and the manual does not open; after a click the list + # has the focus without a focus ring (the ring is for keyboard + # focus). + x, y = firefox.run("const b = document.getElementById('help').getBoundingClientRect(); return [Math.round(b.left + b.width / 2), Math.round(b.top + b.height / 2)];") + press = [{"type": "pointerDown", "button": 0}, {"type": "pointerUp", "button": 0}] + firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": x, "y": y}, + *press, {"type": "pause", "duration": 120}, *press]) + self.assertFalse(hidden("help_popup")) + time.sleep(0.6) + self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;")) + firefox.run("document.getElementById('page').dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));") + firefox.pointer_actions([{"type": "pointerMove", "duration": 0, "origin": "viewport", "x": x, "y": y}, *press]) + self.wait_until_true(lambda: firefox.run("return document.getElementById('manual_dialog').open;"), bool, "the manual", seconds=3) + self.assertEqual(firefox.run("const e = document.getElementById('manual_text'); return [document.activeElement.id, getComputedStyle(e).outlineStyle];"), ["manual_text", "none"]) + firefox.run("document.getElementById('manual_close').click();") def test_shrink_ignores_the_invisible(self): """Andy, 2026-10-14: what cannot be seen does not count for Shrink: