moontide-mcp 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -5
- package/dist/ask.js +30 -8
- package/dist/mcp.js +60 -14
- package/dist/protocol.js +89 -2
- package/dist/reference.js +22 -0
- package/dist/session.js +53 -0
- package/package.json +1 -1
- package/page/ask-view.js +21 -7
- package/page/ask.js +292 -34
- package/page/change-timing.js +25 -4
- package/page/harmony.js +13 -0
- package/page/index.html +4 -3
- package/page/jam-model.js +11 -2
- package/page/jam-view.js +17 -0
- package/page/lights.js +278 -0
- package/page/main.js +1723 -140
- package/page/midi-clock.js +1 -1
- package/page/midi-out.js +12 -0
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +282 -13
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +381 -0
- package/page/note-tag.js +126 -0
- package/page/page-rewrite.js +184 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +491 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +760 -0
- package/page/style.css +879 -84
- package/page/takeover.js +20 -5
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +196 -3
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +25 -8
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
// DOM view of Moontide's Song grid in the Parts view: a table with one column per section entry and one row per group or
|
|
2
|
+
// track, drawn from the model of page/song-grid-model.js. Everything that comes from the code (names, harmony text,
|
|
3
|
+
// counts) reaches the page through textContent, title and setAttribute only, and nodes are built with createElement.
|
|
4
|
+
// Gestures are routed by one delegated listener per kind of event on the table to the handlers the page passes in; a
|
|
5
|
+
// gesture whose handler is missing throws, so nothing is ignored silently. The live tick writes only what changed.
|
|
6
|
+
// `document` is injected, so the module has no DOM access at import time and node:test drives it with a fake document.
|
|
7
|
+
|
|
8
|
+
import { cellFitPrefill } from './ask.js';
|
|
9
|
+
import { SONG_COPY } from './structure.js';
|
|
10
|
+
import { SONG_GRID_DEEP_FROM, controlsDisabled, gridMove, headerLive, launchOptions, listPlacement, nowText } from './song-grid-model.js';
|
|
11
|
+
|
|
12
|
+
/** The deepest indent the name column shows, in levels; deeper rows keep this indent. */
|
|
13
|
+
const MAX_INDENT_LEVELS = 4;
|
|
14
|
+
|
|
15
|
+
/** The id of the muted line in the list that says a change applies to every use of a section. */
|
|
16
|
+
const LIST_NOTE_ID = 'song-cell-list-note';
|
|
17
|
+
|
|
18
|
+
/** True for the two keyboard gestures that mean a right-click: the Menu key, and F10 with Shift (and no other modifier). */
|
|
19
|
+
function isAskKey(event) {
|
|
20
|
+
if (event.altKey || event.metaKey || event.ctrlKey) return false;
|
|
21
|
+
if (event.key === 'ContextMenu') return !event.shiftKey;
|
|
22
|
+
return event.key === 'F10' && event.shiftKey === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The keys the grid moves focus with. */
|
|
26
|
+
const MOVE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `createSongGridView({ document, rows, bar, now, line, launchGroup, list, handlers })` returns `{ render(model), tick(live),
|
|
30
|
+
* hide(), scrollPlayingIntoView(), openList(cellButton), closeList(), isListOpen(), askTarget(element) }`. `rows` is the element the grid is
|
|
31
|
+
* drawn in, `bar` the toolbar above it, `now` the line that says which section plays, `line` the sentence that says why the
|
|
32
|
+
* grid is off, what waits or why a gesture changed nothing, `launchGroup` the group that holds the three launch buttons and
|
|
33
|
+
* `list` the popup that opens on a cell. `handlers` is an object of functions keyed by gesture (`fold(groupKey)`,
|
|
34
|
+
* `launchChoice(value)`, `launch(sectionIndex)`, `choose(seen, choice)`); a gesture whose handler is absent throws. A click
|
|
35
|
+
* on a cell opens the list; choosing an option calls `choose` with what the person saw (`{ track, index, name, length, key }`
|
|
36
|
+
* of the cell, `key` null for a rest) and the choice (`{ key }` or `{ rest: true }`). A grid that is drawn again while the
|
|
37
|
+
* list is open waits until it closes. `askTarget(element)` says what a right-click on `element` asks about (see below), and
|
|
38
|
+
* the Menu key or Shift+F10 on a focused control calls `handlers.ask(descriptor, point)` with the same descriptor and the
|
|
39
|
+
* centre of the control.
|
|
40
|
+
*/
|
|
41
|
+
export function createSongGridView({ document, rows, bar, now, line, launchGroup, list, handlers }) {
|
|
42
|
+
for (const [name, element] of Object.entries({ rows, bar, now, line, launchGroup, list })) {
|
|
43
|
+
if (!element) throw new Error(`Moontide: the song grid view needs its '${name}' element.`);
|
|
44
|
+
}
|
|
45
|
+
let model = null;
|
|
46
|
+
let scroller = null;
|
|
47
|
+
// The controls of the grid drawn now, by node and by position.
|
|
48
|
+
let controls = new Map();
|
|
49
|
+
let byPosition = new Map();
|
|
50
|
+
let heads = [];
|
|
51
|
+
let cols = [];
|
|
52
|
+
// The rows that are not controls themselves (a group row, a track row), by node, with what a right-click on them asks about.
|
|
53
|
+
let askRows = new Map();
|
|
54
|
+
let roving = null;
|
|
55
|
+
let shownPlaying = -1;
|
|
56
|
+
let rovingPosition = null;
|
|
57
|
+
|
|
58
|
+
function make(tag, className, text) {
|
|
59
|
+
const node = document.createElement(tag);
|
|
60
|
+
if (className !== '') node.className = className;
|
|
61
|
+
if (text !== undefined) node.textContent = text;
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The launch choice: three buttons that say when a launched section starts; the pressed one is the choice in the model.
|
|
66
|
+
const launchButtons = launchOptions().map((option) => {
|
|
67
|
+
const button = make('button', '', option.label);
|
|
68
|
+
button.setAttribute('type', 'button');
|
|
69
|
+
button.setAttribute('aria-pressed', 'false');
|
|
70
|
+
button.setAttribute('aria-label', option.name);
|
|
71
|
+
button.setAttribute('data-value', option.value);
|
|
72
|
+
return { option, button, pressed: 'false' };
|
|
73
|
+
});
|
|
74
|
+
launchGroup.setAttribute('title', SONG_COPY.launchGroupTitle);
|
|
75
|
+
launchGroup.replaceChildren(...launchButtons.map((entry) => entry.button));
|
|
76
|
+
launchGroup.addEventListener('click', (event) => {
|
|
77
|
+
const entry = launchButtons.find((candidate) => candidate.button === event.target);
|
|
78
|
+
if (entry === undefined) return;
|
|
79
|
+
if (typeof handlers.launchChoice !== 'function') throw new Error("Moontide: the song grid has no handler for 'launchChoice'.");
|
|
80
|
+
handlers.launchChoice(entry.option.value);
|
|
81
|
+
});
|
|
82
|
+
let shownNow = null;
|
|
83
|
+
// The open list: the cell it belongs to, what it shows, its option nodes and the active one; null while closed. A model
|
|
84
|
+
// that arrives while it is open waits in `waitingModel`.
|
|
85
|
+
let open = null;
|
|
86
|
+
let waitingModel = null;
|
|
87
|
+
// The sentence and the switched-off controls the line gave last, so a tick writes only a change.
|
|
88
|
+
let shownLine = { kind: '', text: '' };
|
|
89
|
+
let shownOff = { headers: false, cells: false };
|
|
90
|
+
|
|
91
|
+
function positionKey(rowKey, column) {
|
|
92
|
+
return `${rowKey}\u0000${column}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function register(node, info) {
|
|
96
|
+
node.setAttribute('tabindex', '-1');
|
|
97
|
+
controls.set(node, info);
|
|
98
|
+
byPosition.set(positionKey(info.rowKey, info.column), node);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The tab stop moves to `node`; every other control leaves the tab order. */
|
|
102
|
+
function setRoving(node) {
|
|
103
|
+
if (roving === node) return;
|
|
104
|
+
if (roving !== null) roving.setAttribute('tabindex', '-1');
|
|
105
|
+
roving = node;
|
|
106
|
+
node.setAttribute('tabindex', '0');
|
|
107
|
+
rovingPosition = controls.get(node);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildHead(column) {
|
|
111
|
+
const th = make('th', 'song-colhead');
|
|
112
|
+
th.setAttribute('scope', 'col');
|
|
113
|
+
const button = make('button', 'part-card song-head');
|
|
114
|
+
button.setAttribute('type', 'button');
|
|
115
|
+
button.setAttribute('data-part', column.name);
|
|
116
|
+
button.setAttribute('data-index', String(column.index));
|
|
117
|
+
button.setAttribute('data-state', 'idle');
|
|
118
|
+
const name = make('span', 'part-name', column.text);
|
|
119
|
+
if (column.title !== null) name.setAttribute('title', column.title);
|
|
120
|
+
const line = make('span', 'song-head-line');
|
|
121
|
+
const state = make('span', 'part-state', column.lengthText);
|
|
122
|
+
const action = make('span', 'song-head-action', '');
|
|
123
|
+
action.setAttribute('hidden', '');
|
|
124
|
+
line.appendChild(state);
|
|
125
|
+
line.appendChild(action);
|
|
126
|
+
const harmony = make('span', 'song-harmony', column.harmonyText);
|
|
127
|
+
if (column.harmonyTitle !== column.harmonyText) harmony.setAttribute('title', column.harmonyTitle);
|
|
128
|
+
const progress = make('span', 'part-progress');
|
|
129
|
+
const fill = make('span', 'part-progress-fill');
|
|
130
|
+
progress.appendChild(fill);
|
|
131
|
+
button.appendChild(name);
|
|
132
|
+
button.appendChild(line);
|
|
133
|
+
button.appendChild(harmony);
|
|
134
|
+
if (column.usedText !== null) {
|
|
135
|
+
const used = make('span', 'song-used', column.usedText);
|
|
136
|
+
used.setAttribute('title', column.usedTitle);
|
|
137
|
+
button.appendChild(used);
|
|
138
|
+
}
|
|
139
|
+
button.appendChild(progress);
|
|
140
|
+
th.appendChild(button);
|
|
141
|
+
register(button, { kind: 'head', rowKey: 'h', column: column.index + 1, index: column.index });
|
|
142
|
+
heads.push({ th, button, state, action, fill, shown: { state: 'idle', stateText: column.lengthText, action: '', title: '', width: '0%', line: '' } });
|
|
143
|
+
return th;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function indent(head, row) {
|
|
147
|
+
head.style.setProperty('--depth', String(Math.min(row.depth, MAX_INDENT_LEVELS)));
|
|
148
|
+
if (row.level >= SONG_GRID_DEEP_FROM) head.setAttribute('data-deep', 'true');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function nameHead(row) {
|
|
152
|
+
const head = make('th', 'song-rowhead');
|
|
153
|
+
head.setAttribute('scope', 'row');
|
|
154
|
+
if (row.title !== null) head.setAttribute('title', row.title);
|
|
155
|
+
indent(head, row);
|
|
156
|
+
return head;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildGroupRow(row, sections) {
|
|
160
|
+
const tr = make('tr', 'song-group-row');
|
|
161
|
+
const head = nameHead(row);
|
|
162
|
+
const fold = make('button', 'song-fold');
|
|
163
|
+
fold.setAttribute('type', 'button');
|
|
164
|
+
fold.setAttribute('aria-expanded', String(row.open));
|
|
165
|
+
fold.setAttribute('title', row.foldTitle);
|
|
166
|
+
const chevron = make('span', 'song-chevron', row.open ? '▾' : '▸');
|
|
167
|
+
chevron.setAttribute('aria-hidden', 'true');
|
|
168
|
+
fold.appendChild(chevron);
|
|
169
|
+
fold.appendChild(make('span', 'song-fold-name', row.text));
|
|
170
|
+
fold.appendChild(make('span', 'song-count', row.countText));
|
|
171
|
+
head.appendChild(fold);
|
|
172
|
+
// The names come from the model, never from the shown text, which may be cut; the parent is the group this one sits in.
|
|
173
|
+
const ask = row.path.length > 1 ? { kind: 'group', name: row.name, parent: row.path[row.path.length - 2] } : { kind: 'group', name: row.name };
|
|
174
|
+
register(fold, { kind: 'fold', rowKey: row.key, column: 0, groupKey: row.groupKey, ask });
|
|
175
|
+
askRows.set(tr, ask);
|
|
176
|
+
tr.appendChild(head);
|
|
177
|
+
const fill = make('td', 'song-group-fill');
|
|
178
|
+
fill.setAttribute('colspan', String(sections));
|
|
179
|
+
tr.appendChild(fill);
|
|
180
|
+
return tr;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildTrackRow(row, columnsByIndex) {
|
|
184
|
+
const tr = make('tr', 'song-track-row');
|
|
185
|
+
const head = nameHead(row);
|
|
186
|
+
head.textContent = row.text;
|
|
187
|
+
askRows.set(tr, { kind: 'track', track: row.name });
|
|
188
|
+
tr.appendChild(head);
|
|
189
|
+
row.cells.forEach((cell) => {
|
|
190
|
+
const td = make('td', 'song-cell-box');
|
|
191
|
+
const button = make('button', 'song-cell');
|
|
192
|
+
button.setAttribute('type', 'button');
|
|
193
|
+
button.setAttribute('data-track', row.name);
|
|
194
|
+
button.setAttribute('data-section', String(cell.section));
|
|
195
|
+
button.setAttribute('data-kind', cell.kind);
|
|
196
|
+
button.setAttribute('title', cell.title);
|
|
197
|
+
button.setAttribute('aria-haspopup', 'listbox');
|
|
198
|
+
button.setAttribute('aria-expanded', 'false');
|
|
199
|
+
if (cell.shared) button.setAttribute('data-shared', 'true');
|
|
200
|
+
button.appendChild(make('span', 'song-variation', cell.text));
|
|
201
|
+
// The fit words of a fixed variation, once the page has them: words only, the whole text is in the cell's title.
|
|
202
|
+
if (cell.fit !== null) {
|
|
203
|
+
button.setAttribute('data-fit', cell.fit.state);
|
|
204
|
+
if (cell.fit.outside !== null) button.setAttribute('data-outside', cell.fit.outside);
|
|
205
|
+
button.appendChild(make('span', 'song-fit', cell.fit.line));
|
|
206
|
+
}
|
|
207
|
+
if (cell.wait !== null) button.appendChild(make('span', 'song-wait', cell.wait));
|
|
208
|
+
td.appendChild(button);
|
|
209
|
+
tr.appendChild(td);
|
|
210
|
+
const column = columnsByIndex[cell.section];
|
|
211
|
+
register(button, {
|
|
212
|
+
kind: 'cell',
|
|
213
|
+
rowKey: row.key,
|
|
214
|
+
column: cell.section + 1,
|
|
215
|
+
track: row.name,
|
|
216
|
+
section: cell.section,
|
|
217
|
+
seen: { track: row.name, index: cell.section, name: column.name, length: column.length, key: cell.key },
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
return tr;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildOutsideRow(row, sections) {
|
|
224
|
+
const tr = make('tr', 'song-outside-row');
|
|
225
|
+
const head = nameHead(row);
|
|
226
|
+
head.textContent = row.text;
|
|
227
|
+
askRows.set(tr, { kind: 'track', track: row.name });
|
|
228
|
+
tr.appendChild(head);
|
|
229
|
+
const note = make('td', 'song-outside-note', row.note);
|
|
230
|
+
note.setAttribute('colspan', String(sections));
|
|
231
|
+
tr.appendChild(note);
|
|
232
|
+
return tr;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function buildRow(row, sections) {
|
|
236
|
+
const tr = row.kind === 'group' ? buildGroupRow(row, sections) : row.kind === 'track' ? buildTrackRow(row, model.columns) : buildOutsideRow(row, sections);
|
|
237
|
+
tr.setAttribute('data-key', row.key);
|
|
238
|
+
tr.setAttribute('aria-level', String(row.level));
|
|
239
|
+
if (row.hidden) tr.setAttribute('hidden', '');
|
|
240
|
+
return tr;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The visible rows of the grid as columns that hold a control, header row first (what gridMove reads). */
|
|
244
|
+
function layoutOf() {
|
|
245
|
+
const keys = ['h'];
|
|
246
|
+
const layout = [model.columns.map((column) => column.index + 1)];
|
|
247
|
+
for (const row of model.rows) {
|
|
248
|
+
if (row.hidden) continue;
|
|
249
|
+
keys.push(row.key);
|
|
250
|
+
layout.push(row.kind === 'group' ? [0] : row.kind === 'track' ? model.columns.map((column) => column.index + 1) : []);
|
|
251
|
+
}
|
|
252
|
+
return { keys, layout };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function route(info) {
|
|
256
|
+
const need = (name) => {
|
|
257
|
+
if (typeof handlers[name] !== 'function') throw new Error(`Moontide: the song grid has no handler for '${name}'.`);
|
|
258
|
+
return handlers[name];
|
|
259
|
+
};
|
|
260
|
+
if (info.kind === 'fold') need('fold')(info.groupKey);
|
|
261
|
+
else if (info.kind === 'head') need('launch')(info.index);
|
|
262
|
+
else {
|
|
263
|
+
// A cell opens its list (a click on the cell that is open closes it); the gesture is chosen from the list.
|
|
264
|
+
need('choose');
|
|
265
|
+
if (open !== null && open.cell === info.node) closeList({ focusCell: true });
|
|
266
|
+
else openList(info.node);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function controlOf(target) {
|
|
271
|
+
for (let node = target; node; node = node.parentNode) {
|
|
272
|
+
if (controls.has(node)) return node;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function onClick(event) {
|
|
278
|
+
const node = controlOf(event.target);
|
|
279
|
+
if (node === null) return;
|
|
280
|
+
// A control that is switched off stays where it is and keeps its text, and a click on it does nothing: the line says why.
|
|
281
|
+
if (node.getAttribute('aria-disabled') === 'true') return;
|
|
282
|
+
route({ ...controls.get(node), node });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function onFocusIn(event) {
|
|
286
|
+
const node = controlOf(event.target);
|
|
287
|
+
if (node !== null) setRoving(node);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function onKeyDown(event) {
|
|
291
|
+
const node = controlOf(event.target);
|
|
292
|
+
if (node !== null && isAskKey(event)) {
|
|
293
|
+
// The keyboard's right-click: the page's own contextmenu listener ignores a Shift press, so the grid asks directly.
|
|
294
|
+
if (typeof handlers.ask !== 'function') throw new Error("Moontide: the song grid has no handler for 'ask'.");
|
|
295
|
+
const descriptor = askTarget(node);
|
|
296
|
+
if (descriptor === null) return;
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
event.stopPropagation();
|
|
299
|
+
const at = node.getBoundingClientRect();
|
|
300
|
+
handlers.ask(descriptor, { x: at.left + at.width / 2, y: at.top + at.height / 2 });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (node === null || !MOVE_KEYS.has(event.key)) return;
|
|
304
|
+
if (event.altKey || event.metaKey || event.shiftKey) return;
|
|
305
|
+
const vertical = event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'ArrowLeft' || event.key === 'ArrowRight';
|
|
306
|
+
if (event.ctrlKey && vertical) return;
|
|
307
|
+
const info = controls.get(node);
|
|
308
|
+
const { keys, layout } = layoutOf();
|
|
309
|
+
const row = keys.indexOf(info.rowKey);
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
event.stopPropagation();
|
|
312
|
+
if (row < 0) return;
|
|
313
|
+
const target = gridMove(layout, { row, column: info.column }, event.key, { ctrl: event.ctrlKey });
|
|
314
|
+
if (target === null) return;
|
|
315
|
+
const next = byPosition.get(positionKey(keys[target.row], target.column));
|
|
316
|
+
if (next === undefined) return;
|
|
317
|
+
setRoving(next);
|
|
318
|
+
next.focus();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- what a right-click asks about ----
|
|
322
|
+
|
|
323
|
+
/** The Ask descriptor of one control of the grid (a section header, a fold button or a cell). */
|
|
324
|
+
function askOfControl(node, info) {
|
|
325
|
+
if (info.kind === 'head') return { kind: 'part', name: model.columns[info.index].name };
|
|
326
|
+
if (info.kind === 'fold') return { ...info.ask };
|
|
327
|
+
const { track, name: section, key } = info.seen;
|
|
328
|
+
if (key === null) return { kind: 'cell', section, track, rest: true };
|
|
329
|
+
const descriptor = { kind: 'cell', section, track, variation: key };
|
|
330
|
+
// The fit is what the cell shows now: a cell without fit words has none to carry.
|
|
331
|
+
const fit = node.getAttribute('data-fit');
|
|
332
|
+
if (fit === null) return descriptor;
|
|
333
|
+
descriptor.fit = fit;
|
|
334
|
+
const outside = node.getAttribute('data-outside');
|
|
335
|
+
if (outside !== null) descriptor.outside = outside;
|
|
336
|
+
// Only a variation whose notes do not fit offers a request, written from the cell's own facts and never sent by the grid.
|
|
337
|
+
if (fit === 'does-not-fit') {
|
|
338
|
+
const harmony = model.sections[info.section].harmony;
|
|
339
|
+
descriptor.prefill = cellFitPrefill({ variation: key, track, section, harmony: harmony.text, notes: outside });
|
|
340
|
+
}
|
|
341
|
+
return descriptor;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* What a right-click on `element` asks about, or null when it is not on something the grid knows: a section header gives
|
|
346
|
+
* `{ kind: 'part', name }`, a cell `{ kind: 'cell', section, track, variation | rest: true, fit?, outside?, prefill? }`, a
|
|
347
|
+
* fold button or a group row `{ kind: 'group', name, parent? }` and a track name `{ kind: 'track', track }`. The most
|
|
348
|
+
* specific one wins (a cell before its row, a fold button before its group row). Names come from the model, not from the
|
|
349
|
+
* shown text, which may be cut.
|
|
350
|
+
*/
|
|
351
|
+
function askTarget(element) {
|
|
352
|
+
if (model === null) return null;
|
|
353
|
+
for (let node = element; node; node = node.parentNode) {
|
|
354
|
+
const info = controls.get(node);
|
|
355
|
+
if (info !== undefined) return askOfControl(node, info);
|
|
356
|
+
const row = askRows.get(node);
|
|
357
|
+
if (row !== undefined) return { ...row };
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- the list that opens on a cell ----
|
|
363
|
+
|
|
364
|
+
function optionNode(option, listModel) {
|
|
365
|
+
const node = make('div', option.rest ? 'song-option song-option-rest' : 'song-option');
|
|
366
|
+
node.setAttribute('role', 'option');
|
|
367
|
+
node.setAttribute('id', option.id);
|
|
368
|
+
node.setAttribute('aria-selected', String(option.current));
|
|
369
|
+
const name = make('span', 'song-option-name', option.text);
|
|
370
|
+
if (option.title !== null) name.setAttribute('title', option.title);
|
|
371
|
+
node.appendChild(name);
|
|
372
|
+
if (option.current) node.appendChild(make('span', 'song-option-note', listModel.currentText));
|
|
373
|
+
if (option.note !== null) node.appendChild(make('span', 'song-option-note', option.note));
|
|
374
|
+
return node;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function setActive(index) {
|
|
378
|
+
if (open === null) return;
|
|
379
|
+
const clamped = Math.max(0, Math.min(open.nodes.length - 1, index));
|
|
380
|
+
if (clamped !== open.active) {
|
|
381
|
+
open.nodes[open.active].removeAttribute('data-active');
|
|
382
|
+
open.nodes[clamped].setAttribute('data-active', 'true');
|
|
383
|
+
open.active = clamped;
|
|
384
|
+
list.setAttribute('aria-activedescendant', open.nodes[clamped].getAttribute('id'));
|
|
385
|
+
}
|
|
386
|
+
if (typeof open.nodes[clamped].scrollIntoView === 'function') open.nodes[clamped].scrollIntoView({ block: 'nearest' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Puts the list below the cell, above it when the room below is short, and inside the viewport. */
|
|
390
|
+
function place(cell) {
|
|
391
|
+
const root = document.documentElement;
|
|
392
|
+
list.style.left = '0px';
|
|
393
|
+
list.style.top = '0px';
|
|
394
|
+
list.style.maxHeight = '';
|
|
395
|
+
const at = cell.getBoundingClientRect();
|
|
396
|
+
const box = list.getBoundingClientRect();
|
|
397
|
+
const spot = listPlacement({
|
|
398
|
+
cell: { left: at.left, top: at.top, bottom: at.bottom },
|
|
399
|
+
size: { width: box.width, height: box.height },
|
|
400
|
+
viewport: { width: root.clientWidth, height: root.clientHeight },
|
|
401
|
+
});
|
|
402
|
+
list.style.left = `${spot.left}px`;
|
|
403
|
+
list.style.top = `${spot.top}px`;
|
|
404
|
+
list.style.maxHeight = spot.maxHeight === null ? '' : `${spot.maxHeight}px`;
|
|
405
|
+
list.setAttribute('data-placement', spot.above ? 'above' : 'below');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Opens the list of the variations of `cellButton`'s track for its section, current one marked, rest last. `listModel` is
|
|
410
|
+
* the content (the grid model gives it by default). Focus moves into the list; the cell says it is expanded.
|
|
411
|
+
*/
|
|
412
|
+
function openList(cellButton, listModel) {
|
|
413
|
+
const info = controls.get(cellButton);
|
|
414
|
+
if (info === undefined || info.kind !== 'cell') throw new Error('Moontide: the song grid can open a list only on one of its cells.');
|
|
415
|
+
if (typeof handlers.choose !== 'function') throw new Error("Moontide: the song grid has no handler for 'choose'.");
|
|
416
|
+
if (open !== null) closeList({ focusCell: false });
|
|
417
|
+
const content = listModel ?? model.listFor(info.track, info.section);
|
|
418
|
+
const nodes = content.options.map((option) => optionNode(option, content));
|
|
419
|
+
const children = [];
|
|
420
|
+
list.setAttribute('aria-label', content.label);
|
|
421
|
+
if (content.note === null) list.removeAttribute('aria-describedby');
|
|
422
|
+
else {
|
|
423
|
+
const note = make('div', 'song-option-note song-list-note', content.note);
|
|
424
|
+
note.setAttribute('id', LIST_NOTE_ID);
|
|
425
|
+
note.setAttribute('role', 'presentation');
|
|
426
|
+
children.push(note);
|
|
427
|
+
list.setAttribute('aria-describedby', LIST_NOTE_ID);
|
|
428
|
+
}
|
|
429
|
+
children.push(...nodes);
|
|
430
|
+
list.replaceChildren(...children);
|
|
431
|
+
const current = Math.max(0, content.options.findIndex((option) => option.current));
|
|
432
|
+
open = { cell: cellButton, content, nodes, active: current, seen: info.seen };
|
|
433
|
+
nodes[current].setAttribute('data-active', 'true');
|
|
434
|
+
list.setAttribute('aria-activedescendant', nodes[current].getAttribute('id'));
|
|
435
|
+
cellButton.setAttribute('aria-expanded', 'true');
|
|
436
|
+
list.hidden = false;
|
|
437
|
+
place(cellButton);
|
|
438
|
+
list.focus();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Closes the list; with `focusCell` focus goes back to the cell it belonged to. A grid that waited for it is drawn now. */
|
|
442
|
+
function closeList({ focusCell = false } = {}) {
|
|
443
|
+
if (open === null) return;
|
|
444
|
+
const { cell } = open;
|
|
445
|
+
open = null;
|
|
446
|
+
list.hidden = true;
|
|
447
|
+
list.replaceChildren();
|
|
448
|
+
list.removeAttribute('aria-activedescendant');
|
|
449
|
+
list.removeAttribute('aria-describedby');
|
|
450
|
+
if (cell.parentNode !== null) cell.setAttribute('aria-expanded', 'false');
|
|
451
|
+
if (focusCell && cell.parentNode !== null) cell.focus({ preventScroll: true });
|
|
452
|
+
if (waitingModel !== null) {
|
|
453
|
+
const next = waitingModel;
|
|
454
|
+
waitingModel = null;
|
|
455
|
+
render(next);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function isListOpen() {
|
|
460
|
+
return open !== null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function choose(index) {
|
|
464
|
+
if (open === null) return;
|
|
465
|
+
const option = open.content.options[index];
|
|
466
|
+
const { seen } = open;
|
|
467
|
+
closeList({ focusCell: true });
|
|
468
|
+
// What the cell holds already is no change: nothing is written, evaluated or recorded, so the page is not even asked.
|
|
469
|
+
if (option.current) return;
|
|
470
|
+
handlers.choose(seen, option.rest ? { rest: true } : { key: option.key });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
list.addEventListener('click', (event) => {
|
|
474
|
+
if (open === null) return;
|
|
475
|
+
for (let node = event.target; node && node !== list; node = node.parentNode) {
|
|
476
|
+
const index = open.nodes.indexOf(node);
|
|
477
|
+
if (index >= 0) {
|
|
478
|
+
choose(index);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
list.addEventListener('keydown', (event) => {
|
|
484
|
+
if (open === null) return;
|
|
485
|
+
if (event.altKey || event.metaKey || event.ctrlKey) return;
|
|
486
|
+
const key = event.key;
|
|
487
|
+
if (key === 'Escape' || key === 'Tab') {
|
|
488
|
+
// Esc closes the list and nothing else: the page's own Esc, which closes the view, must not see this press.
|
|
489
|
+
event.preventDefault();
|
|
490
|
+
event.stopPropagation();
|
|
491
|
+
closeList({ focusCell: true });
|
|
492
|
+
} else if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
|
|
493
|
+
event.preventDefault();
|
|
494
|
+
event.stopPropagation();
|
|
495
|
+
if (key === 'ArrowDown') setActive(open.active + 1);
|
|
496
|
+
else if (key === 'ArrowUp') setActive(open.active - 1);
|
|
497
|
+
else setActive(key === 'Home' ? 0 : open.nodes.length - 1);
|
|
498
|
+
} else if (key === 'Enter' || key === ' ') {
|
|
499
|
+
event.preventDefault();
|
|
500
|
+
event.stopPropagation();
|
|
501
|
+
choose(open.active);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
// A press anywhere else closes the list (the cell it belongs to toggles it with its own click).
|
|
505
|
+
document.addEventListener('pointerdown', (event) => {
|
|
506
|
+
if (open === null) return;
|
|
507
|
+
for (let node = event.target; node; node = node.parentNode) {
|
|
508
|
+
if (node === list || node === open.cell) return;
|
|
509
|
+
}
|
|
510
|
+
closeList({ focusCell: false });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
function render(next) {
|
|
514
|
+
// A grid drawn again while the list is open waits: the list belongs to the cell it was opened on.
|
|
515
|
+
if (open !== null) {
|
|
516
|
+
waitingModel = next;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// What a rebuild keeps: the scroll offsets on both axes and the focused control by its row and column.
|
|
520
|
+
const left = scroller === null ? 0 : scroller.scrollLeft;
|
|
521
|
+
const top = scroller === null ? 0 : scroller.scrollTop;
|
|
522
|
+
const focused = document.activeElement ? controls.get(document.activeElement) : undefined;
|
|
523
|
+
const restoreFocus = focused === undefined ? null : { rowKey: focused.rowKey, column: focused.column };
|
|
524
|
+
const wantedRoving = rovingPosition;
|
|
525
|
+
|
|
526
|
+
model = next;
|
|
527
|
+
controls = new Map();
|
|
528
|
+
byPosition = new Map();
|
|
529
|
+
askRows = new Map();
|
|
530
|
+
heads = [];
|
|
531
|
+
roving = null;
|
|
532
|
+
shownPlaying = -1;
|
|
533
|
+
// The controls are new and switched on; the next tick switches off what the line says.
|
|
534
|
+
shownOff = { headers: false, cells: false };
|
|
535
|
+
const sections = model.columns.length;
|
|
536
|
+
|
|
537
|
+
scroller = make('div', 'song-grid-scroll');
|
|
538
|
+
const table = make('table', 'song-grid');
|
|
539
|
+
table.setAttribute('role', 'grid');
|
|
540
|
+
table.setAttribute('aria-label', model.title);
|
|
541
|
+
table.style.setProperty('--song-columns', String(sections));
|
|
542
|
+
const colgroup = make('colgroup', '');
|
|
543
|
+
colgroup.appendChild(make('col', 'song-col-name'));
|
|
544
|
+
cols = model.columns.map(() => {
|
|
545
|
+
const col = make('col', '');
|
|
546
|
+
colgroup.appendChild(col);
|
|
547
|
+
return col;
|
|
548
|
+
});
|
|
549
|
+
table.appendChild(colgroup);
|
|
550
|
+
const thead = make('thead', '');
|
|
551
|
+
const headRow = make('tr', '');
|
|
552
|
+
headRow.appendChild(make('th', 'song-corner', model.corner));
|
|
553
|
+
for (const column of model.columns) headRow.appendChild(buildHead(column));
|
|
554
|
+
thead.appendChild(headRow);
|
|
555
|
+
table.appendChild(thead);
|
|
556
|
+
const tbody = make('tbody', '');
|
|
557
|
+
for (const row of model.rows) tbody.appendChild(buildRow(row, sections));
|
|
558
|
+
table.appendChild(tbody);
|
|
559
|
+
table.addEventListener('click', onClick);
|
|
560
|
+
table.addEventListener('focusin', onFocusIn);
|
|
561
|
+
table.addEventListener('keydown', onKeyDown);
|
|
562
|
+
scroller.appendChild(table);
|
|
563
|
+
|
|
564
|
+
rows.replaceChildren(scroller);
|
|
565
|
+
rows.setAttribute('data-view', 'song');
|
|
566
|
+
bar.hidden = false;
|
|
567
|
+
for (const entry of launchButtons) {
|
|
568
|
+
const pressed = String(entry.option.value === model.launchChoice);
|
|
569
|
+
if (pressed !== entry.pressed) {
|
|
570
|
+
entry.button.setAttribute('aria-pressed', pressed);
|
|
571
|
+
entry.pressed = pressed;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
scroller.scrollLeft = left;
|
|
575
|
+
scroller.scrollTop = top;
|
|
576
|
+
|
|
577
|
+
const stop = (wantedRoving !== null && byPosition.get(positionKey(wantedRoving.rowKey, wantedRoving.column))) || byPosition.values().next().value;
|
|
578
|
+
if (stop !== undefined) setRoving(stop);
|
|
579
|
+
if (restoreFocus !== null) {
|
|
580
|
+
const again = byPosition.get(positionKey(restoreFocus.rowKey, restoreFocus.column));
|
|
581
|
+
if (again !== undefined) {
|
|
582
|
+
setRoving(again);
|
|
583
|
+
again.focus({ preventScroll: true });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Writes what the live state changed, and nothing else: a second call with the same state touches no node. `live` is
|
|
590
|
+
* `{ playing, stopped, motion, queued, line }`, where `queued` is `{ index, countdownText, line }` for the section a
|
|
591
|
+
* launch waits for (or null) and `line` the `{ kind, text }` sentence of songLine (or null).
|
|
592
|
+
*/
|
|
593
|
+
function tick(live) {
|
|
594
|
+
if (model === null) return;
|
|
595
|
+
let playing = live.stopped ? null : live.playing;
|
|
596
|
+
// A newer grid waits for the list to close and may have more sections than the one drawn: its playing section has no
|
|
597
|
+
// header here yet.
|
|
598
|
+
if (playing !== null && playing !== undefined && playing.index >= model.columns.length) playing = null;
|
|
599
|
+
model.columns.forEach((column, i) => {
|
|
600
|
+
const head = heads[i];
|
|
601
|
+
const now = headerLive({ column, playing, stopped: live.stopped, launchChoice: model.launchChoice, motion: live.motion, queued: live.queued ?? null });
|
|
602
|
+
const shown = head.shown;
|
|
603
|
+
if (now.state !== shown.state) {
|
|
604
|
+
head.button.setAttribute('data-state', now.state);
|
|
605
|
+
shown.state = now.state;
|
|
606
|
+
}
|
|
607
|
+
if (now.stateText !== shown.stateText) {
|
|
608
|
+
head.state.textContent = now.stateText;
|
|
609
|
+
shown.stateText = now.stateText;
|
|
610
|
+
}
|
|
611
|
+
if (now.action !== shown.action) {
|
|
612
|
+
head.action.textContent = now.action;
|
|
613
|
+
if (now.action === '') head.action.setAttribute('hidden', '');
|
|
614
|
+
else head.action.removeAttribute('hidden');
|
|
615
|
+
shown.action = now.action;
|
|
616
|
+
}
|
|
617
|
+
if (now.title !== shown.title) {
|
|
618
|
+
head.button.setAttribute('title', now.title);
|
|
619
|
+
shown.title = now.title;
|
|
620
|
+
}
|
|
621
|
+
const width = `${now.progress * 100}%`;
|
|
622
|
+
if (width !== shown.width) {
|
|
623
|
+
head.fill.style.width = width;
|
|
624
|
+
shown.width = width;
|
|
625
|
+
}
|
|
626
|
+
// The line a queued launch takes over on, for whoever wants to compare it with where the music lands.
|
|
627
|
+
const takeoverLine = now.line === undefined ? '' : String(now.line);
|
|
628
|
+
if (takeoverLine !== shown.line) {
|
|
629
|
+
if (takeoverLine === '') head.button.removeAttribute('data-line');
|
|
630
|
+
else head.button.setAttribute('data-line', takeoverLine);
|
|
631
|
+
shown.line = takeoverLine;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
const sentence = nowText(model, playing);
|
|
635
|
+
if (sentence !== shownNow) {
|
|
636
|
+
now.textContent = sentence ?? '';
|
|
637
|
+
now.hidden = sentence === null;
|
|
638
|
+
shownNow = sentence;
|
|
639
|
+
}
|
|
640
|
+
const index = playing === null ? -1 : playing.index;
|
|
641
|
+
if (index !== shownPlaying) {
|
|
642
|
+
if (shownPlaying >= 0) cols[shownPlaying].removeAttribute('data-playing');
|
|
643
|
+
if (index >= 0) cols[index].setAttribute('data-playing', 'true');
|
|
644
|
+
shownPlaying = index;
|
|
645
|
+
}
|
|
646
|
+
writeLine(live.line ?? null);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** The sentence under the toolbar and the controls it switches off, each written only when it changed. */
|
|
650
|
+
function writeLine(sentence) {
|
|
651
|
+
const kind = sentence === null ? '' : sentence.kind;
|
|
652
|
+
const text = sentence === null ? '' : sentence.text;
|
|
653
|
+
if (kind !== shownLine.kind || text !== shownLine.text) {
|
|
654
|
+
if (text !== shownLine.text) line.textContent = text;
|
|
655
|
+
if (kind !== shownLine.kind) {
|
|
656
|
+
if (kind === '') line.removeAttribute('data-kind');
|
|
657
|
+
else line.setAttribute('data-kind', kind);
|
|
658
|
+
}
|
|
659
|
+
line.hidden = kind === '';
|
|
660
|
+
shownLine = { kind, text };
|
|
661
|
+
}
|
|
662
|
+
const off = controlsDisabled(kind === '' ? null : kind);
|
|
663
|
+
for (const part of ['headers', 'cells']) {
|
|
664
|
+
if (off[part] === shownOff[part]) continue;
|
|
665
|
+
for (const [node, info] of controls) {
|
|
666
|
+
if ((part === 'headers' && info.kind === 'head') || (part === 'cells' && info.kind === 'cell')) {
|
|
667
|
+
if (off[part]) node.setAttribute('aria-disabled', 'true');
|
|
668
|
+
else node.removeAttribute('aria-disabled');
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
shownOff[part] = off[part];
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Takes the grid away; does nothing when no grid is drawn, so the rows of another view are never touched. */
|
|
676
|
+
function hide() {
|
|
677
|
+
waitingModel = null;
|
|
678
|
+
if (open !== null) closeList({ focusCell: false });
|
|
679
|
+
if (model === null) return;
|
|
680
|
+
rows.replaceChildren();
|
|
681
|
+
rows.removeAttribute('data-view');
|
|
682
|
+
bar.hidden = true;
|
|
683
|
+
now.hidden = true;
|
|
684
|
+
now.textContent = '';
|
|
685
|
+
shownNow = null;
|
|
686
|
+
line.hidden = true;
|
|
687
|
+
line.textContent = '';
|
|
688
|
+
line.removeAttribute('data-kind');
|
|
689
|
+
shownLine = { kind: '', text: '' };
|
|
690
|
+
shownOff = { headers: false, cells: false };
|
|
691
|
+
model = null;
|
|
692
|
+
scroller = null;
|
|
693
|
+
controls = new Map();
|
|
694
|
+
byPosition = new Map();
|
|
695
|
+
askRows = new Map();
|
|
696
|
+
heads = [];
|
|
697
|
+
cols = [];
|
|
698
|
+
roving = null;
|
|
699
|
+
rovingPosition = null;
|
|
700
|
+
shownPlaying = -1;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Brings the playing section's header into view once, by the shortest scroll and without animation. */
|
|
704
|
+
function scrollPlayingIntoView() {
|
|
705
|
+
if (shownPlaying < 0) return;
|
|
706
|
+
heads[shownPlaying].th.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return { render, tick, hide, scrollPlayingIntoView, openList, closeList, isListOpen, askTarget };
|
|
710
|
+
}
|