moontide-mcp 2.2.0 → 2.4.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.
@@ -0,0 +1,2176 @@
1
+ // The Scratchpad view's DOM: the piano roll on the cycle grid (the ruler, the piano names, the notes and the keyboard cursor)
2
+ // and the information line under it, built with DOM methods only. Text reaches the page only through textContent, title,
3
+ // setAttribute and dataset, never as markup. The view attaches no right-click listener: page/main.js owns the only one. The motif
4
+ // lives in this closure and nowhere else: nothing is stored or played from here, and the one thing that leaves it is the motif request
5
+ // the user sends with the Send to Claude press, which the view hands to the page's onSend and never sends itself. No DOM access at import time: main.js calls
6
+ // createScratchView() with its document, so this module can be loaded anywhere. The motif model and the words come from
7
+ // page/scratch-model.js, the numbers of a pointer from page/scratch-gesture.js; the stylesheet does the geometry from the custom
8
+ // properties this module sets. The mouse and the keys both end in the one apply function, so they obey the same rules.
9
+
10
+ import {
11
+ UNITS_PER_CYCLE,
12
+ RING_CYCLES,
13
+ WINDOW_ROWS,
14
+ GRIDS,
15
+ MIN_CYCLES,
16
+ MAX_CYCLES,
17
+ MIN_PITCH,
18
+ MAX_PITCH,
19
+ MIN_WINDOW_START,
20
+ MAX_WINDOW_START,
21
+ SCRATCH_COPY,
22
+ fillScratch,
23
+ DEFAULT_VELOCITY,
24
+ emptyMotif,
25
+ createHistory,
26
+ commit,
27
+ undo,
28
+ redo,
29
+ motifUnits,
30
+ gridOf,
31
+ noteAt,
32
+ toggleAt,
33
+ placeNote,
34
+ moveNote,
35
+ resizeNote,
36
+ eraseNote,
37
+ setGrid,
38
+ setCycles,
39
+ setVelocity,
40
+ tapNote,
41
+ moveNotes,
42
+ alignMotif,
43
+ clearMotif,
44
+ resizeNotes,
45
+ eraseNotes,
46
+ setVelocities,
47
+ selectionText,
48
+ selectionParts,
49
+ neighborNote,
50
+ recordBuffer,
51
+ ringLayout,
52
+ motifPhrase,
53
+ motifRequest,
54
+ motifResultText,
55
+ sendRefusalText,
56
+ sentText,
57
+ listeningText,
58
+ heardText,
59
+ hearText,
60
+ hearWarnings,
61
+ hasChord,
62
+ laneOrder,
63
+ velocityText,
64
+ cyclesText,
65
+ shownRows,
66
+ rangeText,
67
+ rangeSpoken,
68
+ windowStartFor,
69
+ noteName,
70
+ isBlackKey,
71
+ noteParts,
72
+ spokenNote,
73
+ emptyCellParts,
74
+ spokenEmptyCell,
75
+ summaryLine,
76
+ touchedNotes,
77
+ } from './scratch-model.js';
78
+ import { BEAT_MIN_REM, DRAG_THRESHOLD_REM, GUTTER_REM, LIVE_BEAT_MIN_REM, barAt, beatWidthRem, cellAt, drawLength, edgeScrollPx, groupMoveTarget, historyScroll, lockDrag, moveTarget, notesInBox, pickPart, pointerRow, pointerUnit, resizeLength, rowHeightRem, selectionBox, velocityAt } from './scratch-gesture.js';
79
+
80
+ const BEATS_PER_CYCLE = UNITS_PER_CYCLE / 12;
81
+ const OCTAVE = 12;
82
+ const UNITS_PER_BEAT = 12;
83
+ const ARROWS = {
84
+ ArrowLeft: { right: -1, up: 0 },
85
+ ArrowRight: { right: 1, up: 0 },
86
+ ArrowUp: { right: 0, up: 1 },
87
+ ArrowDown: { right: 0, up: -1 },
88
+ };
89
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
90
+ // The live view turns the page when the playhead passes this share of the visible width, and stops following for this long after a hand scroll.
91
+ const FOLLOW_EDGE = 0.9;
92
+ const FOLLOW_PAUSE_MS = 5000;
93
+ const mod = (value, size) => ((value % size) + size) % size;
94
+ const roundUnit = (value) => Number(value.toFixed(2));
95
+ const NO_LAYOUT = Object.freeze({ settled: [], heldNotes: [], pitches: [], heldPitches: new Set() });
96
+ // The listening states in which the Allow MIDI to listen button shows.
97
+ const ALLOW_STATES = ['needs-permission', 'failed'];
98
+
99
+ /**
100
+ * Builds the Scratchpad view's DOM once and returns `{ render, closed, cancelGesture, handleContextMenu, rollContains, resize }`. `render()` redraws from the state and writes
101
+ * only what changed. `closed()` is called whenever the view is not the open one: it ends a drag with nothing committed, hides the
102
+ * cursor and clears the notice of the last edit. `cancelGesture()` ends a drag in progress with nothing committed. `rootFontPx()`
103
+ * gives the root font size in pixels, read when a press starts, so the pointer's targets scale with the page. `handleContextMenu(event)`
104
+ * erases the note under a right-click inside the roll, and `rollContains(node)` tells page/main.js, which owns the page's only
105
+ * contextmenu listener, whether a right-click is the roll's. `resize()` sizes the beat and the row from the room the view has; the
106
+ * page calls it when the view opens and when its size changes. Every edit goes through
107
+ * the one apply function inside, which takes the `{ motif, notice }` a model command returns.
108
+ *
109
+ * Select and Draw: the roll's default gesture is to select. A drag on an empty cell draws a dashed box and selects the notes it touches, a click on
110
+ * empty space clears the selection and puts the cursor there, a click on a note selects only it, and none of them writes a note. A note is written
111
+ * by a double-click on an empty cell, by Enter at the cursor, or by a drag while Draw (the `#scratch-draw` toggle, key d) is on. The selection is a
112
+ * set of note ids held by the view alone: it is never part of the motif or of the history, and the cursor on a note does not select it.
113
+ *
114
+ * Tap mode (a computer key places a note where it falls): `tapOn()` tells whether it is on; `setMusicPlaying(playing)` tells the view
115
+ * whether the music plays, because Tap needs it (and a Tap that is on turns itself off when the music stops); `tap({ ts, h, anchors })`
116
+ * places a tap at the cursor's row, or puts the reason it was refused in the line; `setNowUnit(unit)` gives the marker's place in
117
+ * the motif (or null), and `motifCycles()` how many cycles the motif has now. `onTapChange(on)` is called whenever Tap turns on or off, so the page can start or stop what feeds it. The view
118
+ * reads no clock itself: the page reads the key's stamp and the cycle lines and hands them in.
119
+ *
120
+ * Listening (a MIDI keyboard): `setListening(state)` takes the state of page/midi-in.js and writes the strip under the information line, the
121
+ * state sentence and the line about the last note, and the read-outs on the view (data-listen, data-heard-count, -pitch, -cycle, -source and
122
+ * -refused). It writes only what changed. The Allow MIDI to listen button (`#scratch-allow`) shows only while asking can help (the permission has
123
+ * not been asked for, or MIDI failed to start), and a press calls `onAllow()`: the page answers by asking through the one shared enable.
124
+ *
125
+ * Record that (`#scratch-record`): a press with notes in the buffer calls `onRecord()`, and the page answers with `record(snapshot)`, the snapshot
126
+ * of page/midi-in.js (null when the music's clock could not be read). The motif is replaced in one step that Undo takes back; with nothing
127
+ * to record the press says so in the line and changes nothing.
128
+ *
129
+ * Listen (`#scratch-play`, the hear strip): a menu of the setup file's instruments and channels (`setOutputs(options, setup)` takes the options of
130
+ * listenOptions in page/scratch-model.js and the setup file's word), the Start choice (In the music, the default, or At once), the Loop switch, and the
131
+ * Listen and Stop buttons. A Listen press with a choice and notes calls `onListen({ instrumentId, channel, start, loop })`; the view plays nothing and
132
+ * sends nothing itself. Stop calls `onStop()` and the Loop switch `onLoop(on)`. The choice, the start and Loop are kept for the session only. The page tells
133
+ * the view what became of a play with `setHear(state)` (see hearText in page/scratch-model.js); the one live region of the strip, `#scratch-play-note`,
134
+ * says it, the instrument id apart from the rest. Stop carries aria-disabled unless a play is waiting or playing. After that sentence the strip adds
135
+ * its Heads up sentences for the instrument and channel of the play, or the one chosen: the unmuted tracks the page says are playing on it
136
+ * (`playingTracks(instrumentId, channel)`, a list of display names) and a chord on an instrument that plays one note at a time (the option's
137
+ * `monophonic`). They never disable anything; `refreshHear()` asks again when the playing code or the playing state changed.
138
+ *
139
+ * Send to Claude (`#scratch-send`): the strip shows the phrase of the motif (the very text sent, written by motifPhrase on every render), a field
140
+ * for the user's words (the default sentence to start with) and the Send to Claude button. Only a press of the button, or Enter in the field, sends:
141
+ * the view calls `onSend({ words, motif })`, with `motif` the request of motifRequest (the cycle from `musicCycle()`, the music's cycle now or null
142
+ * while it is stopped), and writes what the page answered in the status line. The words are read at the press, never before. The button carries
143
+ * aria-disabled, with the reason as its title, while the motif has no notes, the page is not connected or the last send is still on its way
144
+ * (`setSendState({ connected, pending })`, told by the page); a press then says the reason in the status line. `motifResult(result)` takes what became
145
+ * of a request the page sent (refused, not confirmed, waiting for Claude) and says it in the status line.
146
+ */
147
+ export function createScratchView({
148
+ document,
149
+ rootFontPx,
150
+ onTapChange = () => {},
151
+ onRecord = () => {},
152
+ onAllow = () => {},
153
+ onSend = () => {
154
+ throw new Error('Moontide: the Scratchpad view was made without onSend, so Send to Claude has nowhere to go.');
155
+ },
156
+ musicCycle = () => null,
157
+ onListen = () => {
158
+ throw new Error('Moontide: the Scratchpad view was made without onListen, so Listen has nowhere to go.');
159
+ },
160
+ onStop = () => {
161
+ throw new Error('Moontide: the Scratchpad view was made without onStop, so Stop has nowhere to go.');
162
+ },
163
+ onLoop = () => {
164
+ throw new Error('Moontide: the Scratchpad view was made without onLoop, so Loop has nowhere to go.');
165
+ },
166
+ playingTracks = () => [],
167
+ }) {
168
+ if (typeof rootFontPx !== 'function') throw new TypeError('Moontide: the Scratchpad view needs rootFontPx, a function that gives the root font size in pixels.');
169
+ const need = (id) => {
170
+ const element = document.getElementById(id);
171
+ if (element === null) throw new Error(`Moontide: the Scratchpad view needs #${id} in the page.`);
172
+ return element;
173
+ };
174
+ const viewEl = need('scratch-view');
175
+ const scrollEl = need('scratch-scroll');
176
+ const rulerEl = need('scratch-ruler');
177
+ const rollEl = need('scratch-roll');
178
+ const gutterEl = need('scratch-gutter');
179
+ const gridEl = need('scratch-grid');
180
+ const notesEl = need('scratch-notes');
181
+ const liveEl = need('scratch-live');
182
+ const cursorEl = need('scratch-cursor');
183
+ const previewEl = need('scratch-preview');
184
+ const hoverCellEl = need('scratch-hover-cell');
185
+ const selectRectEl = need('scratch-select-rect');
186
+ const infoEl = need('scratch-note');
187
+ const hoverEl = need('scratch-note-hover');
188
+ const lineEl = need('scratch-note-line');
189
+ const nameEl = need('scratch-note-name');
190
+ const restEl = need('scratch-note-rest');
191
+ const spokenEl = need('scratch-note-spoken');
192
+ const hintEl = need('scratch-hint');
193
+ const gridButtons = GRIDS.map((grid) => ({ grid, element: need(`scratch-grid-${grid.id}`) }));
194
+ const fewerEl = need('scratch-fewer');
195
+ const moreEl = need('scratch-more');
196
+ const cyclesEl = need('scratch-cycles');
197
+ const lowerEl = need('scratch-lower');
198
+ const higherEl = need('scratch-higher');
199
+ const windowEl = need('scratch-window');
200
+ const undoEl = need('scratch-undo');
201
+ const redoEl = need('scratch-redo');
202
+ const drawEl = need('scratch-draw');
203
+ const earlierEl = need('scratch-earlier');
204
+ const laterEl = need('scratch-later');
205
+ const alignEl = need('scratch-align');
206
+ const clearEl = need('scratch-clear');
207
+ const tapEl = need('scratch-tap');
208
+ const recordEl = need('scratch-record');
209
+ const earlyEl = need('scratch-early');
210
+ const nowEl = need('scratch-now');
211
+ const playheadEl = need('scratch-playhead');
212
+ const laneEl = need('scratch-lane');
213
+ const laneLabelEl = need('scratch-lane-label');
214
+ const barsEl = need('scratch-bars');
215
+ const listenStateEl = need('scratch-listen-state');
216
+ const heardEl = need('scratch-heard');
217
+ const allowEl = need('scratch-allow');
218
+ const phraseEl = need('scratch-phrase');
219
+ const wordsEl = need('scratch-words');
220
+ const sendEl = need('scratch-send-button');
221
+ const sendStatusEl = need('scratch-send-status');
222
+ const playOutputEl = need('scratch-play-output');
223
+ const playMusicEl = need('scratch-play-music');
224
+ const playNowEl = need('scratch-play-now');
225
+ const playLoopEl = need('scratch-play-loop');
226
+ const playListenEl = need('scratch-play-listen');
227
+ const playStopEl = need('scratch-play-stop');
228
+ const playNoteEl = need('scratch-play-note');
229
+
230
+ let motif = emptyMotif();
231
+ // The Scratchpad's own edits, apart from the music's History: the motif is always the history's present.
232
+ let history = createHistory(motif);
233
+ // The base window of two octaves starts where the motif's notes are, worked out when the view is made (the motif is empty then, so it
234
+ // starts on C3) and again when Record that replaces the motif; after that only Lower and Higher move it. It is where the rows start from:
235
+ // the rows the roll shows are worked out from the notes each time it is drawn (see `shown`), so the base can never hide a note.
236
+ let base = windowStartFor(motif);
237
+ let cursor = { unit: 0, pitch: base + WINDOW_ROWS / 2 };
238
+ // The selection is view state, a set of note ids; it is never part of the motif or of the history. A click, a box, a double-click and the
239
+ // selection keys change it, and the cursor does not: the cursor on a note selects nothing by itself.
240
+ let selection = new Set();
241
+ // Draw mode (a drag on an empty cell writes a note) is off whenever the view is made; it is kept for the session only.
242
+ let draw = false;
243
+ // The box a selection drag shows while it runs, in fractions of units and rows, or null.
244
+ let rectBox = null;
245
+ // A drag of several selected notes shows them all at the place it would give: `{ kind: 'move' | 'edge', dStart, dPitch, dSteps }`, or null.
246
+ let groupDraft = null;
247
+ // The key press in progress: a held key repeats, and the repeats are folded into the step its first press made (see runEdit).
248
+ let run = null;
249
+ let hasFocus = false;
250
+ // The sentence of the last edit; it stays on the line until the cursor moves.
251
+ let notice = null;
252
+ // The pointer: a press in progress (null when there is none), the note or the empty cell it hovers over, and the note a draw is
253
+ // previewing. None of them is part of the motif, and none of them reaches the live region.
254
+ let press = null;
255
+ let hoverId = null;
256
+ let hoverCell = null;
257
+ let preview = null;
258
+ // The velocity lane's own press (null when there is none) and the velocity a drag shows before the release writes it.
259
+ let lanePress = null;
260
+ let laneDraft = null;
261
+ // Tap mode: whether it is on, whether the music plays (Tap needs it) and where in the motif the music is now, in units (null when unknown).
262
+ let tapping = false;
263
+ let musicPlaying = false;
264
+ let nowUnit = null;
265
+ // How many played notes the keyboard buffer holds, as the last listening state said; Record that has nothing to take at 0.
266
+ let heardCount = 0;
267
+
268
+ // The live view (see setLive). `liveMode` is 'off' (nothing listened to), 'armed' (an input is listened to and the buffer holds no note) or
269
+ // 'live' (it holds a note: the roll shows the buffer's 8 cycles as a ring). `liveGiven` is the last `{ now, snapshot, frameMs }` the page
270
+ // handed over. A switch into or out of 'live' changes the geometry under the pointer, so while a press is on it waits (`liveHold`,
271
+ // `liveNext`) and is applied at the release. `liveRange` is the widest rows seen while live, so the range only grows until live ends.
272
+ let liveMode = 'off';
273
+ let liveGiven = null;
274
+ let liveHold = false;
275
+ let liveNext = null;
276
+ let liveRange = null;
277
+ // Following the playhead: the page's frame time before which the box is not turned (a hand scroll or a press pauses it), the scroll position
278
+ // the view itself last wrote (any other position is a hand), and where the playhead was last, to see it wrap.
279
+ let lastFrameMs = 0;
280
+ let followPausedUntil = -Infinity;
281
+ let lastScroll = 0;
282
+ let lastPlayhead = null;
283
+ // The snapshot laid out on the ring, for the grid it was laid out for: the pieces of the notes that are let go (they change when the
284
+ // snapshot does), the held notes (their pieces grow with the frame), the pitches in play and the pitches held now.
285
+ let layoutSnapshot = null;
286
+ let layoutGrid = '';
287
+ let layout = NO_LAYOUT;
288
+ // The last thing the page handed to setLive (even one put off for a press), so the live view can come back when a Listen play ends, and the
289
+ // place of the Listen playhead in the motif's units (null while a play waits or nothing plays). While Listen waits or plays the live overlay is
290
+ // paused: the roll shows the motif's own cycles and the one accent playhead crosses the motif.
291
+ let liveRaw = null;
292
+ let hearUnit = null;
293
+
294
+ const setAttr = (element, name, value) => {
295
+ const text = String(value);
296
+ if (element.getAttribute(name) !== text) element.setAttribute(name, text);
297
+ };
298
+ const setText = (element, text) => {
299
+ if (element.textContent !== text) element.textContent = text;
300
+ };
301
+ const stepUnits = () => gridOf(motif.grid).unit;
302
+ // The rows the roll shows: the base, every note and the cursor, each with its margin, rounded out to whole octaves. They are worked out at
303
+ // the start of every render and never stored apart from this, so the pointer, the keys and the drawing all read the rows that are on screen.
304
+ const measureRows = () => {
305
+ let { low, high } = shownRows(motif, { base, extras: layout.pitches, cursor: cursor.pitch });
306
+ // While live the range only grows: a pitch played and let go keeps its rows until the buffer is empty again.
307
+ if (liveMode === 'live') {
308
+ if (liveRange !== null) {
309
+ low = Math.min(low, liveRange.low);
310
+ high = Math.max(high, liveRange.high);
311
+ }
312
+ liveRange = { low, high };
313
+ } else {
314
+ liveRange = null;
315
+ }
316
+ return { low, high, rows: high - low + 1 };
317
+ };
318
+ // The cycles the roll shows: the motif's own, or the buffer's 8 while live. Every read of the roll's geometry goes through these, while an
319
+ // edit still stops at the motif's own end.
320
+ const displayCycles = () => (liveMode === 'live' ? RING_CYCLES : motif.cycles);
321
+ const displayUnits = () => displayCycles() * UNITS_PER_CYCLE;
322
+ const displayBeats = () => displayCycles() * BEATS_PER_CYCLE;
323
+ let shown = measureRows();
324
+ const rowOf = (pitch) => shown.high - pitch;
325
+
326
+ // The words of the toolbar come from the copy: a button's visible text, its accessible name and its tooltip.
327
+ for (const { grid, element } of gridButtons) {
328
+ element.textContent = grid.text;
329
+ element.setAttribute('aria-label', grid.name);
330
+ element.setAttribute('title', grid.name);
331
+ }
332
+ laneLabelEl.textContent = SCRATCH_COPY.laneLabel;
333
+ fewerEl.textContent = SCRATCH_COPY.fewerSign;
334
+ moreEl.textContent = SCRATCH_COPY.moreSign;
335
+ lowerEl.textContent = SCRATCH_COPY.lower;
336
+ higherEl.textContent = SCRATCH_COPY.higher;
337
+ undoEl.textContent = SCRATCH_COPY.undo;
338
+ redoEl.textContent = SCRATCH_COPY.redo;
339
+ drawEl.textContent = SCRATCH_COPY.draw;
340
+ drawEl.setAttribute('aria-label', SCRATCH_COPY.drawName);
341
+ drawEl.setAttribute('title', SCRATCH_COPY.drawTitle);
342
+ for (const [element, text, name] of [[earlierEl, SCRATCH_COPY.earlier, SCRATCH_COPY.earlierName], [laterEl, SCRATCH_COPY.later, SCRATCH_COPY.laterName], [alignEl, SCRATCH_COPY.align, SCRATCH_COPY.alignName], [clearEl, SCRATCH_COPY.clear, SCRATCH_COPY.clearName]]) {
343
+ element.textContent = text;
344
+ element.setAttribute('aria-label', name);
345
+ }
346
+ tapEl.textContent = SCRATCH_COPY.tap;
347
+ recordEl.textContent = SCRATCH_COPY.record;
348
+ earlyEl.textContent = SCRATCH_COPY.early;
349
+ earlyEl.setAttribute('title', SCRATCH_COPY.earlyTitle);
350
+ allowEl.textContent = SCRATCH_COPY.allow;
351
+ allowEl.setAttribute('title', SCRATCH_COPY.allowTitle);
352
+ phraseEl.setAttribute('aria-label', SCRATCH_COPY.phraseLabel);
353
+ wordsEl.setAttribute('aria-label', SCRATCH_COPY.wordsLabel);
354
+ wordsEl.value = SCRATCH_COPY.defaultWords;
355
+ playOutputEl.setAttribute('aria-label', SCRATCH_COPY.hearOutputName);
356
+ for (const [element, text, title] of [[playMusicEl, SCRATCH_COPY.hearStartMusic, SCRATCH_COPY.hearStartMusicTitle], [playNowEl, SCRATCH_COPY.hearStartNow, SCRATCH_COPY.hearStartNowTitle], [playLoopEl, SCRATCH_COPY.loop, SCRATCH_COPY.loopTitle], [playListenEl, SCRATCH_COPY.listen, SCRATCH_COPY.listenTitle], [playStopEl, SCRATCH_COPY.stop, SCRATCH_COPY.stopTitle]]) {
357
+ element.textContent = text;
358
+ element.setAttribute('title', title);
359
+ }
360
+ sendEl.textContent = SCRATCH_COPY.send;
361
+ sendEl.setAttribute('title', SCRATCH_COPY.sendTitle);
362
+ for (const [element, name] of [[fewerEl, SCRATCH_COPY.fewer], [moreEl, SCRATCH_COPY.more], [lowerEl, SCRATCH_COPY.lowerName], [higherEl, SCRATCH_COPY.higherName]]) {
363
+ element.setAttribute('aria-label', name);
364
+ }
365
+
366
+ // A button that cannot act now keeps focus and says why in its tooltip; it never takes the disabled attribute.
367
+ function setDisabled(element, disabled, name, why) {
368
+ setAttr(element, 'aria-disabled', disabled);
369
+ setAttr(element, 'title', disabled ? why : name);
370
+ }
371
+
372
+ // The notes Earlier and Later move: the selection, else every note of the motif.
373
+ const movedIds = () => (selection.size > 0 ? [...selection].sort((a, b) => a - b) : motif.notes.map((note) => note.id));
374
+
375
+ // The Edit group: each button that cannot act keeps focus, carries aria-disabled and says why in its title. Earlier and Later work the
376
+ // move out ahead, so a move that would leave the motif is shown as unavailable with the very sentence a press would give.
377
+ function renderEdit() {
378
+ const empty = motif.notes.length === 0;
379
+ for (const [element, name, direction] of [[earlierEl, SCRATCH_COPY.earlierName, -1], [laterEl, SCRATCH_COPY.laterName, 1]]) {
380
+ const refusal = empty ? SCRATCH_COPY.moveNothing : (moveNotes(motif, movedIds(), { dStart: direction * stepUnits() }).refused ?? null);
381
+ setDisabled(element, refusal !== null, name, refusal ?? '');
382
+ }
383
+ setDisabled(alignEl, empty, SCRATCH_COPY.alignTitle, SCRATCH_COPY.alignNothing);
384
+ setDisabled(clearEl, empty, SCRATCH_COPY.clearName, SCRATCH_COPY.clearNothing);
385
+ }
386
+
387
+ // The toolbar's state: the pressed grid, the length and the window readouts, and the buttons that have reached an end.
388
+ function renderToolbar() {
389
+ for (const { grid, element } of gridButtons) setAttr(element, 'aria-pressed', grid.id === motif.grid);
390
+ setText(cyclesEl, cyclesText(motif.cycles));
391
+ setText(windowEl, rangeText(shown.low, shown.high));
392
+ setAttr(windowEl, 'aria-label', rangeSpoken(shown.low, shown.high));
393
+ setDisabled(fewerEl, motif.cycles <= MIN_CYCLES, SCRATCH_COPY.fewer, SCRATCH_COPY.fewerLeast);
394
+ setDisabled(moreEl, motif.cycles >= MAX_CYCLES, SCRATCH_COPY.more, SCRATCH_COPY.moreMost);
395
+ setDisabled(lowerEl, base <= MIN_WINDOW_START, SCRATCH_COPY.lowerName, SCRATCH_COPY.lowerLowest);
396
+ setDisabled(higherEl, base >= MAX_WINDOW_START, SCRATCH_COPY.higherName, SCRATCH_COPY.higherHighest);
397
+ setDisabled(undoEl, history.past.length === 0, SCRATCH_COPY.undo, SCRATCH_COPY.nothingToUndo);
398
+ setDisabled(redoEl, history.future.length === 0, SCRATCH_COPY.redo, SCRATCH_COPY.nothingToRedo);
399
+ setAttr(drawEl, 'aria-pressed', draw);
400
+ renderEdit();
401
+ setAttr(tapEl, 'aria-pressed', tapping);
402
+ setDisabled(tapEl, !musicPlaying, SCRATCH_COPY.tapTitle, SCRATCH_COPY.tapNeedsMusic);
403
+ setDisabled(recordEl, heardCount === 0, SCRATCH_COPY.recordTitle, SCRATCH_COPY.recordNothing);
404
+ }
405
+
406
+ // The listening strip: the state sentence (the one live element) and the line about the last note. Each is written only when its text
407
+ // changed, so a new note rewrites the heard line and never the live sentence. The read-outs on the view are for the probes and benches.
408
+ function setListening(state) {
409
+ const sentence = listeningText(state);
410
+ const heardLine = heardText(state);
411
+ setText(listenStateEl, sentence);
412
+ setAttr(listenStateEl, 'title', sentence);
413
+ // The one way to ask for MIDI: offered only when asking can help, never pressed by the page itself.
414
+ allowEl.toggleAttribute('hidden', !ALLOW_STATES.includes(state.listen));
415
+ setText(heardEl, heardLine);
416
+ setAttr(heardEl, 'title', heardLine);
417
+ heardCount = state.count;
418
+ setDisabled(recordEl, heardCount === 0, SCRATCH_COPY.recordTitle, SCRATCH_COPY.recordNothing);
419
+ setAttr(viewEl, 'data-listen', state.listen);
420
+ setAttr(viewEl, 'data-heard-count', state.count);
421
+ setAttr(viewEl, 'data-heard-pitch', state.heard === null ? '' : state.heard.pitch);
422
+ setAttr(viewEl, 'data-heard-cycle', state.heard === null ? '' : String(state.heard.cycle));
423
+ setAttr(viewEl, 'data-heard-source', state.heard === null ? '' : state.heard.source);
424
+ setAttr(viewEl, 'data-heard-refused', state.refused === null ? '' : state.refused);
425
+ }
426
+
427
+ // The marker shows where the music is in the motif: where a tap would land while Tap is on (the page gives the place), else, while an
428
+ // input is listened to and nothing is played, where the cycle axis is in the motif's cycles, so the beat can be felt before the first
429
+ // note. In Live the playhead replaces it.
430
+ let nowKey = '';
431
+ function renderNow() {
432
+ let unit = null;
433
+ if (liveMode !== 'live') {
434
+ if (tapping) unit = nowUnit;
435
+ else if (liveMode === 'armed' && liveGiven !== null) unit = roundUnit(mod(liveGiven.now, motif.cycles) * UNITS_PER_CYCLE);
436
+ }
437
+ const visible = unit !== null;
438
+ nowEl.toggleAttribute('hidden', !visible);
439
+ setAttr(viewEl, 'data-tap', tapping ? 'on' : 'off');
440
+ setAttr(viewEl, 'data-now-unit', visible ? unit : '');
441
+ if (visible && String(unit) !== nowKey) {
442
+ nowKey = String(unit);
443
+ nowEl.style.setProperty('--sp-now', nowKey);
444
+ }
445
+ }
446
+
447
+ // The hover line is its own text, two spans like the shown line, so the name can be heavier than the rest.
448
+ const hoverName = document.createElement('span');
449
+ hoverName.className = 'sp-hover-name';
450
+ const hoverRest = document.createElement('span');
451
+ const hoverText = document.createElement('span');
452
+ hoverText.append(hoverName, hoverRest);
453
+ hoverEl.appendChild(hoverText);
454
+
455
+ // The piano cells are built when a row first needs one (at most 108, C0 to B8) and reused after that; the gutter holds exactly as many
456
+ // as the roll shows rows, and render() names them and marks the black keys for the rows' place.
457
+ const keyCells = [];
458
+ let keyRows = 0;
459
+
460
+ // One ruler label per beat, in the top bar's cycle.beat form; rebuilt only when the count of beats changes.
461
+ let rulerBeats = 0;
462
+ function renderRuler() {
463
+ const beats = displayBeats();
464
+ if (beats === rulerBeats) return;
465
+ rulerBeats = beats;
466
+ const labels = [];
467
+ for (let beat = 0; beat < beats; beat += 1) {
468
+ const label = document.createElement('div');
469
+ label.className = beat % BEATS_PER_CYCLE === 0 ? 'sp-beat sp-beat-first' : 'sp-beat';
470
+ label.textContent = `${Math.floor(beat / BEATS_PER_CYCLE) + 1}.${(beat % BEATS_PER_CYCLE) + 1}`;
471
+ labels.push(label);
472
+ }
473
+ rulerEl.replaceChildren(...labels);
474
+ }
475
+
476
+ function renderKeys() {
477
+ while (keyCells.length < shown.rows) {
478
+ const cell = document.createElement('div');
479
+ cell.className = 'sp-key';
480
+ keyCells.push(cell);
481
+ }
482
+ if (keyRows !== shown.rows) {
483
+ keyRows = shown.rows;
484
+ gutterEl.replaceChildren(...keyCells.slice(0, shown.rows));
485
+ }
486
+ for (let row = 0; row < shown.rows; row += 1) {
487
+ const cell = keyCells[row];
488
+ const pitch = shown.high - row;
489
+ const atCursor = hasFocus && pitch === cursor.pitch;
490
+ setAttr(cell, 'data-black', isBlackKey(pitch));
491
+ setAttr(cell, 'data-cursor', atCursor);
492
+ // A key held now carries its name and a bar of the accent.
493
+ const held = layout.heldPitches.has(pitch);
494
+ setAttr(cell, 'data-held', held);
495
+ setText(cell, pitch % 12 === 0 || atCursor || held ? noteName(pitch) : '');
496
+ }
497
+ }
498
+
499
+ // Each note is an element placed through custom properties only; an element is rebuilt only when its numbers change.
500
+ const noteRefs = new Map();
501
+ function renderNotes(hoveredId) {
502
+ // A drag of several notes draws each of them where the drop would put it, with the dashed ring; the notes themselves change at the release.
503
+ const atDraft = (note) => {
504
+ if (groupDraft === null || !selection.has(note.id)) return { note, drafted: false };
505
+ if (groupDraft.kind === 'edge') {
506
+ const length = clamp(note.length + groupDraft.dSteps * stepUnits(), stepUnits(), motifUnits(motif) - note.start);
507
+ return { note: { ...note, length }, drafted: true };
508
+ }
509
+ return { note: { ...note, start: note.start + groupDraft.dStart, pitch: note.pitch + groupDraft.dPitch }, drafted: true };
510
+ };
511
+ const visible = motif.notes
512
+ .map((note) => ({ real: note, ...atDraft(note) }))
513
+ .sort((a, b) => a.note.start - b.note.start || b.note.pitch - a.note.pitch || a.note.id - b.note.id);
514
+ const elements = [];
515
+ const seen = new Set();
516
+ for (const { real, note, drafted: isDraft } of visible) {
517
+ seen.add(note.id);
518
+ let ref = noteRefs.get(note.id);
519
+ if (ref === undefined) {
520
+ const element = document.createElement('div');
521
+ element.className = 'sp-note';
522
+ element.setAttribute('role', 'img');
523
+ // The handle at the right side: it only shows the resize cursor; the press is told apart from the numbers of the pointer.
524
+ const edge = document.createElement('div');
525
+ edge.className = 'sp-edge';
526
+ element.appendChild(edge);
527
+ ref = { element, signature: '' };
528
+ noteRefs.set(note.id, ref);
529
+ }
530
+ const selected = selection.has(note.id);
531
+ const hovered = note.id === hoveredId;
532
+ const signature = `${note.pitch}|${note.start}|${note.length}|${note.velocity}|${motif.grid}|${shown.high}|${selected}|${hovered}|${isDraft}`;
533
+ if (ref.signature !== signature) {
534
+ ref.signature = signature;
535
+ const { element } = ref;
536
+ element.className = ['sp-note', selected ? 'sp-selected' : '', isDraft ? 'sp-draft' : '', hovered ? 'sp-hover' : ''].filter(Boolean).join(' ');
537
+ setAttr(element, 'aria-label', spokenNote(real, motif.grid));
538
+ setAttr(element, 'data-id', real.id);
539
+ setAttr(element, 'data-pitch', real.pitch);
540
+ setAttr(element, 'data-start', real.start);
541
+ setAttr(element, 'data-length', real.length);
542
+ setAttr(element, 'data-velocity', real.velocity);
543
+ element.style.setProperty('--sp-start', String(note.start));
544
+ element.style.setProperty('--sp-len', String(note.length));
545
+ element.style.setProperty('--sp-row', String(rowOf(note.pitch)));
546
+ element.style.setProperty('--v', String(note.velocity / 100));
547
+ }
548
+ elements.push(ref.element);
549
+ }
550
+ for (const id of [...noteRefs.keys()]) if (!seen.has(id)) noteRefs.delete(id);
551
+ const current = notesEl.children;
552
+ const same = current.length === elements.length && elements.every((element, index) => current[index] === element);
553
+ if (!same) notesEl.replaceChildren(...elements);
554
+ }
555
+
556
+ // Where each bar of the lane stands: its note's start column and its place among the notes that start there, in the lane's order.
557
+ function barLayout() {
558
+ const byId = new Map(motif.notes.map((note) => [note.id, note]));
559
+ const order = laneOrder(motif).map((id) => byId.get(id));
560
+ const counts = new Map();
561
+ for (const note of order) counts.set(note.start, (counts.get(note.start) ?? 0) + 1);
562
+ const seen = new Map();
563
+ return order.map((note) => {
564
+ const slot = seen.get(note.start) ?? 0;
565
+ seen.set(note.start, slot + 1);
566
+ return { note, id: note.id, start: note.start, slot, slots: counts.get(note.start) };
567
+ });
568
+ }
569
+
570
+ // One bar per note, in the lane's order; a bar is rebuilt only when its numbers change. A drag in progress shows its velocity here.
571
+ const barRefs = new Map();
572
+ let barsKey = '';
573
+ function renderBars() {
574
+ const key = `${displayCycles()}|${stepUnits()}`;
575
+ if (key !== barsKey) {
576
+ barsKey = key;
577
+ barsEl.style.setProperty('--sp-cycles', String(displayCycles()));
578
+ barsEl.style.setProperty('--sp-units', String(displayUnits()));
579
+ barsEl.style.setProperty('--sp-step', String(stepUnits()));
580
+ }
581
+ const elements = [];
582
+ const seen = new Set();
583
+ for (const { note, slot, slots } of barLayout()) {
584
+ seen.add(note.id);
585
+ let ref = barRefs.get(note.id);
586
+ if (ref === undefined) {
587
+ const element = document.createElement('div');
588
+ element.className = 'sp-bar';
589
+ element.setAttribute('role', 'img');
590
+ ref = { element, signature: '' };
591
+ barRefs.set(note.id, ref);
592
+ }
593
+ const velocity = laneDraft !== null && laneDraft.id === note.id ? laneDraft.velocity : note.velocity;
594
+ const selected = selection.has(note.id);
595
+ const signature = `${note.pitch}|${note.start}|${slot}|${slots}|${velocity}|${selected}`;
596
+ if (ref.signature !== signature) {
597
+ ref.signature = signature;
598
+ const { element } = ref;
599
+ element.className = selected ? 'sp-bar sp-selected' : 'sp-bar';
600
+ setAttr(element, 'aria-label', fillScratch(SCRATCH_COPY.barName, { name: noteName(note.pitch), velocity: velocityText(velocity) }));
601
+ setAttr(element, 'data-id', note.id);
602
+ setAttr(element, 'data-start', note.start);
603
+ setAttr(element, 'data-velocity', velocity);
604
+ element.style.setProperty('--sp-start', String(note.start));
605
+ element.style.setProperty('--sp-slot', String(slot));
606
+ element.style.setProperty('--sp-slots', String(slots));
607
+ element.style.setProperty('--v', String(velocity / 100));
608
+ }
609
+ elements.push(ref.element);
610
+ }
611
+ for (const id of [...barRefs.keys()]) if (!seen.has(id)) barRefs.delete(id);
612
+ const current = barsEl.children;
613
+ const same = current.length === elements.length && elements.every((element, index) => current[index] === element);
614
+ if (!same) barsEl.replaceChildren(...elements);
615
+ }
616
+
617
+ let rollKey = '';
618
+ let cursorKey = '';
619
+ function renderCursor() {
620
+ // While a drag is on the dashed preview is the only dashed ring, so the two are never confused.
621
+ cursorEl.toggleAttribute('hidden', !hasFocus || (press !== null && press.locked));
622
+ const key = `${cursor.unit}|${stepUnits()}|${rowOf(cursor.pitch)}`;
623
+ if (key !== cursorKey) {
624
+ cursorKey = key;
625
+ cursorEl.style.setProperty('--sp-start', String(cursor.unit));
626
+ cursorEl.style.setProperty('--sp-len', String(stepUnits()));
627
+ cursorEl.style.setProperty('--sp-row', String(rowOf(cursor.pitch)));
628
+ }
629
+ setAttr(rollEl, 'data-cursor-unit', cursor.unit);
630
+ setAttr(rollEl, 'data-cursor-pitch', cursor.pitch);
631
+ }
632
+
633
+ // What the line says: the last edit's notice; else, while the roll has focus, the cursor's cell; else the summary or the empty state.
634
+ function lineContent() {
635
+ if (notice !== null) return { kind: 'notice', name: '', rest: notice, spoken: notice };
636
+ // Several selected notes are read as a group, before the cursor's cell: a box or Select all must show what it selected.
637
+ if (selection.size > 1) {
638
+ const parts = selectionParts(motif, [...selection]);
639
+ return { kind: 'selection', ...parts, spoken: parts.name + parts.rest };
640
+ }
641
+ if (hasFocus) {
642
+ const note = noteAt(motif, cursor.pitch, cursor.unit);
643
+ if (note !== null) return { kind: 'note', ...noteParts(note, motif.grid), spoken: spokenNote(note, motif.grid) };
644
+ return { kind: 'cell', ...emptyCellParts(cursor.pitch, cursor.unit), spoken: spokenEmptyCell(cursor.pitch, cursor.unit) };
645
+ }
646
+ const selected = selection.size === 1 ? motif.notes.find((note) => selection.has(note.id)) : undefined;
647
+ if (selected !== undefined) return { kind: 'note', ...noteParts(selected, motif.grid), spoken: spokenNote(selected, motif.grid) };
648
+ if (motif.notes.length > 0) {
649
+ const summary = summaryLine(motif);
650
+ return { kind: 'summary', name: '', rest: summary, spoken: summary };
651
+ }
652
+ return {
653
+ kind: 'empty',
654
+ name: SCRATCH_COPY.emptyHeading,
655
+ rest: ` · ${SCRATCH_COPY.emptyBody}`,
656
+ spoken: `${SCRATCH_COPY.emptyHeading}. ${SCRATCH_COPY.emptyBody}`,
657
+ };
658
+ }
659
+
660
+ function renderLine() {
661
+ const content = lineContent();
662
+ setAttr(lineEl, 'data-kind', content.kind);
663
+ setText(nameEl, content.name);
664
+ setText(restEl, content.rest);
665
+ setText(spokenEl, content.spoken);
666
+ }
667
+
668
+ // The beat and the row take the room the scroll box has: the width left after the piano names for the beats of the motif (no upper
669
+ // limit, so the roll uses all its width), the height left after the ruler and every other element in the box for the rows shown. The
670
+ // sizes are custom properties on the box, which the ruler and the roll both inherit; they are written only when they change, and not at
671
+ // all while the view has no size. When the rows at their smallest height do not fit, the box scrolls vertically.
672
+ let fitKey = '';
673
+ function resize() {
674
+ const remPx = rootFontPx();
675
+ const width = scrollEl.clientWidth;
676
+ const height = scrollEl.clientHeight;
677
+ if (!(remPx > 0) || !(width > 0) || !(height > 0)) return;
678
+ const box = document.defaultView.getComputedStyle(scrollEl);
679
+ const padX = parseFloat(box.paddingLeft) + parseFloat(box.paddingRight);
680
+ const padY = parseFloat(box.paddingTop) + parseFloat(box.paddingBottom);
681
+ let others = 0;
682
+ for (const child of scrollEl.children) if (child !== rollEl) others += child.getBoundingClientRect().height;
683
+ const rounded = (rem) => `${Number(rem.toFixed(4))}rem`;
684
+ const beat = rounded(beatWidthRem({ availableRem: (width - padX) / remPx - GUTTER_REM, beats: displayBeats(), minRem: liveMode === 'live' ? LIVE_BEAT_MIN_REM : BEAT_MIN_REM }));
685
+ const row = rounded(rowHeightRem({ availableRem: (height - padY - others) / remPx, rows: shown.rows }));
686
+ const [lastBeat, lastRow] = fitKey === '' ? ['', ''] : fitKey.split('|');
687
+ if (beat !== lastBeat) scrollEl.style.setProperty('--sp-beat-w', beat);
688
+ if (row !== lastRow) scrollEl.style.setProperty('--sp-row-h', row);
689
+ fitKey = `${beat}|${row}`;
690
+ }
691
+
692
+ // Puts a ring on a one-row box through the same custom properties a note is placed with.
693
+ function placeBox(element, unit, length, pitch) {
694
+ element.style.setProperty('--sp-start', String(unit));
695
+ element.style.setProperty('--sp-len', String(length));
696
+ element.style.setProperty('--sp-row', String(rowOf(pitch)));
697
+ }
698
+
699
+ // The preview of a draw, the outline of the empty cell under the pointer and the hover line. The hover line shows what a draw in
700
+ // progress would give, else the note under the pointer; it replaces the shown line for the eye and is never read aloud.
701
+ let previewKey = '';
702
+ let hoverCellKey = '';
703
+ let rectKey = '';
704
+ function renderPointer(hovered) {
705
+ previewEl.toggleAttribute('hidden', preview === null);
706
+ if (preview !== null) {
707
+ const key = `${preview.start}|${preview.length}|${rowOf(preview.pitch)}`;
708
+ if (key !== previewKey) {
709
+ previewKey = key;
710
+ placeBox(previewEl, preview.start, preview.length, preview.pitch);
711
+ }
712
+ }
713
+ selectRectEl.toggleAttribute('hidden', rectBox === null);
714
+ if (rectBox !== null) {
715
+ const round = (value) => String(Number(value.toFixed(3)));
716
+ const key = [rectBox.fromUnit, rectBox.toUnit, rectBox.topRow, rectBox.bottomRow].map(round).join('|');
717
+ if (key !== rectKey) {
718
+ rectKey = key;
719
+ selectRectEl.style.setProperty('--sp-start', round(rectBox.fromUnit));
720
+ selectRectEl.style.setProperty('--sp-len', round(rectBox.toUnit - rectBox.fromUnit));
721
+ selectRectEl.style.setProperty('--sp-row', round(rectBox.topRow));
722
+ selectRectEl.style.setProperty('--sp-rows-span', round(rectBox.bottomRow - rectBox.topRow));
723
+ }
724
+ }
725
+ // The outline of the empty cell a drag would start in shows in Draw mode only: in Select mode a drag starts a box, not a note.
726
+ const showCell = draw && press === null && hovered === null && hoverCell !== null;
727
+ hoverCellEl.toggleAttribute('hidden', !showCell);
728
+ if (showCell) {
729
+ const key = `${hoverCell.unit}|${stepUnits()}|${rowOf(hoverCell.pitch)}`;
730
+ if (key !== hoverCellKey) {
731
+ hoverCellKey = key;
732
+ placeBox(hoverCellEl, hoverCell.unit, stepUnits(), hoverCell.pitch);
733
+ }
734
+ }
735
+ const draftOf = laneDraft === null ? undefined : motif.notes.find((note) => note.id === laneDraft.id);
736
+ const draftNote = draftOf === undefined ? null : { ...draftOf, velocity: laneDraft.velocity };
737
+ const shown = preview ?? draftNote ?? (press === null && lanePress === null ? hovered : null);
738
+ hoverEl.toggleAttribute('hidden', shown === null);
739
+ setAttr(infoEl, 'data-hover', shown !== null);
740
+ if (shown !== null) {
741
+ const parts = noteParts(shown, motif.grid);
742
+ setText(hoverName, parts.name);
743
+ setText(hoverRest, parts.rest);
744
+ }
745
+ }
746
+
747
+ // --- the live view ---
748
+
749
+ // Lays the snapshot out on the ring (see ringLayout) when it is another one, or the grid has changed under it. Notes still held are kept
750
+ // apart: their pieces grow with every frame, while the rest change only with the snapshot.
751
+ function syncLayout() {
752
+ const snapshot = liveMode === 'live' ? liveGiven.snapshot : null;
753
+ if (snapshot === layoutSnapshot && motif.grid === layoutGrid) return;
754
+ layoutSnapshot = snapshot;
755
+ layoutGrid = motif.grid;
756
+ if (snapshot === null) {
757
+ layout = NO_LAYOUT;
758
+ return;
759
+ }
760
+ const settled = ringLayout({ ...snapshot, notes: snapshot.notes.filter((note) => note.held !== true) }, motif.grid).pieces;
761
+ const heldNotes = snapshot.notes.filter((note) => note.held === true);
762
+ const held = ringLayout({ ...snapshot, notes: heldNotes }, motif.grid).pieces;
763
+ layout = { settled, heldNotes, pitches: [...new Set([...settled, ...held].map((piece) => piece.pitch))], heldPitches: new Set(held.filter((piece) => piece.held).map((piece) => piece.pitch)) };
764
+ }
765
+
766
+ // The pieces of the held notes at this frame: each grows to the playhead, snapped to the grid like everything the layout places.
767
+ function heldPieces() {
768
+ if (layout.heldNotes.length === 0) return [];
769
+ const { snapshot, now } = liveGiven;
770
+ const notes = layout.heldNotes.map((note) => ({ ...note, end: Math.max(note.end, now - snapshot.start) }));
771
+ return ringLayout({ ...snapshot, notes }, motif.grid).pieces;
772
+ }
773
+
774
+ // One element per piece of the live layer, placed through custom properties only and written only when its numbers change. The layer takes
775
+ // no pointer and is aria-hidden: the strip's sentence is the text form of what was heard.
776
+ const settledRefs = [];
777
+ const heldRefs = [];
778
+ let layerKey = '';
779
+ function drawPiece(refs, index, piece) {
780
+ if (refs[index] === undefined) {
781
+ const element = document.createElement('div');
782
+ element.className = 'sp-live';
783
+ refs[index] = { element, signature: '', values: {} };
784
+ }
785
+ const ref = refs[index];
786
+ const signature = `${piece.pitch}|${piece.start}|${piece.end}|${piece.velocity}|${piece.held}|${shown.high}`;
787
+ if (ref.signature === signature) return;
788
+ ref.signature = signature;
789
+ const { element } = ref;
790
+ setAttr(element, 'data-pitch', piece.pitch);
791
+ setAttr(element, 'data-start', piece.start);
792
+ setAttr(element, 'data-length', piece.end - piece.start);
793
+ setAttr(element, 'data-velocity', piece.velocity);
794
+ setAttr(element, 'data-held', piece.held);
795
+ // A held piece grows every few frames: only the properties that changed are written.
796
+ const values = { '--sp-start': String(piece.start), '--sp-len': String(piece.end - piece.start), '--sp-row': String(rowOf(piece.pitch)), '--v': String(piece.velocity / 100) };
797
+ for (const [name, value] of Object.entries(values)) {
798
+ if (ref.values[name] === value) continue;
799
+ ref.values[name] = value;
800
+ element.style.setProperty(name, value);
801
+ }
802
+ }
803
+
804
+ // `all` draws every piece (the snapshot or the rows changed); otherwise only the held ones are looked at.
805
+ function renderLiveLayer(all) {
806
+ const held = liveMode === 'live' ? heldPieces() : [];
807
+ const settled = layout.settled;
808
+ if (all) {
809
+ settled.forEach((piece, index) => drawPiece(settledRefs, index, piece));
810
+ settledRefs.length = settled.length;
811
+ }
812
+ held.forEach((piece, index) => drawPiece(heldRefs, index, piece));
813
+ heldRefs.length = held.length;
814
+ setAttr(viewEl, 'data-live-notes', settledRefs.length + heldRefs.length);
815
+ const key = `${settledRefs.length}|${heldRefs.length}`;
816
+ if (!all && key === layerKey) return;
817
+ layerKey = key;
818
+ const elements = [...settledRefs, ...heldRefs].map((ref) => ref.element);
819
+ const current = liveEl.children;
820
+ const same = current.length === elements.length && elements.every((element, index) => current[index] === element);
821
+ if (!same) liveEl.replaceChildren(...elements);
822
+ }
823
+
824
+ // The playhead is where the axis cycle is on the ring: its cycle modulo 8 in units. It is a position readout, so it has no transition and
825
+ // moves in both motion modes. The ring cycle read-out changes once per cycle.
826
+ let playheadKey = '';
827
+ function renderPlayhead() {
828
+ const live = liveMode === 'live';
829
+ const heard = hearTracking();
830
+ playheadEl.toggleAttribute('hidden', !live && !heard);
831
+ setAttr(viewEl, 'data-ring-cycle', liveMode === 'off' ? '' : Math.floor(mod(liveGiven.now, RING_CYCLES)));
832
+ if (!live && !heard) {
833
+ lastPlayhead = null;
834
+ return;
835
+ }
836
+ const unit = heard ? roundUnit(hearUnit) : roundUnit(mod(liveGiven.now, RING_CYCLES) * UNITS_PER_CYCLE);
837
+ if (String(unit) !== playheadKey) {
838
+ playheadKey = String(unit);
839
+ playheadEl.style.setProperty('--sp-playhead', playheadKey);
840
+ }
841
+ }
842
+
843
+ // Writes the box's sideways scroll and remembers what it became, so a scroll event that lands elsewhere is known for a hand.
844
+ function writeScroll(left) {
845
+ scrollEl.scrollLeft = left;
846
+ lastScroll = scrollEl.scrollLeft;
847
+ }
848
+
849
+ // Whether the box turns itself to follow the playhead now: live, and not within the pause after a hand scroll or a press.
850
+ const following = () => (liveMode === 'live' || hearTracking()) && lastFrameMs >= followPausedUntil;
851
+ const pauseFollowing = () => {
852
+ if (liveMode === 'live' || hearTracking()) followPausedUntil = lastFrameMs + FOLLOW_PAUSE_MS;
853
+ };
854
+ scrollEl.addEventListener('wheel', pauseFollowing);
855
+ scrollEl.addEventListener('pointerdown', pauseFollowing);
856
+ scrollEl.addEventListener('scroll', () => {
857
+ if (Math.abs(scrollEl.scrollLeft - lastScroll) > 1) pauseFollowing();
858
+ lastScroll = scrollEl.scrollLeft;
859
+ });
860
+
861
+ // The page turn: when the playhead passes 90 percent of the visible width the box scrolls so it stands one beat right of the piano names,
862
+ // instantly, and when it wraps the box goes back to the start.
863
+ function follow(unit) {
864
+ const wrapped = lastPlayhead !== null && unit < lastPlayhead;
865
+ lastPlayhead = unit;
866
+ if (!following()) return;
867
+ if (wrapped) {
868
+ writeScroll(0);
869
+ return;
870
+ }
871
+ const gridRect = gridEl.getBoundingClientRect();
872
+ if (!(gridRect.width > 0)) return;
873
+ const x = gridRect.left + (unit / displayUnits()) * gridRect.width;
874
+ const visibleLeft = gutterEl.getBoundingClientRect().right;
875
+ const visibleRight = scrollEl.getBoundingClientRect().right;
876
+ if (!(visibleRight > visibleLeft)) return;
877
+ if (x < visibleLeft || x > visibleLeft + FOLLOW_EDGE * (visibleRight - visibleLeft)) {
878
+ writeScroll(Math.max(0, scrollEl.scrollLeft + x - visibleLeft - gridRect.width / displayBeats()));
879
+ }
880
+ }
881
+
882
+ // What the page hands over on every animation frame while the Scratchpad is open: `null` (no input is listened to: Off) or
883
+ // `{ now, snapshot, frameMs }`, `now` the cycle axis now, `snapshot` the keyboard buffer's (page/played-buffer.js; the same object until the
884
+ // buffer changes, or null when the clock gives none) and `frameMs` the frame's time, the only clock the view has. A snapshot with notes is
885
+ // Live and shows the buffer's 8 cycles; any other is Armed. The switch into or out of Live waits for a press in progress to end.
886
+ function setLive(next) {
887
+ if (next !== null) {
888
+ const valid =
889
+ typeof next === 'object' &&
890
+ Number.isFinite(next.now) &&
891
+ Number.isFinite(next.frameMs) &&
892
+ (next.snapshot === null || (typeof next.snapshot === 'object' && Array.isArray(next.snapshot.notes)));
893
+ if (!valid) throw new TypeError('Moontide: the Scratchpad live view needs null or { now, snapshot, frameMs }: finite now and frameMs, and a snapshot that is null or holds a list of notes.');
894
+ }
895
+ liveRaw = next;
896
+ const want = hearing() || next === null ? 'off' : next.snapshot !== null && next.snapshot.notes.length > 0 ? 'live' : 'armed';
897
+ const switching = (want === 'live') !== (liveMode === 'live');
898
+ if (switching && (press !== null || lanePress !== null)) {
899
+ liveHold = true;
900
+ liveNext = next;
901
+ return;
902
+ }
903
+ liveHold = false;
904
+ liveNext = null;
905
+ const snapshotChanged = (next === null ? null : next.snapshot) !== (liveGiven === null ? null : liveGiven.snapshot);
906
+ const modeChanged = want !== liveMode;
907
+ liveGiven = next;
908
+ liveMode = want;
909
+ if (next !== null) lastFrameMs = next.frameMs;
910
+ if (switching) {
911
+ writeScroll(0);
912
+ lastPlayhead = null;
913
+ }
914
+ if (modeChanged || snapshotChanged) {
915
+ render();
916
+ } else if (liveMode !== 'off') {
917
+ renderNow();
918
+ if (liveMode === 'live') {
919
+ renderLiveLayer(false);
920
+ renderPlayhead();
921
+ follow(mod(liveGiven.now, RING_CYCLES) * UNITS_PER_CYCLE);
922
+ } else {
923
+ renderPlayhead();
924
+ }
925
+ }
926
+ }
927
+
928
+ // A press has ended: a switch that waited for it is applied now.
929
+ function flushLive() {
930
+ if (!liveHold) return;
931
+ const next = liveNext;
932
+ liveHold = false;
933
+ liveNext = null;
934
+ setLive(next);
935
+ }
936
+
937
+ let rowsKey = 0;
938
+ function render() {
939
+ syncLayout();
940
+ shown = measureRows();
941
+ let sized = false;
942
+ const grid = gridOf(motif.grid);
943
+ const hovered = hoverId === null ? null : (motif.notes.find((note) => note.id === hoverId) ?? null);
944
+ setAttr(viewEl, 'data-cycles', motif.cycles);
945
+ setAttr(viewEl, 'data-shown-cycles', displayCycles());
946
+ setAttr(viewEl, 'data-live', liveMode);
947
+ setAttr(viewEl, 'data-grid', motif.grid);
948
+ setAttr(viewEl, 'data-window-start', base);
949
+ setAttr(viewEl, 'data-rows', shown.rows);
950
+ setAttr(viewEl, 'data-row-low', shown.low);
951
+ setAttr(viewEl, 'data-row-high', shown.high);
952
+ setAttr(viewEl, 'data-notes', motif.notes.length);
953
+ setAttr(viewEl, 'data-selected', [...selection].sort((a, b) => a - b).join(','));
954
+ setAttr(viewEl, 'data-selected-count', selection.size);
955
+ setAttr(viewEl, 'data-draw', draw ? 'on' : 'off');
956
+ setText(hintEl, draw ? SCRATCH_COPY.hintDraw : SCRATCH_COPY.hintSelect);
957
+ const key = `${displayCycles()}|${grid.steps}`;
958
+ if (key !== rollKey) {
959
+ rollKey = key;
960
+ rollEl.style.setProperty('--sp-cycles', String(displayCycles()));
961
+ rollEl.style.setProperty('--sp-steps', String(grid.steps));
962
+ rollEl.style.setProperty('--sp-units', String(displayUnits()));
963
+ sized = true;
964
+ }
965
+ // More or fewer beats share the width differently, and more or fewer rows share the height.
966
+ if (shown.rows !== rowsKey) {
967
+ rowsKey = shown.rows;
968
+ rollEl.style.setProperty('--sp-rows', String(shown.rows));
969
+ sized = true;
970
+ }
971
+ if (sized) resize();
972
+ renderToolbar();
973
+ renderRuler();
974
+ renderKeys();
975
+ renderNotes(press === null && hovered !== null ? hovered.id : null);
976
+ renderLiveLayer(true);
977
+ renderBars();
978
+ renderCursor();
979
+ renderNow();
980
+ renderPlayhead();
981
+ renderLine();
982
+ renderPointer(hovered);
983
+ renderSend();
984
+ renderHear();
985
+ }
986
+
987
+ // The strip: the phrase of the motif, written again only when the motif is another one (it is the text onSend receives in the motif
988
+ // request), and the Send button, which says why it cannot act while it cannot.
989
+ let phraseMotif = null;
990
+ function renderSend() {
991
+ if (motif !== phraseMotif) {
992
+ phraseMotif = motif;
993
+ setText(phraseEl, motifPhrase(motif) ?? SCRATCH_COPY.phraseEmpty);
994
+ setAttr(phraseEl, 'data-empty', motif.notes.length === 0);
995
+ }
996
+ const blocked = sendBlock();
997
+ setDisabled(sendEl, blocked !== null, SCRATCH_COPY.sendTitle, blocked === null ? '' : sendRefusalText({ refused: blocked }));
998
+ }
999
+
1000
+ // The selection keeps only notes that still exist: an edit or an Undo may have taken some away.
1001
+ function syncSelection() {
1002
+ const alive = new Set(motif.notes.map((note) => note.id));
1003
+ for (const id of selection) if (!alive.has(id)) selection.delete(id);
1004
+ }
1005
+
1006
+ // Scrolls the box, instantly, when the cursor's cell leaves the part of the roll that shows: sideways past the piano names and the right edge,
1007
+ // and up or down past the ruler (held at the top of the box) and the velocity lane (held at its bottom). Called after the render, so the
1008
+ // rows it counts are the ones on screen.
1009
+ function revealCursor() {
1010
+ // The playhead is followed instead: while it is, nothing the cursor does scrolls the box.
1011
+ if (following()) return;
1012
+ revealCursorSideways();
1013
+ const rows = measureRowGeometry();
1014
+ if (rows === null) return;
1015
+ const rowTop = rows.topOf(cursor.pitch);
1016
+ if (rowTop < rows.visibleTop) scrollEl.scrollTop -= rows.visibleTop - rowTop;
1017
+ else if (rowTop + rows.rowHeight > rows.visibleBottom) scrollEl.scrollTop += rowTop + rows.rowHeight - rows.visibleBottom;
1018
+ }
1019
+
1020
+ // The sideways half of revealCursor: scrolls past the piano names on the left and the right edge of the box.
1021
+ function revealCursorSideways() {
1022
+ const gridRect = gridEl.getBoundingClientRect();
1023
+ if (gridRect.width <= 0) return;
1024
+ const units = displayUnits();
1025
+ const left = gridRect.left + (cursor.unit / units) * gridRect.width;
1026
+ const right = gridRect.left + ((cursor.unit + stepUnits()) / units) * gridRect.width;
1027
+ const visibleLeft = gutterEl.getBoundingClientRect().right;
1028
+ const visibleRight = scrollEl.getBoundingClientRect().right;
1029
+ if (left < visibleLeft) writeScroll(scrollEl.scrollLeft - (visibleLeft - left));
1030
+ else if (right > visibleRight) writeScroll(scrollEl.scrollLeft + (right - visibleRight));
1031
+ }
1032
+
1033
+ // The rows as they sit on screen right now: one row's height, the screen top of any pitch's row, and the band between the ruler (held at
1034
+ // the top of the box) and the velocity lane (held at its bottom). Null while the grid has no height or the band is not measured.
1035
+ function measureRowGeometry() {
1036
+ const gridRect = gridEl.getBoundingClientRect();
1037
+ if (gridRect.height <= 0) return null;
1038
+ const rowHeight = gridRect.height / shown.rows;
1039
+ const visibleTop = rulerEl.getBoundingClientRect().bottom;
1040
+ const visibleBottom = laneEl.getBoundingClientRect().top;
1041
+ if (!(visibleBottom > visibleTop)) return null;
1042
+ return { rowHeight, visibleTop, visibleBottom, topOf: (pitch) => gridRect.top + rowOf(pitch) * rowHeight };
1043
+ }
1044
+
1045
+ // The grid or the length may have changed under the cursor (an edit, or an undo): it stays on a step of the grid, inside the motif.
1046
+ function fitCursor() {
1047
+ const step = stepUnits();
1048
+ cursor = { unit: clamp(Math.floor(cursor.unit / step) * step, 0, motifUnits(motif) - step), pitch: cursor.pitch };
1049
+ }
1050
+
1051
+ // The one place an edit lands: a model command's result is committed to the history and becomes the motif, and its sentence the line.
1052
+ // Nothing else writes the motif except stepping through the history. `cursorTo` puts the cursor where the edit carried it (a note
1053
+ // that moved takes the cursor along).
1054
+ function apply(result, cursorTo = null, { amend = false } = {}) {
1055
+ history = commit(history, result, { amend });
1056
+ motif = result.motif;
1057
+ notice = result.notice;
1058
+ if (cursorTo !== null) cursor = cursorTo;
1059
+ fitCursor();
1060
+ syncSelection();
1061
+ render();
1062
+ if (cursorTo !== null) revealCursor();
1063
+ }
1064
+
1065
+ // Undo and redo step through the Scratchpad's own edits. A drag in progress ends first, so it cannot land on a motif that changed under
1066
+ // it. A selection whose note is gone is cleared by syncSelection, and the cursor is held inside the motif. The box then scrolls to the
1067
+ // notes the step brought back or changed (or to the notes that remain, when it only took notes away) and keeps the cursor row in view
1068
+ // with them; when the cursor is too far from them to share the view, it moves onto the nearest one. The cursor is never stored.
1069
+ function stepHistory(step) {
1070
+ cancelGesture();
1071
+ run = null;
1072
+ const before = motif;
1073
+ const done = step(history);
1074
+ history = done.history;
1075
+ motif = done.motif;
1076
+ notice = done.notice;
1077
+ fitCursor();
1078
+ syncSelection();
1079
+ render();
1080
+ if (motif !== before) placeAfterStep(before);
1081
+ }
1082
+
1083
+ // Scrolls the box vertically to the notes a history step touched. At most one cursor move and two measures: no loop.
1084
+ function placeAfterStep(before) {
1085
+ const touched = touchedNotes(before, motif);
1086
+ const targets = (touched.length > 0 ? touched : motif.notes).slice().sort((a, b) => Math.abs(a.start - cursor.unit) - Math.abs(b.start - cursor.unit) || a.start - b.start);
1087
+ const scrollTo = () => {
1088
+ const rows = measureRowGeometry();
1089
+ if (rows === null) return undefined;
1090
+ const { by, nearest } = historyScroll({ tops: targets.map((note) => rows.topOf(note.pitch)), cursorTop: rows.topOf(cursor.pitch), rowHeight: rows.rowHeight, visibleTop: rows.visibleTop, visibleBottom: rows.visibleBottom });
1091
+ return { by, nearest };
1092
+ };
1093
+ let result = scrollTo();
1094
+ if (result === undefined) return;
1095
+ let moved = false;
1096
+ if (result.by === null) {
1097
+ const target = targets[result.nearest];
1098
+ cursor = { unit: target.start, pitch: target.pitch };
1099
+ fitCursor();
1100
+ render();
1101
+ moved = true;
1102
+ result = scrollTo();
1103
+ if (result === undefined || result.by === null) return;
1104
+ }
1105
+ scrollEl.scrollTop += result.by;
1106
+ if (moved && !following()) revealCursorSideways();
1107
+ }
1108
+
1109
+ // A cursor move: the edit's notice goes, and the cell or note under the new place is read.
1110
+ function placeCursor(unit, pitch) {
1111
+ cursor = { unit: clamp(unit, 0, motifUnits(motif) - stepUnits()), pitch: clamp(pitch, MIN_PITCH, MAX_PITCH) };
1112
+ notice = null;
1113
+ syncSelection();
1114
+ render();
1115
+ revealCursor();
1116
+ }
1117
+
1118
+ // Lower and Higher move the base window by an octave and the cursor with it. The rows still follow the notes, so when they pin the range
1119
+ // a press may change nothing visible.
1120
+ function moveWindow(octaves) {
1121
+ const next = clamp(base + octaves * OCTAVE, MIN_WINDOW_START, MAX_WINDOW_START);
1122
+ if (next === base) return;
1123
+ cursor = { unit: cursor.unit, pitch: clamp(cursor.pitch + next - base, MIN_PITCH, MAX_PITCH) };
1124
+ base = next;
1125
+ notice = null;
1126
+ syncSelection();
1127
+ render();
1128
+ }
1129
+
1130
+ // The commands that act on the note under the cursor say so when there is none.
1131
+ function withNoteUnderCursor(act) {
1132
+ const note = noteAt(motif, cursor.pitch, cursor.unit);
1133
+ if (note === null) {
1134
+ notice = SCRATCH_COPY.noNote;
1135
+ render();
1136
+ return;
1137
+ }
1138
+ act(note);
1139
+ }
1140
+
1141
+ // The ids of the selected notes, ascending.
1142
+ const selectedIds = () => [...selection].sort((a, b) => a - b);
1143
+
1144
+ // One undo step is one physical key press. A held key repeats, and each repeat computes the whole edit again from the motif the first
1145
+ // press started from, with the summed distance, and replaces the step the first press made instead of adding one: Undo then goes back to
1146
+ // before the key went down, and the notice says the sum. `make(base, count)` gives the command's result for `count` presses from `base`;
1147
+ // `cursorMoves(count)` gives how far the cursor follows, or null. A refusal says why and changes nothing, the earlier repeats stay.
1148
+ function runEdit(signature, repeat, make, cursorMoves) {
1149
+ const continuing = repeat && run !== null && run.signature === signature && run.present === motif;
1150
+ if (!repeat) run = null;
1151
+ const count = continuing ? run.count + 1 : 1;
1152
+ const base = continuing ? run.base : motif;
1153
+ const from = continuing ? run.cursor : cursor;
1154
+ const result = make(base, count);
1155
+ if (result.refused !== undefined) {
1156
+ notice = result.refused;
1157
+ render();
1158
+ return;
1159
+ }
1160
+ if (result.motif === motif && result.notice === '') return;
1161
+ const moves = cursorMoves(count);
1162
+ const cursorTo = moves === null ? null : { unit: from.unit + moves.dUnit, pitch: clamp(from.pitch + moves.dPitch, MIN_PITCH, MAX_PITCH) };
1163
+ apply(result, cursorTo, { amend: continuing });
1164
+ run = { signature, base, count, cursor: from, present: motif };
1165
+ }
1166
+
1167
+ // An arrow with a selection moves the selection, one grid step or one semitone (Alt: one beat or one octave), and the cursor follows; with
1168
+ // none it moves the cursor the same distance. Shift with Left or Right changes the length of the selection, else of the note under the cursor.
1169
+ function arrowKey(event, arrow) {
1170
+ const signature = `${event.key}|${event.altKey}|${event.shiftKey}`;
1171
+ if (!event.altKey && event.shiftKey && arrow.right !== 0) {
1172
+ lengthenBy(arrow.right, signature, event.repeat);
1173
+ return;
1174
+ }
1175
+ const dUnit = arrow.right * (event.altKey ? UNITS_PER_BEAT : stepUnits());
1176
+ const dPitch = arrow.up * (event.altKey ? OCTAVE : 1);
1177
+ if (selection.size === 0) {
1178
+ placeCursor(cursor.unit + dUnit, cursor.pitch + dPitch);
1179
+ return;
1180
+ }
1181
+ const ids = selectedIds();
1182
+ cancelGesture();
1183
+ runEdit(signature, event.repeat, (base, count) => moveNotes(base, ids, { dStart: dUnit * count, dPitch: dPitch * count }), (count) => ({ dUnit: dUnit * count, dPitch: dPitch * count }));
1184
+ }
1185
+
1186
+ function lengthenBy(steps, signature, repeat) {
1187
+ const ids = selectedIds();
1188
+ if (ids.length === 0) {
1189
+ const note = noteAt(motif, cursor.pitch, cursor.unit);
1190
+ if (note === null) {
1191
+ notice = SCRATCH_COPY.noNote;
1192
+ render();
1193
+ return;
1194
+ }
1195
+ ids.push(note.id);
1196
+ }
1197
+ cancelGesture();
1198
+ runEdit(signature, repeat, (base, count) => resizeNotes(base, ids, steps * count), () => null);
1199
+ }
1200
+
1201
+ // The selection keys. None of them is an edit: the history is not touched.
1202
+ function selectAll() {
1203
+ selection = new Set(motif.notes.map((note) => note.id));
1204
+ notice = selectionText({ kind: 'all', count: selection.size });
1205
+ render();
1206
+ }
1207
+
1208
+ function selectNone() {
1209
+ selection = new Set();
1210
+ notice = selectionText({ kind: 'none' });
1211
+ render();
1212
+ }
1213
+
1214
+ // s adds the note under the cursor to the selection or takes it out.
1215
+ function toggleUnderCursor() {
1216
+ const note = noteAt(motif, cursor.pitch, cursor.unit);
1217
+ if (note === null) {
1218
+ notice = selectionText({ kind: 'nothing-here' });
1219
+ render();
1220
+ return;
1221
+ }
1222
+ const had = selection.has(note.id);
1223
+ selection = new Set(had ? [...selection].filter((id) => id !== note.id) : [...selection, note.id]);
1224
+ notice = selectionText({ kind: had ? 'removed' : 'added', name: noteName(note.pitch), count: selection.size });
1225
+ render();
1226
+ }
1227
+
1228
+ // [ and ] walk the cursor to the previous or next note in time order and leave the selection as it is. With a group selected the line
1229
+ // reads the note the cursor came to, because the group's own line would hide it.
1230
+ function walkNotes(direction) {
1231
+ const note = neighborNote(motif, cursor, direction);
1232
+ if (note === null) {
1233
+ notice = direction > 0 ? SCRATCH_COPY.noLaterNote : SCRATCH_COPY.noEarlierNote;
1234
+ render();
1235
+ return;
1236
+ }
1237
+ placeCursor(note.start, note.pitch);
1238
+ if (selection.size > 1) {
1239
+ notice = spokenNote(note, motif.grid);
1240
+ render();
1241
+ }
1242
+ }
1243
+
1244
+ rollEl.addEventListener('focus', () => {
1245
+ hasFocus = true;
1246
+ render();
1247
+ });
1248
+ rollEl.addEventListener('blur', () => {
1249
+ hasFocus = false;
1250
+ render();
1251
+ });
1252
+
1253
+ rollEl.addEventListener('keydown', (event) => {
1254
+ // A key that edits the motif ends a mouse press first, so the release cannot act on a note the key moved or erased.
1255
+ // The bracket keys are matched by the character the key gives, whatever modifier made it: on some layouts the bracket needs Alt (AltGr
1256
+ // on others, which reads as Ctrl and Alt together). Ctrl or Cmd alone with a bracket stays the browser's.
1257
+ if ((event.key === '[' || event.key === ']') && ((!event.ctrlKey && !event.metaKey) || event.altKey)) {
1258
+ walkNotes(event.key === ']' ? 1 : -1);
1259
+ event.preventDefault();
1260
+ return;
1261
+ }
1262
+ // Select all and select none are taken before the Ctrl and Cmd return below, and the default is prevented so the page text is not selected.
1263
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && typeof event.key === 'string' && event.key.toLowerCase() === 'a') {
1264
+ cancelGesture();
1265
+ if (event.shiftKey) selectNone();
1266
+ else selectAll();
1267
+ event.preventDefault();
1268
+ return;
1269
+ }
1270
+ // Other Ctrl and Cmd combinations are the browser's; Alt combinations are taken only for the arrows.
1271
+ if (event.ctrlKey || event.metaKey) return;
1272
+ const arrow = ARROWS[event.key];
1273
+ if (arrow !== undefined) {
1274
+ arrowKey(event, arrow);
1275
+ } else if (event.altKey) {
1276
+ return;
1277
+ } else {
1278
+ switch (event.key) {
1279
+ case 'Home':
1280
+ placeCursor(0, cursor.pitch);
1281
+ break;
1282
+ case 'End':
1283
+ placeCursor(motifUnits(motif) - stepUnits(), cursor.pitch);
1284
+ break;
1285
+ case 'PageUp':
1286
+ moveWindow(1);
1287
+ break;
1288
+ case 'PageDown':
1289
+ moveWindow(-1);
1290
+ break;
1291
+ case 'v':
1292
+ // The velocity lane, on the note under the cursor.
1293
+ withNoteUnderCursor((note) => {
1294
+ selection = new Set([note.id]);
1295
+ render();
1296
+ laneEl.focus();
1297
+ });
1298
+ break;
1299
+ case 't':
1300
+ toggleTap();
1301
+ break;
1302
+ case 'd':
1303
+ cancelGesture();
1304
+ toggleDraw();
1305
+ break;
1306
+ case 's':
1307
+ toggleUnderCursor();
1308
+ break;
1309
+ case 'l':
1310
+ cancelGesture();
1311
+ alignAll();
1312
+ break;
1313
+ case 'Enter':
1314
+ cancelGesture();
1315
+ if (!event.repeat) apply(toggleAt(motif, { pitch: cursor.pitch, unit: cursor.unit }));
1316
+ break;
1317
+ case 'Delete':
1318
+ case 'Backspace':
1319
+ cancelGesture();
1320
+ // The selection goes in one step, else the note under the cursor; a held key erases once.
1321
+ if (event.repeat) break;
1322
+ if (selection.size > 0) apply(eraseNotes(motif, selectedIds()));
1323
+ else withNoteUnderCursor((note) => apply(eraseNote(motif, note.id)));
1324
+ break;
1325
+ case ' ':
1326
+ break;
1327
+ default:
1328
+ return;
1329
+ }
1330
+ }
1331
+ // A handled key never scrolls the box on its own.
1332
+ event.preventDefault();
1333
+ });
1334
+
1335
+ // Draw mode on or off. It changes how a drag on an empty cell reads, and nothing else; it is kept for the session only.
1336
+ function toggleDraw() {
1337
+ draw = !draw;
1338
+ notice = draw ? SCRATCH_COPY.drawOn : SCRATCH_COPY.drawOff;
1339
+ render();
1340
+ }
1341
+
1342
+ // --- Tap ---
1343
+
1344
+ // Tap on or off. Turning it on needs the music playing: with none the line says so and Tap stays off. Every change is told to the page.
1345
+ function setTapping(on, words) {
1346
+ if (tapping === on) return;
1347
+ tapping = on;
1348
+ if (!on) nowUnit = null;
1349
+ notice = words;
1350
+ render();
1351
+ onTapChange(on);
1352
+ }
1353
+
1354
+ function toggleTap() {
1355
+ if (tapping) {
1356
+ setTapping(false, SCRATCH_COPY.tapStopped);
1357
+ } else if (!musicPlaying) {
1358
+ notice = SCRATCH_COPY.tapNeedsMusic;
1359
+ render();
1360
+ } else {
1361
+ setTapping(true, SCRATCH_COPY.tapStarted);
1362
+ }
1363
+ }
1364
+
1365
+ // The button is never disabled, so a press with no music can say why; it is the only button that answers while it carries aria-disabled.
1366
+ tapEl.addEventListener('click', toggleTap);
1367
+
1368
+ function setMusicPlaying(playing) {
1369
+ musicPlaying = playing === true;
1370
+ if (!musicPlaying && tapping) setTapping(false, SCRATCH_COPY.tapMusicStopped);
1371
+ else render();
1372
+ }
1373
+
1374
+ function setNowUnit(unit) {
1375
+ if (unit !== null && (typeof unit !== 'number' || !Number.isFinite(unit))) {
1376
+ throw new TypeError(`Moontide: the Scratchpad marker needs a finite number of units or null, got ${String(unit)}`);
1377
+ }
1378
+ if (unit === nowUnit) return;
1379
+ nowUnit = unit;
1380
+ renderNow();
1381
+ }
1382
+
1383
+ // A key press the page measured: the stamp of the press, the page's clock read just before, and the stamps of two cycle lines. The
1384
+ // note lands at the cursor's row; a refused stamp or a clock that gave no lines says so in the line and places nothing.
1385
+ function tap({ ts, h, anchors }) {
1386
+ if (!tapping) return;
1387
+ applyCommand(tapNote(motif, { ts, h, anchors, pitch: cursor.pitch }));
1388
+ }
1389
+
1390
+ // --- Listen: the hear strip ---
1391
+
1392
+ // The menu's options as the page last gave them, the setup file's word, the option the user chose ('' for none), the way to start and Loop (kept
1393
+ // for the session only) and the last state the page told: { kind: 'idle' } until a play has been asked for.
1394
+ let outputs = [];
1395
+ let outputsKey = '';
1396
+ let outputsSetup = 'valid';
1397
+ let chosen = '';
1398
+ let startWay = 'music';
1399
+ let loopOn = false;
1400
+ let hear = { kind: 'idle' };
1401
+ // A play that waits or plays: the live overlay is paused and the playhead crosses the motif. Following the playhead needs a place to follow.
1402
+ function hearing() {
1403
+ return hear.kind === 'waiting' || hear.kind === 'playing';
1404
+ }
1405
+ const hearTracking = () => hearing() && hearUnit !== null;
1406
+ const hearActive = () => hear.kind === 'asking' || hearing();
1407
+
1408
+ // The state the strip's sentence is for: the page's, or while no play is going on what the strip itself can say (nothing to choose, nothing chosen).
1409
+ function shownHear() {
1410
+ if (hear.kind !== 'idle') return hear;
1411
+ if (outputs.length === 0) return { kind: 'no-instrument', setup: outputsSetup };
1412
+ if (chosen === '') return { kind: 'choose' };
1413
+ return null;
1414
+ }
1415
+
1416
+ // The option the Heads up sentences are for: the instrument and channel of a play that asks, waits or plays, else the one chosen in the menu.
1417
+ function warnedOption() {
1418
+ if (hearActive()) {
1419
+ const playing = outputs.find((candidate) => candidate.instrumentId === hear.instrumentId && candidate.channel === hear.channel);
1420
+ if (playing !== undefined) return playing;
1421
+ }
1422
+ return outputs.find((candidate) => candidate.value === chosen) ?? null;
1423
+ }
1424
+
1425
+ // The warnings never reach the buttons: they are words in the strip's one live region, after the sentence of the state, one sentence each.
1426
+ function hearSentences() {
1427
+ const shown = shownHear();
1428
+ const sentences = shown === null ? [] : [hearText(shown)];
1429
+ const option = warnedOption();
1430
+ if (option !== null) {
1431
+ const tracks = playingTracks(option.instrumentId, option.channel);
1432
+ if (!Array.isArray(tracks)) throw new TypeError('Moontide: playingTracks must give a list of track names.');
1433
+ sentences.push(
1434
+ ...hearWarnings({ instrumentId: option.instrumentId, channel: option.channel, tracks, monophonic: option.monophonic === true, chord: hasChord(motif) }),
1435
+ );
1436
+ }
1437
+ return sentences;
1438
+ }
1439
+
1440
+ let hearKey = '';
1441
+ function renderHear() {
1442
+ const sentences = hearSentences();
1443
+ const key = JSON.stringify(sentences.map((parts) => [parts.before, parts.id, parts.after]));
1444
+ if (key !== hearKey) {
1445
+ hearKey = key;
1446
+ const pieces = [];
1447
+ sentences.forEach((parts, index) => {
1448
+ // Sentences follow each other in one line; the space between them belongs to the first piece of the next one.
1449
+ let gap = index === 0 ? '' : ' ';
1450
+ for (const [text, className] of [[parts.before, ''], [parts.id, 'sp-hear-id'], [parts.after, '']]) {
1451
+ if (text === '') continue;
1452
+ const element = document.createElement('span');
1453
+ element.className = className;
1454
+ element.textContent = gap + text;
1455
+ gap = '';
1456
+ pieces.push(element);
1457
+ }
1458
+ });
1459
+ playNoteEl.replaceChildren(...pieces);
1460
+ }
1461
+ setAttr(viewEl, 'data-hear', hear.kind);
1462
+ const why = outputs.length === 0 ? hearText({ kind: 'no-instrument', setup: outputsSetup }).before : chosen === '' ? SCRATCH_COPY.hearChoose : null;
1463
+ setDisabled(playListenEl, why !== null, SCRATCH_COPY.listenTitle, why ?? '');
1464
+ setDisabled(playStopEl, !hearActive(), SCRATCH_COPY.stopTitle, SCRATCH_COPY.stopNothing);
1465
+ setAttr(playMusicEl, 'aria-pressed', startWay === 'music');
1466
+ setAttr(playNowEl, 'aria-pressed', startWay === 'now');
1467
+ setAttr(playLoopEl, 'aria-pressed', loopOn);
1468
+ }
1469
+
1470
+ // The menu of instruments and channels: the placeholder, then one option for each of `options` (page/scratch-model.js listenOptions). It is rebuilt
1471
+ // only when the list changed, and the choice stays when the new list still holds it. The change is seen on every field the view reads, so a
1472
+ // model saved as monophonic, a switch-off or a new port shows at once. `setup` is the setup file's word, for the sentence when the list is empty.
1473
+ function setOutputs(options, setup = 'valid') {
1474
+ if (!Array.isArray(options)) throw new TypeError('Moontide: the Scratchpad menu needs a list of options.');
1475
+ const key = JSON.stringify(options.map((option) => [option.value, option.text, option.title, option.port, option.enabled, option.monophonic]));
1476
+ outputsSetup = setup;
1477
+ if (key !== outputsKey) {
1478
+ outputsKey = key;
1479
+ outputs = options;
1480
+ const made = [];
1481
+ for (const [value, text, title] of [['', SCRATCH_COPY.hearOutputPlaceholder, null], ...options.map((option) => [option.value, option.text, option.title])]) {
1482
+ const element = document.createElement('option');
1483
+ element.value = value;
1484
+ element.textContent = text;
1485
+ if (title !== null) element.setAttribute('title', title);
1486
+ made.push(element);
1487
+ }
1488
+ playOutputEl.replaceChildren(...made);
1489
+ }
1490
+ if (chosen !== '' && !options.some((option) => option.value === chosen)) chosen = '';
1491
+ playOutputEl.value = chosen;
1492
+ renderHear();
1493
+ }
1494
+
1495
+ // What the page tells the view about a play: { kind, instrumentId, channel, ... } (see hearText), or { kind: 'idle' } for none.
1496
+ function setHear(next) {
1497
+ if (next === null || typeof next !== 'object' || typeof next.kind !== 'string') throw new TypeError('Moontide: the Scratchpad hear state needs an object with a kind.');
1498
+ if (next.kind !== 'idle') hearText(next);
1499
+ const was = hearing();
1500
+ hear = next;
1501
+ renderHear();
1502
+ if (hearing() === was) return;
1503
+ // The play began or ended: the live overlay is put away or comes back, and the playhead starts again from nothing.
1504
+ hearUnit = null;
1505
+ lastPlayhead = null;
1506
+ setLive(liveRaw);
1507
+ renderPlayhead();
1508
+ }
1509
+
1510
+ // Where the Listen play is in the motif, in units, or null while it waits or nothing plays; `frameMs` is the page's frame time, the clock that
1511
+ // lets following pause after a hand scroll. The playhead is drawn only while a play waits or plays, and the box turns to follow it as it does the
1512
+ // live playhead.
1513
+ function setHearPosition(unit, frameMs) {
1514
+ if (unit !== null && (typeof unit !== 'number' || !Number.isFinite(unit))) {
1515
+ throw new TypeError(`Moontide: the Scratchpad Listen playhead needs a finite number of units or null, got ${String(unit)}`);
1516
+ }
1517
+ if (Number.isFinite(frameMs)) lastFrameMs = frameMs;
1518
+ if (unit === hearUnit) return;
1519
+ hearUnit = unit;
1520
+ if (!hearing()) return;
1521
+ renderPlayhead();
1522
+ if (unit !== null) follow(unit);
1523
+ }
1524
+
1525
+ // The page tells the view that the playing code or the playing state changed, so the tracks on the chosen channel are asked again.
1526
+ function refreshHear() {
1527
+ renderHear();
1528
+ }
1529
+
1530
+ // A press of Listen: with notes and a choice it asks the page to play; the view never plays or sends. A press with no notes still replaces
1531
+ // the play before it: that play is stopped first, then the refusal is said, so Stop is never greyed out over a play that goes on.
1532
+ function listenPress() {
1533
+ if (motif.notes.length === 0) {
1534
+ if (hearActive()) onStop();
1535
+ setHear({ kind: 'refused', refused: 'empty', instrumentId: '', channel: '', mid: false });
1536
+ return;
1537
+ }
1538
+ const option = outputs.find((candidate) => candidate.value === chosen);
1539
+ if (option === undefined) return;
1540
+ onListen({ instrumentId: option.instrumentId, channel: option.channel, start: startWay, loop: loopOn });
1541
+ }
1542
+
1543
+ playOutputEl.addEventListener('change', () => {
1544
+ chosen = playOutputEl.value;
1545
+ // A sentence about a play that is over belongs to the instrument it was for.
1546
+ if (!hearActive()) hear = { kind: 'idle' };
1547
+ renderHear();
1548
+ });
1549
+ onPress(playMusicEl, () => {
1550
+ startWay = 'music';
1551
+ renderHear();
1552
+ });
1553
+ onPress(playNowEl, () => {
1554
+ startWay = 'now';
1555
+ renderHear();
1556
+ });
1557
+ onPress(playLoopEl, () => {
1558
+ loopOn = !loopOn;
1559
+ renderHear();
1560
+ onLoop(loopOn);
1561
+ });
1562
+ onPress(playListenEl, listenPress);
1563
+ onPress(playStopEl, () => onStop());
1564
+
1565
+ // --- the toolbar ---
1566
+
1567
+ // A command of the toolbar: a refusal goes to the line and changes nothing; a command that changes nothing is no edit.
1568
+ function applyCommand(result) {
1569
+ if (result.refused !== undefined) {
1570
+ notice = result.refused;
1571
+ render();
1572
+ return;
1573
+ }
1574
+ if (result.motif === motif && result.notice === '') return;
1575
+ apply(result);
1576
+ }
1577
+
1578
+ // Record that takes the keyboard's snapshot (the page reads it) and puts it on the roll through the one apply function, so Undo takes it
1579
+ // back. The pitch window moves to show the notes, with the cursor on the lowest one. A snapshot of null means the music's clock could
1580
+ // not be read; an empty snapshot is said by the model. Either goes to the line and changes nothing. Returns whether the motif was
1581
+ // replaced, so the page can empty the keyboard buffer: the live layer showed exactly the notes that were just written.
1582
+ function record(snapshot) {
1583
+ if (snapshot === null || snapshot === undefined) {
1584
+ notice = SCRATCH_COPY.recordClockUnreadable;
1585
+ render();
1586
+ return false;
1587
+ }
1588
+ const result = recordBuffer(motif, snapshot);
1589
+ if (result.refused !== undefined) {
1590
+ applyCommand(result);
1591
+ return false;
1592
+ }
1593
+ cancelGesture();
1594
+ base = windowStartFor(result.motif);
1595
+ const lowest = result.motif.notes.reduce((best, note) => (note.pitch < best.pitch || (note.pitch === best.pitch && note.start < best.start) ? note : best));
1596
+ apply(result, { unit: lowest.start, pitch: lowest.pitch });
1597
+ return true;
1598
+ }
1599
+
1600
+ // The page asks for MIDI only from this press, through the one shared enable.
1601
+ allowEl.addEventListener('click', () => onAllow());
1602
+
1603
+ // Send to Claude: the only way the motif leaves the view, and only by the user's press. The words are read here and nowhere else.
1604
+ // Whether the page is connected and whether the last send is still on its way is told by the page (setSendState).
1605
+ let sendConnected = true;
1606
+ let sendPending = false;
1607
+ // The line written for the last request that went, so the sentence of a request that waits for Claude can follow it.
1608
+ let sentLine = '';
1609
+
1610
+ // The word for why a send cannot go now, or null.
1611
+ function sendBlock() {
1612
+ if (motif.notes.length === 0) return 'no-notes';
1613
+ if (!sendConnected) return 'disconnected';
1614
+ if (sendPending) return 'pending';
1615
+ return null;
1616
+ }
1617
+
1618
+ function send() {
1619
+ const blocked = sendBlock();
1620
+ if (blocked !== null) {
1621
+ setText(sendStatusEl, sendRefusalText({ refused: blocked }));
1622
+ return;
1623
+ }
1624
+ const request = motifRequest(motif, musicCycle());
1625
+ const result = onSend({ words: wordsEl.value, motif: request });
1626
+ if (result !== undefined && result !== null && result.refused !== undefined) setText(sendStatusEl, sendRefusalText(result));
1627
+ else if (result !== undefined && result !== null && result.id !== undefined) {
1628
+ sentLine = sentText(request);
1629
+ setText(sendStatusEl, sentLine);
1630
+ } else throw new Error('Moontide: onSend must answer { id } for a request that went or { refused } for one that did not.');
1631
+ }
1632
+
1633
+ // The page tells the view whether it is connected to Moontide and whether the last motif request is still on its way.
1634
+ function setSendState(state) {
1635
+ if (state === null || typeof state !== 'object' || typeof state.connected !== 'boolean' || typeof state.pending !== 'boolean') {
1636
+ throw new TypeError('Moontide: the Scratchpad send state needs connected and pending, both true or false.');
1637
+ }
1638
+ sendConnected = state.connected;
1639
+ sendPending = state.pending;
1640
+ renderSend();
1641
+ }
1642
+
1643
+ // What became of the request the page sent: a refusal or a request nobody confirmed is said in the status line; one that arrived keeps the sent
1644
+ // line, and one that waits for Claude says so after it.
1645
+ function motifResult(result) {
1646
+ const words = motifResultText(result);
1647
+ if (words === null) return;
1648
+ setText(sendStatusEl, result.status === 'waiting' ? `${sentLine} ${words}`.trim() : words);
1649
+ }
1650
+
1651
+ sendEl.addEventListener('click', send);
1652
+
1653
+ wordsEl.addEventListener('keydown', (event) => {
1654
+ if (event.key !== 'Enter' || event.isComposing) return;
1655
+ // No other key handler of the page sees this Enter, and a held key does not send twice.
1656
+ event.preventDefault();
1657
+ event.stopPropagation();
1658
+ if (event.repeat) return;
1659
+ send();
1660
+ });
1661
+
1662
+ // The button keeps focus when it has nothing to take: a press then says why in the line, like the Tap button.
1663
+ recordEl.addEventListener('click', () => {
1664
+ if (heardCount === 0) {
1665
+ notice = SCRATCH_COPY.recordNothing;
1666
+ render();
1667
+ return;
1668
+ }
1669
+ onRecord();
1670
+ });
1671
+
1672
+ // A button that carries aria-disabled does nothing when it is pressed.
1673
+ function onPress(element, act) {
1674
+ element.addEventListener('click', () => {
1675
+ if (element.getAttribute('aria-disabled') === 'true') return;
1676
+ act();
1677
+ });
1678
+ }
1679
+
1680
+ for (const { grid, element } of gridButtons) onPress(element, () => applyCommand(setGrid(motif, grid.id)));
1681
+ onPress(fewerEl, () => applyCommand(setCycles(motif, motif.cycles - 1)));
1682
+ onPress(moreEl, () => applyCommand(setCycles(motif, motif.cycles + 1)));
1683
+ onPress(lowerEl, () => moveWindow(-1));
1684
+ onPress(higherEl, () => moveWindow(1));
1685
+ onPress(drawEl, () => {
1686
+ cancelGesture();
1687
+ toggleDraw();
1688
+ });
1689
+ // Earlier and Later move the selection, else the whole motif, one grid step; a press that cannot move says why, as the title does. Align moves the
1690
+ // whole motif whatever is selected. Clear empties the roll with no question: the notice names Undo as the way back. A press on a button that
1691
+ // carries aria-disabled gets the same sentence from the model, so none of them needs a check of its own.
1692
+ const nudge = (direction) => {
1693
+ cancelGesture();
1694
+ if (motif.notes.length === 0) {
1695
+ notice = SCRATCH_COPY.moveNothing;
1696
+ render();
1697
+ return;
1698
+ }
1699
+ applyCommand(moveNotes(motif, movedIds(), { dStart: direction * stepUnits() }));
1700
+ };
1701
+ function alignAll() {
1702
+ applyCommand(alignMotif(motif));
1703
+ }
1704
+ earlierEl.addEventListener('click', () => nudge(-1));
1705
+ laterEl.addEventListener('click', () => nudge(1));
1706
+ alignEl.addEventListener('click', () => {
1707
+ cancelGesture();
1708
+ alignAll();
1709
+ });
1710
+ clearEl.addEventListener('click', () => {
1711
+ cancelGesture();
1712
+ applyCommand(clearMotif(motif));
1713
+ });
1714
+ onPress(undoEl, () => stepHistory(undo));
1715
+ onPress(redoEl, () => stepHistory(redo));
1716
+
1717
+ // Ctrl or Cmd with Z undoes, with Shift redoes (Ctrl+Y redoes too). The keys are heard on the view alone, so they reach the motif only
1718
+ // while focus is inside the Scratchpad; the code editor's own undo is never taken. The roll and the lane leave these keys alone.
1719
+ viewEl.addEventListener('keydown', (event) => {
1720
+ // The words field has its own undo for what is typed in it.
1721
+ if (event.target === wordsEl) return;
1722
+ if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
1723
+ const letter = typeof event.key === 'string' ? event.key.toLowerCase() : '';
1724
+ if (letter === 'z') stepHistory(event.shiftKey ? redo : undo);
1725
+ else if (letter === 'y' && event.ctrlKey && !event.shiftKey) stepHistory(redo);
1726
+ else return;
1727
+ event.preventDefault();
1728
+ });
1729
+
1730
+ // --- the mouse ---
1731
+
1732
+ // The point of an event on the grid, as the numbers the gesture helpers read. The grid's box is read again on every call, because
1733
+ // the box scrolls under a drag.
1734
+ function readPoint(event) {
1735
+ const rect = gridEl.getBoundingClientRect();
1736
+ // The pointer is read against the units on screen (the motif's, or the buffer's 8 cycles while live); `limit` is the motif's own end, where
1737
+ // every edit stops, and a cell past it takes no press.
1738
+ const units = displayUnits();
1739
+ const limit = motifUnits(motif);
1740
+ const geometry = { rect, x: event.clientX, y: event.clientY, units, gridUnit: stepUnits(), rows: shown.rows, high: shown.high };
1741
+ const cell = cellAt(geometry);
1742
+ return { rect, units, limit, at: pointerUnit(geometry), row: pointerRow(geometry), cell: cell !== null && cell.unit >= limit ? null : cell };
1743
+ }
1744
+
1745
+ function setHover(id, cell) {
1746
+ const same = id === hoverId && (cell === null ? hoverCell === null : hoverCell !== null && hoverCell.unit === cell.unit && hoverCell.pitch === cell.pitch);
1747
+ if (same) return;
1748
+ hoverId = id;
1749
+ hoverCell = cell;
1750
+ render();
1751
+ }
1752
+
1753
+ // With no press on: the note under the pointer is read and outlined, or the empty cell a drag would start in.
1754
+ function hover(event) {
1755
+ const { at, cell } = readPoint(event);
1756
+ if (cell === null) {
1757
+ setHover(null, null);
1758
+ return;
1759
+ }
1760
+ const note = noteAt(motif, cell.pitch, Math.floor(at));
1761
+ if (note !== null) setHover(note.id, null);
1762
+ else setHover(null, cell);
1763
+ }
1764
+
1765
+ function setPreview(next) {
1766
+ const same = preview !== null && next !== null && preview.pitch === next.pitch && preview.start === next.start && preview.length === next.length;
1767
+ if (same || (preview === null && next === null)) return;
1768
+ preview = next;
1769
+ render();
1770
+ }
1771
+
1772
+ // Ends a press with nothing committed: the preview goes and the pointer is let go.
1773
+ function cancelGesture() {
1774
+ if (press === null && lanePress === null) return;
1775
+ if (press !== null) {
1776
+ const { pointerId, startSelection } = press;
1777
+ // A press that is cancelled changes nothing, the selection it was building included.
1778
+ selection = startSelection;
1779
+ syncSelection();
1780
+ press = null;
1781
+ preview = null;
1782
+ rectBox = null;
1783
+ groupDraft = null;
1784
+ if (gridEl.hasPointerCapture(pointerId)) gridEl.releasePointerCapture(pointerId);
1785
+ }
1786
+ if (lanePress !== null) {
1787
+ const { pointerId } = lanePress;
1788
+ lanePress = null;
1789
+ laneDraft = null;
1790
+ if (barsEl.hasPointerCapture(pointerId)) barsEl.releasePointerCapture(pointerId);
1791
+ }
1792
+ render();
1793
+ flushLive();
1794
+ }
1795
+
1796
+ // A drag near the edge of the part of the roll that shows scrolls the box by a fixed step on each pointer move. There is no timer:
1797
+ // the pointer has to keep moving, which keeps the scroll as steady as the hand.
1798
+ function scrollAtEdge(event) {
1799
+ const step = edgeScrollPx({ x: event.clientX, left: gutterEl.getBoundingClientRect().right, right: scrollEl.getBoundingClientRect().right, remPx: press.remPx });
1800
+ if (step !== 0) writeScroll(Math.max(0, scrollEl.scrollLeft + step));
1801
+ }
1802
+
1803
+ gridEl.addEventListener('pointerdown', (event) => {
1804
+ if (press !== null || lanePress !== null || event.button !== 0) return;
1805
+ const { rect, units, at, row, cell } = readPoint(event);
1806
+ if (cell === null) return;
1807
+ const remPx = rootFontPx();
1808
+ const note = noteAt(motif, cell.pitch, Math.floor(at));
1809
+ // On a note, the right edge resizes it and the rest of it moves it.
1810
+ let part = null;
1811
+ if (note !== null) {
1812
+ const left = rect.left + (note.start / units) * rect.width;
1813
+ part = pickPart({ left, width: (note.length / units) * rect.width, x: event.clientX, remPx });
1814
+ }
1815
+ // What the press does once it is a drag: a box in Select mode or a note in Draw mode on an empty cell, else a move or a resize.
1816
+ const kind = note === null ? (draw ? 'draw' : 'rect') : part === 'edge' ? 'edge' : 'move';
1817
+ // Shift, Ctrl or Cmd adds to the selection instead of replacing it (Ctrl on every system: no platform is read here).
1818
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey;
1819
+ press = {
1820
+ pointerId: event.pointerId,
1821
+ x: event.clientX,
1822
+ y: event.clientY,
1823
+ remPx,
1824
+ thresholdPx: DRAG_THRESHOLD_REM * remPx,
1825
+ cell,
1826
+ at0: at,
1827
+ // The press is kept by pitch, not by row: while live the range can grow under a drag, and a row counted from the old top would
1828
+ // then point an octave away. `pitchY0` is the press's height in pitches, the top edge of a row being its pitch plus one.
1829
+ pitch0: shown.high - row,
1830
+ pitchY0: shown.high + 1 - (event.clientY - rect.top) / (rect.height / shown.rows),
1831
+ note,
1832
+ part,
1833
+ kind,
1834
+ additive,
1835
+ startSelection: new Set(selection),
1836
+ toggleOff: false,
1837
+ group: false,
1838
+ locked: false,
1839
+ };
1840
+ if (note !== null) {
1841
+ if (additive) {
1842
+ // A modifier press adds the note now, or marks it to be taken out if the press turns out to be a click.
1843
+ press.toggleOff = selection.has(note.id);
1844
+ if (!press.toggleOff) selection = new Set([...selection, note.id]);
1845
+ } else if (!selection.has(note.id)) {
1846
+ // A press on a note that is not selected selects it, so a drag moves it alone.
1847
+ selection = new Set([note.id]);
1848
+ }
1849
+ // A drag of a selected note carries the whole selection, as one rigid group.
1850
+ press.group = selection.size > 1;
1851
+ }
1852
+ hoverId = null;
1853
+ hoverCell = null;
1854
+ // The grid keeps the pointer: the notes under it are redrawn, and a drag may leave the box.
1855
+ gridEl.setPointerCapture(event.pointerId);
1856
+ render();
1857
+ });
1858
+
1859
+ gridEl.addEventListener('pointermove', (event) => {
1860
+ if (press === null) {
1861
+ hover(event);
1862
+ return;
1863
+ }
1864
+ if (event.pointerId !== press.pointerId) return;
1865
+ if (!press.locked) {
1866
+ if (!lockDrag({ dx: event.clientX - press.x, dy: event.clientY - press.y, thresholdPx: press.thresholdPx })) return;
1867
+ press.locked = true;
1868
+ }
1869
+ scrollAtEdge(event);
1870
+ const { rect, at, row, units, limit } = readPoint(event);
1871
+ const { note, cell } = press;
1872
+ if (press.kind === 'rect') {
1873
+ showBox(event, rect, units, limit);
1874
+ } else if (press.kind === 'draw') {
1875
+ setPreview({ pitch: cell.pitch, start: cell.unit, length: drawLength({ start: cell.unit, pointerUnit: at, gridUnit: stepUnits(), units: limit }), velocity: DEFAULT_VELOCITY });
1876
+ } else if (press.group) {
1877
+ showGroup(at, row, limit);
1878
+ } else if (press.part === 'edge') {
1879
+ setPreview({ ...note, length: resizeLength({ start: note.start, pointerUnit: at, gridUnit: stepUnits(), units: limit }) });
1880
+ } else {
1881
+ const target = moveTarget({ note, dUnits: at - press.at0, dRows: row - rowOf(press.pitch0), gridUnit: stepUnits(), units: limit, low: shown.low, high: shown.high });
1882
+ setPreview({ ...note, start: target.start, pitch: target.pitch });
1883
+ }
1884
+ });
1885
+
1886
+ // A drag of a note that belongs to a selection of several: the whole group moves by the same distance, or changes length by the same
1887
+ // number of steps, and is drawn there until the release commits it.
1888
+ function showGroup(at, row, units) {
1889
+ const { note } = press;
1890
+ let next;
1891
+ if (press.part === 'edge') {
1892
+ const length = resizeLength({ start: note.start, pointerUnit: at, gridUnit: stepUnits(), units });
1893
+ next = { kind: 'edge', dStart: 0, dPitch: 0, dSteps: (length - note.length) / stepUnits() };
1894
+ } else {
1895
+ const target = groupMoveTarget({
1896
+ notes: motif.notes.filter((candidate) => selection.has(candidate.id)),
1897
+ dUnits: at - press.at0,
1898
+ dRows: row - rowOf(press.pitch0),
1899
+ gridUnit: stepUnits(),
1900
+ units,
1901
+ low: shown.low,
1902
+ high: shown.high,
1903
+ });
1904
+ next = { kind: 'move', dStart: target.dStart, dPitch: target.dPitch, dSteps: 0 };
1905
+ }
1906
+ if (groupDraft !== null && Object.keys(next).every((name) => next[name] === groupDraft[name])) return;
1907
+ groupDraft = next;
1908
+ render();
1909
+ }
1910
+
1911
+ // The box of a selection drag, from the press to the pointer: the notes it touches become the selection while the pointer moves, and
1912
+ // nothing enters the history. The press is kept in units and pitches, so a box still starts where it did after the roll scrolled or the
1913
+ // live range grew.
1914
+ function showBox(event, rect, units, limit) {
1915
+ const spanned = selectionBox({
1916
+ rect,
1917
+ x0: rect.left + (press.at0 / units) * rect.width,
1918
+ y0: rect.top + ((shown.high + 1 - press.pitchY0) / shown.rows) * rect.height,
1919
+ x1: event.clientX,
1920
+ y1: event.clientY,
1921
+ units,
1922
+ rows: shown.rows,
1923
+ });
1924
+ // The box stops at the motif's own end: the cells past it (live shows 8 cycles) take no selection.
1925
+ const box = { ...spanned, fromUnit: Math.min(spanned.fromUnit, limit), toUnit: Math.min(spanned.toUnit, limit) };
1926
+ const hits = notesInBox({ notes: motif.notes, box, high: shown.high });
1927
+ const same = rectBox !== null && Object.keys(box).every((name) => box[name] === rectBox[name]) && hits.every((id) => selection.has(id)) && (press.additive || hits.length === selection.size);
1928
+ if (same) return;
1929
+ rectBox = box;
1930
+ selection = new Set(press.additive ? [...press.startSelection, ...hits] : hits);
1931
+ render();
1932
+ }
1933
+
1934
+ // The cursor goes to the step under the pointer, held inside the note, so the note is the one under the cursor.
1935
+ function cursorOn(note, at) {
1936
+ const step = stepUnits();
1937
+ return { unit: clamp(Math.floor(at / step) * step, note.start, note.start + note.length - step), pitch: note.pitch };
1938
+ }
1939
+
1940
+ gridEl.addEventListener('pointerup', (event) => {
1941
+ if (press === null || event.pointerId !== press.pointerId) return;
1942
+ const done = press;
1943
+ const draft = preview;
1944
+ const groupMove = groupDraft;
1945
+ press = null;
1946
+ preview = null;
1947
+ rectBox = null;
1948
+ groupDraft = null;
1949
+ if (gridEl.hasPointerCapture(done.pointerId)) gridEl.releasePointerCapture(done.pointerId);
1950
+ const { at } = readPoint(event);
1951
+ const { note } = done;
1952
+ if (note === null) {
1953
+ if (done.kind === 'rect' && done.locked) {
1954
+ // The box already made the selection while it ran; the release commits nothing.
1955
+ render();
1956
+ } else if (done.kind === 'draw' && done.locked && draft !== null) {
1957
+ const result = placeNote(motif, { pitch: done.cell.pitch, start: done.cell.unit, length: draft.length });
1958
+ selection = new Set([result.note.id]);
1959
+ apply(result, cursorOn(result.note, at));
1960
+ } else {
1961
+ // A click on empty space never writes a note: it clears the selection, unless a modifier is held, and puts the cursor on the cell.
1962
+ if (!done.additive) selection = new Set();
1963
+ placeCursor(done.cell.unit, done.cell.pitch);
1964
+ }
1965
+ } else if (done.group && groupMove !== null) {
1966
+ // What the group showed is what is committed.
1967
+ const ids = selectedIds();
1968
+ const result = groupMove.kind === 'edge' ? resizeNotes(motif, ids, groupMove.dSteps) : moveNotes(motif, ids, { dStart: groupMove.dStart, dPitch: groupMove.dPitch });
1969
+ const where = { ...note, start: note.start + groupMove.dStart, pitch: note.pitch + groupMove.dPitch };
1970
+ if (result.refused !== undefined) {
1971
+ applyCommand(result);
1972
+ } else if (result.motif === motif && result.notice === '') {
1973
+ const place = cursorOn(note, done.cell.unit);
1974
+ placeCursor(place.unit, place.pitch);
1975
+ } else {
1976
+ apply(result, cursorOn(where, at));
1977
+ }
1978
+ } else if (draft === null) {
1979
+ // A press on a note that did not become a drag only selects it (a modifier adds it or takes it out), and nothing else.
1980
+ if (!done.additive) selection = new Set([note.id]);
1981
+ else if (done.toggleOff) selection = new Set([...selection].filter((id) => id !== note.id));
1982
+ const place = cursorOn(note, done.cell.unit);
1983
+ placeCursor(place.unit, place.pitch);
1984
+ } else {
1985
+ // What was previewed is what is committed, whatever the pointer did since.
1986
+ const result = done.part === 'edge' ? resizeNote(motif, note.id, draft.length) : moveNote(motif, note.id, { start: draft.start, pitch: draft.pitch });
1987
+ if (result.motif === motif && result.notice === '') {
1988
+ const place = cursorOn(note, done.cell.unit);
1989
+ placeCursor(place.unit, place.pitch);
1990
+ } else {
1991
+ apply(result, cursorOn(result.note, at));
1992
+ }
1993
+ }
1994
+ hover(event);
1995
+ flushLive();
1996
+ });
1997
+
1998
+ // A double-click on an empty cell writes a one-step note there and selects it; on a note it only selects it. It goes through placeNote,
1999
+ // never toggleAt, so a cell that already holds the note is not erased by the second click. A plain click writes nothing.
2000
+ gridEl.addEventListener('dblclick', (event) => {
2001
+ if (event.button !== 0 || press !== null || lanePress !== null) return;
2002
+ const { at, cell } = readPoint(event);
2003
+ if (cell === null) return;
2004
+ const note = noteAt(motif, cell.pitch, Math.floor(at));
2005
+ if (note !== null) {
2006
+ selection = new Set([note.id]);
2007
+ const place = cursorOn(note, cell.unit);
2008
+ placeCursor(place.unit, place.pitch);
2009
+ return;
2010
+ }
2011
+ const result = placeNote(motif, { pitch: cell.pitch, start: cell.unit, length: stepUnits() });
2012
+ selection = new Set([result.note.id]);
2013
+ apply(result, cursorOn(result.note, at));
2014
+ });
2015
+
2016
+ // --- the velocity lane ---
2017
+
2018
+ // A press on the column of a bar selects its note and puts the cursor on it; a drag then sets the velocity from the pointer's height,
2019
+ // shown live, and the release writes it as one edit. Nothing is written before the release.
2020
+ barsEl.addEventListener('pointerdown', (event) => {
2021
+ if (press !== null || lanePress !== null || event.button !== 0 || event.ctrlKey || event.metaKey) return;
2022
+ const remPx = rootFontPx();
2023
+ const id = barAt({ rect: barsEl.getBoundingClientRect(), x: event.clientX, bars: barLayout(), units: displayUnits(), gridUnit: stepUnits(), remPx });
2024
+ if (id === null) return;
2025
+ const note = motif.notes.find((candidate) => candidate.id === id);
2026
+ lanePress = { pointerId: event.pointerId, id, x: event.clientX, y: event.clientY, thresholdPx: DRAG_THRESHOLD_REM * remPx, locked: false };
2027
+ hoverId = null;
2028
+ hoverCell = null;
2029
+ barsEl.setPointerCapture(event.pointerId);
2030
+ selection = new Set([id]);
2031
+ placeCursor(note.start, note.pitch);
2032
+ });
2033
+
2034
+ barsEl.addEventListener('pointermove', (event) => {
2035
+ if (lanePress === null || event.pointerId !== lanePress.pointerId) return;
2036
+ if (!lanePress.locked) {
2037
+ if (!lockDrag({ dx: event.clientX - lanePress.x, dy: event.clientY - lanePress.y, thresholdPx: lanePress.thresholdPx })) return;
2038
+ lanePress.locked = true;
2039
+ }
2040
+ const velocity = velocityAt({ rect: barsEl.getBoundingClientRect(), y: event.clientY });
2041
+ if (laneDraft !== null && laneDraft.velocity === velocity) return;
2042
+ laneDraft = { id: lanePress.id, velocity };
2043
+ render();
2044
+ });
2045
+
2046
+ barsEl.addEventListener('pointerup', (event) => {
2047
+ if (lanePress === null || event.pointerId !== lanePress.pointerId) return;
2048
+ const done = lanePress;
2049
+ const draft = laneDraft;
2050
+ lanePress = null;
2051
+ laneDraft = null;
2052
+ if (barsEl.hasPointerCapture(done.pointerId)) barsEl.releasePointerCapture(done.pointerId);
2053
+ if (draft === null) {
2054
+ render();
2055
+ } else {
2056
+ // What was shown is what is written, whatever the pointer did since.
2057
+ const result = setVelocity(motif, done.id, draft.velocity);
2058
+ if (result.motif === motif && result.notice === '') render();
2059
+ else apply(result, { unit: result.note.start, pitch: result.note.pitch });
2060
+ }
2061
+ flushLive();
2062
+ });
2063
+
2064
+ for (const type of ['pointercancel', 'lostpointercapture']) {
2065
+ barsEl.addEventListener(type, (event) => {
2066
+ if (lanePress !== null && event.pointerId === lanePress.pointerId) cancelGesture();
2067
+ });
2068
+ }
2069
+
2070
+ // Left and Right go from note to note in the lane's order; with nothing selected, Right takes the first note and Left the last.
2071
+ function selectNote(direction) {
2072
+ const order = laneOrder(motif);
2073
+ if (order.length === 0) {
2074
+ notice = SCRATCH_COPY.noNote;
2075
+ render();
2076
+ return;
2077
+ }
2078
+ const here = laneNote();
2079
+ const at = here === undefined ? -1 : order.indexOf(here.id);
2080
+ const index = at === -1 ? (direction > 0 ? 0 : order.length - 1) : clamp(at + direction, 0, order.length - 1);
2081
+ const note = motif.notes.find((candidate) => candidate.id === order[index]);
2082
+ selection = new Set([note.id]);
2083
+ placeCursor(note.start, note.pitch);
2084
+ }
2085
+
2086
+ // The note the lane works on: the only selected note, else the note under the cursor.
2087
+ function laneNote() {
2088
+ if (selection.size === 1) return motif.notes.find((candidate) => selection.has(candidate.id));
2089
+ return noteAt(motif, cursor.pitch, cursor.unit) ?? undefined;
2090
+ }
2091
+
2092
+ // Up and Down change the velocity of every selected note by the same step, else of the lane's note; at an end of the range there is
2093
+ // nothing to change.
2094
+ function changeVelocity(delta) {
2095
+ const note = laneNote();
2096
+ if (selection.size === 0 && note === undefined) {
2097
+ notice = SCRATCH_COPY.noNote;
2098
+ render();
2099
+ return;
2100
+ }
2101
+ const ids = selection.size > 0 ? selectedIds() : [note.id];
2102
+ const result = setVelocities(motif, ids, delta);
2103
+ if (result.motif === motif && result.notice === '') return;
2104
+ apply(result, note === undefined ? null : { unit: note.start, pitch: note.pitch });
2105
+ }
2106
+
2107
+ laneEl.addEventListener('keydown', (event) => {
2108
+ // Select all and select none work from the lane too; the other Ctrl, Cmd and Alt combinations are left alone.
2109
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && typeof event.key === 'string' && event.key.toLowerCase() === 'a') {
2110
+ if (event.shiftKey) selectNone();
2111
+ else selectAll();
2112
+ event.preventDefault();
2113
+ return;
2114
+ }
2115
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
2116
+ switch (event.key) {
2117
+ case 'ArrowLeft':
2118
+ selectNote(-1);
2119
+ break;
2120
+ case 'ArrowRight':
2121
+ selectNote(1);
2122
+ break;
2123
+ case 'ArrowUp':
2124
+ changeVelocity(event.shiftKey ? 1 : 5);
2125
+ break;
2126
+ case 'ArrowDown':
2127
+ changeVelocity(event.shiftKey ? -1 : -5);
2128
+ break;
2129
+ default:
2130
+ return;
2131
+ }
2132
+ event.preventDefault();
2133
+ });
2134
+
2135
+ // Whether a node is the roll or lies inside it: the piano names, the grid and the notes. The ruler and the information line do not.
2136
+ function rollContains(node) {
2137
+ return node !== null && node !== undefined && rollEl.contains(node);
2138
+ }
2139
+
2140
+ // A right-click inside the roll erases the note under it and opens nothing; on empty space it does nothing. The page's one contextmenu
2141
+ // listener calls this for a right-click inside the roll, so the Ask box does not open there. Returns whether the roll took the click.
2142
+ function handleContextMenu(event) {
2143
+ if (!rollContains(event.target)) return false;
2144
+ // A Ctrl+click is a right-click on macOS, and here it adds to the selection: the roll takes the click and erases nothing.
2145
+ if (event.ctrlKey || press !== null) return true;
2146
+ const { at, cell } = readPoint(event);
2147
+ if (cell === null) return true;
2148
+ const note = noteAt(motif, cell.pitch, Math.floor(at));
2149
+ if (note !== null) apply(eraseNote(motif, note.id));
2150
+ return true;
2151
+ }
2152
+
2153
+ gridEl.addEventListener('pointerleave', () => {
2154
+ if (press === null) setHover(null, null);
2155
+ });
2156
+
2157
+ for (const type of ['pointercancel', 'lostpointercapture']) {
2158
+ gridEl.addEventListener(type, (event) => {
2159
+ if (press !== null && event.pointerId === press.pointerId) cancelGesture();
2160
+ });
2161
+ }
2162
+
2163
+ function closed() {
2164
+ cancelGesture();
2165
+ hasFocus = false;
2166
+ hoverId = null;
2167
+ hoverCell = null;
2168
+ // Tap does not outlast the view; the page is told, and the line is cleared as it always was.
2169
+ setTapping(false, null);
2170
+ notice = null;
2171
+ render();
2172
+ }
2173
+
2174
+ render();
2175
+ return { render, closed, cancelGesture, handleContextMenu, rollContains, resize, tapOn: () => tapping, motifCycles: () => motif.cycles, setMusicPlaying, setNowUnit, tap, setListening, record, setLive, setSendState, motifResult, setOutputs, setHear, setHearPosition, refreshHear, motif: () => motif };
2176
+ }