moontide-mcp 2.0.2 → 2.2.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 +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- 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 +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -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 +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: which groups of the Song grid are folded, remembered in this browser. The
|
|
2
|
+
// state is one stored value, a JSON list of group paths (each path is the list of group names from the root down), so a
|
|
3
|
+
// group name is only ever data and never a key of an object. Reading validates everything and never throws: a value that
|
|
4
|
+
// cannot be read gives no folded group and says so, and a storage that is blocked says that instead. No DOM access at
|
|
5
|
+
// import time, so node:test and page/main.js import it without a build step.
|
|
6
|
+
|
|
7
|
+
/** The browser-storage key of the folded groups. */
|
|
8
|
+
export const SONG_FOLDS_KEY = 'moontide.songFolds';
|
|
9
|
+
/** The most folded groups that are remembered; a longer stored list is refused, and a longer one to write keeps the newest. */
|
|
10
|
+
export const MAX_SONG_FOLDS = 500;
|
|
11
|
+
/** The longest name a stored path may hold, in characters. */
|
|
12
|
+
const MAX_NAME_LENGTH = 200;
|
|
13
|
+
/** The longest stored text that is read, in characters. */
|
|
14
|
+
const MAX_STORED_LENGTH = 64000;
|
|
15
|
+
|
|
16
|
+
/** The sentence logged once when the stored fold state cannot be read. */
|
|
17
|
+
export const SONG_FOLDS_COPY = Object.freeze({
|
|
18
|
+
badStored: `Moontide: the stored fold state under '${SONG_FOLDS_KEY}' cannot be read; every group starts open.`,
|
|
19
|
+
// Said once per page session when a group is folded that cannot be remembered.
|
|
20
|
+
notRemembered: `Moontide: a group whose name is longer than ${MAX_NAME_LENGTH} characters folds on this page but is not remembered after a reload.`,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** True for a path that may be stored: a non-empty list of non-empty names, each short enough. */
|
|
24
|
+
function isPath(path) {
|
|
25
|
+
return (
|
|
26
|
+
Array.isArray(path) &&
|
|
27
|
+
path.length > 0 &&
|
|
28
|
+
path.every((name) => typeof name === 'string' && name.length > 0 && name.length <= MAX_NAME_LENGTH)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when `key` (a group key, the JSON text of a path) is one writeSongFolds keeps: a fold that is not is not remembered. */
|
|
33
|
+
export function isRememberable(key) {
|
|
34
|
+
let path;
|
|
35
|
+
try {
|
|
36
|
+
path = JSON.parse(key);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return isPath(path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads the folded groups from `storage` (a Storage-like object, or null): `{ value, available, rejected }`. `value` is the
|
|
45
|
+
* list of group keys (JSON text of a path, as page/song-grid-model.js groupKey makes them). `rejected` is true when a value
|
|
46
|
+
* was stored and refused (not JSON, not a list, more than MAX_SONG_FOLDS paths, longer than 64000 characters, or a path
|
|
47
|
+
* that is not a non-empty list of non-empty names of at most 200 characters); then `value` is empty. `available` is false
|
|
48
|
+
* when the storage is missing or its read throws. Never throws.
|
|
49
|
+
*/
|
|
50
|
+
export function readSongFolds(storage) {
|
|
51
|
+
let raw;
|
|
52
|
+
try {
|
|
53
|
+
raw = storage ? storage.getItem(SONG_FOLDS_KEY) : undefined;
|
|
54
|
+
} catch {
|
|
55
|
+
return { value: [], available: false, rejected: false };
|
|
56
|
+
}
|
|
57
|
+
if (raw === undefined) return { value: [], available: false, rejected: false };
|
|
58
|
+
if (raw === null) return { value: [], available: true, rejected: false };
|
|
59
|
+
const refused = { value: [], available: true, rejected: true };
|
|
60
|
+
if (typeof raw !== 'string' || raw.length > MAX_STORED_LENGTH) return refused;
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
} catch {
|
|
65
|
+
return refused;
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(parsed) || parsed.length > MAX_SONG_FOLDS || !parsed.every(isPath)) return refused;
|
|
68
|
+
return { value: parsed.map((path) => JSON.stringify(path)), available: true, rejected: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Writes the folded groups (a list of group keys) to `storage`: returns true when written and false, never throwing, when
|
|
73
|
+
* the storage is missing or refuses. A group the reader would refuse (a name over 200 characters) is folded in the page
|
|
74
|
+
* but not remembered, and the newest MAX_SONG_FOLDS are kept, so what is written can always be read back.
|
|
75
|
+
*/
|
|
76
|
+
export function writeSongFolds(storage, keys) {
|
|
77
|
+
if (!storage) return false;
|
|
78
|
+
let paths = [];
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
let path;
|
|
81
|
+
try {
|
|
82
|
+
path = JSON.parse(key);
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (isPath(path)) paths.push(path);
|
|
87
|
+
}
|
|
88
|
+
paths = paths.slice(-MAX_SONG_FOLDS);
|
|
89
|
+
let text = JSON.stringify(paths);
|
|
90
|
+
while (text.length > MAX_STORED_LENGTH && paths.length > 0) {
|
|
91
|
+
paths = paths.slice(1);
|
|
92
|
+
text = JSON.stringify(paths);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
storage.setItem(SONG_FOLDS_KEY, text);
|
|
96
|
+
return true;
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: checks what a structured piece really plays against what its song declares.
|
|
2
|
+
// After each version plays, the page asks every unmuted play() track for its own pattern (the same per-track program the
|
|
3
|
+
// Timeline uses) and compares the first cycle of every section entry, repeated names included, with the cell the song
|
|
4
|
+
// declares there. The declared side is built by the page itself from the code's own constants and the reader's static
|
|
5
|
+
// cells, never through the code's play() helper, so a helper that was changed (or a one-sided edit) shows as a difference.
|
|
6
|
+
//
|
|
7
|
+
// What is compared, for one section entry of one track: the onsets in the first cycle of the entry. The counts must be
|
|
8
|
+
// equal; each declared onset needs a played onset with the same whole begin and end (a declared whole that runs past the
|
|
9
|
+
// end of the section is cut there, the way the section clock cuts a held note) whose value holds every key of the declared
|
|
10
|
+
// value with an equal value. Extra keys on the played side (a gain, a velocity) are ignored and function values count as
|
|
11
|
+
// equal. A rest declares no onset. The check never changes the code, the music or the editor: it only queries patterns.
|
|
12
|
+
//
|
|
13
|
+
// A chain written after play() that changes notes or time (a speed change, a transposition) is a real difference between
|
|
14
|
+
// what is declared and what plays, and it is reported as one.
|
|
15
|
+
//
|
|
16
|
+
// Timing, cancel and failure follow the Timeline's loop: a clock, a budget, a yield to the page between tracks and an
|
|
17
|
+
// `isCancelled` check after every await. `playedPattern`, `declared` and the clock are injected, so this module has no DOM
|
|
18
|
+
// access at import time and node:test drives it with fake patterns.
|
|
19
|
+
|
|
20
|
+
import { soloPrelude } from './tracks.js';
|
|
21
|
+
|
|
22
|
+
/** The tolerance when two edges of a whole are compared, in cycles. */
|
|
23
|
+
const EDGE_TOLERANCE = 1e-9;
|
|
24
|
+
|
|
25
|
+
const AsyncFunctionConstructor = Object.getPrototypeOf(async function () {}).constructor;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where each section entry starts on the section clock, which repeats every `total` cycles: shift plus the lengths before
|
|
29
|
+
* the entry, taken modulo the total, so a negative or fractional shift checks the same places the clock plays.
|
|
30
|
+
* Returns `[{ index, name, start, end }]` with start in [0, total) and end = start + length.
|
|
31
|
+
*/
|
|
32
|
+
export function sectionStarts(reading) {
|
|
33
|
+
const { total } = reading;
|
|
34
|
+
return reading.sections.map((section, index) => {
|
|
35
|
+
let start = section.start % total;
|
|
36
|
+
if (start < 0) start += total;
|
|
37
|
+
if (start >= total) start = 0;
|
|
38
|
+
return { index, name: section.name, start, end: start + section.length };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The (track, section entry) pairs the declared side holds a pattern for, in the order of the array that expectedSource
|
|
44
|
+
* returns: tracks in code order (muted ones skipped), then section entries in order, a rest skipped.
|
|
45
|
+
* Returns `[{ track, section }]` with both as positions in the reading.
|
|
46
|
+
*/
|
|
47
|
+
export function expectedPairs(reading) {
|
|
48
|
+
const pairs = [];
|
|
49
|
+
reading.tracks.forEach((track, trackIndex) => {
|
|
50
|
+
if (track.muted) return;
|
|
51
|
+
track.cells.forEach((cell, sectionIndex) => {
|
|
52
|
+
if (cell.kind !== 'rest') pairs.push({ track: trackIndex, section: sectionIndex });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return pairs;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The body of an async function that returns the declared cells as an array, one pattern per pair of expectedPairs. It
|
|
60
|
+
* starts with the same declarations and string rewrites the per-track programs start with (soloPrelude), then reads each
|
|
61
|
+
* cell from the track's constant by the key the reader found: a function is called with the harmony of the section (its
|
|
62
|
+
* own text, or the code's harmony constant), the second element of a two-element list is the pattern, any other value is
|
|
63
|
+
* the pattern itself. Keys and harmony texts enter only through JSON.stringify; constant names are the identifier names the
|
|
64
|
+
* parser gave the reader. The helper that picks the cell is called through `this`, so no name in the code can shadow it.
|
|
65
|
+
*/
|
|
66
|
+
export function expectedSource(code, reading, { parse }) {
|
|
67
|
+
const entries = expectedPairs(reading).map(({ track, section }) => {
|
|
68
|
+
const { constName, cells } = reading.tracks[track];
|
|
69
|
+
const { harmony } = reading.sections[section];
|
|
70
|
+
const harmonyOf = harmony.from === 'own' ? JSON.stringify(harmony.text) : 'harmony';
|
|
71
|
+
return `this.cell(${constName}[${JSON.stringify(cells[section].key)}], () => ${harmonyOf})`;
|
|
72
|
+
});
|
|
73
|
+
const cell = "(value, harmonyOf) => (typeof value === 'function' ? value(harmonyOf()) : Array.isArray(value) ? value[1] : value)";
|
|
74
|
+
return `${soloPrelude(code, { parse })}return (function () {\nreturn [\n${entries.join(',\n')}\n];\n}).call({ cell: ${cell} });`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Builds and awaits an AsyncFunction from `body` (an expectedSource result), never through the editor's evaluate, so it never
|
|
79
|
+
* touches the scheduler. Throws a 'Moontide: ...' error when the result is not a list of patterns with queryArc.
|
|
80
|
+
*/
|
|
81
|
+
export async function evaluateDeclared(body) {
|
|
82
|
+
const result = await new AsyncFunctionConstructor(body)();
|
|
83
|
+
if (!Array.isArray(result) || result.some((pattern) => !pattern || typeof pattern.queryArc !== 'function')) {
|
|
84
|
+
throw new Error('Moontide: the declared cells did not come out as a list of patterns with queryArc.');
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Deterministic string for one value, stable regardless of key order; every function counts as the same value. Mirrors
|
|
91
|
+
* stableStringify in page/timeline.js; keep the two in step.
|
|
92
|
+
*/
|
|
93
|
+
function stableStringify(value) {
|
|
94
|
+
if (typeof value === 'function') return '"[function]"';
|
|
95
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
96
|
+
if (value && typeof value === 'object') {
|
|
97
|
+
const keys = Object.keys(value).sort();
|
|
98
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
99
|
+
}
|
|
100
|
+
return JSON.stringify(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The onsets of `pattern` in [from, to) as `{ begin, end, value, texts }`, `texts` caching the string of each key. */
|
|
104
|
+
function onsetsOf(pattern, from, to, shiftBy, cutAt) {
|
|
105
|
+
const onsets = [];
|
|
106
|
+
for (const hap of pattern.queryArc(from, to)) {
|
|
107
|
+
if (!hap.whole || !hap.hasOnset()) continue;
|
|
108
|
+
const end = hap.whole.end.valueOf();
|
|
109
|
+
onsets.push({
|
|
110
|
+
begin: hap.whole.begin.valueOf() + shiftBy,
|
|
111
|
+
end: (cutAt === null ? end : Math.min(end, cutAt)) + shiftBy,
|
|
112
|
+
value: hap.value,
|
|
113
|
+
texts: new Map(),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return onsets;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The string of one key of an onset's value, or of the whole value when the key is null; computed once per onset. */
|
|
120
|
+
function textOf(onset, key) {
|
|
121
|
+
const id = key === null ? '' : key;
|
|
122
|
+
if (!onset.texts.has(id)) {
|
|
123
|
+
onset.texts.set(id, stableStringify(key === null ? onset.value : onset.value[key]));
|
|
124
|
+
}
|
|
125
|
+
return onset.texts.get(id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
129
|
+
|
|
130
|
+
/** True when the played onset has the declared one's edges and holds every key of the declared value with an equal value. */
|
|
131
|
+
function onsetMatches(declared, played) {
|
|
132
|
+
if (Math.abs(declared.begin - played.begin) > EDGE_TOLERANCE || Math.abs(declared.end - played.end) > EDGE_TOLERANCE) return false;
|
|
133
|
+
if (!isRecord(declared.value)) return textOf(declared, null) === textOf(played, null);
|
|
134
|
+
if (!isRecord(played.value)) return false;
|
|
135
|
+
return Object.keys(declared.value).every((key) => textOf(declared, key) === textOf(played, key));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* True when the declared onsets and the played onsets pair up one to one: the counts are equal and every declared onset
|
|
140
|
+
* gets its own played onset (a played onset serves one declared onset only).
|
|
141
|
+
*/
|
|
142
|
+
function sameOnsets(declared, played) {
|
|
143
|
+
if (declared.length !== played.length) return false;
|
|
144
|
+
const candidates = declared.map((one) => played.flatMap((other, at) => (onsetMatches(one, other) ? [at] : [])));
|
|
145
|
+
if (candidates.some((list) => list.length === 0)) return false;
|
|
146
|
+
const takenBy = new Array(played.length).fill(-1);
|
|
147
|
+
const place = (at, seen) => {
|
|
148
|
+
for (const candidate of candidates[at]) {
|
|
149
|
+
if (seen[candidate]) continue;
|
|
150
|
+
seen[candidate] = true;
|
|
151
|
+
if (takenBy[candidate] === -1 || place(takenBy[candidate], seen)) {
|
|
152
|
+
takenBy[candidate] = at;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
};
|
|
158
|
+
return declared.every((_, at) => place(at, new Array(played.length).fill(false)));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Compares the first cycle of every section entry of every unmuted play() track with what the song declares there.
|
|
163
|
+
* `reading` is a 'read' reading of page/structure.js, `starts` is sectionStarts(reading), `playedPattern(track)` resolves to
|
|
164
|
+
* the pattern a track plays on its own, `declared()` resolves to the list that expectedSource describes (one pattern per
|
|
165
|
+
* pair of expectedPairs). Resolves to one of:
|
|
166
|
+
* - `{ state: 'agrees', ms }`
|
|
167
|
+
* - `{ state: 'differs', first: { section, track }, more, ms }`: the first place in section order then track order, as names,
|
|
168
|
+
* and how many other places differ;
|
|
169
|
+
* - `{ state: 'failed', message, ms }`: something threw;
|
|
170
|
+
* - `{ state: 'too-slow', ms }`: the budget passed before the answer;
|
|
171
|
+
* - `{ state: 'cancelled' }`: `isCancelled()` turned true, and nothing else is said.
|
|
172
|
+
* It yields to the page after each track and never throws.
|
|
173
|
+
*/
|
|
174
|
+
export async function checkSections({ reading, starts, playedPattern, declared, budgetMs, clock, yieldToPage, isCancelled }) {
|
|
175
|
+
const begun = clock();
|
|
176
|
+
const elapsed = () => clock() - begun;
|
|
177
|
+
try {
|
|
178
|
+
const pairs = expectedPairs(reading);
|
|
179
|
+
const patterns = await declared();
|
|
180
|
+
if (isCancelled()) return { state: 'cancelled' };
|
|
181
|
+
if (!Array.isArray(patterns) || patterns.length !== pairs.length) {
|
|
182
|
+
throw new Error('Moontide: the declared cells do not match the cells of the reading.');
|
|
183
|
+
}
|
|
184
|
+
const declaredAt = new Map(pairs.map((pair, at) => [`${pair.track}:${pair.section}`, patterns[at]]));
|
|
185
|
+
const played = await Promise.all(reading.tracks.map((track) => (track.muted ? null : playedPattern(track))));
|
|
186
|
+
if (isCancelled()) return { state: 'cancelled' };
|
|
187
|
+
if (elapsed() > budgetMs) return { state: 'too-slow', ms: elapsed() };
|
|
188
|
+
|
|
189
|
+
const places = [];
|
|
190
|
+
for (let trackIndex = 0; trackIndex < reading.tracks.length; trackIndex++) {
|
|
191
|
+
if (played[trackIndex] === null) continue;
|
|
192
|
+
for (const { index, start } of starts) {
|
|
193
|
+
const length = reading.sections[index].length;
|
|
194
|
+
const pattern = declaredAt.get(`${trackIndex}:${index}`);
|
|
195
|
+
const expected = pattern === undefined ? [] : onsetsOf(pattern, 0, 1, start, length);
|
|
196
|
+
const actual = onsetsOf(played[trackIndex], start, start + 1, 0, null);
|
|
197
|
+
if (!sameOnsets(expected, actual)) places.push({ section: index, track: trackIndex });
|
|
198
|
+
}
|
|
199
|
+
if (elapsed() > budgetMs) return { state: 'too-slow', ms: elapsed() };
|
|
200
|
+
await yieldToPage();
|
|
201
|
+
if (isCancelled()) return { state: 'cancelled' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (places.length === 0) return { state: 'agrees', ms: elapsed() };
|
|
205
|
+
places.sort((a, b) => a.section - b.section || a.track - b.track);
|
|
206
|
+
const [first] = places;
|
|
207
|
+
return {
|
|
208
|
+
state: 'differs',
|
|
209
|
+
first: { section: reading.sections[first.section].name, track: reading.tracks[first.track].name },
|
|
210
|
+
more: places.length - 1,
|
|
211
|
+
ms: elapsed(),
|
|
212
|
+
};
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { state: 'failed', message: err instanceof Error ? err.message : String(err), ms: elapsed() };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// DOM builder for the lines above Moontide's Song grid in the Parts view: up to a few plain lines in #song-notes (the
|
|
2
|
+
// helper changed or missing, what plays differs, the check failed). The grid itself is drawn by page/song-grid-view.js.
|
|
3
|
+
// Everything that comes from the code reaches the page through textContent, title or setAttribute only; the lines are
|
|
4
|
+
// read-only text (no listener, no focusable element). `document` is injected, so the module has no DOM access at import
|
|
5
|
+
// time and node:test drives it with a tiny fake document.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `createSongView({ document, notes })` returns `{ showNotes(lines), clearNotes() }`.
|
|
9
|
+
* `notes` is the element that holds the lines. A line is a string or `{ text, kind }`; kind 'check-failed' is drawn
|
|
10
|
+
* muted. showNotes writes the DOM only when the text of the lines changed.
|
|
11
|
+
*/
|
|
12
|
+
export function createSongView({ document, notes }) {
|
|
13
|
+
let shownNotes = '';
|
|
14
|
+
|
|
15
|
+
function make(tag, className, text) {
|
|
16
|
+
const node = document.createElement(tag);
|
|
17
|
+
node.className = className;
|
|
18
|
+
if (text !== undefined) node.textContent = text;
|
|
19
|
+
return node;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function showNotes(lines) {
|
|
23
|
+
const entries = lines.map((line) => (typeof line === 'string' ? { text: line, kind: null } : { text: line.text, kind: line.kind ?? null }));
|
|
24
|
+
const key = entries.map((entry) => `${entry.kind ?? ''}\u0000${entry.text}`).join('\u0001');
|
|
25
|
+
if (key === shownNotes) return;
|
|
26
|
+
shownNotes = key;
|
|
27
|
+
notes.replaceChildren(
|
|
28
|
+
...entries.map((entry) => {
|
|
29
|
+
const node = make('p', 'song-note', entry.text);
|
|
30
|
+
if (entry.kind !== null) node.setAttribute('data-kind', entry.kind);
|
|
31
|
+
return node;
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
notes.hidden = entries.length === 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { showNotes, clearNotes: () => showNotes([]) };
|
|
38
|
+
}
|