webui: a grouped keyboard shortcut panel for the sheet

The sheet's controls were listed in one run-on line under it, which was
hard to scan and grew with every feature. Replace it with a panel grouped
the way an editor's reference card is (select, move, length, notes, kind,
syllable, line) with keys drawn as key caps, opened by a KEYS button or ?,
closed by Esc, and remembered open or closed across visits.

The list is data (SHORTCUTS), and a test checks every letter key it names
is one the sheet's key handler handles, so the two cannot drift apart.
This commit is contained in:
2026-10-12 23:13:45 -07:00
parent 381a499f2b
commit 2aeb16decb
3 changed files with 159 additions and 7 deletions
+36
View File
@@ -443,6 +443,42 @@ button { cursor: pointer; }
.sheet-stale-head { color: var(--warn); margin-bottom: 6px; }
.sheet-stale-row { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
.sheet-stale-text { flex: 1; color: var(--text-muted); }
.sheet-nav > .action[aria-expanded] { margin-left: 4px; }
.sheet-keys {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px 22px;
margin-bottom: 14px;
padding: 12px 14px;
background: var(--panel-raised);
border: 1px solid var(--border);
}
.sheet-keys[hidden] { display: none; }
.sheet-keys-head {
margin-bottom: 6px;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.66rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--aqua);
}
.sheet-keys dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; margin: 0; font-size: 0.78rem; }
.sheet-keys dt { white-space: nowrap; color: var(--text-muted); }
.sheet-keys dd { margin: 0; color: var(--text); }
.sheet-keys kbd {
display: inline-block;
min-width: 1.4em;
padding: 0 5px;
font: 0.72rem var(--font-mono);
text-align: center;
color: var(--text);
background: var(--panel);
border: 1px solid var(--control);
border-bottom-width: 2px;
border-radius: 3px;
}
.sheet-help[hidden] { display: none; }
.sheet-help { margin-top: 10px; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); }
.sheet-svg { display: block; }
.sheet-row { fill: var(--panel-raised); }
+98 -7
View File
@@ -12,6 +12,53 @@ const PLOT_HEIGHT = 300; // px; rows divide this between the pitches shown
const CONTEXT = 0.75; // seconds shown either side of the line
const EDGE = 6; // px from a note's end that grabs it for resizing
const KINDS = { "*": "golden", F: "free", R: "rap", G: "golden-rap" };
const KEYS_OPEN = "songtrace.sheet-keys";
// The sheet's controls, grouped as an editor's reference card lists them.
// "+" joins keys pressed together, " / " separates alternatives, and a
// lowercase word is something done with the mouse rather than a key.
export const SHORTCUTS = [
["Select", [
["click", "select a note"],
["Shift+click", "add to the selection"],
["Tab / Shift+Tab", "next, previous note"],
["Esc", "clear the selection"]]],
["Move", [
["drag", "move in time and pitch"],
["← / →", "a beat earlier, later"],
["↑ / ↓", "a semitone up, down"],
["Shift+↑ / Shift+↓", "an octave up, down"]]],
["Length", [
["drag an end", "resize"],
["Shift+← / Shift+→", "a beat shorter, longer"]]],
["Notes", [
["double-click", "add a note, on empty space"],
["S", "split in two"],
["M", "merge with the next"],
["Delete", "remove"]]],
["Kind", [
["G", "golden"],
["F", "freestyle"],
["R", "rap"],
["G / F / R", "again: back to normal"]]],
["Syllable", [
["double-click", "edit, on a note"],
["Enter", "edit the selected note"]]],
["Line", [
["Ctrl+Z", "undo"],
["Ctrl+Shift+Z / Ctrl+Y", "redo"],
["Ctrl+S", "save"],
["P", "play"],
["Page Up / Page Down", "previous, next line"],
["?", "show or hide these keys"]]],
];
// "Shift+Tab / Esc" as key caps, with mouse actions left as words
export function comboParts(combo) {
return combo.split(" / ").map(alt => alt.split("+").map(part => ({
text: part, key: !/^[a-z]/.test(part),
})));
}
const wrap = (n, d) => ((n % d) + d) % d;
const mod12 = (p) => wrap(p, 12);
@@ -88,7 +135,9 @@ export function createSheet(root, { seek, playLine, onSaved }) {
const prev = button("◀ PREV", "previous line (Page Up)", () => page(-1));
const next = button("NEXT ▶", "next line (Page Down)", () => page(1));
const nav = div("sheet-nav");
nav.append(prev, title, next, badges);
const keysButton = button("KEYS ?", "keyboard shortcuts (?)", () => toggleKeys());
keysButton.setAttribute("aria-expanded", "false");
nav.append(prev, title, next, badges, keysButton);
const tools = {
undo: button("UNDO", "Ctrl+Z", () => undo()),
@@ -123,12 +172,48 @@ export function createSheet(root, { seek, playLine, onSaved }) {
plot.append(svg, playhead);
const syllables = div("sheet-syllables");
const words = div("sheet-words");
const help = div("sheet-help",
"Drag a note to move it, its ends to resize · double-click empty space to add a note, " +
"a note to edit its syllable · ←→ move · ↑↓ pitch (Shift: octave) · " +
"Shift+←→ length · Tab next note · Delete · S split · M merge · G/F/R kind");
const help = div("sheet-help", "Press ? for keyboard shortcuts.");
const keys = div("sheet-keys");
keys.id = "sheet-keys";
keysButton.setAttribute("aria-controls", keys.id);
for (const [group, entries] of SHORTCUTS) {
const section = div("sheet-keys-group");
const heading = div("sheet-keys-head", group);
const list = document.createElement("dl");
for (const [combo, action] of entries) {
const dt = document.createElement("dt");
comboParts(combo).forEach((alt, i) => {
if (i) dt.append(" / ");
alt.forEach((part, j) => {
if (j) dt.append("+");
const node = document.createElement(part.key ? "kbd" : "span");
node.textContent = part.text;
dt.append(node);
});
});
const dd = document.createElement("dd");
dd.textContent = action;
list.append(dt, dd);
}
section.append(heading, list);
keys.append(section);
}
const stale = div("sheet-stale");
root.replaceChildren(nav, stale, toolbar, plot, syllables, words, help);
root.replaceChildren(nav, stale, toolbar, keys, plot, syllables, words, help);
// stays as the person left it, across lines, songs and visits
function showKeys(open) {
keys.hidden = !open;
help.hidden = open;
keysButton.setAttribute("aria-expanded", String(open));
try { localStorage.setItem(KEYS_OPEN, open ? "1" : ""); } catch {}
}
function toggleKeys() {
showKeys(keys.hidden);
}
let keysOpen = false;
try { keysOpen = !!localStorage.getItem(KEYS_OPEN); } catch {}
showKeys(keysOpen);
let review = null;
let curve = null;
@@ -589,7 +674,13 @@ export function createSheet(root, { seek, playLine, onSaved }) {
render();
return;
}
if (key === "Escape") { handled(); selected = new Set(); render(); return; }
if (key === "?") { handled(); toggleKeys(); return; }
if (key === "Escape") {
handled();
if (!keys.hidden) showKeys(false);
else { selected = new Set(); render(); }
return;
}
if (key.toLowerCase() === "p") { handled(); playCurrent(); return; }
if (!selected.size) return;
const arrows = { ArrowLeft: -1, ArrowRight: 1 };
+25
View File
@@ -45,3 +45,28 @@ test("a line spans its notes and words", () => {
{ start: 1.5, end: 3 });
assert.equal(lineSpan({ notes: [], words: [] }), null);
});
import { readFileSync } from "node:fs";
import { SHORTCUTS, comboParts } from "../../src/songtrace/static/sheet.js";
test("shortcut combos split into keys and mouse actions", () => {
assert.deepEqual(comboParts("Shift+click / Tab"), [
[{ text: "Shift", key: true }, { text: "click", key: false }],
[{ text: "Tab", key: true }],
]);
assert.deepEqual(comboParts("Page Up"), [[{ text: "Page Up", key: true }]]);
});
test("every letter key the shortcut list names is one the sheet handles", () => {
const source = readFileSync(new URL("../../src/songtrace/static/sheet.js", import.meta.url), "utf8");
const handler = source.slice(source.indexOf('plot.addEventListener("keydown"'));
const letters = SHORTCUTS.flatMap(([, entries]) => entries)
.flatMap(([combo]) => comboParts(combo).flat())
.filter(p => p.key && /^[A-Z]$/.test(p.text))
.map(p => p.text.toLowerCase());
assert.ok(letters.length >= 6);
for (const letter of new Set(letters)) {
assert.ok(handler.includes(`"${letter}"`) || handler.includes(`${letter}:`),
`the list names ${letter.toUpperCase()} but the sheet does not handle it`);
}
});