135 lines
4.9 KiB
JavaScript
135 lines
4.9 KiB
JavaScript
// @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')}`;
|
||
|
|
}
|