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:
1 parent
8ecf3e94a6
commit
35c4f434f3
5 files changed
+419
-55
No files matched your search
+33
-14
@@ -75,21 +75,28 @@
|
|||||||
<button id="undo" type="button" disabled
|
<button id="undo" type="button" disabled
|
||||||
data-help="Take back your latest mark: a stroke, an erasure, a text block typed or moved, a Hide rectangle. Again for the one before. Also Ctrl+Z (Command+Z on a Mac), or two quick taps of the eraser on empty paper. Only your own marks, and only on this page since you opened it.">Undo</button>
|
data-help="Take back your latest mark: a stroke, an erasure, a text block typed or moved, a Hide rectangle. Again for the one before. Also Ctrl+Z (Command+Z on a Mac), or two quick taps of the eraser on empty paper. Only your own marks, and only on this page since you opened it.">Undo</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="group page_view" aria-label="Page and view">
|
|
||||||
<button id="zoom_fit" type="button"
|
|
||||||
data-help="Show the whole page in the window. Pressed by the host, it does so on every screen. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit</button>
|
|
||||||
<button id="zoom_sheet" type="button"
|
|
||||||
data-help="Show the page at the scale a new page has: a full A4 page filling the window. A shrunk page is then seen at its own size rather than enlarged. Pressed by the host, it does so on every screen.">A4</button>
|
|
||||||
<button id="zoom_expand" type="button" hidden
|
|
||||||
data-help="Make the page fill the window, for everyone, at this zoom, in both directions. Nothing on it moves.">Expand</button>
|
|
||||||
<button id="zoom_shrink" type="button" hidden
|
|
||||||
data-help="Make the page, for everyone, just large enough for what is on it, with the margin around. Nothing on it moves.">Shrink</button>
|
|
||||||
<label class="toggle" data-help="Let the page grow when your writing reaches an edge. Off, your writing stops at the edge. Images always get the room they need.">
|
|
||||||
Extend <input id="extend" type="checkbox" checked>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="toolbar_right">
|
<div class="toolbar_right">
|
||||||
<div class="group" id="host_tools">
|
<div class="group" id="host_tools">
|
||||||
|
<button id="page_menu_button" type="button" aria-haspopup="menu" aria-expanded="false"
|
||||||
|
data-help="The page and the view: fit the page to the window, show it at A4 scale, make it larger or smaller, and whether it grows as you write.">Page</button>
|
||||||
|
<div id="page_menu" class="popup menu" role="menu" hidden>
|
||||||
|
<div class="menu_title">Change the display of the page</div>
|
||||||
|
<button id="zoom_fit" type="button" class="menu_item" role="menuitem"
|
||||||
|
data-help="Show the whole page in the window. Pressed by the host, it does so on every screen. Ctrl (Command on a Mac) with the scroll wheel, or with a pen stroke up or down, zooms about the pointer.">Fit to the window</button>
|
||||||
|
<button id="zoom_sheet" type="button" class="menu_item" role="menuitem"
|
||||||
|
data-help="Show the page at the scale a new page has: a full A4 page filling the window. A shrunk page is then seen at its own size rather than enlarged. Pressed by the host, it does so on every screen.">A4 scale</button>
|
||||||
|
<hr>
|
||||||
|
<div class="menu_title">Change the size of the page</div>
|
||||||
|
<button id="zoom_expand" type="button" class="menu_item" role="menuitem" hidden
|
||||||
|
data-help="Make the page fill the window, for everyone, at this zoom, in both directions. Nothing on it moves.">Expand to the window</button>
|
||||||
|
<button id="zoom_shrink" type="button" class="menu_item" role="menuitem" hidden
|
||||||
|
data-help="Make the page, for everyone, just large enough for what is on it, with the margin around. Nothing on it moves.">Shrink to the contents</button>
|
||||||
|
<hr>
|
||||||
|
<label class="menu_item toggle" role="menuitemcheckbox"
|
||||||
|
data-help="Let the page grow when your writing reaches an edge. Off, your writing stops at the edge. Images always get the room they need.">
|
||||||
|
<input id="extend" type="checkbox" checked> Extend the page while writing
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button id="new_page" type="button"
|
<button id="new_page" type="button"
|
||||||
data-help="Start a new blank page for everyone. The current page is kept in each notebook.">New</button>
|
data-help="Start a new blank page for everyone. The current page is kept in each notebook.">New</button>
|
||||||
<button id="load_image" type="button"
|
<button id="load_image" type="button"
|
||||||
@@ -109,6 +116,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="session_button" type="button"
|
<button id="session_button" type="button"
|
||||||
data-help="Who is here, and the invitation to send to others.">Session</button>
|
data-help="Who is here, and the invitation to send to others.">Session</button>
|
||||||
|
<button id="help" type="button" data-help="Where to read about Kokkoja. With Ctrl (Command on a Mac) held, the explanations of every command in one text.">Help</button>
|
||||||
|
<div id="help_popup" class="popup help_popup" hidden>Help is coming!</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div id="notice" class="notice" hidden></div>
|
<div id="notice" class="notice" hidden></div>
|
||||||
@@ -134,7 +143,7 @@
|
|||||||
<button id="copy_invitation" type="button"
|
<button id="copy_invitation" type="button"
|
||||||
data-help="Copy the invitation, to send to the others by any means.">Copy</button>
|
data-help="Copy the invitation, to send to the others by any means.">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="invitation_code" class="invitation_code" hidden
|
<div id="invitation_code" class="invitation_code" hidden aria-label="QR code"
|
||||||
data-help="The invitation as a QR code. Point the camera of a phone or a tablet at it, and the link opens there."></div>
|
data-help="The invitation as a QR code. Point the camera of a phone or a tablet at it, and the link opens there."></div>
|
||||||
<p class="note session_state">
|
<p class="note session_state">
|
||||||
<span id="page_number" class="page_number"></span>
|
<span id="page_number" class="page_number"></span>
|
||||||
@@ -151,6 +160,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="manual_dialog" class="chooser manual">
|
||||||
|
<header>
|
||||||
|
<h2>Help</h2>
|
||||||
|
<button id="copy_manual" type="button" class="action"
|
||||||
|
data-help="Copy this text.">Copy</button>
|
||||||
|
<button id="manual_close" type="button" aria-label="Close">×</button>
|
||||||
|
</header>
|
||||||
|
<pre id="manual_text" class="manual_text" tabindex="0" autofocus></pre>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="notebook_chooser" class="chooser">
|
<dialog id="notebook_chooser" class="chooser">
|
||||||
<header>
|
<header>
|
||||||
<h2>Notebooks</h2>
|
<h2>Notebooks</h2>
|
||||||
|
|||||||
+86
-27
@@ -35,6 +35,7 @@ import { TextEditor } from './text_editor.js';
|
|||||||
import { enableTooltips } from './tooltip.js';
|
import { enableTooltips } from './tooltip.js';
|
||||||
import { guardSelection, inField } from './selection_guard.js';
|
import { guardSelection, inField } from './selection_guard.js';
|
||||||
import { QUIET_ZONE, qrModules, qrPath } from './qr_code.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
|
* 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);
|
new ResizeObserver(watchToolbar).observe(toolbar);
|
||||||
watchToolbar();
|
watchToolbar();
|
||||||
// The drawing group keeps the width of its widest state, the pen's
|
// The drawing group and the settings (colours, widths, font and size)
|
||||||
// colours and widths, so that the page-and-view group, centred between
|
// keep the width of their widest state, so that Eraser, Hide and Undo,
|
||||||
// it and the session group, stays put when the tool changes (Andy,
|
// which stand after the settings since 2026-10-11, do not jump when a
|
||||||
// 2026-10-11). The floor rises if the group ever grows, as when a
|
// tool shows fewer settings (the drawing group's floor also kept the
|
||||||
// colour is added.
|
// middle group still while there was one, until 2026-10-14). The
|
||||||
// The settings (colours, widths, font and size) keep their widest
|
// floor rises if a group ever grows, as when a colour is added.
|
||||||
// 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.
|
|
||||||
for (const part of [element('drawing'), element('settings')]) {
|
for (const part of [element('drawing'), element('settings')]) {
|
||||||
let widest = 0;
|
let widest = 0;
|
||||||
new ResizeObserver(() => {
|
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
|
// The host's Fit and A4 reach everyone (Andy, 2026-10-08); a guest's
|
||||||
// are the guest's own.
|
// 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', () => {
|
element('zoom_fit').addEventListener('click', () => {
|
||||||
renderer.fit();
|
renderer.fit();
|
||||||
session.sendViewCommand('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 {object} options
|
||||||
* @param {string} options.color Initial color.
|
* @param {string} options.color Initial color.
|
||||||
@@ -1374,25 +1450,7 @@ function buildToolbar(options) {
|
|||||||
palette.append(button);
|
palette.append(button);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
more.addEventListener('click', () => {
|
attachPopup(more, palette, showPalette);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const widths = element('widths');
|
const widths = element('widths');
|
||||||
for (const width of WIDTHS) {
|
for (const width of WIDTHS) {
|
||||||
@@ -1466,6 +1524,7 @@ function showParticipants(session, renderer) {
|
|||||||
const visible = document.createElement('input');
|
const visible = document.createElement('input');
|
||||||
visible.type = 'checkbox';
|
visible.type = 'checkbox';
|
||||||
visible.checked = !renderer.hidden.has(participant.id);
|
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.setAttribute('data-help', 'Show or hide this participant’s writing on your screen only.');
|
||||||
visible.addEventListener('change', () => renderer.setLayerVisible(participant.id, visible.checked));
|
visible.addEventListener('change', () => renderer.setLayerVisible(participant.id, visible.checked));
|
||||||
label.append(visible, ` ${participant.name}`);
|
label.append(visible, ` ${participant.name}`);
|
||||||
|
|||||||
@@ -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')}`;
|
||||||
|
}
|
||||||
+62
-14
@@ -108,23 +108,22 @@ h2 {
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Three groups (Andy, 2026-10-11): the drawing tools with their settings
|
/* Two groups since 2026-10-14 (three from 2026-10-11, when the page and
|
||||||
and Undo on the left; the page and the view in the middle, centred
|
the view stood centred between them; those commands are in the Page
|
||||||
between the other two; the session's buttons flush right. The
|
menu now): the drawing tools with their settings and Undo on the
|
||||||
drawing group keeps the width of its widest state (main.js sets its
|
left; the page and session buttons flush right, Help last. The
|
||||||
min-width by measuring), so that the middle group stays put when the
|
drawing group and the settings keep the width of their widest state
|
||||||
tool changes. */
|
(main.js sets their min-width by measuring), so that nothing after
|
||||||
|
them moves when the tool changes. */
|
||||||
.drawing, .settings { display: flex; align-items: center; gap: 0.5rem 0.55rem; }
|
.drawing, .settings { display: flex; align-items: center; gap: 0.5rem 0.55rem; }
|
||||||
/* Twice the gap before Eraser, Hide and Undo, so that with the pen the
|
/* Twice the gap before Eraser, Hide and Undo, so that with the pen the
|
||||||
widest line and the Eraser read as two groups (Andy, 2026-10-11). */
|
widest line and the Eraser read as two groups (Andy, 2026-10-11). */
|
||||||
.drawing .after_settings { margin-left: 0.55rem; }
|
.drawing .after_settings { margin-left: 0.55rem; }
|
||||||
.toolbar .page_view { margin-left: auto; margin-right: auto; }
|
|
||||||
|
|
||||||
/* When the window is too narrow for one row, the tools are centred
|
/* When the window is too narrow for one row, the tools are centred
|
||||||
instead of standing at the two ends (Andy, 2026-10-09; main.js sets
|
instead of standing at the two ends (Andy, 2026-10-09; main.js sets
|
||||||
the class by measuring). */
|
the class by measuring). */
|
||||||
.toolbar.wrapped { justify-content: center; }
|
.toolbar.wrapped { justify-content: center; }
|
||||||
.toolbar.wrapped .page_view { margin-left: 0; margin-right: 0; }
|
|
||||||
.toolbar.wrapped .toolbar_right { margin-left: 0; justify-content: center; }
|
.toolbar.wrapped .toolbar_right { margin-left: 0; justify-content: center; }
|
||||||
|
|
||||||
/* 9rem wide until 2026-10-11: room for Undo. The buttons' height, else
|
/* 9rem wide until 2026-10-11: room for Undo. The buttons' height, else
|
||||||
@@ -179,12 +178,12 @@ h2 {
|
|||||||
|
|
||||||
/* The palette that "+" opens: no system dialog, which crashes Firefox on
|
/* The palette that "+" opens: no system dialog, which crashes Firefox on
|
||||||
Linux (Hollis, 2026-10-04). */
|
Linux (Hollis, 2026-10-04). */
|
||||||
.palette {
|
/* A popup of Kokkoja's own below a toolbar button (no system menu or
|
||||||
|
dialog): the colour palette, the Page menu, the Help note. main.js
|
||||||
|
places it, and closes it on a press elsewhere or Escape. */
|
||||||
|
.popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(8, 1.5rem);
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -192,6 +191,55 @@ h2 {
|
|||||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
|
box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1.5rem);
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Page menu: commands one below the other, read as a list, with a
|
||||||
|
line between the view, the page's size and the writing mode, and a
|
||||||
|
title over the first two in the muted grey of what cannot be pressed
|
||||||
|
(Andy, 2026-10-14: grey, not italics). */
|
||||||
|
.menu { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.35rem; min-width: 14rem; }
|
||||||
|
.menu .menu_item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu .menu_item:hover { background: var(--panel); border-color: var(--line); }
|
||||||
|
.menu .menu_title { padding: 0.2rem 0.6rem 0.1rem; color: var(--muted); cursor: default; }
|
||||||
|
.menu hr { width: 100%; margin: 0.2rem 0; border: 0; border-top: 1px solid var(--line); }
|
||||||
|
.menu .toggle input { margin: 0; }
|
||||||
|
|
||||||
|
.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. */
|
||||||
|
.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; }
|
||||||
|
.manual_text {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.palette .swatch { width: 1.5rem; height: 1.5rem; }
|
.palette .swatch { width: 1.5rem; height: 1.5rem; }
|
||||||
/* A colour already in the toolbar leaves its place in the grid empty. */
|
/* A colour already in the toolbar leaves its place in the grid empty. */
|
||||||
.palette .swatch.missing { visibility: hidden; }
|
.palette .swatch.missing { visibility: hidden; }
|
||||||
@@ -226,7 +274,7 @@ h2 {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem 0.55rem;
|
gap: 0.5rem 0.55rem;
|
||||||
margin-left: 0; /* auto until 2026-10-11; the middle group's margins push it right now. */
|
margin-left: auto; /* 0 from 2026-10-11 to 2026-10-14, when the middle group's margins pushed it right. */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,7 +647,7 @@ button.mark.unavailable { opacity: 0.4; }
|
|||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10;
|
z-index: 6;
|
||||||
max-width: 20rem;
|
max-width: 20rem;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
@@ -1484,6 +1484,109 @@ class EndToEndTest(unittest.TestCase):
|
|||||||
choose("arrange")
|
choose("arrange")
|
||||||
self.assertEqual(cues(), ["text"])
|
self.assertEqual(cues(), ["text"])
|
||||||
|
|
||||||
|
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."""
|
||||||
|
firefox = self.firefox
|
||||||
|
firefox.go(self.address("Late"))
|
||||||
|
self.enter_name("Menuist", "start_session")
|
||||||
|
self.wait_until(lambda s: s["page"]["number"] == 1, "the page")
|
||||||
|
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];")
|
||||||
|
self.assertTrue(hidden("page_menu"))
|
||||||
|
self.assertIsNone(firefox.run("return document.querySelector('.page_view');"), "the middle group is gone")
|
||||||
|
# The Page button is the first of the right-hand group; Help the last of all.
|
||||||
|
self.assertEqual(firefox.run("return [...document.querySelectorAll('.toolbar button')].at(-1).id;"), "help")
|
||||||
|
self.assertEqual(firefox.run("return document.querySelector('.toolbar_right button').id;"), "page_menu_button")
|
||||||
|
|
||||||
|
# Open: below the button, left edges aligned, within the window.
|
||||||
|
click("page_menu_button")
|
||||||
|
self.assertFalse(hidden("page_menu"))
|
||||||
|
self.assertEqual(firefox.run("return document.getElementById('page_menu_button').getAttribute('aria-expanded');"), "true")
|
||||||
|
button, menu = box("page_menu_button"), box("page_menu")
|
||||||
|
self.assertAlmostEqual(menu[0], button[0], delta=1)
|
||||||
|
self.assertGreater(menu[1], button[3])
|
||||||
|
self.assertLess(menu[2], firefox.run("return window.innerWidth;"))
|
||||||
|
items = firefox.run("return [...document.querySelectorAll('#page_menu .menu_item')].filter((e) => !e.hidden).map((e) => e.textContent.trim());")
|
||||||
|
self.assertEqual(items, ["Fit to the window", "A4 scale", "Expand to the window",
|
||||||
|
"Shrink to the contents", "Extend the page while writing"])
|
||||||
|
# Two grey titles, over the first two sections, in the muted colour.
|
||||||
|
self.assertEqual(firefox.run("return [...document.querySelectorAll('#page_menu .menu_title')].map((e) => e.textContent);"),
|
||||||
|
["Change the display of the page", "Change the size of the page"])
|
||||||
|
self.assertEqual(firefox.run("const t = document.querySelector('#page_menu .menu_title'); return [getComputedStyle(t).color, getComputedStyle(t).fontStyle];"),
|
||||||
|
firefox.run("return [getComputedStyle(document.getElementById('undo')).color, 'normal'];"))
|
||||||
|
# An item's press runs it and closes the menu.
|
||||||
|
click("zoom_sheet")
|
||||||
|
self.assertTrue(hidden("page_menu"))
|
||||||
|
# The checkbox item toggles Extend and closes too.
|
||||||
|
click("page_menu_button")
|
||||||
|
self.assertTrue(firefox.run("return document.getElementById('extend').checked;"))
|
||||||
|
click("extend")
|
||||||
|
self.assertFalse(firefox.run("return document.getElementById('extend').checked;"))
|
||||||
|
self.assertTrue(hidden("page_menu"))
|
||||||
|
click("extend") # back on, for the tests that follow
|
||||||
|
# Escape closes; so does a press elsewhere.
|
||||||
|
click("page_menu_button")
|
||||||
|
self.assertFalse(hidden("page_menu"))
|
||||||
|
firefox.chord("\ue00c")
|
||||||
|
self.assertTrue(hidden("page_menu"))
|
||||||
|
click("page_menu_button")
|
||||||
|
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.
|
||||||
|
click("page_menu_button")
|
||||||
|
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!")
|
||||||
|
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;"))
|
||||||
|
self.assertTrue(hidden("help_popup"), "the note did not open as well")
|
||||||
|
manual = firefox.run("return document.getElementById('manual_text').textContent;")
|
||||||
|
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, sorted(names, key=str.casefold), "alphabetical")
|
||||||
|
self.assertIn("Pen", names)
|
||||||
|
self.assertIn("Color", names) # the swatches as one entry
|
||||||
|
self.assertIn("Line width", names) # the widths as one entry
|
||||||
|
self.assertIn("Notebook for the pages of this session", names)
|
||||||
|
self.assertIn("QR code", names)
|
||||||
|
# A name two controls share gets each one's context: the nearest heading above it.
|
||||||
|
self.assertIn("Invitation: Copy", names)
|
||||||
|
self.assertIn("Log: Copy", names)
|
||||||
|
self.assertNotIn("Copy", names)
|
||||||
|
self.assertEqual(len(names), len(set(names)), "no two entries of one name")
|
||||||
|
self.assertIn("\nPen\nWrite on the page. Only your own writing can be erased.\n", manual)
|
||||||
|
self.assertIn("\nFit to the window\nShow the whole page in the window.", manual)
|
||||||
|
self.assertTrue(all(len(line) <= 80 for line in manual.split("\n")), "wrapped at 80")
|
||||||
|
self.assertTrue(any(len(line) > 60 for line in manual.split("\n")), "filled, not one word a line")
|
||||||
|
# Every tooltip is in it; those that differ only by a number (the
|
||||||
|
# line widths) as one entry, so the numbers are set aside.
|
||||||
|
helps = firefox.run("return [...document.querySelectorAll('[data-help]')].filter((e) => !e.closest('#manual_dialog')).map((e) => e.getAttribute('data-help'));")
|
||||||
|
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])
|
||||||
|
firefox.run("document.getElementById('manual_close').click();")
|
||||||
|
self.assertFalse(firefox.run("return document.getElementById('manual_dialog').open;"))
|
||||||
|
|
||||||
def test_text_being_typed_stays_on_the_page_left(self):
|
def test_text_being_typed_stays_on_the_page_left(self):
|
||||||
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
"""Andy, 2026-10-06: a guest was typing when the host pressed New,
|
||||||
and the text reappeared on the new page."""
|
and the text reappeared on the new page."""
|
||||||
|
|||||||
Reference in new issue
Block a user