moontide-mcp 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -5
- package/dist/ask.js +30 -8
- package/dist/mcp.js +60 -14
- package/dist/protocol.js +89 -2
- package/dist/reference.js +22 -0
- package/dist/session.js +53 -0
- package/package.json +1 -1
- package/page/ask-view.js +21 -7
- package/page/ask.js +292 -34
- package/page/change-timing.js +25 -4
- package/page/harmony.js +13 -0
- package/page/index.html +4 -3
- package/page/jam-model.js +11 -2
- package/page/jam-view.js +17 -0
- package/page/lights.js +278 -0
- package/page/main.js +1723 -140
- package/page/midi-clock.js +1 -1
- package/page/midi-out.js +12 -0
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +282 -13
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +381 -0
- package/page/note-tag.js +126 -0
- package/page/page-rewrite.js +184 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +491 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +760 -0
- package/page/style.css +879 -84
- package/page/takeover.js +20 -5
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +196 -3
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +25 -8
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
// DOM view of Moontide's editable Timeline: the note panel under the lanes and the marks on the lanes. A click on a note
|
|
2
|
+
// selects it (a ring around the note and the panel under the lanes, which names the note, its pitch, its velocity and whether
|
|
3
|
+
// the page can edit it). The keys Up and Down (Shift: an octave), the panel's pitch buttons and its velocity buttons are steps:
|
|
4
|
+
// they gather, and a pause after the last one asks the page once, so a run of presses is one change. Delete, Backspace and the
|
|
5
|
+
// Make a rest button ask for a rest at once. Dragging the note up or down changes its pitch too: a dashed ghost and a sentence
|
|
6
|
+
// show the new pitch while the button is held, and the release asks the page once. A drag that is cancelled (Escape, a pointer
|
|
7
|
+
// cancel, a lost capture, a window blur, a redraw or a page turn) writes nothing. Steps that wait for the pause are written for
|
|
8
|
+
// their note before the selection moves to another note, a cursor or a range, and before the lanes are drawn again; only Escape
|
|
9
|
+
// drops them on purpose, and a drag that takes their place says so in the sentence. Every edit (a burst of steps, a rest, a drag
|
|
10
|
+
// release) is one record aimed at its note and waits its turn in one queue: the page has at most one edit at a time, and the
|
|
11
|
+
// next goes only after the answer of the one before and the lanes that answer brings, aimed at its note as those lanes draw it.
|
|
12
|
+
// An answer is matched to its edit and speaks for that note only; an answer late by a bounded wait is said to be late, and the
|
|
13
|
+
// queue goes on after a second bounded wait. A closed Timeline or a page turn empties the queue, but the edit already on its way
|
|
14
|
+
// is still written and holds the next one until its answer. A drag across an empty part of a lane, or sideways from a
|
|
15
|
+
// note, selects whole cycles of that lane: a wash over them, held inside the page, and on release the page opens the Ask box for
|
|
16
|
+
// the range. The wash stays until Escape, a click on empty area, another selection, a redraw or a page turn takes it away, or
|
|
17
|
+
// until the request is sent; the panel names the range and offers only Ask Claude. Shift with Left or Right starts a range at the
|
|
18
|
+
// selected note's cycle and widens it a cycle a press. Selecting a range writes nothing and sends nothing. The keyboard alone
|
|
19
|
+
// does all of it: the lanes are one Tab stop that selects nothing, Left and Right walk the notes of a lane, Home and End go to
|
|
20
|
+
// its ends, Page Up and Page Down change lane (a lane with no note on the page gets a cursor, and the panel offers only Ask
|
|
21
|
+
// Claude), and the note or cursor that is active is told to screen readers by one element the lanes point at with
|
|
22
|
+
// aria-activedescendant. Escape steps back one thing at a time: steps not written (waiting for their pause or behind another
|
|
23
|
+
// edit), then a range, then the selection, and only
|
|
24
|
+
// with nothing left does it go on to the page, which closes the
|
|
25
|
+
// view. When the token plays in several places the standing sentence says so as soon as the page has counted them, the other notes of the token get a dotted ring, and the previews repeat the count. While the page cannot write
|
|
26
|
+
// safely the edit controls are off and a notice says why. Everything that comes from the code (a lane name, a section name, a token) reaches
|
|
27
|
+
// the page through textContent, title and setAttribute only, and every node is built with createElement. The text is written
|
|
28
|
+
// only when it changes, so a panel drawn again for the same state writes nothing. `document` is injected, so the module has no
|
|
29
|
+
// DOM access at import time and node:test drives it with a fake document. A gesture whose handler is missing throws, so
|
|
30
|
+
// nothing is ignored silently. Reading a note, drawing the panel, the ring and the ghost never write to the editor, evaluate
|
|
31
|
+
// or send anything: only a handler does.
|
|
32
|
+
|
|
33
|
+
import { PAGE_CYCLES, TIMELINE_COPY, cycleAt, noteNameToMidi, pitchOf } from './timeline.js';
|
|
34
|
+
import {
|
|
35
|
+
DRAG_THRESHOLD_REM,
|
|
36
|
+
HIT_TOLERANCE_REM,
|
|
37
|
+
LANE_KEYS,
|
|
38
|
+
NOTE_MARK_HEIGHT,
|
|
39
|
+
SEMITONE_REM,
|
|
40
|
+
STEP_GATHER_MS,
|
|
41
|
+
createStepGatherer,
|
|
42
|
+
lockGesture,
|
|
43
|
+
noteBoxes,
|
|
44
|
+
pickNote,
|
|
45
|
+
rangeCycles,
|
|
46
|
+
laneMove,
|
|
47
|
+
semitonesFrom,
|
|
48
|
+
} from './timeline-gesture.js';
|
|
49
|
+
import { MAX_GESTURE_SEMITONES as MAX_REQUEST_SEMITONES } from './ask.js';
|
|
50
|
+
import { stepPitch, velocityTarget } from './note-edit.js';
|
|
51
|
+
import { sameTokenEvents } from './note-locate.js';
|
|
52
|
+
import {
|
|
53
|
+
activeCursorName,
|
|
54
|
+
activeNoteName,
|
|
55
|
+
changeDroppedText,
|
|
56
|
+
changeNotWrittenText,
|
|
57
|
+
cursorPositionText,
|
|
58
|
+
editTimeoutText,
|
|
59
|
+
hintText,
|
|
60
|
+
limitText,
|
|
61
|
+
namedText,
|
|
62
|
+
noneText,
|
|
63
|
+
noticeText,
|
|
64
|
+
pitchValueText,
|
|
65
|
+
positionText,
|
|
66
|
+
previewText,
|
|
67
|
+
rangePositionText,
|
|
68
|
+
rangeWhereText,
|
|
69
|
+
refusedPreviewText,
|
|
70
|
+
requestReadyText,
|
|
71
|
+
resultText,
|
|
72
|
+
standingText,
|
|
73
|
+
stateWord,
|
|
74
|
+
stepsDroppedText,
|
|
75
|
+
stepsNotWrittenText,
|
|
76
|
+
velocityFixedText,
|
|
77
|
+
velocityLimitText,
|
|
78
|
+
velocityPreviewText,
|
|
79
|
+
velocityRefusalText,
|
|
80
|
+
velocityValueText,
|
|
81
|
+
} from './note-panel.js';
|
|
82
|
+
|
|
83
|
+
/** The reasons an edit is refused after the page read the edited code back, or could not write it: the person can ask Claude for it (an error only when it came before the write). */
|
|
84
|
+
const ASK_AGAIN_REASONS = new Set(['read-back', 'write-mismatch', 'not-played', 'no-parser', 'error']);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True for an outcome whose change can be handed to Claude as it is: the page wrote nothing. An error that came at or after the
|
|
88
|
+
* write is not one, because the code may already hold the change.
|
|
89
|
+
*/
|
|
90
|
+
function askable(outcome) {
|
|
91
|
+
if (outcome.kind === 'failed') return true;
|
|
92
|
+
return outcome.kind === 'refused' && ASK_AGAIN_REASONS.has(outcome.reason) && outcome.afterWrite !== true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True for the two keyboard gestures that mean a right-click: the Menu key, and F10 with Shift (and no other modifier). */
|
|
96
|
+
function isAskKey(event) {
|
|
97
|
+
if (event.altKey || event.metaKey || event.ctrlKey) return false;
|
|
98
|
+
if (event.key === 'ContextMenu') return !event.shiftKey;
|
|
99
|
+
return event.key === 'F10' && event.shiftKey === true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The id of the one element the lanes point at with aria-activedescendant. */
|
|
103
|
+
const ACTIVE_ID = 'timeline-active-note';
|
|
104
|
+
|
|
105
|
+
/** How long the page has to answer an edit, and then to draw the lanes again from the code it wrote, before the queue goes on. */
|
|
106
|
+
const EDIT_WAIT_MS = 10000;
|
|
107
|
+
|
|
108
|
+
/** The notices the page can give the view, with the words of the notice each one shows (the Mixer's order). */
|
|
109
|
+
const NOTICE_KINDS = Object.freeze({ closed: 'disconnected', waiting: 'waiting', dirty: 'unevaluated' });
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `createTimelineEditView({ document, lanes, panel, line, rootFontPx, setTimer, clearTimer, handlers })` returns `{ onRedraw(drawn),
|
|
113
|
+
* showOutcome(id, outcome), setNotice(kind), finishWait({ tookOver }), close(), cancelGesture(), selection() }`. `lanes`
|
|
114
|
+
* is the element that holds the lanes, `panel` the section under them and `line` the sentence slot inside it. `rootFontPx()`
|
|
115
|
+
* gives the root font size in pixels, read when a press starts, because every distance of a gesture is written in rem.
|
|
116
|
+
* `setTimer(fn, ms)` and `clearTimer(handle)` are the page's timers, which time the pause that gathers steps and the wait for
|
|
117
|
+
* an answer. `handlers` is `{ describe(laneIndex, eventIndex), edit(info, change, id) }`: `describe` gives what the page knows of a note (`{ laneIndex,
|
|
118
|
+
* eventIndex, track, cycle, beat, section, noteText, midi, drum, velocity, velocityWritable, editable, reason, seen, places,
|
|
119
|
+
* perCycle, velocityPlaces }`, where `places` is a promise or a value of `{ count, sections, onPage }` for a token of a structured piece, null
|
|
120
|
+
* otherwise (`velocityPlaces` is the same for the velocity number of a note whose velocity the page can write), and `perCycle` the number of notes the token draws in each cycle in a piece without a structure) and
|
|
121
|
+
* `edit` is called with that note, a change (`{ kind: 'pitch', semitones }`, `{ kind: 'rest' }` or `{ kind: 'velocity',
|
|
122
|
+
* steps }`) and the id of the edit, for a note that can be edited, one edit at a time. `showOutcome(id, outcome)` gives the answer
|
|
123
|
+
* of the edit with that id; an id the view never handed over, or one already answered, throws. `setNotice(kind)` says the page cannot write safely (`'closed'`, `'waiting'`,
|
|
124
|
+
* `'dirty'`, or null for none): the edit controls are off and one sentence says why, only while the kind changes.
|
|
125
|
+
* An outcome that waits for its line (`showOutcome`) keeps the edit controls off, with the edit's own sentence, until
|
|
126
|
+
* `finishWait({ tookOver })` ends the wait; the sentence then says the change was made when it took over. `onRedraw(drawn)` tells the view what the lanes were drawn from (`{ pageStart, lanes: [{ index, identity, events,
|
|
127
|
+
* shapes, rowCount, drumMap }] }`, each event with the `locations` it comes from in the code) or null when no lanes are drawn.
|
|
128
|
+
* It ends a gesture in progress, and it selects the same note again when the lanes come back for the same page with a note at
|
|
129
|
+
* the same time on the same lane (by its identity, or its track index when the identity was built from strings), at the same
|
|
130
|
+
* rank among the notes that begin then (in the order of their places in the code), whose pitch is the one it has or the one an
|
|
131
|
+
* edit on its way asked for; any other page, or no such note, leaves nothing selected. After a rest, a note of the same chord is
|
|
132
|
+
* looked for at the rank the lanes drawn from the code the rest left give it, and not at all when the page cannot tell (those
|
|
133
|
+
* lanes do not show the chord without the rested note, or the rest did not take over, or ended in an error or no answer). `showOutcome` puts the sentence of the outcome of an edit in the
|
|
134
|
+
* sentence slot, where it stays through redraws until the next selection; when the selection has moved on from the edit's note,
|
|
135
|
+
* the sentence names that note. `close()` says the Timeline closed: steps and edits not handed over yet are dropped, and nothing is
|
|
136
|
+
* handed over until lanes are drawn again; the edit already handed over is still the page's to finish. `cancelGesture()` ends a gesture in progress with no
|
|
137
|
+
* write (the page calls it when the window loses focus). The drawn lanes also carry `track` (the name the lane shows), which names
|
|
138
|
+
* the lane in the panel and to screen readers when the keyboard cursor is on it. The handler `askLane(laneIndex, cycle, point)`
|
|
139
|
+
* opens the Ask box for the lane under the keyboard cursor.
|
|
140
|
+
*/
|
|
141
|
+
export function createTimelineEditView({ document, lanes, panel, line, rootFontPx, setTimer, clearTimer, handlers }) {
|
|
142
|
+
for (const [name, element] of Object.entries({ lanes, panel, line })) {
|
|
143
|
+
if (!element) throw new Error(`Moontide: the Timeline edit view needs its '${name}' element.`);
|
|
144
|
+
}
|
|
145
|
+
if (typeof rootFontPx !== 'function') throw new Error('Moontide: the Timeline edit view needs rootFontPx, a function that gives the root font size.');
|
|
146
|
+
for (const [name, timer] of Object.entries({ setTimer, clearTimer })) {
|
|
147
|
+
if (typeof timer !== 'function') throw new Error(`Moontide: the Timeline edit view needs ${name}, a function.`);
|
|
148
|
+
}
|
|
149
|
+
for (const gesture of ['describe', 'edit', 'prepare', 'ask', 'range', 'describeRange', 'askLane']) {
|
|
150
|
+
if (handlers === null || typeof handlers !== 'object' || typeof handlers[gesture] !== 'function') {
|
|
151
|
+
throw new Error(`Moontide: the Timeline edit view has no handler for '${gesture}'.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (line.parentNode !== panel) throw new Error('Moontide: the sentence slot of the Timeline edit view must sit inside its panel.');
|
|
155
|
+
|
|
156
|
+
let drawn = null;
|
|
157
|
+
// The mark layer of each drawn lane, by position.
|
|
158
|
+
let marks = [];
|
|
159
|
+
// The note that is selected (`{ laneIndex, eventIndex, info, target, places }`): `target` is the note as edits aim at it and as a
|
|
160
|
+
// redraw finds it again (see targetOf); `places` is where its token plays once the page has counted that (`{ count, sections,
|
|
161
|
+
// onPage }`), null until then.
|
|
162
|
+
let selected = null;
|
|
163
|
+
let ring = null;
|
|
164
|
+
// The dotted rings of the other notes that carry the selected note's token, in the mark layers of the lane.
|
|
165
|
+
let sames = [];
|
|
166
|
+
// The target of the selection when the lanes went away, kept until the lanes come back: a redraw that finds that note selects
|
|
167
|
+
// it again.
|
|
168
|
+
let restore = null;
|
|
169
|
+
// The sentence of the last edit (`{ text, kind }`), kept until the next selection. A sentence of an edit that waits for its
|
|
170
|
+
// line also holds that outcome (`wait`), so it can say the change was made when the line takes over.
|
|
171
|
+
let result = null;
|
|
172
|
+
// The notice that switches the edit controls off (null, 'closed', 'waiting' or 'dirty') and whether the person's own edit
|
|
173
|
+
// waits for its line; both are told by the page.
|
|
174
|
+
let notice = null;
|
|
175
|
+
let ownWait = false;
|
|
176
|
+
// Steps of a key or a button that wait for the pause: the sentence that shows them, and the selection they were taken on.
|
|
177
|
+
let stepSaid = null;
|
|
178
|
+
let stepOwner = null;
|
|
179
|
+
// The press that is on its way to a click or a gesture: where it began, the geometry it is judged against, the note it began
|
|
180
|
+
// on (an event index, or null), the gesture it locked into (null, 'pitch' or 'range') and, for a pitch drag, the drag itself.
|
|
181
|
+
let press = null;
|
|
182
|
+
// The edits that wait their turn, oldest first: each `{ id, target, change, steps }`, where `steps` says it gathers steps of a
|
|
183
|
+
// key or a button (a rest and a drag release are not steps). An edit is aimed at its target, not at the lanes it was made on.
|
|
184
|
+
let queue = [];
|
|
185
|
+
// The one edit handed to the page and not settled yet: `{ edit, phase, timer, lanesAway, freshLanes }`. `phase` is 'answer'
|
|
186
|
+
// (no outcome yet), 'late' (no outcome in time: the panel said so, and the page may still write it), 'waiting' (the change
|
|
187
|
+
// waits for its line) or 'lanes' (written, its lanes not drawn yet); `timer` bounds each phase; `lanesAway` and `freshLanes`
|
|
188
|
+
// say the lanes went away and came back since it was handed over. It stays through a close and a page turn: the page still
|
|
189
|
+
// runs it, and the next edit is aimed at the code it leaves.
|
|
190
|
+
let inFlight = null;
|
|
191
|
+
// Every edit handed over whose outcome has not come, by id: an outcome that comes after its wait ended is still told.
|
|
192
|
+
const handed = new Map();
|
|
193
|
+
// The sentence of a burst of steps that waits in the queue behind another edit (`{ edit, said }`): it stays on screen for its
|
|
194
|
+
// note until the edit is handed over.
|
|
195
|
+
let queuedSaid = null;
|
|
196
|
+
let nextEditId = 1;
|
|
197
|
+
// The edit whose change waits for its line (its outcome said 'waiting'), until the wait ends: its note has the new pitch only
|
|
198
|
+
// once the change takes over.
|
|
199
|
+
let waited = null;
|
|
200
|
+
// The rest on its way, until the page knows where the other notes of its chord are (`{ edit, target, place, before, members }`):
|
|
201
|
+
// `before` is the chord as it was drawn when the rest was handed over (see chordOf), `place` the code place of the rested token,
|
|
202
|
+
// and `members` the targets of the other notes of the chord whose ranks were counted on that chord. Their ranks change only
|
|
203
|
+
// once the lanes drawn after the rest show what it removed (see resolveRest).
|
|
204
|
+
let pendingRest = null;
|
|
205
|
+
// While above 0, an edit added to the queue is not handed over yet (a redraw is under way and its lanes come first).
|
|
206
|
+
let pumpHeld = 0;
|
|
207
|
+
// True from the Timeline's close until lanes are drawn again: nothing is handed over meanwhile.
|
|
208
|
+
let closed = false;
|
|
209
|
+
// The page the lanes were last drawn for: a redraw for another page is a page turn, which empties the queue.
|
|
210
|
+
let shownPage = null;
|
|
211
|
+
// The sentence of a drag in progress (`{ text, kind }`); it takes the place of the others while the drag lasts.
|
|
212
|
+
let preview = null;
|
|
213
|
+
// The change of the selected note that the page refused or could not evaluate (`{ target, change }`): Ask Claude prepares that
|
|
214
|
+
// change as a request until the next gesture or selection.
|
|
215
|
+
let askAgain = null;
|
|
216
|
+
// The range of cycles that is selected (`{ laneIndex, from, to, cut, wash, anchor, head, info }`): whole cycles of one lane, drawn
|
|
217
|
+
// as `wash`, a span in that lane's mark layer. `cut` is null or `{ from, to }` when the pointer left the event area and the range
|
|
218
|
+
// was held at the page's edge. `anchor` is the cycle the range began at and `head` the cycle its other end follows (the keys move
|
|
219
|
+
// the head); `info` is what the page says of it (`{ track, section, sectionTo, count }`), shown in the panel. A range drag in
|
|
220
|
+
// progress holds its own range in `press.range` until it is released. A range and a selected note never exist together.
|
|
221
|
+
let range = null;
|
|
222
|
+
// The keyboard cursor on a lane that has no note on the page (`{ laneIndex, cycle }`), or null. It is a place to stand, not a
|
|
223
|
+
// selection: the panel names the lane and offers only Ask Claude, and Shift+Left or Shift+Right starts a range from its cycle.
|
|
224
|
+
// A cursor, a selected note and a range never exist together.
|
|
225
|
+
let cursor = null;
|
|
226
|
+
// The one element the lanes point at with aria-activedescendant: it names the selected note or the cursor for screen readers.
|
|
227
|
+
let active = null;
|
|
228
|
+
|
|
229
|
+
function make(tag, className) {
|
|
230
|
+
const node = document.createElement(tag);
|
|
231
|
+
if (className !== '') node.className = className;
|
|
232
|
+
return node;
|
|
233
|
+
}
|
|
234
|
+
function setText(node, text) {
|
|
235
|
+
if (node.textContent !== text) node.textContent = text;
|
|
236
|
+
}
|
|
237
|
+
function setAttr(node, name, value) {
|
|
238
|
+
if (node.getAttribute(name) !== value) node.setAttribute(name, value);
|
|
239
|
+
}
|
|
240
|
+
function setHidden(node, hidden) {
|
|
241
|
+
if (node.hidden !== hidden) node.hidden = hidden;
|
|
242
|
+
}
|
|
243
|
+
function setOff(node, off) {
|
|
244
|
+
if (off) setAttr(node, 'aria-disabled', 'true');
|
|
245
|
+
else if (node.getAttribute('aria-disabled') !== null) node.removeAttribute('aria-disabled');
|
|
246
|
+
}
|
|
247
|
+
/** True while a notice or the person's own waiting edit keeps the edit controls off. */
|
|
248
|
+
function controlsOff() {
|
|
249
|
+
return notice !== null || ownWait;
|
|
250
|
+
}
|
|
251
|
+
function button(className, text, name, title, onClick) {
|
|
252
|
+
const node = make('button', className);
|
|
253
|
+
node.setAttribute('type', 'button');
|
|
254
|
+
node.textContent = text;
|
|
255
|
+
node.setAttribute('aria-label', name);
|
|
256
|
+
node.setAttribute('title', title);
|
|
257
|
+
node.addEventListener('click', onClick);
|
|
258
|
+
return node;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The panel: where the note is and whether it can be edited, the pitch and velocity with their buttons, the sentence
|
|
262
|
+
// slot (given) and the hint. Built once; render() only changes text.
|
|
263
|
+
const where = make('span', 'tl-note-where');
|
|
264
|
+
const laneName = make('span', 'tl-lane-name');
|
|
265
|
+
const position = make('span', 'tl-note-position');
|
|
266
|
+
where.appendChild(laneName);
|
|
267
|
+
where.appendChild(position);
|
|
268
|
+
const state = make('span', 'tl-note-state');
|
|
269
|
+
const controls = make('div', 'tl-note-controls');
|
|
270
|
+
const pitchGroup = make('div', 'tl-group');
|
|
271
|
+
pitchGroup.setAttribute('role', 'group');
|
|
272
|
+
pitchGroup.setAttribute('aria-label', TIMELINE_COPY.notePitchLabel);
|
|
273
|
+
const pitchLabel = make('span', 'tl-label');
|
|
274
|
+
pitchLabel.textContent = TIMELINE_COPY.notePitchLabel;
|
|
275
|
+
const pitchValue = make('span', 'tl-value');
|
|
276
|
+
pitchGroup.appendChild(pitchLabel);
|
|
277
|
+
pitchGroup.appendChild(pitchValue);
|
|
278
|
+
const pitchDown = button('tl-btn', TIMELINE_COPY.notePitchDown, TIMELINE_COPY.notePitchDownName, TIMELINE_COPY.notePitchDownTitle, () => pitchStep(-1));
|
|
279
|
+
const pitchUp = button('tl-btn', TIMELINE_COPY.notePitchUp, TIMELINE_COPY.notePitchUpName, TIMELINE_COPY.notePitchUpTitle, () => pitchStep(1));
|
|
280
|
+
pitchGroup.appendChild(pitchDown);
|
|
281
|
+
pitchGroup.appendChild(pitchUp);
|
|
282
|
+
const velocityGroup = make('div', 'tl-group');
|
|
283
|
+
velocityGroup.setAttribute('role', 'group');
|
|
284
|
+
velocityGroup.setAttribute('aria-label', TIMELINE_COPY.noteVelocityLabel);
|
|
285
|
+
const velocityLabel = make('span', 'tl-label');
|
|
286
|
+
velocityLabel.textContent = TIMELINE_COPY.noteVelocityLabel;
|
|
287
|
+
const velocityValue = make('span', 'tl-value');
|
|
288
|
+
// The words after the velocity when the page cannot write it: it comes from something other than one number in the code.
|
|
289
|
+
const velocityFixed = make('span', 'tl-fixed');
|
|
290
|
+
const velocityDown = button('tl-btn', TIMELINE_COPY.noteVelocityDown, TIMELINE_COPY.noteVelocityDownName, TIMELINE_COPY.noteVelocityDownTitle, () => velocityStepBy(-1));
|
|
291
|
+
const velocityUp = button('tl-btn', TIMELINE_COPY.noteVelocityUp, TIMELINE_COPY.noteVelocityUpName, TIMELINE_COPY.noteVelocityUpTitle, () => velocityStepBy(1));
|
|
292
|
+
velocityGroup.appendChild(velocityLabel);
|
|
293
|
+
velocityGroup.appendChild(velocityValue);
|
|
294
|
+
velocityGroup.appendChild(velocityFixed);
|
|
295
|
+
velocityGroup.appendChild(velocityDown);
|
|
296
|
+
velocityGroup.appendChild(velocityUp);
|
|
297
|
+
const restButton = button('tl-btn', TIMELINE_COPY.noteMakeRest, TIMELINE_COPY.noteMakeRestName, TIMELINE_COPY.noteMakeRestTitle, () => rest());
|
|
298
|
+
// Ask Claude is always on: it writes nothing, so no notice switches it off.
|
|
299
|
+
const askButton = button('tl-btn', TIMELINE_COPY.noteAsk, TIMELINE_COPY.noteAskName, TIMELINE_COPY.noteAskTitle, () => askAbout());
|
|
300
|
+
controls.appendChild(pitchGroup);
|
|
301
|
+
controls.appendChild(velocityGroup);
|
|
302
|
+
controls.appendChild(restButton);
|
|
303
|
+
controls.appendChild(askButton);
|
|
304
|
+
const hint = make('p', 'tl-note-hint');
|
|
305
|
+
panel.insertBefore(where, line);
|
|
306
|
+
panel.insertBefore(state, line);
|
|
307
|
+
panel.insertBefore(controls, line);
|
|
308
|
+
panel.appendChild(hint);
|
|
309
|
+
|
|
310
|
+
function render() {
|
|
311
|
+
setHidden(panel, drawn === null);
|
|
312
|
+
const info = selected === null ? null : selected.info;
|
|
313
|
+
// A range names itself where a note would: the lane, then the cycles, the section and the notes.
|
|
314
|
+
const rangeName = range === null ? null : rangeWhereText(range.info.track);
|
|
315
|
+
// The cursor on a lane without notes names the lane as the lane shows it.
|
|
316
|
+
const name = rangeName !== null ? rangeName : cursor !== null ? drawn.lanes[cursor.laneIndex].track : info === null ? '' : info.track;
|
|
317
|
+
setText(laneName, name);
|
|
318
|
+
setAttr(laneName, 'title', name);
|
|
319
|
+
setText(position, range !== null ? rangePositionText({ from: range.from, to: range.to, ...range.info }) : cursor !== null ? cursorPositionText() : info === null ? noneText() : positionText(info));
|
|
320
|
+
setText(state, info === null ? '' : stateWord(info));
|
|
321
|
+
setHidden(controls, info === null && range === null && cursor === null);
|
|
322
|
+
// A range and a lane without notes offer only Ask Claude; every control that changes a note is away.
|
|
323
|
+
for (const group of [pitchGroup, velocityGroup, restButton]) setHidden(group, range !== null || cursor !== null);
|
|
324
|
+
setAttr(askButton, 'aria-label', range !== null ? TIMELINE_COPY.noteRangeAskName : cursor !== null ? TIMELINE_COPY.noteLaneAskName : TIMELINE_COPY.noteAskName);
|
|
325
|
+
setAttr(askButton, 'title', range !== null ? TIMELINE_COPY.noteRangeAskTitle : cursor !== null ? TIMELINE_COPY.noteLaneAskTitle : TIMELINE_COPY.noteAskTitle);
|
|
326
|
+
setText(pitchValue, info === null ? '' : pitchValueText(info));
|
|
327
|
+
setText(velocityValue, info === null ? '' : velocityValueText(info.velocity));
|
|
328
|
+
const editable = info !== null && info.editable;
|
|
329
|
+
// The edit controls keep their words and their place and carry aria-disabled while the page could not write safely, and the
|
|
330
|
+
// velocity ones while the velocity is not one number in the code. A note the page cannot edit keeps them on.
|
|
331
|
+
const off = editable && controlsOff();
|
|
332
|
+
const velocityOff = off || (editable && info.velocityWritable !== true);
|
|
333
|
+
setOff(pitchDown, off);
|
|
334
|
+
setOff(pitchUp, off);
|
|
335
|
+
setOff(restButton, off);
|
|
336
|
+
setOff(velocityDown, velocityOff);
|
|
337
|
+
setOff(velocityUp, velocityOff);
|
|
338
|
+
setText(velocityFixed, editable && info.velocityWritable !== true ? velocityFixedText() : '');
|
|
339
|
+
let said = null;
|
|
340
|
+
if (range !== null) said = null;
|
|
341
|
+
else if (editable && notice !== null) {
|
|
342
|
+
// Steps this notice took away are named beside it, so they are not just gone; steps lost to an earlier move are not.
|
|
343
|
+
const lost = result !== null && result.lostTo === notice ? ` ${result.text}` : '';
|
|
344
|
+
said = { kind: 'notice', text: `${noticeText(NOTICE_KINDS[notice])}${lost}` };
|
|
345
|
+
}
|
|
346
|
+
else if (preview !== null) said = preview;
|
|
347
|
+
else if (stepSaid !== null) said = stepSaid;
|
|
348
|
+
else if (queuedSaid !== null && queue.includes(queuedSaid.edit) && aboutShown(queuedSaid.edit)) said = queuedSaid.said;
|
|
349
|
+
else if (result !== null) said = result;
|
|
350
|
+
else if (info !== null) said = { kind: 'standing', text: standingText(info, selected.places) };
|
|
351
|
+
setText(line, said === null ? '' : said.text);
|
|
352
|
+
if (said === null) {
|
|
353
|
+
if (line.getAttribute('data-kind') !== null) line.removeAttribute('data-kind');
|
|
354
|
+
} else {
|
|
355
|
+
setAttr(line, 'data-kind', said.kind);
|
|
356
|
+
}
|
|
357
|
+
setText(hint, hintText(range !== null ? 'range' : cursor !== null ? 'cursor' : info === null ? 'none' : info.editable ? 'editable' : 'refused'));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Where the note is drawn in its lane's mark layer, as shares of the lane: `{ left, width, top, height }`. */
|
|
361
|
+
function markBox(laneIndex, eventIndex) {
|
|
362
|
+
const lane = drawn.lanes[laneIndex];
|
|
363
|
+
const shape = lane.shapes[eventIndex];
|
|
364
|
+
let top;
|
|
365
|
+
let height;
|
|
366
|
+
if (lane.rowCount === null) {
|
|
367
|
+
top = Math.max(0, Math.min(1 - NOTE_MARK_HEIGHT, shape.y - NOTE_MARK_HEIGHT / 2));
|
|
368
|
+
height = NOTE_MARK_HEIGHT;
|
|
369
|
+
} else {
|
|
370
|
+
top = (shape.row + 0.25) / lane.rowCount;
|
|
371
|
+
height = 0.5 / lane.rowCount;
|
|
372
|
+
}
|
|
373
|
+
return { left: shape.x / PAGE_CYCLES, width: shape.width / PAGE_CYCLES, top, height };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function placeMark(node, box) {
|
|
377
|
+
node.style.left = `${box.left * 100}%`;
|
|
378
|
+
node.style.width = `${box.width * 100}%`;
|
|
379
|
+
node.style.top = `${box.top * 100}%`;
|
|
380
|
+
node.style.height = `${box.height * 100}%`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Makes the one element the lanes point at: a new one in the lane of the selected note or of the cursor (not in the mark layer,
|
|
385
|
+
* which screen readers skip), named for a screen reader, and puts its id in aria-activedescendant. With neither a note nor a
|
|
386
|
+
* cursor there is no element and no attribute. The element is drawn again on every selection and every redraw.
|
|
387
|
+
*/
|
|
388
|
+
function drawActive() {
|
|
389
|
+
let laneIndex;
|
|
390
|
+
let name;
|
|
391
|
+
if (selected !== null) {
|
|
392
|
+
laneIndex = selected.laneIndex;
|
|
393
|
+
name = activeNoteName(selected.info);
|
|
394
|
+
} else if (cursor !== null) {
|
|
395
|
+
laneIndex = cursor.laneIndex;
|
|
396
|
+
name = activeCursorName({ lane: drawn.lanes[laneIndex].track, cycle: cursor.cycle });
|
|
397
|
+
} else {
|
|
398
|
+
if (active !== null) {
|
|
399
|
+
active.remove();
|
|
400
|
+
active = null;
|
|
401
|
+
}
|
|
402
|
+
removeAttr(lanes, 'aria-activedescendant');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// The same note again keeps its element, so picking it twice writes nothing.
|
|
406
|
+
if (active !== null && active.parentNode === lanes.children[laneIndex] && active.getAttribute('aria-label') === name) return;
|
|
407
|
+
if (active !== null) active.remove();
|
|
408
|
+
active = make('div', 'tl-active');
|
|
409
|
+
active.id = ACTIVE_ID;
|
|
410
|
+
active.setAttribute('role', 'img');
|
|
411
|
+
active.setAttribute('aria-label', name);
|
|
412
|
+
lanes.children[laneIndex].appendChild(active);
|
|
413
|
+
setAttr(lanes, 'aria-activedescendant', ACTIVE_ID);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Draws the ring around the selected note, in the mark layer of its lane, at the place the note is drawn. */
|
|
417
|
+
function drawRing() {
|
|
418
|
+
if (ring !== null) {
|
|
419
|
+
ring.remove();
|
|
420
|
+
ring = null;
|
|
421
|
+
}
|
|
422
|
+
for (const mark of sames) mark.remove();
|
|
423
|
+
sames = [];
|
|
424
|
+
drawActive();
|
|
425
|
+
if (selected === null) return;
|
|
426
|
+
ring = make('span', 'tl-ring');
|
|
427
|
+
placeMark(ring, markBox(selected.laneIndex, selected.eventIndex));
|
|
428
|
+
marks[selected.laneIndex].appendChild(ring);
|
|
429
|
+
// The other notes of the page that carry the same token get a dotted ring: an edit changes them too. A note the page
|
|
430
|
+
// cannot edit has no token to share.
|
|
431
|
+
if (!selected.info.editable) return;
|
|
432
|
+
for (const index of sameTokenEvents({ events: drawn.lanes[selected.laneIndex].events, token: selected.info.seen.token })) {
|
|
433
|
+
if (index === selected.eventIndex) continue;
|
|
434
|
+
const mark = make('span', 'tl-same');
|
|
435
|
+
placeMark(mark, markBox(selected.laneIndex, index));
|
|
436
|
+
marks[selected.laneIndex].appendChild(mark);
|
|
437
|
+
sames.push(mark);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The places in the code an event's value comes from, as their start offsets, the first first. */
|
|
442
|
+
function codePlaces(entry) {
|
|
443
|
+
if (!Array.isArray(entry.locations)) return [];
|
|
444
|
+
return entry.locations.map((place) => place.start).filter((start) => Number.isFinite(start)).sort((a, b) => a - b);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The events of `lane` that begin at `begin`, as event indexes in the order of their places in the code (then in the order the
|
|
449
|
+
* lane lists them). An edit rewrites one token and moves every later offset by the same amount, so it never changes this order:
|
|
450
|
+
* a note keeps its rank among the notes that begin with it through any edit of a token.
|
|
451
|
+
*/
|
|
452
|
+
function notesAt(lane, begin) {
|
|
453
|
+
const indexes = [];
|
|
454
|
+
lane.events.forEach((entry, index) => {
|
|
455
|
+
if (Math.abs(entry.begin - begin) < 1e-9) indexes.push(index);
|
|
456
|
+
});
|
|
457
|
+
const places = new Map(indexes.map((index) => [index, codePlaces(lane.events[index])]));
|
|
458
|
+
return indexes.sort((i, j) => {
|
|
459
|
+
const a = places.get(i);
|
|
460
|
+
const b = places.get(j);
|
|
461
|
+
for (let k = 0; k < Math.min(a.length, b.length); k++) if (a[k] !== b[k]) return a[k] - b[k];
|
|
462
|
+
return a.length - b.length || i - j;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** True when two pitches (numbers, or null for an event that has none) are the same. */
|
|
467
|
+
function samePitch(a, b) {
|
|
468
|
+
if (a === null || a === undefined || b === null || b === undefined) return (a ?? null) === (b ?? null);
|
|
469
|
+
return Math.abs(a - b) < 1e-6;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The note at (`laneIndex`, `eventIndex`) as an edit aims at it and a redraw finds it again: its lane (`laneIdentity` for a lane
|
|
474
|
+
* written with a name, `trackIndex` for any other, see sameLane), the time it begins at, the page, its
|
|
475
|
+
* `rank` among the notes that begin with it on its lane (see notesAt), and its token and lane as the panel names them. The
|
|
476
|
+
* pitch is only a check: `pitch` is the pitch the note has as far as the page knows (the one it had, or the one an edit of it
|
|
477
|
+
* wrote), `asked` the pitch an edit on its way may give it (null when none is on its way), and `gone` says the page aims at it no
|
|
478
|
+
* more: a rest took it away, or a rest beside it in its chord left the page unsure which note it is. A note taken beside a rest
|
|
479
|
+
* on its way, on lanes that still draw the chord as the rest found it, has its rank counted on that chord like the others.
|
|
480
|
+
*/
|
|
481
|
+
function targetOf(laneIndex, eventIndex, info) {
|
|
482
|
+
const lane = drawn.lanes[laneIndex];
|
|
483
|
+
const entry = lane.events[eventIndex];
|
|
484
|
+
const note = info.editable && info.seen !== null && info.seen !== undefined ? info.seen.token.text : String(info.noteText);
|
|
485
|
+
const rank = notesAt(lane, entry.begin).indexOf(eventIndex);
|
|
486
|
+
const target = { laneIdentity: lane.identity, trackIndex: lane.index, begin: entry.begin, pageStart: drawn.pageStart, rank, pitch: pitchOf(entry.value), asked: null, gone: false, note, lane: info.track };
|
|
487
|
+
if (pendingRest !== null && beside(target, pendingRest.target) && sameChord(chordOf(lane, entry.begin), pendingRest.before)) pendingRest.members.add(target);
|
|
488
|
+
return target;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** True for the identity of a lane written with a name (`name: ...`), which no edit of a token changes. */
|
|
492
|
+
function labeled(identity) {
|
|
493
|
+
return typeof identity === 'string' && identity.startsWith('label:');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* True when two targets are on the same lane: by the name for a lane written with one, by the track index for any other (an
|
|
498
|
+
* identity built from the strings of the code changes with every edit, while an edit of a token never adds, removes or moves a
|
|
499
|
+
* statement).
|
|
500
|
+
*/
|
|
501
|
+
function sameLane(a, b) {
|
|
502
|
+
if (labeled(a.laneIdentity) || labeled(b.laneIdentity)) return a.laneIdentity === b.laneIdentity;
|
|
503
|
+
return a.trackIndex !== undefined && a.trackIndex === b.trackIndex;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** The index of the drawn lane of `target`, found the way sameLane compares lanes, or -1. */
|
|
507
|
+
function laneOf(target) {
|
|
508
|
+
if (labeled(target.laneIdentity)) return drawn.lanes.findIndex((lane) => lane.identity === target.laneIdentity);
|
|
509
|
+
if (target.trackIndex === undefined) return -1;
|
|
510
|
+
return drawn.lanes.findIndex((lane) => lane.index === target.trackIndex && !labeled(lane.identity));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Where `target` is in the lanes drawn now, `{ laneIndex, eventIndex }`, or null: another page, no note at its rank, or a note
|
|
515
|
+
* there whose pitch is neither the one it has nor the one an edit on its way asked for (that note is another one, so nothing is
|
|
516
|
+
* aimed at it).
|
|
517
|
+
*/
|
|
518
|
+
function findNote(target) {
|
|
519
|
+
if (drawn === null || drawn.pageStart !== target.pageStart || target.gone) return null;
|
|
520
|
+
const laneIndex = laneOf(target);
|
|
521
|
+
if (laneIndex === -1) return null;
|
|
522
|
+
const lane = drawn.lanes[laneIndex];
|
|
523
|
+
// A note beside a rest on its way has its rank counted on its chord as it was before the rest: it is found only on lanes that
|
|
524
|
+
// still draw that chord, until the lanes after the rest tell where it went.
|
|
525
|
+
if (pendingRest !== null && pendingRest.members.has(target) && !sameChord(chordOf(lane, target.begin), pendingRest.before)) return null;
|
|
526
|
+
const eventIndex = notesAt(lane, target.begin)[target.rank];
|
|
527
|
+
if (eventIndex === undefined) return null;
|
|
528
|
+
const pitch = pitchOf(lane.events[eventIndex].value);
|
|
529
|
+
if (!samePitch(pitch, target.pitch) && (target.asked === null || !samePitch(pitch, target.asked))) return null;
|
|
530
|
+
return { laneIndex, eventIndex };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Every note target the view holds: the selection, the one a redraw brings back, the edits that wait or are on their way and
|
|
535
|
+
* the steps that wait for the pause.
|
|
536
|
+
*/
|
|
537
|
+
function liveTargets() {
|
|
538
|
+
const targets = new Set(queue.map((edit) => edit.target));
|
|
539
|
+
for (const edit of handed.values()) targets.add(edit.target);
|
|
540
|
+
if (inFlight !== null) targets.add(inFlight.edit.target);
|
|
541
|
+
for (const choice of [selected, stepOwner]) if (choice !== null) targets.add(choice.target);
|
|
542
|
+
if (restore !== null) targets.add(restore);
|
|
543
|
+
if (waited !== null) targets.add(waited.target);
|
|
544
|
+
return targets;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** The notes of `lane` that begin at `begin`, in the order of notesAt, as `{ pitch, places }`: a chord as a rest compares it. */
|
|
548
|
+
function chordOf(lane, begin) {
|
|
549
|
+
return notesAt(lane, begin).map((index) => ({ pitch: pitchOf(lane.events[index].value), places: codePlaces(lane.events[index]) }));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** True when two chords (see chordOf) have the same notes, with the same pitches, at the same places in the code. */
|
|
553
|
+
function sameChord(a, b) {
|
|
554
|
+
if (a.length !== b.length) return false;
|
|
555
|
+
return a.every((note, k) => samePitch(note.pitch, b[k].pitch) && note.places.length === b[k].places.length && note.places.every((place, i) => place === b[k].places[i]));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** True when `target` is another note that begins with the note of `rested`, on the same lane of the same page. */
|
|
559
|
+
function beside(target, rested) {
|
|
560
|
+
return target !== rested && !target.gone && target.pageStart === rested.pageStart && Math.abs(target.begin - rested.begin) < 1e-9 && sameLane(target, rested);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The new ranks of the notes beside the rest `rest`, read from the lanes drawn now, as a map from each old rank to the new one,
|
|
565
|
+
* or null when those lanes do not show exactly the chord without the rested note: every note that held the rested token's place
|
|
566
|
+
* (the note and any copy of it) is gone, every other note is there with its pitch and in the same order, the places before the
|
|
567
|
+
* token have not moved and the places after it have all moved by the same amount.
|
|
568
|
+
*/
|
|
569
|
+
function ranksAfterRest(rest) {
|
|
570
|
+
if (drawn === null || drawn.pageStart !== rest.target.pageStart || rest.place === undefined) return null;
|
|
571
|
+
const laneIndex = laneOf(rest.target);
|
|
572
|
+
if (laneIndex === -1) return null;
|
|
573
|
+
const after = chordOf(drawn.lanes[laneIndex], rest.target.begin);
|
|
574
|
+
const kept = [];
|
|
575
|
+
rest.before.forEach((note, rank) => {
|
|
576
|
+
if (!note.places.includes(rest.place)) kept.push(rank);
|
|
577
|
+
});
|
|
578
|
+
if (kept.length === rest.before.length || after.length !== kept.length) return null;
|
|
579
|
+
let shift = null;
|
|
580
|
+
const ranks = new Map();
|
|
581
|
+
for (let j = 0; j < kept.length; j++) {
|
|
582
|
+
const was = rest.before[kept[j]];
|
|
583
|
+
const now = after[j];
|
|
584
|
+
if (!samePitch(was.pitch, now.pitch) || was.places.length !== now.places.length) return null;
|
|
585
|
+
for (let k = 0; k < was.places.length; k++) {
|
|
586
|
+
const moved = now.places[k] - was.places[k];
|
|
587
|
+
if (was.places[k] < rest.place) {
|
|
588
|
+
if (moved !== 0) return null;
|
|
589
|
+
} else if (shift === null) {
|
|
590
|
+
shift = moved;
|
|
591
|
+
} else if (moved !== shift) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
ranks.set(kept[j], j);
|
|
596
|
+
}
|
|
597
|
+
return ranks;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The rest on its way is settled, `how`: 'unwritten' (the page wrote nothing: the notes beside it keep their ranks), 'lanes' (it
|
|
602
|
+
* was written, and the lanes drawn now come from the code it left: each note beside it takes the rank they give it) or 'unsure'
|
|
603
|
+
* (the change did not take over, an error came at or after the write, or no answer came). When the page cannot tell where a note
|
|
604
|
+
* beside the rest went, it aims at it no more: its edits that wait are dropped, and the panel says they were not written (see
|
|
605
|
+
* sayAlso), each named after its note.
|
|
606
|
+
*/
|
|
607
|
+
function resolveRest(how) {
|
|
608
|
+
const rest = pendingRest;
|
|
609
|
+
pendingRest = null;
|
|
610
|
+
if (rest === null || how === 'unwritten') return;
|
|
611
|
+
const ranks = how === 'lanes' ? ranksAfterRest(rest) : null;
|
|
612
|
+
for (const member of rest.members) {
|
|
613
|
+
if (member.gone) continue;
|
|
614
|
+
const rank = ranks === null ? undefined : ranks.get(member.rank);
|
|
615
|
+
if (rank === undefined) {
|
|
616
|
+
member.gone = true;
|
|
617
|
+
member.asked = null;
|
|
618
|
+
} else {
|
|
619
|
+
member.rank = rank;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const lost = queue.filter((edit) => edit.target.gone);
|
|
623
|
+
if (lost.length === 0) return;
|
|
624
|
+
queue = queue.filter((edit) => !edit.target.gone);
|
|
625
|
+
sayAlso(notWrittenText(lost));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Puts `text`, a sentence of edits that were not written, after the sentence of the last edit (or alone when there is none). A
|
|
630
|
+
* sentence of a change that waits for its line keeps it when the wait ends.
|
|
631
|
+
*/
|
|
632
|
+
function sayAlso(text) {
|
|
633
|
+
if (text === '') return;
|
|
634
|
+
if (result === null) {
|
|
635
|
+
result = { kind: 'refused', text };
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const kept = result.wait === undefined ? {} : { lostText: result.lostText === undefined ? text : `${result.lostText} ${text}` };
|
|
639
|
+
result = { ...result, kind: 'refused', text: `${result.text} ${text}`, ...kept };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The lanes drawn after an edit whose outcome left its pitch open (an error at or after the write): the note at the target's rank
|
|
644
|
+
* says which pitch it has, the asked one or the old one.
|
|
645
|
+
*/
|
|
646
|
+
function resolveAsked(target) {
|
|
647
|
+
if (target.asked === null) return;
|
|
648
|
+
const asked = target.asked;
|
|
649
|
+
target.asked = null;
|
|
650
|
+
const found = findNote({ ...target, pitch: asked });
|
|
651
|
+
if (found !== null) target.pitch = asked;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The pitch an outcome says was written, as a number, or null when it names none. */
|
|
655
|
+
function writtenPitch(outcome) {
|
|
656
|
+
const to = String(outcome.to);
|
|
657
|
+
return /^-?\d+(\.\d+)?$/.test(to) ? Number(to) : noteNameToMidi(to);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The target of an edit in the queue or on its way that is the note at (`laneIndex`, `eventIndex`), so its answer speaks for the selection again. */
|
|
661
|
+
function queuedTarget(laneIndex, eventIndex) {
|
|
662
|
+
const targets = queue.map((edit) => edit.target);
|
|
663
|
+
if (inFlight !== null) targets.push(inFlight.edit.target);
|
|
664
|
+
return (
|
|
665
|
+
targets.find((target) => {
|
|
666
|
+
const found = findNote(target);
|
|
667
|
+
return found !== null && found.laneIndex === laneIndex && found.eventIndex === eventIndex;
|
|
668
|
+
}) ?? null
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* What the page knows of the note at (`laneIndex`, `eventIndex`) as a selection. Where its token plays is counted by the page,
|
|
674
|
+
* perhaps after a moment: the sentence and the previews learn it when it arrives, if the note is still the one selected.
|
|
675
|
+
*/
|
|
676
|
+
function choose(laneIndex, eventIndex, target = null) {
|
|
677
|
+
const info = handlers.describe(laneIndex, eventIndex);
|
|
678
|
+
const aim = target ?? queuedTarget(laneIndex, eventIndex) ?? targetOf(laneIndex, eventIndex, info);
|
|
679
|
+
const choice = { laneIndex, eventIndex, info, target: aim, places: null, velocityPlaces: null };
|
|
680
|
+
// Where the pitch token plays, and where the velocity token plays: each is a promise or a value, and each arrives on its own.
|
|
681
|
+
for (const [field, counting] of [['places', info.places], ['velocityPlaces', info.velocityPlaces]]) {
|
|
682
|
+
if (counting !== undefined && counting !== null && typeof counting.then === 'function') {
|
|
683
|
+
counting.then(
|
|
684
|
+
(told) => {
|
|
685
|
+
choice[field] = told;
|
|
686
|
+
if (selected !== choice) return;
|
|
687
|
+
// Words already on screen that leave out the count are written again with it.
|
|
688
|
+
if (gatherer.pending() !== null && stepOwner === choice) stepSaid = stepsSentence(gatherer.pending().kind, gatherer.pending().total);
|
|
689
|
+
if (press !== null && press.drag !== null && press.drag !== undefined && press.drag.info === info) showDrag(press.drag);
|
|
690
|
+
else render();
|
|
691
|
+
},
|
|
692
|
+
(error) => {
|
|
693
|
+
console.error('Moontide: the places of the selected token could not be counted:', error);
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
} else if (counting !== undefined && counting !== null) {
|
|
697
|
+
choice[field] = counting;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return choice;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The count a preview or a result repeats for the selection: `{ count, onPage }`, a count of 0 or 1 says nothing. */
|
|
704
|
+
function placesOfSelection() {
|
|
705
|
+
if (selected === null) return { count: 0, onPage: false };
|
|
706
|
+
if (selected.places !== null) return { count: selected.places.count, onPage: selected.places.onPage === true };
|
|
707
|
+
const perCycle = selected.info.perCycle;
|
|
708
|
+
return { count: typeof perCycle === 'number' ? perCycle : 0, onPage: false };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Where the velocity number of the selected note plays once the page has counted it: `{ count, onPage }`, a count of 0 or 1 says nothing. */
|
|
712
|
+
function velocityPlacesOfSelection() {
|
|
713
|
+
if (selected === null || selected.velocityPlaces === null) return { count: 0, onPage: false };
|
|
714
|
+
return { count: selected.velocityPlaces.count, onPage: selected.velocityPlaces.onPage === true };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** How many notes of the page share the written number that holds the velocity of the selected note (1 when it is its own). */
|
|
718
|
+
function velocityNotes() {
|
|
719
|
+
const velocity = selected.info.seen === null || selected.info.seen === undefined ? undefined : selected.info.seen.velocity;
|
|
720
|
+
if (velocity === undefined || velocity === null || velocity.writable !== true) return 0;
|
|
721
|
+
return sameTokenEvents({ events: drawn.lanes[selected.laneIndex].events, token: velocity.token }).length;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Steps the person pressed on the selected note are written for that note before the selection moves away from it or the lanes
|
|
726
|
+
* are drawn again: the pause is cut short and they join the queue, never forgotten. Steps that cannot be written (the controls
|
|
727
|
+
* are off, or the note cannot be edited) are dropped. Gives true when steps were dropped, so the caller says so where the
|
|
728
|
+
* selection's sentence would go.
|
|
729
|
+
*/
|
|
730
|
+
function leaveNote() {
|
|
731
|
+
if (gatherer.pending() === null) return false;
|
|
732
|
+
if (stepOwner !== null && stepOwner.info.editable && !controlsOff()) {
|
|
733
|
+
gatherer.flush();
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
gatherer.drop();
|
|
737
|
+
stepOwner = null;
|
|
738
|
+
stepSaid = null;
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** The sentence of steps that were not written because the page could not write them when the person moved on. */
|
|
743
|
+
function lostStepsResult() {
|
|
744
|
+
return { kind: 'refused', text: stepsNotWrittenText(), lost: true };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function select(laneIndex, eventIndex) {
|
|
748
|
+
// Picking the note that is selected again keeps what an edit of it will look for, unless the page aims at that no more.
|
|
749
|
+
const again = selected !== null && selected.laneIndex === laneIndex && selected.eventIndex === eventIndex && !selected.target.gone ? selected : null;
|
|
750
|
+
// Steps that waited were aimed at the note that was shown: they are written for it now.
|
|
751
|
+
const lost = leaveNote();
|
|
752
|
+
stepOwner = null;
|
|
753
|
+
stepSaid = null;
|
|
754
|
+
askAgain = null;
|
|
755
|
+
// A new selection replaces a range and the cursor.
|
|
756
|
+
dropRange();
|
|
757
|
+
cursor = null;
|
|
758
|
+
selected = choose(laneIndex, eventIndex, again === null ? null : again.target);
|
|
759
|
+
result = lost ? lostStepsResult() : null;
|
|
760
|
+
render();
|
|
761
|
+
drawRing();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function clearSelection() {
|
|
765
|
+
if (selected === null && cursor === null) return;
|
|
766
|
+
const lost = leaveNote();
|
|
767
|
+
stepOwner = null;
|
|
768
|
+
stepSaid = null;
|
|
769
|
+
askAgain = null;
|
|
770
|
+
selected = null;
|
|
771
|
+
cursor = null;
|
|
772
|
+
result = lost ? lostStepsResult() : null;
|
|
773
|
+
render();
|
|
774
|
+
drawRing();
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** Puts the keyboard cursor on a lane that has no note on the page, at `cycle`; the selected note and any range go. */
|
|
778
|
+
function placeCursor(laneIndex, cycle) {
|
|
779
|
+
const lost = leaveNote();
|
|
780
|
+
stepOwner = null;
|
|
781
|
+
stepSaid = null;
|
|
782
|
+
askAgain = null;
|
|
783
|
+
dropRange();
|
|
784
|
+
selected = null;
|
|
785
|
+
cursor = { laneIndex, cycle };
|
|
786
|
+
result = lost ? lostStepsResult() : null;
|
|
787
|
+
render();
|
|
788
|
+
drawRing();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** The middle of the selected note's mark on the page, in pixels: where a request prepared by a key or a button opens the box. */
|
|
792
|
+
function notePoint() {
|
|
793
|
+
const svg = lanes.children[selected.laneIndex].querySelector('svg.lane-events');
|
|
794
|
+
const rect = svg.getBoundingClientRect();
|
|
795
|
+
const box = markBox(selected.laneIndex, selected.eventIndex);
|
|
796
|
+
return { x: rect.left + (box.left + box.width / 2) * rect.width, y: rect.top + (box.top + box.height / 2) * rect.height };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Asks the page to put the ready-made request for `change` of the selected note in the Ask box, opened at `at` (the note's
|
|
801
|
+
* middle when none is given). Nothing is written and nothing is sent: only the person's Enter in the box sends. The sentence says
|
|
802
|
+
* so once the box is open.
|
|
803
|
+
*/
|
|
804
|
+
function prepareRequest(info, change, at) {
|
|
805
|
+
dropSteps();
|
|
806
|
+
askAgain = null;
|
|
807
|
+
const opened = handlers.prepare(info, change, at === undefined ? notePoint() : at);
|
|
808
|
+
// The page opened nothing (the overlay shows, or the note could not be described): it has said why where it says such things.
|
|
809
|
+
if (opened === false) return;
|
|
810
|
+
result = { kind: 'result', text: requestReadyText() };
|
|
811
|
+
render();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Ask Claude about the selected note: the box opens at the note with the note's lane and moment and an empty field. After an
|
|
816
|
+
* edit that was refused at the read-back or did not evaluate, the same change is prepared as a ready-made request instead,
|
|
817
|
+
* until the next gesture or selection.
|
|
818
|
+
*/
|
|
819
|
+
function askAbout() {
|
|
820
|
+
if (range !== null) {
|
|
821
|
+
askRange();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (cursor !== null) {
|
|
825
|
+
askLane();
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (selected === null) return;
|
|
829
|
+
const at = notePoint();
|
|
830
|
+
if (askAgain !== null && askAgain.target === selected.target) {
|
|
831
|
+
// A request is not a gesture on the note: it stays on offer until the person acts on the note or picks another.
|
|
832
|
+
const again = askAgain;
|
|
833
|
+
prepareRequest(selected.info, again.change, at);
|
|
834
|
+
askAgain = again;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
handlers.ask(selected.info, at);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** The middle of the lane under the keyboard cursor, at its cycle, in pixels: where the box opens for the lane. */
|
|
841
|
+
function cursorPoint() {
|
|
842
|
+
const svg = lanes.children[cursor.laneIndex].querySelector('svg.lane-events');
|
|
843
|
+
const rect = svg.getBoundingClientRect();
|
|
844
|
+
return { x: rect.left + ((cursor.cycle + 0.5 - drawn.pageStart) / PAGE_CYCLES) * rect.width, y: rect.top + rect.height / 2 };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Ask Claude about the lane under the keyboard cursor, at its cycle: the right-click's lane context, with an empty field. */
|
|
848
|
+
function askLane() {
|
|
849
|
+
handlers.askLane(cursor.laneIndex, cursor.cycle, cursorPoint());
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// --- the queue of edits ---
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Adds one edit of the note `target` to the queue and hands it over when its turn has come. `steps` says it gathers steps;
|
|
856
|
+
* `said` is their sentence, which stays on screen while the edit waits behind another.
|
|
857
|
+
*/
|
|
858
|
+
function enqueue(target, change, steps, said = null) {
|
|
859
|
+
const edit = { id: nextEditId++, target, change, steps };
|
|
860
|
+
if (closed) {
|
|
861
|
+
// The Timeline is closed: nothing is handed over, and the panel says the change was not written.
|
|
862
|
+
tellNotWritten([edit]);
|
|
863
|
+
render();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
queue.push(edit);
|
|
867
|
+
pump();
|
|
868
|
+
if (said !== null && queue.includes(edit)) queuedSaid = { edit, said };
|
|
869
|
+
render();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Hands the next edits over, one at a time: only while no edit is on its way, the lanes are drawn and the Timeline is open. Each
|
|
874
|
+
* is aimed at its note as the lanes draw it now; an edit whose note is not there any more, or cannot be edited any more, is
|
|
875
|
+
* dropped and the panel says which note's change was not written.
|
|
876
|
+
*/
|
|
877
|
+
function pump() {
|
|
878
|
+
while (inFlight === null && pumpHeld === 0 && !closed && drawn !== null && queue.length > 0) {
|
|
879
|
+
const edit = queue.shift();
|
|
880
|
+
const found = findNote(edit.target);
|
|
881
|
+
let info = null;
|
|
882
|
+
if (found !== null) {
|
|
883
|
+
const shown = selected !== null && selected.target === edit.target && selected.laneIndex === found.laneIndex && selected.eventIndex === found.eventIndex;
|
|
884
|
+
info = shown ? selected.info : handlers.describe(found.laneIndex, found.eventIndex);
|
|
885
|
+
}
|
|
886
|
+
if (info === null || !info.editable) {
|
|
887
|
+
tellDropped(edit);
|
|
888
|
+
render();
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
handOver(edit, info, found);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Hands `edit` to the page for the note `info`, drawn at `found`, with a bounded wait for its answer. A rest remembers its chord
|
|
897
|
+
* as it is drawn now and the notes beside it, which are found again from the lanes drawn after it (see resolveRest).
|
|
898
|
+
*/
|
|
899
|
+
function handOver(edit, info, found) {
|
|
900
|
+
edit.note = info.seen.token.text;
|
|
901
|
+
edit.lane = info.track;
|
|
902
|
+
edit.target.note = edit.note;
|
|
903
|
+
edit.target.lane = edit.lane;
|
|
904
|
+
// The lanes this edit brings may draw the note at the pitch it asks for before its answer comes: until the answer, the note
|
|
905
|
+
// may have that pitch as well as its own.
|
|
906
|
+
edit.target.asked = null;
|
|
907
|
+
if (edit.change.kind === 'pitch') {
|
|
908
|
+
const next = stepPitch(info.seen.token.text, edit.change.semitones);
|
|
909
|
+
if (next.limit === undefined) edit.target.asked = next.midi;
|
|
910
|
+
}
|
|
911
|
+
if (queuedSaid !== null && queuedSaid.edit === edit) queuedSaid = null;
|
|
912
|
+
if (edit.change.kind === 'rest') {
|
|
913
|
+
const before = chordOf(drawn.lanes[found.laneIndex], edit.target.begin);
|
|
914
|
+
const rested = before[edit.target.rank];
|
|
915
|
+
const members = new Set([...liveTargets()].filter((target) => beside(target, edit.target)));
|
|
916
|
+
pendingRest = { edit, target: edit.target, place: rested === undefined ? undefined : rested.places[0], before, members };
|
|
917
|
+
}
|
|
918
|
+
inFlight = { edit, phase: 'answer', timer: setTimer(expire, EDIT_WAIT_MS), lanesAway: false, freshLanes: false };
|
|
919
|
+
handed.set(edit.id, edit);
|
|
920
|
+
try {
|
|
921
|
+
handlers.edit(info, edit.change, edit.id);
|
|
922
|
+
} catch (error) {
|
|
923
|
+
// The page could not even take the edit: nothing waits for an answer, and the error is not hidden.
|
|
924
|
+
clearTimer(inFlight.timer);
|
|
925
|
+
inFlight = null;
|
|
926
|
+
pendingRest = null;
|
|
927
|
+
handed.delete(edit.id);
|
|
928
|
+
throw error;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* The edit on its way is settled: the next one may go. A pitch its outcome left open (an error at or after the write) is read
|
|
934
|
+
* from the lanes drawn now, which are drawn from the code it left, and so are the notes beside a rest that was written.
|
|
935
|
+
*/
|
|
936
|
+
function settle() {
|
|
937
|
+
if (inFlight.timer !== null) clearTimer(inFlight.timer);
|
|
938
|
+
resolveAsked(inFlight.edit.target);
|
|
939
|
+
inFlight = null;
|
|
940
|
+
resolveRest('lanes');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* A wait of the edit on its way is over. With no answer, the panel says so at once, but the page may still be running the edit,
|
|
945
|
+
* so the next one waits for that answer a while longer (an edit handed over meanwhile would be aimed at code about to change);
|
|
946
|
+
* after that, or when the lanes of a written edit never came, the queue goes on. The notes beside a rest that ends this way are
|
|
947
|
+
* aimed at no more: the page cannot tell where they went.
|
|
948
|
+
*/
|
|
949
|
+
function expire() {
|
|
950
|
+
const late = inFlight;
|
|
951
|
+
if (late === null) return;
|
|
952
|
+
if (late.phase === 'answer') {
|
|
953
|
+
console.error(`Moontide: the page did not answer a Timeline edit within ${EDIT_WAIT_MS} ms; the next edit waits for that answer a while longer.`);
|
|
954
|
+
result = sentenceFor(late.edit, { kind: 'refused', text: editTimeoutText() });
|
|
955
|
+
// The sentence of a burst waiting behind it would hide this one: the burst still goes, after the late answer.
|
|
956
|
+
queuedSaid = null;
|
|
957
|
+
late.edit.late = true;
|
|
958
|
+
late.phase = 'late';
|
|
959
|
+
late.timer = setTimer(expire, EDIT_WAIT_MS);
|
|
960
|
+
render();
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
inFlight = null;
|
|
964
|
+
resolveRest('unsure');
|
|
965
|
+
if (late.phase === 'late') {
|
|
966
|
+
console.error(`Moontide: the page never answered a Timeline edit; the next edit goes on.`);
|
|
967
|
+
} else if (!closed) {
|
|
968
|
+
// A closed Timeline draws no lanes, so a written edit waits for none there: it is settled with no error.
|
|
969
|
+
console.error(`Moontide: the Timeline was not drawn again within ${EDIT_WAIT_MS} ms of a written edit; the next edit goes on.`);
|
|
970
|
+
}
|
|
971
|
+
pump();
|
|
972
|
+
render();
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Forgets the edits that wait their turn and gives them back. The edit on its way is kept: the page still runs it, its answer is
|
|
977
|
+
* told, and nothing goes after it before that answer (and the lanes of a write) came.
|
|
978
|
+
*/
|
|
979
|
+
function dropEdits() {
|
|
980
|
+
const dropped = queue;
|
|
981
|
+
queue = [];
|
|
982
|
+
return dropped;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* The sentence that says the edits in `dropped` were not written: steps in the words of steps, a rest or a drag in the words of a
|
|
987
|
+
* change, each named after its note when the panel shows another note. '' when nothing was dropped.
|
|
988
|
+
*/
|
|
989
|
+
function notWrittenText(dropped) {
|
|
990
|
+
const texts = dropped.map((edit) => sentenceFor(edit, { kind: 'refused', text: edit.steps ? stepsNotWrittenText() : changeNotWrittenText() }).text);
|
|
991
|
+
return [...new Set(texts)].join(' ');
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** The steps that wait for their pause, as an edit of the note they were taken on, or none: what a drop of everything waiting loses first. */
|
|
995
|
+
function pendingSteps() {
|
|
996
|
+
return gatherer.pending() !== null && stepOwner !== null ? [{ target: stepOwner.target, steps: true }] : [];
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Says that the edits in `dropped` were not written (the page turned, or the Timeline closed), each named after its note when the panel shows another. */
|
|
1000
|
+
function tellNotWritten(dropped) {
|
|
1001
|
+
if (dropped.length === 0) return;
|
|
1002
|
+
result = { kind: 'refused', text: notWrittenText(dropped) };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** True when `edit` is about the note the panel shows, or the note that comes back when the lanes are drawn again. */
|
|
1006
|
+
function aboutShown(edit) {
|
|
1007
|
+
if (selected !== null) return selected.target === edit.target;
|
|
1008
|
+
return drawn === null && restore === edit.target;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** The sentence `said` for `edit`: as it is for the note the panel shows, named after its note for any other. */
|
|
1012
|
+
function sentenceFor(edit, said) {
|
|
1013
|
+
if (aboutShown(edit)) return said;
|
|
1014
|
+
const note = edit.note ?? edit.target.note;
|
|
1015
|
+
const lane = edit.lane ?? edit.target.lane;
|
|
1016
|
+
return { ...said, text: namedText({ note, lane, text: said.text }) };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Says that `edit` was not written: its note is not where it was, or cannot be edited any more, or the Timeline closed. */
|
|
1020
|
+
function tellDropped(edit) {
|
|
1021
|
+
result = sentenceFor(edit, { kind: 'refused', text: edit.steps ? stepsDroppedText() : changeDroppedText() });
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Tells the outcome of `edit`, whose sentence is `said` (null for one with nothing to say): a written pitch becomes the pitch its
|
|
1026
|
+
* note is checked against (a refusal keeps the old one, a rest takes the note away), the sentence goes to the slot, named after the note when the panel shows another one, and
|
|
1027
|
+
* Ask Claude carries the change when the page wrote nothing and the note is the one shown. A change that waits for its line
|
|
1028
|
+
* keeps the edit controls off, and the steps and edits that wait are dropped with it.
|
|
1029
|
+
*/
|
|
1030
|
+
function tellOutcome(edit, outcome, said) {
|
|
1031
|
+
const target = edit.target;
|
|
1032
|
+
const made = outcome.kind === 'written' || outcome.kind === 'waiting';
|
|
1033
|
+
const pitchKind = outcome.what === undefined || outcome.what === 'pitch';
|
|
1034
|
+
// How the rest on its way is settled by this answer, when it is its answer (see resolveRest); null leaves it on its way.
|
|
1035
|
+
let restHow = null;
|
|
1036
|
+
if (made && outcome.what === 'rest') {
|
|
1037
|
+
// The note leaves the lane: nothing is there to select again or to aim at. The notes beside it wait for the lanes drawn
|
|
1038
|
+
// after the rest (written) or for the end of the wait (waiting).
|
|
1039
|
+
target.gone = true;
|
|
1040
|
+
target.asked = null;
|
|
1041
|
+
} else if (outcome.kind === 'written' && pitchKind) {
|
|
1042
|
+
// The page wrote the pitch: it is the one the note is checked against from now on.
|
|
1043
|
+
const midi = writtenPitch(outcome);
|
|
1044
|
+
target.pitch = midi ?? target.asked;
|
|
1045
|
+
target.asked = null;
|
|
1046
|
+
target.note = String(outcome.to);
|
|
1047
|
+
} else if (outcome.kind === 'waiting' && pitchKind) {
|
|
1048
|
+
// The pitch changes when the line takes over: until the wait ends the note may have either pitch.
|
|
1049
|
+
const midi = writtenPitch(outcome);
|
|
1050
|
+
if (midi !== null) target.asked = midi;
|
|
1051
|
+
target.note = String(outcome.to);
|
|
1052
|
+
} else if (outcome.afterWrite === true) {
|
|
1053
|
+
// The code may or may not hold the change: a rest is taken as made, and the page cannot tell where the notes beside it are,
|
|
1054
|
+
// so none of them is aimed at again. The pitch asked for stays possible until the lanes drawn from the code tell.
|
|
1055
|
+
if (edit.change.kind === 'rest') {
|
|
1056
|
+
target.gone = true;
|
|
1057
|
+
target.asked = null;
|
|
1058
|
+
restHow = 'unsure';
|
|
1059
|
+
}
|
|
1060
|
+
} else {
|
|
1061
|
+
// Refused, failed or unchanged: the page wrote nothing, so the note keeps the pitch it had and the notes beside a rest keep
|
|
1062
|
+
// their ranks.
|
|
1063
|
+
target.asked = null;
|
|
1064
|
+
restHow = 'unwritten';
|
|
1065
|
+
}
|
|
1066
|
+
const shown = aboutShown(edit);
|
|
1067
|
+
if (shown) askAgain = askable(outcome) ? { target: edit.target, change: edit.change } : null;
|
|
1068
|
+
if (shown) result = said;
|
|
1069
|
+
else if (said !== null) result = sentenceFor(edit, said);
|
|
1070
|
+
if (restHow !== null && pendingRest !== null && pendingRest.edit === edit) resolveRest(restHow);
|
|
1071
|
+
if (outcome.kind === 'waiting' && edit.late === true) {
|
|
1072
|
+
// An answer that came after the panel said it was late: the edits made since then were made on purpose after it, so they
|
|
1073
|
+
// stay in the queue and go when the wait ends, took over or not. When this answer came after the second wait, the queue
|
|
1074
|
+
// already goes on meanwhile, and the page refuses those edits because a change waits for its line.
|
|
1075
|
+
result = { ...result, wait: outcome, edit, shown };
|
|
1076
|
+
ownWait = true;
|
|
1077
|
+
waited = edit;
|
|
1078
|
+
} else if (outcome.kind === 'waiting') {
|
|
1079
|
+
// One change waits at a time: the edit controls stay off until it takes over (finishWait), and what waited is not written.
|
|
1080
|
+
const lostText = notWrittenText([...pendingSteps(), ...queue]);
|
|
1081
|
+
result = { ...result, wait: outcome, edit, shown, ...(lostText !== '' ? { lostText, text: `${result.text} ${lostText}` } : {}) };
|
|
1082
|
+
ownWait = true;
|
|
1083
|
+
waited = edit;
|
|
1084
|
+
gatherer.drop();
|
|
1085
|
+
queue = [];
|
|
1086
|
+
stepOwner = null;
|
|
1087
|
+
stepSaid = null;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// --- steps ---
|
|
1092
|
+
|
|
1093
|
+
/** Takes the pending steps and their sentence away without writing them. */
|
|
1094
|
+
function dropSteps() {
|
|
1095
|
+
gatherer.drop();
|
|
1096
|
+
stepOwner = null;
|
|
1097
|
+
if (stepSaid !== null) {
|
|
1098
|
+
stepSaid = null;
|
|
1099
|
+
render();
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* The pause after the last step is over, or was cut short: the total of the burst becomes one edit of the note it was taken on,
|
|
1105
|
+
* which waits its turn in the queue. The gatherer is not entered again from here.
|
|
1106
|
+
*/
|
|
1107
|
+
function flushSteps(kind, total) {
|
|
1108
|
+
const said = stepSaid;
|
|
1109
|
+
stepSaid = null;
|
|
1110
|
+
const owner = stepOwner;
|
|
1111
|
+
stepOwner = null;
|
|
1112
|
+
if (owner === null || !owner.info.editable) {
|
|
1113
|
+
// Steps with no note to be written for are told, not hidden.
|
|
1114
|
+
result = { kind: 'refused', text: stepsDroppedText() };
|
|
1115
|
+
render();
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
enqueue(owner.target, kind === 'pitch' ? { kind: 'pitch', semitones: total } : { kind: 'velocity', steps: total }, true, said);
|
|
1119
|
+
render();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
const gatherer = createStepGatherer({
|
|
1123
|
+
setTimer,
|
|
1124
|
+
clearTimer,
|
|
1125
|
+
delayMs: STEP_GATHER_MS,
|
|
1126
|
+
onFlush: flushSteps,
|
|
1127
|
+
// A write the page could not even start is not hidden: it is reported where the page reports a failed handler.
|
|
1128
|
+
onError: (error) => {
|
|
1129
|
+
console.error('Moontide: the steps of a Timeline note could not be handed to the page:', error);
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* The edits of `target` that the lanes drawn now do not show yet, in the order the page takes them: the one on its way, until
|
|
1135
|
+
* the lanes its write brings are here, then those that wait their turn.
|
|
1136
|
+
*/
|
|
1137
|
+
function unshownEdits(target) {
|
|
1138
|
+
const edits = [];
|
|
1139
|
+
if (inFlight !== null && inFlight.edit.target === target && !inFlight.freshLanes) edits.push(inFlight.edit);
|
|
1140
|
+
for (const edit of queue) if (edit.target === target) edits.push(edit);
|
|
1141
|
+
return edits;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* The token of the selected note (`choice`) as it will be once the pitch edits of it that wait or are on their way are written:
|
|
1146
|
+
* `{ text, midi }`. A new step is shown, and held at the ends of the range, from there, because that is the token it is written on.
|
|
1147
|
+
*/
|
|
1148
|
+
function expectedPitch(choice) {
|
|
1149
|
+
let text = choice.info.seen.token.text;
|
|
1150
|
+
let midi = choice.info.midi;
|
|
1151
|
+
for (const edit of unshownEdits(choice.target)) {
|
|
1152
|
+
if (edit.change.kind !== 'pitch') continue;
|
|
1153
|
+
const next = stepPitch(text, edit.change.semitones);
|
|
1154
|
+
if (next.limit !== undefined) continue;
|
|
1155
|
+
text = next.text;
|
|
1156
|
+
midi = next.midi;
|
|
1157
|
+
}
|
|
1158
|
+
return { text, midi };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** The velocity of the selected note (`choice`) once the velocity edits of it that wait or are on their way are written. */
|
|
1162
|
+
function expectedVelocity(choice) {
|
|
1163
|
+
let shown = choice.info.velocity;
|
|
1164
|
+
for (const edit of unshownEdits(choice.target)) {
|
|
1165
|
+
if (edit.change.kind === 'velocity') shown = velocityTarget({ shown, steps: edit.change.steps });
|
|
1166
|
+
}
|
|
1167
|
+
return shown;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* The sentence of `total` steps of `kind` that wait for the pause, taken on the selected note as it will be once the edits of it
|
|
1172
|
+
* before them are written, or null when they add up to nothing. It repeats how many places the token plays, or how many notes
|
|
1173
|
+
* share the velocity number.
|
|
1174
|
+
*/
|
|
1175
|
+
function stepsSentence(kind, total) {
|
|
1176
|
+
if (kind === 'pitch') {
|
|
1177
|
+
if (total === 0) return null;
|
|
1178
|
+
const from = expectedPitch(selected);
|
|
1179
|
+
const next = stepPitch(from.text, total);
|
|
1180
|
+
if (next.limit !== undefined) return { kind: 'preview', text: limitText(next.limit) };
|
|
1181
|
+
const { count, onPage } = placesOfSelection();
|
|
1182
|
+
return {
|
|
1183
|
+
kind: 'preview',
|
|
1184
|
+
text: previewText({
|
|
1185
|
+
from: from.text,
|
|
1186
|
+
to: next.text,
|
|
1187
|
+
semitones: total,
|
|
1188
|
+
drumFrom: drumLabel(selected.laneIndex, from.midi),
|
|
1189
|
+
drumTo: drumLabel(selected.laneIndex, next.midi),
|
|
1190
|
+
waiting: true,
|
|
1191
|
+
places: count,
|
|
1192
|
+
onPage,
|
|
1193
|
+
}),
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
const shown = expectedVelocity(selected);
|
|
1197
|
+
const target = velocityTarget({ shown, steps: total });
|
|
1198
|
+
if (target === Math.round(shown * 100) / 100) return null;
|
|
1199
|
+
const { count, onPage } = velocityPlacesOfSelection();
|
|
1200
|
+
return { kind: 'preview', text: velocityPreviewText({ from: shown, to: target, notes: velocityNotes(), places: count, onPage }) };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** The total of the steps of `kind` that wait for the pause, 0 when there are none of that kind. */
|
|
1204
|
+
function pendingTotal(kind) {
|
|
1205
|
+
const pending = gatherer.pending();
|
|
1206
|
+
return pending !== null && pending.kind === kind ? pending.total : 0;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Says the note is at the end of its range: with steps waiting the sentence is theirs, otherwise it is the result, in the place
|
|
1211
|
+
* of a burst that waits behind another edit (that burst is what took the note to the end).
|
|
1212
|
+
*/
|
|
1213
|
+
function sayLimit(text) {
|
|
1214
|
+
if (gatherer.pending() !== null) {
|
|
1215
|
+
stepSaid = { kind: 'preview', text };
|
|
1216
|
+
} else {
|
|
1217
|
+
result = { kind: 'result', text };
|
|
1218
|
+
queuedSaid = null;
|
|
1219
|
+
}
|
|
1220
|
+
render();
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* One step of the pitch of the selected note, a semitone or an octave up or down (`amount`): the step joins the steps that wait
|
|
1225
|
+
* for the pause and the sentence shows where they would take the note. A note the page cannot edit does nothing, and so does
|
|
1226
|
+
* a note while the controls are off.
|
|
1227
|
+
*/
|
|
1228
|
+
function pitchStep(amount) {
|
|
1229
|
+
if (selected === null) return;
|
|
1230
|
+
// A note the page cannot edit moves nothing: the step becomes a request in the Ask box.
|
|
1231
|
+
if (!selected.info.editable) {
|
|
1232
|
+
prepareRequest(selected.info, { kind: 'pitch', semitones: amount });
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (controlsOff()) return;
|
|
1236
|
+
askAgain = null;
|
|
1237
|
+
const total = pendingTotal('pitch') + amount;
|
|
1238
|
+
const next = stepPitch(expectedPitch(selected).text, total);
|
|
1239
|
+
if (next.limit !== undefined) {
|
|
1240
|
+
sayLimit(limitText(next.limit));
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
gatherer.step('pitch', amount);
|
|
1244
|
+
stepOwner = selected;
|
|
1245
|
+
// Back where it started (a total of 0): nothing will be written, and nothing is said.
|
|
1246
|
+
stepSaid = stepsSentence('pitch', total);
|
|
1247
|
+
render();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** One step of the velocity of the selected note, a tenth up or down (`amount`); the steps gather like the pitch ones. */
|
|
1251
|
+
function velocityStepBy(amount) {
|
|
1252
|
+
if (selected === null) return;
|
|
1253
|
+
// A note the page cannot edit moves nothing: the step becomes a request in the Ask box.
|
|
1254
|
+
if (!selected.info.editable) {
|
|
1255
|
+
prepareRequest(selected.info, { kind: 'velocity', steps: amount });
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (controlsOff()) return;
|
|
1259
|
+
askAgain = null;
|
|
1260
|
+
const info = selected.info;
|
|
1261
|
+
if (info.velocityWritable !== true) {
|
|
1262
|
+
dropSteps();
|
|
1263
|
+
result = { kind: 'refused', text: velocityRefusalText() };
|
|
1264
|
+
// The sentence says to ask Claude, and Ask Claude then carries the step that was refused.
|
|
1265
|
+
askAgain = { target: selected.target, change: { kind: 'velocity', steps: amount } };
|
|
1266
|
+
render();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const shown = expectedVelocity(selected);
|
|
1270
|
+
const before = pendingTotal('velocity');
|
|
1271
|
+
const total = before + amount;
|
|
1272
|
+
if (velocityTarget({ shown, steps: total }) === velocityTarget({ shown, steps: before })) {
|
|
1273
|
+
sayLimit(velocityLimitText(amount > 0 ? 'highest' : 'lowest'));
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
gatherer.step('velocity', amount);
|
|
1277
|
+
stepOwner = selected;
|
|
1278
|
+
stepSaid = stepsSentence('velocity', total);
|
|
1279
|
+
render();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Turns the selected note into a rest, whatever steps were waiting: it is handed over at once, or after the edits before it. A note the page cannot edit is not changed: the
|
|
1284
|
+
* request for it is prepared in the Ask box.
|
|
1285
|
+
*/
|
|
1286
|
+
function rest() {
|
|
1287
|
+
if (selected === null) return;
|
|
1288
|
+
if (!selected.info.editable) {
|
|
1289
|
+
prepareRequest(selected.info, { kind: 'rest' });
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
dropSteps();
|
|
1293
|
+
if (controlsOff()) return;
|
|
1294
|
+
askAgain = null;
|
|
1295
|
+
enqueue(selected.target, { kind: 'rest' }, false);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Selects again the note the person had selected before the lanes were drawn again: the same lane (by its identity, or by its
|
|
1300
|
+
* track index when the identity was built from strings an edit may have changed), the same time and rank, and a pitch the note may
|
|
1301
|
+
* have. A page that is not the same page, or no such note, leaves nothing selected. The sentence of the last edit stays.
|
|
1302
|
+
*/
|
|
1303
|
+
function restoreSelection() {
|
|
1304
|
+
const target = restore;
|
|
1305
|
+
restore = null;
|
|
1306
|
+
if (target === null) return;
|
|
1307
|
+
const found = findNote(target);
|
|
1308
|
+
if (found === null) return;
|
|
1309
|
+
selected = choose(found.laneIndex, found.eventIndex, target);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// --- the pointer ---
|
|
1313
|
+
|
|
1314
|
+
/** The lane whose event area holds the point, as `{ index, rect }`, or null. */
|
|
1315
|
+
function laneAt(x, y) {
|
|
1316
|
+
if (drawn === null) return null;
|
|
1317
|
+
for (let index = 0; index < drawn.lanes.length; index++) {
|
|
1318
|
+
const laneElement = lanes.children[index];
|
|
1319
|
+
if (!laneElement) continue;
|
|
1320
|
+
const svg = laneElement.querySelector('svg.lane-events');
|
|
1321
|
+
if (!svg) continue;
|
|
1322
|
+
const rect = svg.getBoundingClientRect();
|
|
1323
|
+
if (rect.width > 0 && x >= rect.left && x <= rect.left + rect.width && y >= rect.top && y <= rect.top + rect.height) return { index, rect };
|
|
1324
|
+
}
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function endPress() {
|
|
1329
|
+
if (press === null) return;
|
|
1330
|
+
const { pointerId } = press;
|
|
1331
|
+
press = null;
|
|
1332
|
+
if (typeof lanes.hasPointerCapture === 'function' && lanes.hasPointerCapture(pointerId)) lanes.releasePointerCapture(pointerId);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function removeAttr(node, name) {
|
|
1336
|
+
if (node.getAttribute(name) !== null) node.removeAttribute(name);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** The drum the lane's map names `midi`, `{ map }` for a note the map does not name, null when the lane is not drums. */
|
|
1340
|
+
function drumLabel(laneIndex, midi) {
|
|
1341
|
+
const map = drawn.lanes[laneIndex].drumMap;
|
|
1342
|
+
if (map === null || map === undefined) return null;
|
|
1343
|
+
const name = map.byNote.get(midi);
|
|
1344
|
+
return name === undefined ? { map: map.name } : name;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* The change of a pitch drag that stands for `semitones` of travel: the same step the page writes, from the token the drag
|
|
1349
|
+
* starts from, held where the note range ends. Gives `{ semitones, limit }`; `limit` is `'lowest'`, `'highest'` or null when
|
|
1350
|
+
* the travel was not held.
|
|
1351
|
+
*/
|
|
1352
|
+
function heldChange(drag, semitones) {
|
|
1353
|
+
let held = Math.max(-127, Math.min(127, semitones));
|
|
1354
|
+
while (held !== 0 && stepPitch(drag.from.text, held).limit !== undefined) held += held > 0 ? -1 : 1;
|
|
1355
|
+
return { semitones: held, limit: held === semitones ? null : semitones > 0 ? 'highest' : 'lowest' };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Puts the ghost and the sentence of the drag where its whole-step value says, and writes nothing else. */
|
|
1359
|
+
function showDrag(drag) {
|
|
1360
|
+
if (drag.semitones === 0) {
|
|
1361
|
+
if (drag.ghost !== null) {
|
|
1362
|
+
drag.ghost.remove();
|
|
1363
|
+
drag.ghost = null;
|
|
1364
|
+
}
|
|
1365
|
+
preview = drag.limit === null ? null : { kind: 'preview', text: limitText(drag.limit) };
|
|
1366
|
+
render();
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const info = drag.info;
|
|
1370
|
+
const step = stepPitch(drag.from.text, drag.semitones);
|
|
1371
|
+
const box = markBox(drag.laneIndex, drag.eventIndex);
|
|
1372
|
+
// The note stays where it is; the ghost is a fixed step higher or lower for each semitone, inside the lane.
|
|
1373
|
+
const top = Math.max(0, Math.min(1 - box.height, box.top - (drag.semitones * drag.stepPx) / drag.laneHeight));
|
|
1374
|
+
if (drag.ghost === null) {
|
|
1375
|
+
drag.ghost = make('span', 'tl-ghost');
|
|
1376
|
+
marks[drag.laneIndex].appendChild(drag.ghost);
|
|
1377
|
+
}
|
|
1378
|
+
placeMark(drag.ghost, { ...box, top });
|
|
1379
|
+
const { count, onPage } = selected !== null && selected.info === info ? placesOfSelection() : { count: 0, onPage: false };
|
|
1380
|
+
preview = {
|
|
1381
|
+
kind: 'preview',
|
|
1382
|
+
text: previewText({
|
|
1383
|
+
from: drag.from.text,
|
|
1384
|
+
to: step.text,
|
|
1385
|
+
semitones: drag.semitones,
|
|
1386
|
+
drumFrom: drumLabel(drag.laneIndex, drag.from.midi),
|
|
1387
|
+
drumTo: drumLabel(drag.laneIndex, step.midi),
|
|
1388
|
+
places: count,
|
|
1389
|
+
onPage,
|
|
1390
|
+
}),
|
|
1391
|
+
};
|
|
1392
|
+
render();
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** Puts the wash of a range over its whole cycles, in percentages of the lane. */
|
|
1396
|
+
function placeWash(node, from, to) {
|
|
1397
|
+
node.style.left = `${((from - drawn.pageStart) / PAGE_CYCLES) * 100}%`;
|
|
1398
|
+
node.style.width = `${((to - from + 1) / PAGE_CYCLES) * 100}%`;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* The range a press covers with the pointer at `x`: the cycle under the press and the cycle under the pointer, held inside the
|
|
1403
|
+
* page. The press keeps one range object for the whole drag; its wash is drawn the first time and moved afterwards.
|
|
1404
|
+
*/
|
|
1405
|
+
function dragRange(held, x) {
|
|
1406
|
+
const width = held.rect.width;
|
|
1407
|
+
const cycles = rangeCycles({ pageStart: drawn.pageStart, pressFraction: (held.x - held.rect.left) / width, pointerFraction: (x - held.rect.left) / width });
|
|
1408
|
+
// The cycle the press began in is where the range is anchored; the other end is the cycle the pointer is in.
|
|
1409
|
+
const anchor = cycleAt({ pageStart: drawn.pageStart, fraction: (held.x - held.rect.left) / width });
|
|
1410
|
+
const head = anchor === cycles.from ? cycles.to : cycles.from;
|
|
1411
|
+
if (held.range === undefined || held.range === null) {
|
|
1412
|
+
held.range = { laneIndex: held.laneIndex, from: cycles.from, to: cycles.to, cut: cycles.cut, anchor, head, wash: make('span', 'tl-range') };
|
|
1413
|
+
placeWash(held.range.wash, cycles.from, cycles.to);
|
|
1414
|
+
marks[held.laneIndex].appendChild(held.range.wash);
|
|
1415
|
+
return held.range;
|
|
1416
|
+
}
|
|
1417
|
+
const current = held.range;
|
|
1418
|
+
if (current.from !== cycles.from || current.to !== cycles.to) placeWash(current.wash, cycles.from, cycles.to);
|
|
1419
|
+
current.from = cycles.from;
|
|
1420
|
+
current.to = cycles.to;
|
|
1421
|
+
current.cut = cycles.cut;
|
|
1422
|
+
current.head = head;
|
|
1423
|
+
return current;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** Takes the wash of the selected range away and forgets the range. */
|
|
1427
|
+
function dropRange() {
|
|
1428
|
+
if (range === null) return false;
|
|
1429
|
+
range.wash.remove();
|
|
1430
|
+
range = null;
|
|
1431
|
+
return true;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** The selected range as the page takes it: `{ laneIndex, from, to, cut }`. */
|
|
1435
|
+
function rangeSelection() {
|
|
1436
|
+
return { laneIndex: range.laneIndex, from: range.from, to: range.to, cut: range.cut };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Makes `held` (a range with its wash drawn) the selected range, with what the page says of it for the panel. */
|
|
1440
|
+
function adoptRange(held) {
|
|
1441
|
+
held.info = handlers.describeRange({ laneIndex: held.laneIndex, from: held.from, to: held.to, cut: held.cut });
|
|
1442
|
+
range = held;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** The middle of the selected range's wash on the page, in pixels: where the box opens when no pointer made the range. */
|
|
1446
|
+
function rangePoint() {
|
|
1447
|
+
const svg = lanes.children[range.laneIndex].querySelector('svg.lane-events');
|
|
1448
|
+
const rect = svg.getBoundingClientRect();
|
|
1449
|
+
return { x: rect.left + (((range.from + range.to + 1) / 2 - drawn.pageStart) / PAGE_CYCLES) * rect.width, y: rect.top + rect.height / 2 };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** Ask Claude about the selected range: the box opens at the middle of the wash with an empty field. */
|
|
1453
|
+
function askRange() {
|
|
1454
|
+
handlers.range(rangeSelection(), rangePoint());
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Moves the head of the range one cycle left or right (`direction` is -1 or 1), held inside the page, and draws it again. With no
|
|
1459
|
+
* range the key starts one at the cycle of the selected note or of the cursor, which it replaces. Without a range, a note or a
|
|
1460
|
+
* cursor it does nothing and gives false.
|
|
1461
|
+
*/
|
|
1462
|
+
function widenRange(direction) {
|
|
1463
|
+
let laneIndex;
|
|
1464
|
+
let anchor;
|
|
1465
|
+
let head;
|
|
1466
|
+
if (range !== null) {
|
|
1467
|
+
({ laneIndex, anchor, head } = range);
|
|
1468
|
+
} else if (selected !== null) {
|
|
1469
|
+
laneIndex = selected.laneIndex;
|
|
1470
|
+
const begin = drawn.lanes[laneIndex].events[selected.eventIndex].begin;
|
|
1471
|
+
anchor = cycleAt({ pageStart: drawn.pageStart, fraction: (begin - drawn.pageStart) / PAGE_CYCLES });
|
|
1472
|
+
head = anchor;
|
|
1473
|
+
} else if (cursor !== null) {
|
|
1474
|
+
laneIndex = cursor.laneIndex;
|
|
1475
|
+
anchor = cursor.cycle;
|
|
1476
|
+
head = anchor;
|
|
1477
|
+
} else {
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
head = Math.max(drawn.pageStart, Math.min(drawn.pageStart + PAGE_CYCLES - 1, head + direction));
|
|
1481
|
+
clearSelection();
|
|
1482
|
+
dropRange();
|
|
1483
|
+
const from = Math.min(anchor, head);
|
|
1484
|
+
const to = Math.max(anchor, head);
|
|
1485
|
+
const wash = make('span', 'tl-range');
|
|
1486
|
+
placeWash(wash, from, to);
|
|
1487
|
+
marks[laneIndex].appendChild(wash);
|
|
1488
|
+
adoptRange({ laneIndex, from, to, cut: null, anchor, head, wash });
|
|
1489
|
+
render();
|
|
1490
|
+
return true;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** The press moved far enough to be a gesture: set it up. */
|
|
1494
|
+
function beginGesture() {
|
|
1495
|
+
if (press.lock === 'range') {
|
|
1496
|
+
setAttr(lanes, 'data-dragging', 'range');
|
|
1497
|
+
press.range = null;
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (selected === null || selected.laneIndex !== press.laneIndex || selected.eventIndex !== press.picked) select(press.laneIndex, press.picked);
|
|
1501
|
+
// A drag takes the place of steps that were waiting on this note, and the sentence says so once the drag is over.
|
|
1502
|
+
const replaced = gatherer.pending() !== null;
|
|
1503
|
+
dropSteps();
|
|
1504
|
+
if (replaced) {
|
|
1505
|
+
result = lostStepsResult();
|
|
1506
|
+
render();
|
|
1507
|
+
}
|
|
1508
|
+
const info = selected.info;
|
|
1509
|
+
setAttr(lanes, 'data-dragging', info.editable ? 'pitch' : 'refused');
|
|
1510
|
+
askAgain = null;
|
|
1511
|
+
if (!info.editable) {
|
|
1512
|
+
// A note the page cannot edit does not move and shows no ghost: the travel only decides the request prepared on release.
|
|
1513
|
+
press.refused = { info, semitones: 0, stepPx: SEMITONE_REM * press.rem };
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
// The drag starts from the note as it will be once the edits of it before the drag are written: that is what it changes.
|
|
1517
|
+
press.drag = { info, target: selected.target, from: expectedPitch(selected), laneIndex: press.laneIndex, eventIndex: press.picked, semitones: 0, limit: null, ghost: null, stepPx: SEMITONE_REM * press.rem, laneHeight: press.rect.height };
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Takes the drag's marks and its sentence away; the selection and the panel stay as they were. */
|
|
1521
|
+
function clearGesture() {
|
|
1522
|
+
if (press !== null && press.drag !== undefined && press.drag !== null && press.drag.ghost !== null) press.drag.ghost.remove();
|
|
1523
|
+
removeAttr(lanes, 'data-dragging');
|
|
1524
|
+
if (preview !== null) {
|
|
1525
|
+
preview = null;
|
|
1526
|
+
render();
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** Ends a gesture in progress without writing anything. */
|
|
1531
|
+
function cancelGesture() {
|
|
1532
|
+
if (press === null) return;
|
|
1533
|
+
// A range that was being drawn goes with the gesture; the range that was selected before it stays.
|
|
1534
|
+
if (press.range !== undefined && press.range !== null) press.range.wash.remove();
|
|
1535
|
+
clearGesture();
|
|
1536
|
+
endPress();
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function pickAt(laneIndex, rect, x, y, rem) {
|
|
1540
|
+
const lane = drawn.lanes[laneIndex];
|
|
1541
|
+
return pickNote({
|
|
1542
|
+
boxes: noteBoxes({ rect, shapes: lane.shapes, rowCount: lane.rowCount, markHeight: NOTE_MARK_HEIGHT }),
|
|
1543
|
+
pitches: lane.events.map((entry) => pitchOf(entry.value) ?? 0),
|
|
1544
|
+
x,
|
|
1545
|
+
y,
|
|
1546
|
+
tolerancePx: HIT_TOLERANCE_REM * rem,
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
lanes.addEventListener('pointerdown', (event) => {
|
|
1551
|
+
if (drawn === null || press !== null) return;
|
|
1552
|
+
if (event.button !== 0 || event.ctrlKey || event.metaKey) return;
|
|
1553
|
+
const at = laneAt(event.clientX, event.clientY);
|
|
1554
|
+
if (at === null) return;
|
|
1555
|
+
const rem = rootFontPx();
|
|
1556
|
+
press = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, laneIndex: at.index, rect: at.rect, rem, picked: pickAt(at.index, at.rect, event.clientX, event.clientY, rem), lock: null, drag: null };
|
|
1557
|
+
// The lanes' children are replaced when the Timeline redraws, so the container itself keeps the pointer.
|
|
1558
|
+
if (typeof lanes.setPointerCapture === 'function') lanes.setPointerCapture(event.pointerId);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
/** The word on `data-hover` says what is under the pointer (`note` or `empty`); it is written only when it changes. */
|
|
1562
|
+
function hover(event) {
|
|
1563
|
+
const at = drawn === null ? null : laneAt(event.clientX, event.clientY);
|
|
1564
|
+
if (at === null) {
|
|
1565
|
+
removeAttr(lanes, 'data-hover');
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
setAttr(lanes, 'data-hover', pickAt(at.index, at.rect, event.clientX, event.clientY, rootFontPx()) === null ? 'empty' : 'note');
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
lanes.addEventListener('pointerleave', () => {
|
|
1572
|
+
if (press === null) removeAttr(lanes, 'data-hover');
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
lanes.addEventListener('pointermove', (event) => {
|
|
1576
|
+
if (press === null) {
|
|
1577
|
+
hover(event);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
if (event.pointerId !== press.pointerId) return;
|
|
1581
|
+
const dx = event.clientX - press.x;
|
|
1582
|
+
const dy = event.clientY - press.y;
|
|
1583
|
+
if (press.lock === null) {
|
|
1584
|
+
press.lock = lockGesture({ dx, dy, onNote: press.picked !== null, thresholdPx: DRAG_THRESHOLD_REM * press.rem });
|
|
1585
|
+
if (press.lock === null) return;
|
|
1586
|
+
beginGesture();
|
|
1587
|
+
}
|
|
1588
|
+
if (press.lock === 'range') {
|
|
1589
|
+
dragRange(press, event.clientX);
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (press.lock === 'pitch' && press.refused !== undefined) {
|
|
1593
|
+
const travel = press.refused;
|
|
1594
|
+
const semitones = Math.max(-MAX_REQUEST_SEMITONES, Math.min(MAX_REQUEST_SEMITONES, semitonesFrom({ dy, stepPx: travel.stepPx })));
|
|
1595
|
+
// The sentence changes only when the whole-step value does.
|
|
1596
|
+
if (semitones === travel.semitones) return;
|
|
1597
|
+
travel.semitones = semitones;
|
|
1598
|
+
preview = semitones === 0 ? null : { kind: 'preview', text: refusedPreviewText(semitones) };
|
|
1599
|
+
render();
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const drag = press.drag;
|
|
1603
|
+
if (press.lock !== 'pitch' || drag === null) return;
|
|
1604
|
+
const next = heldChange(drag, semitonesFrom({ dy, stepPx: drag.stepPx }));
|
|
1605
|
+
// The ghost and the sentence change only when the whole-step value does, not with every pixel of the pointer.
|
|
1606
|
+
if (next.semitones === drag.semitones && next.limit === drag.limit) return;
|
|
1607
|
+
drag.semitones = next.semitones;
|
|
1608
|
+
drag.limit = next.limit;
|
|
1609
|
+
showDrag(drag);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
lanes.addEventListener('pointerup', (event) => {
|
|
1613
|
+
if (press === null || event.pointerId !== press.pointerId) return;
|
|
1614
|
+
const began = press;
|
|
1615
|
+
clearGesture();
|
|
1616
|
+
endPress();
|
|
1617
|
+
// A release that arrives with no move before it (a pen, a script) is judged by where it is.
|
|
1618
|
+
if (began.lock === null) {
|
|
1619
|
+
began.lock = lockGesture({ dx: event.clientX - began.x, dy: event.clientY - began.y, onNote: began.picked !== null, thresholdPx: DRAG_THRESHOLD_REM * began.rem });
|
|
1620
|
+
}
|
|
1621
|
+
if (began.lock === 'pitch' && began.refused !== undefined) {
|
|
1622
|
+
// Nothing is written: the release prepares the request for what was previewed, opened where the pointer was released.
|
|
1623
|
+
if (began.refused.semitones !== 0) prepareRequest(began.refused.info, { kind: 'pitch', semitones: began.refused.semitones }, { x: event.clientX, y: event.clientY });
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
if (began.lock === 'pitch') {
|
|
1627
|
+
// What was previewed is what is written: the value of the last step shown, whatever the pointer did since.
|
|
1628
|
+
if (began.drag !== null && began.drag.semitones !== 0) enqueue(began.drag.target, { kind: 'pitch', semitones: began.drag.semitones }, false);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (began.lock === 'range') {
|
|
1632
|
+
// The range is judged where the pointer is released; a release with no move before it is drawn here for the first time.
|
|
1633
|
+
const held = dragRange(began, event.clientX);
|
|
1634
|
+
clearSelection();
|
|
1635
|
+
dropRange();
|
|
1636
|
+
adoptRange(held);
|
|
1637
|
+
lanes.focus();
|
|
1638
|
+
render();
|
|
1639
|
+
handlers.range(rangeSelection(), { x: event.clientX, y: event.clientY });
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
if (began.lock !== null || drawn === null) return;
|
|
1643
|
+
if (began.picked === null) {
|
|
1644
|
+
clearSelection();
|
|
1645
|
+
if (dropRange()) render();
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
select(began.laneIndex, began.picked);
|
|
1649
|
+
lanes.focus();
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
for (const type of ['pointercancel', 'lostpointercapture']) {
|
|
1653
|
+
lanes.addEventListener(type, (event) => {
|
|
1654
|
+
if (press !== null && event.pointerId === press.pointerId) cancelGesture();
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// --- the keyboard ---
|
|
1659
|
+
|
|
1660
|
+
/** The events of each drawn lane as the keys walk them: `{ begin, pitch }`, or null for one that has no mark to pick (a tick). */
|
|
1661
|
+
function walkable() {
|
|
1662
|
+
return drawn.lanes.map((lane) =>
|
|
1663
|
+
lane.events.map((entry, index) => (lane.rowCount === null && lane.shapes[index].kind !== 'note' ? null : { begin: entry.begin, pitch: pitchOf(entry.value) })),
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/** Takes the selection or the cursor to the place `laneMove` gave, when it is another place than the one the person is at. */
|
|
1668
|
+
function moveTo(to) {
|
|
1669
|
+
if (to.eventIndex === null) {
|
|
1670
|
+
if (cursor === null || cursor.laneIndex !== to.laneIndex || cursor.cycle !== to.cycle) placeCursor(to.laneIndex, to.cycle);
|
|
1671
|
+
} else if (selected === null || selected.laneIndex !== to.laneIndex || selected.eventIndex !== to.eventIndex) {
|
|
1672
|
+
select(to.laneIndex, to.eventIndex);
|
|
1673
|
+
}
|
|
1674
|
+
// A long Timeline scrolls: the lane the keys arrived on is brought into view.
|
|
1675
|
+
lanes.children[to.laneIndex].scrollIntoView({ block: 'nearest' });
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
lanes.addEventListener('keydown', (event) => {
|
|
1679
|
+
// Escape during a gesture ends it and nothing else; then it drops steps that were not written yet, then it clears the range,
|
|
1680
|
+
// then the selection or the cursor; with none of those left it goes on to the page's own rules (which close the view).
|
|
1681
|
+
if (event.key === 'Escape') {
|
|
1682
|
+
if (press !== null && press.lock !== null) {
|
|
1683
|
+
cancelGesture();
|
|
1684
|
+
event.preventDefault();
|
|
1685
|
+
event.stopPropagation();
|
|
1686
|
+
} else if (gatherer.pending() !== null) {
|
|
1687
|
+
dropSteps();
|
|
1688
|
+
event.preventDefault();
|
|
1689
|
+
event.stopPropagation();
|
|
1690
|
+
} else if (queue.some((edit) => edit.steps && aboutShown(edit))) {
|
|
1691
|
+
// Steps whose pause is over but that wait behind another edit are not written yet either: Escape takes them back too.
|
|
1692
|
+
queue = queue.filter((edit) => !(edit.steps && aboutShown(edit)));
|
|
1693
|
+
queuedSaid = null;
|
|
1694
|
+
render();
|
|
1695
|
+
event.preventDefault();
|
|
1696
|
+
event.stopPropagation();
|
|
1697
|
+
} else if (range !== null) {
|
|
1698
|
+
// Escape steps back: the Ask box first (its own rule), then steps not written, then the range.
|
|
1699
|
+
dropRange();
|
|
1700
|
+
render();
|
|
1701
|
+
event.preventDefault();
|
|
1702
|
+
event.stopPropagation();
|
|
1703
|
+
} else if (selected !== null || cursor !== null) {
|
|
1704
|
+
clearSelection();
|
|
1705
|
+
event.preventDefault();
|
|
1706
|
+
event.stopPropagation();
|
|
1707
|
+
}
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
// While a pointer gesture is in progress the keys do not act on the note under it: two edits from one note would race.
|
|
1711
|
+
if (press !== null && press.lock !== null) return;
|
|
1712
|
+
// Left, Right, Home, End, Page Up and Page Down move along a lane or across lanes; they change nothing in the code. A key
|
|
1713
|
+
// that has no place to start from (nothing selected, and not Left or Right) goes on to the page, which scrolls the lanes.
|
|
1714
|
+
if (LANE_KEYS.includes(event.key) && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1715
|
+
if (drawn === null || range !== null) return;
|
|
1716
|
+
const at = selected !== null ? { laneIndex: selected.laneIndex, eventIndex: selected.eventIndex } : cursor !== null ? { laneIndex: cursor.laneIndex, eventIndex: null, cycle: cursor.cycle } : null;
|
|
1717
|
+
const to = laneMove({ lanes: walkable(), at, key: event.key });
|
|
1718
|
+
if (to === null) return;
|
|
1719
|
+
event.preventDefault();
|
|
1720
|
+
event.stopPropagation();
|
|
1721
|
+
moveTo(to);
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
if (isAskKey(event)) {
|
|
1725
|
+
// The keyboard's right-click: the page's own contextmenu listener ignores a Shift press, so the view asks directly.
|
|
1726
|
+
if (range !== null) {
|
|
1727
|
+
event.preventDefault();
|
|
1728
|
+
event.stopPropagation();
|
|
1729
|
+
askRange();
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
if (cursor !== null) {
|
|
1733
|
+
event.preventDefault();
|
|
1734
|
+
event.stopPropagation();
|
|
1735
|
+
askLane();
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (selected === null) return;
|
|
1739
|
+
event.preventDefault();
|
|
1740
|
+
event.stopPropagation();
|
|
1741
|
+
handlers.ask(selected.info, notePoint());
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
// Shift with Left or Right starts a range at the selected note's cycle and widens it by a cycle a press. Nothing is written, so a
|
|
1745
|
+
// held key may repeat. With neither a note nor a range the key goes on to the page.
|
|
1746
|
+
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1747
|
+
if (drawn === null || (selected === null && range === null && cursor === null)) return;
|
|
1748
|
+
event.preventDefault();
|
|
1749
|
+
event.stopPropagation();
|
|
1750
|
+
widenRange(event.key === 'ArrowRight' ? 1 : -1);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
if (event.key === 'Enter') {
|
|
1754
|
+
if ((selected === null && range === null && cursor === null) || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
1755
|
+
event.preventDefault();
|
|
1756
|
+
event.stopPropagation();
|
|
1757
|
+
if (event.repeat) return;
|
|
1758
|
+
askAbout();
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
1762
|
+
// Backspace does what Delete does: the Mac keyboard has no forward Delete. With nothing selected the keys go on to the page.
|
|
1763
|
+
if (selected === null || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
1764
|
+
event.preventDefault();
|
|
1765
|
+
event.stopPropagation();
|
|
1766
|
+
if (event.repeat) return;
|
|
1767
|
+
rest();
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
|
1771
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
1772
|
+
// With nothing selected the arrows scroll the lanes as they always did.
|
|
1773
|
+
if (selected === null) return;
|
|
1774
|
+
event.preventDefault();
|
|
1775
|
+
event.stopPropagation();
|
|
1776
|
+
// Holding a key is one step, not a run of them.
|
|
1777
|
+
if (event.repeat) return;
|
|
1778
|
+
// Shift makes the step an octave.
|
|
1779
|
+
pitchStep((event.key === 'ArrowUp' ? 1 : -1) * (event.shiftKey ? 12 : 1));
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
render();
|
|
1783
|
+
|
|
1784
|
+
return {
|
|
1785
|
+
onRedraw(next) {
|
|
1786
|
+
// Steps that wait for their pause were aimed at the note as it is drawn now: the pause is cut short and they join the queue,
|
|
1787
|
+
// which aims them at that note in the lanes that come. Nothing is handed over before those lanes are here.
|
|
1788
|
+
pumpHeld += 1;
|
|
1789
|
+
let lost;
|
|
1790
|
+
try {
|
|
1791
|
+
lost = leaveNote();
|
|
1792
|
+
} finally {
|
|
1793
|
+
pumpHeld -= 1;
|
|
1794
|
+
}
|
|
1795
|
+
stepOwner = null;
|
|
1796
|
+
stepSaid = null;
|
|
1797
|
+
if (lost) result = lostStepsResult();
|
|
1798
|
+
// The lanes of the edit on its way: they go away while the new code is computed, and the lanes after that are its own.
|
|
1799
|
+
if (inFlight !== null) {
|
|
1800
|
+
if (next === null) inFlight.lanesAway = true;
|
|
1801
|
+
else if (inFlight.lanesAway) inFlight.freshLanes = true;
|
|
1802
|
+
}
|
|
1803
|
+
// What was selected is kept for the lanes that come next; a gesture on lanes that are gone ends without a write.
|
|
1804
|
+
if (selected !== null) restore = selected.target;
|
|
1805
|
+
cancelGesture();
|
|
1806
|
+
// The wash lives in a mark layer that is replaced below: a range does not survive a redraw.
|
|
1807
|
+
range = null;
|
|
1808
|
+
cursor = null;
|
|
1809
|
+
active = null;
|
|
1810
|
+
removeAttr(lanes, 'data-hover');
|
|
1811
|
+
drawn = next;
|
|
1812
|
+
selected = null;
|
|
1813
|
+
ring = null;
|
|
1814
|
+
sames = [];
|
|
1815
|
+
marks = [];
|
|
1816
|
+
if (drawn !== null) {
|
|
1817
|
+
drawn.lanes.forEach((lane, index) => {
|
|
1818
|
+
const laneElement = lanes.children[index];
|
|
1819
|
+
if (!laneElement) throw new Error(`Moontide: the Timeline draws no lane ${index} for the edit view.`);
|
|
1820
|
+
const old = laneElement.querySelector('.lane-marks');
|
|
1821
|
+
if (old) old.remove();
|
|
1822
|
+
const layer = make('div', 'lane-marks');
|
|
1823
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
1824
|
+
laneElement.appendChild(layer);
|
|
1825
|
+
marks.push(layer);
|
|
1826
|
+
});
|
|
1827
|
+
closed = false;
|
|
1828
|
+
// A page turn: the edits that wait were aimed at notes of the page that went, so they are dropped and the panel says so.
|
|
1829
|
+
const turned = shownPage !== null && drawn.pageStart !== shownPage;
|
|
1830
|
+
shownPage = drawn.pageStart;
|
|
1831
|
+
if (turned) {
|
|
1832
|
+
tellNotWritten(dropEdits());
|
|
1833
|
+
// Lanes of another page drawn before the answer of the edit on its way are not drawn from the code it writes: the
|
|
1834
|
+
// next edit waits for the lanes that come after that answer.
|
|
1835
|
+
if (inFlight !== null && (inFlight.phase === 'answer' || inFlight.phase === 'late')) {
|
|
1836
|
+
inFlight.lanesAway = false;
|
|
1837
|
+
inFlight.freshLanes = false;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
if (inFlight !== null && inFlight.phase === 'lanes' && inFlight.freshLanes) settle();
|
|
1841
|
+
// After the edit on its way is settled, so that a note beside a rest is looked for at the rank these lanes give it.
|
|
1842
|
+
restoreSelection();
|
|
1843
|
+
}
|
|
1844
|
+
pump();
|
|
1845
|
+
render();
|
|
1846
|
+
drawRing();
|
|
1847
|
+
},
|
|
1848
|
+
showOutcome(id, outcome) {
|
|
1849
|
+
const edit = handed.get(id);
|
|
1850
|
+
if (edit === undefined) throw new Error(`Moontide: an outcome came for Timeline edit ${String(id)}, which was never handed over or was already answered.`);
|
|
1851
|
+
// The words first: an outcome the panel does not know throws before anything changes.
|
|
1852
|
+
const said = resultText(outcome);
|
|
1853
|
+
handed.delete(id);
|
|
1854
|
+
const current = inFlight !== null && inFlight.edit === edit;
|
|
1855
|
+
if (current && inFlight.timer !== null) {
|
|
1856
|
+
clearTimer(inFlight.timer);
|
|
1857
|
+
inFlight.timer = null;
|
|
1858
|
+
}
|
|
1859
|
+
tellOutcome(edit, outcome, said);
|
|
1860
|
+
if (current) {
|
|
1861
|
+
if (outcome.kind === 'waiting') {
|
|
1862
|
+
// The code changes when the change takes over: the lanes that come before that are not this edit's.
|
|
1863
|
+
inFlight.phase = 'waiting';
|
|
1864
|
+
inFlight.lanesAway = false;
|
|
1865
|
+
inFlight.freshLanes = false;
|
|
1866
|
+
} else if ((outcome.kind === 'written' || outcome.afterWrite === true) && !inFlight.freshLanes) {
|
|
1867
|
+
// The next edit is aimed at the note as the new code draws it: it waits for those lanes, for a bounded time.
|
|
1868
|
+
inFlight.phase = 'lanes';
|
|
1869
|
+
inFlight.timer = setTimer(expire, EDIT_WAIT_MS);
|
|
1870
|
+
} else {
|
|
1871
|
+
settle();
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
pump();
|
|
1875
|
+
render();
|
|
1876
|
+
},
|
|
1877
|
+
setNotice(kind) {
|
|
1878
|
+
if (kind !== null && !Object.hasOwn(NOTICE_KINDS, kind)) throw new Error(`Moontide: unknown notice '${String(kind)}' of the Timeline note panel.`);
|
|
1879
|
+
if (kind === notice) return;
|
|
1880
|
+
notice = kind;
|
|
1881
|
+
// Nothing is written while the page cannot write safely, so steps and edits that waited are dropped with the controls, and
|
|
1882
|
+
// the sentence says so beside this notice and after it.
|
|
1883
|
+
if (kind !== null) {
|
|
1884
|
+
const lost = [...pendingSteps(), ...queue];
|
|
1885
|
+
if (lost.length > 0) result = { kind: 'refused', text: notWrittenText(lost), lost: true, lostTo: kind };
|
|
1886
|
+
gatherer.drop();
|
|
1887
|
+
queue = [];
|
|
1888
|
+
stepOwner = null;
|
|
1889
|
+
stepSaid = null;
|
|
1890
|
+
}
|
|
1891
|
+
render();
|
|
1892
|
+
},
|
|
1893
|
+
finishWait({ tookOver }) {
|
|
1894
|
+
if (typeof tookOver !== 'boolean') throw new Error('Moontide: a wait ends because the change took over or because it did not.');
|
|
1895
|
+
ownWait = false;
|
|
1896
|
+
// The note of the change that waited has its new pitch once the change took over, and keeps its old one otherwise.
|
|
1897
|
+
if (waited !== null) {
|
|
1898
|
+
const target = waited.target;
|
|
1899
|
+
if (tookOver && target.asked !== null) target.pitch = target.asked;
|
|
1900
|
+
target.asked = null;
|
|
1901
|
+
waited = null;
|
|
1902
|
+
}
|
|
1903
|
+
// The sentence that said the change waits now says it was made, or goes if the change never took over.
|
|
1904
|
+
if (result !== null && result.wait !== undefined) {
|
|
1905
|
+
const lost = result.lostText ?? null;
|
|
1906
|
+
const told = tookOver ? resultText({ ...result.wait, kind: 'written' }) : null;
|
|
1907
|
+
const made = told === null || result.shown ? told : { ...told, text: namedText({ note: result.edit.note, lane: result.edit.lane, text: told.text }) };
|
|
1908
|
+
if (made === null) result = lost === null ? null : { kind: 'refused', text: lost, lost: true };
|
|
1909
|
+
else result = lost === null ? made : { ...made, text: `${made.text} ${lost}`, lost: true };
|
|
1910
|
+
}
|
|
1911
|
+
if (inFlight !== null && inFlight.phase === 'waiting') {
|
|
1912
|
+
if (tookOver && !inFlight.freshLanes) {
|
|
1913
|
+
inFlight.phase = 'lanes';
|
|
1914
|
+
inFlight.timer = setTimer(expire, EDIT_WAIT_MS);
|
|
1915
|
+
} else {
|
|
1916
|
+
// A rest that never took over leaves the page unsure of the notes beside it: the code that plays may have changed meanwhile.
|
|
1917
|
+
if (!tookOver) resolveRest('unsure');
|
|
1918
|
+
settle();
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
pump();
|
|
1922
|
+
render();
|
|
1923
|
+
},
|
|
1924
|
+
close() {
|
|
1925
|
+
// Nothing is handed over while the Timeline is closed: steps and edits that wait are dropped and the panel says so. The edit
|
|
1926
|
+
// already on its way is still written by the page; its answer is told, and it holds the queue until then.
|
|
1927
|
+
cancelGesture();
|
|
1928
|
+
// Steps that wait join the queue first, so the sentence names their note like every edit that is dropped.
|
|
1929
|
+
pumpHeld += 1;
|
|
1930
|
+
let lost;
|
|
1931
|
+
try {
|
|
1932
|
+
lost = leaveNote();
|
|
1933
|
+
} finally {
|
|
1934
|
+
pumpHeld -= 1;
|
|
1935
|
+
}
|
|
1936
|
+
stepOwner = null;
|
|
1937
|
+
stepSaid = null;
|
|
1938
|
+
closed = true;
|
|
1939
|
+
if (lost) result = lostStepsResult();
|
|
1940
|
+
tellNotWritten(dropEdits());
|
|
1941
|
+
render();
|
|
1942
|
+
},
|
|
1943
|
+
cancelGesture,
|
|
1944
|
+
/** Clears the selected range and its wash (the page calls it when the request about the range is sent). */
|
|
1945
|
+
clearRange() {
|
|
1946
|
+
if (dropRange()) render();
|
|
1947
|
+
},
|
|
1948
|
+
/** The selected range as `{ laneIndex, from, to, cut }`, or null. */
|
|
1949
|
+
rangeSelection() {
|
|
1950
|
+
return range === null ? null : rangeSelection();
|
|
1951
|
+
},
|
|
1952
|
+
selection() {
|
|
1953
|
+
return selected === null ? null : { laneIndex: selected.laneIndex, eventIndex: selected.eventIndex, info: selected.info };
|
|
1954
|
+
},
|
|
1955
|
+
};
|
|
1956
|
+
}
|