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 <noreply@anthropic.com>
This commit is contained in:
ackandClaude Fable 5.1 committed 2026-10-14 01:07:53 +02:00
1 parent 8ecf3e94a6
commit 35c4f434f3
5 files changed
+419 -55

No files matched your search

+86 -27
View File
@@ -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}`);
+135
View File
@@ -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<string, {names: string[], contexts: string[], help: string}>} */
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')}`;
}