// @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: the name it gives for this text, when its * label changes (`data-name`; the Shape button is named for the shape it * draws at the moment); 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) { const given = el.getAttribute('data-name'); if (given) return given; 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')}`; }