One click on Help shows the explanations of every command in one window, the names in a sans-serif and the explanations in a serif, with a 2em margin; Copy copies them as plain text. Two quick clicks show the note about the documentation site. The closed window is not displayed (a layout rule had shown it empty on the start screen). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
167 lines
6.1 KiB
JavaScript
167 lines
6.1 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; 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';
|
|
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<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')}`;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|