import type { JSX } from "@char/aftercare/jsx-runtime"; import { audition, play, playhead, playing } from "../audio/transport.ts"; import { sounding, type VoicedChord, voiceTrack } from "../project/harmony.ts"; import { type ChordTrack, findTrack, moveTrack, newId, NOTE_VEL, type NoteTrack, placeChord, type Project, projectEnd, projectKey, type Track, } from "../project/model.ts"; import { beginGesture, commit, project } from "../project/store.ts"; import { origin, prettyRoman } from "../theory/analysis.ts"; import { chordSymbol } from "../theory/chord.ts"; import { type Key, MODES } from "../theory/key.ts"; import { formatNote, mod, pcOf, spellPc } from "../theory/pitch.ts"; import { addTrack, createChordWithNote, removeTrack, toggleChordNote } from "./actions.ts"; import { ChordEditor } from "./chord-editor.tsx"; import { originClass } from "./origin.ts"; import { drag, px, setChildren, watch } from "./dom.tsx"; import { activeTrackId, chordEdit, cursor, HEADER_W, laneHeight, pxPerBeat, ROW_H, rollHeight, selectedIds, selection, STRIP_H, } from "./state.ts"; let lastNoteLength = 1; /** where a shift-click range of chords starts from */ let chordAnchor: string | undefined; export function Timeline(): JSX.Element { const rows = (
) as HTMLDivElement; const cursorLine = (
) as HTMLDivElement; const playLine = (
) as HTMLDivElement; const content = (
{rows} {cursorLine} {playLine}
) as HTMLDivElement; const corner =
; const addRow = (
); // the ruler and headers take clicks and double-clicks, so they're only rebuilt when what they // show has changed: otherwise a re-render mid-click would swallow the click const headers = new Map(); let cachedRuler: { shows: string; el: JSX.Element } | undefined; const rulerFor = (p: Project, beats: number, ppb: number) => { const shows = JSON.stringify([beats, ppb, p.beatsPerBar, p.grid, p.loop, p.loopRegion]); if (cachedRuler?.shows !== shows) cachedRuler = { shows, el: ruler(p, beats, ppb) }; return cachedRuler.el; }; const header = (t: Track, p: Project) => { const bass = t.kind === "chords" && t.bass; const shows = JSON.stringify([t.name, t.muted, t.kind, bass, t.view, p.key.tonic]); const cached = headers.get(t.id); if (cached?.shows === shows) return cached.el; const el = trackHeader(t, p); headers.set(t.id, { shows, el }); return el; }; watch(rows, [project, selection, cursor, activeTrackId, pxPerBeat], () => { const p = project.get(); const ppb = pxPerBeat.get(); const bar = p.beatsPerBar; const beats = Math.max( 16 * bar, Math.ceil((Math.max(projectEnd(p), cursor.get()) + 4 * bar) / bar) * bar, ); content.style.width = px(HEADER_W + beats * ppb); rows.style.gridTemplateColumns = `${HEADER_W}px ${beats * ppb}px`; for (const id of headers.keys()) if (!p.tracks.some(t => t.id === id)) headers.delete(id); setChildren(rows, [ corner, rulerFor(p, beats, ppb), ...p.tracks.flatMap(t => [header(t, p), lane(t, p, ppb)]), addRow, ]); for (const [id, { el }] of headers) el.classList.toggle("active", id === activeTrackId.get()); for (const el of rows.querySelectorAll(".lane")) el.classList.toggle("active", el.dataset.lane === activeTrackId.get()); cursorLine.style.translate = px(HEADER_W + cursor.get() * ppb); }); watch(playLine, [playhead, pxPerBeat], () => { const beat = playhead.get(); playLine.hidden = beat === null; if (beat !== null) playLine.style.translate = px(HEADER_W + beat * pxPerBeat.get()); }); const scroller = (
{content}
) as HTMLDivElement; // ctrl-click moves the cursor to the nearest grid line, as clicking the ruler does rows.addEventListener( "pointerdown", e => { if (e.button !== 0 || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return; const surface = (e.target as Element).closest(".roll, .strip"); const lane = surface?.closest("[data-lane]"); if (!surface || !lane) return; e.preventDefault(); e.stopPropagation(); const grid = project.get().grid; const at = (e.clientX - surface.getBoundingClientRect().left) / pxPerBeat.get(); const beat = Math.max(0, Math.round(at / grid) * grid); activeTrackId.set(lane.dataset.lane!); cursor.set(beat); if (playing.get()) play(beat); }, { capture: true }, ); // middle-dragging pans, with the content following the pointer: sideways through the song, and // up and down through the pitches of the track the drag started on scroller.addEventListener("pointerdown", e => { if (e.button !== 1) return; e.preventDefault(); const at = (e.target as Element).closest("[data-lane], [data-track]")?.dataset; const track = project.get().tracks.find(t => t.id === (at?.lane ?? at?.track)); const left = scroller.scrollLeft; const top = track && viewOf(track).high; document.body.classList.add("panning"); drag( e, m => { scroller.scrollLeft = left - (m.clientX - e.clientX); if (track) panTo(track, top! + Math.round((m.clientY - e.clientY) / ROW_H)); }, () => { document.body.classList.remove("panning"); savePans(); }, ); }); // firefox (and chrome on windows) would start autoscrolling scroller.addEventListener("mousedown", e => e.button === 1 && e.preventDefault()); // zoom around the pointer: the beat under it stays under it scroller.addEventListener("wheel", e => { if (!e.ctrlKey) return; e.preventDefault(); const old = pxPerBeat.get(); // a trackpad pinch comes as many small deltas, a mouse wheel as a few big ones (a notch is // about 100): zoom with the pinch, but no more than about 15% a notch const pixels = e.deltaY * (e.deltaMode === 1 ? 16 : 1); const step = Math.max(-0.15, Math.min(0.15, pixels * 0.01)); const ppb = Math.min(160, Math.max(8, old * Math.exp(-step))); if (ppb === old) return; // measured from the scroller's left edge, not counting the track headers pinned there const x = Math.max(HEADER_W, e.clientX - scroller.getBoundingClientRect().left); const beat = (scroller.scrollLeft + x - HEADER_W) / old; pxPerBeat.set(ppb); // queued behind the re-render the zoom causes, so the timeline is already wide enough queueMicrotask(() => (scroller.scrollLeft = HEADER_W + beat * ppb - x)); }); return scroller; } const gridLines = (ppb: number, bar: number) => [ `repeating-linear-gradient(to right, var(--line-bar) 0 1px, transparent 1px ${ppb * bar}px)`, `repeating-linear-gradient(to right, var(--line-beat) 0 1px, transparent 1px ${ppb}px)`, ].join(","); function ruler(p: Project, beats: number, ppb: number) { const labels = []; for (let b = 0; b < beats; b += p.beatsPerBar) { labels.push({String(b / p.beatsPerBar + 1)}); } // measured as a drag starts: the ruler is redrawn as the loop changes under it const beatsFrom = () => { const left = el.getBoundingClientRect().left; return (x: number) => Math.max(0, Math.round((x - left) / ppb / p.grid) * p.grid); }; const setLoop = (a: number, b: number) => { const loopRegion = { start: Math.min(a, b), end: Math.max(a, b) }; commit(d => Object.assign(d, { loop: true, loopRegion }), { history: false }); }; /** a loop's end, to drag it somewhere else (past the other end, if need be) */ const edge = (moving: "start" | "end") => (
{ if (e.button !== 0 || !p.loopRegion) return; e.stopPropagation(); const beatAt = beatsFrom(); const fixed = moving === "start" ? p.loopRegion.end : p.loopRegion.start; document.body.classList.add("resizing-loop"); drag( e, m => { const to = beatAt(m.clientX); if (to !== fixed) setLoop(fixed, to); }, () => document.body.classList.remove("resizing-loop"), ); }} /> ); const region = p.loopRegion && (
commit(d => void delete d.loopRegion, { history: false })} > {edge("start")} {edge("end")}
); const el = (
{ if (e.button !== 0) return; const beatAt = beatsFrom(); const from = beatAt(e.clientX); let dragging = false; drag( e, m => { dragging ||= Math.abs(m.clientX - e.clientX) > 3; const to = beatAt(m.clientX); if (dragging && to !== from) setLoop(from, to); }, () => { if (dragging) return; cursor.set(from); if (playing.get()) play(from); }, ); }} > {region} {labels}
); return el; } function trackHeader(t: Track, p: Project) { const name = ( commit(d => void (d.tracks.find(x => x.id === t.id)!.name = name.value))} _onkeydown={e => e.key === "Enter" && name.blur()} /> ) as HTMLInputElement; return (
{ activeTrackId.set(t.id); const control = "input, button, label, .keys, .track-resize"; if (e.button === 0 && !(e.target as Element).closest(control)) reorder(e, t); }} >
{name}
{gainSlider(t)} {t.kind === "chords" ? ( ) : undefined} {pianoKeys(t, projectKey(p))} {resizeHandle(t)}
); } /** drag a track's header up or down to move it, with a line showing where it'll go */ function reorder(e: PointerEvent, t: Track) { const heads = [...document.querySelectorAll(".track-head")].map(h => h.getBoundingClientRect(), ); const timeline = document.querySelector(".timeline")!.getBoundingClientRect(); const line = (
) as HTMLDivElement; let to: number | undefined; drag( e, m => { if (to === undefined && Math.abs(m.clientY - e.clientY) < 4) return; if (to === undefined) { document.body.append(line); document.body.classList.add("reordering"); } // the gap between tracks nearest the pointer to = heads.filter(h => m.clientY > h.top + h.height / 2).length; const y = to < heads.length ? heads[to].top : heads.at(-1)!.bottom; Object.assign(line.style, { top: px(y - 1), left: px(timeline.left), width: px(timeline.width), }); }, () => { line.remove(); document.body.classList.remove("reordering"); const from = project.get().tracks.findIndex(x => x.id === t.id); if (to !== undefined && to !== from && to !== from + 1) commit(d => moveTrack(d, t.id, to!)); }, ); } /** a track's volume trim, in dB: one drag is one undo step, and a double click resets it */ function gainSlider(t: Track) { const set = (gain: number) => (d: Project) => void (d.tracks.find(x => x.id === t.id)!.gain = gain); let gesture: ReturnType | undefined; const slider = ( (gesture ??= beginGesture()).update(set(slider.valueAsNumber))} _onchange={() => (gesture = undefined)} // let go when the mouse does, or space would nudge the slider rather than play _onpointerdown={() => addEventListener("pointerup", () => slider.blur(), { once: true })} _ondblclick={() => commit(set(0))} /> ) as HTMLInputElement; const readout = () as HTMLSpanElement; // not rebuilt with the header, which would swap the slider out from under a drag watch(slider, [project], () => { const gain = project.get().tracks.find(x => x.id === t.id)?.gain ?? 0; slider.valueAsNumber = gain; readout.textContent = gain ? `${gain > 0 ? "+" : "−"}${Math.abs(gain)} dB` : "0 dB"; }); return ( ); } const MIN_ROWS = 12; /** dragging a track's bottom edge shows more or fewer rows below, keeping the top in place */ function resizeHandle(t: Track) { return (
{ if (e.button !== 0) return; e.preventDefault(); const { low, high } = viewOf(t); savePans(); let rows = high - low + 1; document.body.classList.add("resizing-track"); drag( e, m => { const want = high - low + 1 + Math.round((m.clientY - e.clientY) / ROW_H); const next = Math.max(MIN_ROWS, Math.min(high + 1, want)); if (next === rows) return; rows = next; commit( d => void (d.tracks.find(x => x.id === t.id)!.view = { low: high - rows + 1, high }), { history: false }, ); }, () => document.body.classList.remove("resizing-track"), ); }} /> ); } const PITCHES = 128; /** where the rows for pitches from `high` down start, in a roll or keyboard that holds them all */ const offset = (high: number) => `0 ${px(-(PITCHES - 1 - high) * ROW_H)}`; /** * pitch ranges moved but not yet saved, by their top pitch. rolls and keyboards hold every pitch, * so moving through them is only a transform; saving (and so re-rendering) waits for the moving * to stop, as doing it every row would drop frames */ const panned = new Map(); let panSave: ReturnType | undefined; const viewOf = (t: Track): Track["view"] => { const high = panned.get(t.id); return high === undefined ? t.view : { low: t.view.low + high - t.view.high, high }; }; function panTo(t: Track, high: number) { high = Math.max(t.view.high - t.view.low, Math.min(PITCHES - 1, high)); panned.set(t.id, high); for (const el of document.querySelectorAll(`[data-pan="${t.id}"]`)) el.style.translate = offset(high); clearTimeout(panSave); panSave = setTimeout(savePans, 250); } function savePans() { clearTimeout(panSave); if (!panned.size) return; const views = new Map(project.get().tracks.map(t => [t.id, viewOf(t)])); panned.clear(); commit(d => d.tracks.forEach(t => (t.view = views.get(t.id)!)), { history: false }); } // fractions of a row scrolled so far const scrolledRows = new Map(); /** vertical wheel moves a track's pitch range like scrolling a document */ function scrollPitches(t: Track) { return (e: WheelEvent) => { const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? rollHeight(t) : 1; // shift+wheel scrolls the timeline sideways. some browsers turn it into a horizontal scroll // themselves, but others (firefox) leave it vertical, which would land here as pitch if (e.shiftKey) { e.preventDefault(); const timeline = (e.currentTarget as Element).closest(".timeline")!; timeline.scrollLeft += (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * unit; return; } // leave zooming and horizontal scrolling to the timeline if (e.ctrlKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; // a mouse wheel's notch moves one key, whether it comes as lines (firefox, sometimes) or as // one big jump in pixels; a trackpad's stream of small ones moves the rows with the fingers const notches = Math.max(1, Math.round(Math.abs(e.deltaY) / 100)) * Math.sign(e.deltaY); const moved = e.deltaMode === 2 ? e.deltaY * (t.view.high - t.view.low + 1) : e.deltaMode === 1 ? e.deltaY / 3 : Math.abs(e.deltaY) >= 40 ? notches : e.deltaY / ROW_H; const total = (scrolledRows.get(t.id) ?? 0) + moved; const rows = Math.trunc(total); const { low, high } = viewOf(t); const shift = Math.max(-low, Math.min(PITCHES - 1 - high, -rows)); // at the end of the range, let the timeline scroll instead if (rows && !shift) return scrolledRows.delete(t.id); e.preventDefault(); scrolledRows.set(t.id, total - rows); if (shift) panTo(t, high + shift); }; } const BLACK_KEYS = [1, 3, 6, 8, 10]; function pianoKeys(t: Track, key: Key) { const keys = []; for (let m = PITCHES - 1; m >= 0; m--) { const cls = [ "key", BLACK_KEYS.includes(mod(m, 12)) ? "black" : "", mod(m - pcOf(key.tonic), 12) === 0 ? "tonic" : "", ]; keys.push(
{mod(m, 12) === 0 ? `C${m / 12 - 1}` : ""}
); } const inner = (
{keys}
) as HTMLDivElement; return (
{ if (e.button !== 0) return; const row = Math.floor((e.clientY - inner.getBoundingClientRect().top) / ROW_H); audition([PITCHES - 1 - row], 0.5); }} _onwheel={scrollPitches(t)} > {inner}
); } function rollBackground(key: Key, ppb: number, bar: number) { const tonic = pcOf(key.tonic); const scale = MODES[key.mode].steps; const stops = []; for (let i = 0, m = PITCHES - 1; m >= 0; m--, i++) { const rel = mod(m - tonic, 12); const color = rel === 0 ? "var(--row-tonic)" : scale.includes(rel) ? "var(--row-scale)" : "var(--row-off)"; stops.push(`${color} ${i * ROW_H}px ${(i + 1) * ROW_H}px`); } return [ gridLines(ppb, bar), `repeating-linear-gradient(to bottom, transparent 0 ${ROW_H - 1}px, var(--line-row) ${ROW_H - 1}px ${ROW_H}px)`, `linear-gradient(to bottom, ${stops.join(",")})`, ].join(","); } function lane(t: Track, p: Project, ppb: number) { const key = projectKey(p); // every pitch, of which the roll shows the track's range const inner = (
) as HTMLDivElement; const roll = (
e.preventDefault()} _onwheel={scrollPitches(t)} > {inner}
) as HTMLDivElement; const locate = (e: PointerEvent) => { const r = inner.getBoundingClientRect(); return { beat: (e.clientX - r.left) / ppb, pitch: PITCHES - 1 - Math.floor((e.clientY - r.top) / ROW_H), }; }; const noteBox = (start: number, dur: number, pitch: number) => ({ left: px(start * ppb), width: px(Math.max(3, dur * ppb - 1)), top: px((PITCHES - 1 - pitch) * ROW_H), height: px(ROW_H - 1), }); const label = (pitch: number) => formatNote(spellPc(mod(pitch, 12), key.tonic)); const inScale = (pitch: number) => MODES[key.mode].steps.includes(mod(pitch - pcOf(key.tonic), 12)); if (t.kind === "notes") { const selected = selectedIds(t.id); inner.append( ...t.notes.map(n => (
{label(n.pitch)}
)), ); roll.addEventListener("pointerdown", e => notePointer(e, t, p, ppb, locate(e))); return (
{roll} {resizeHandle(t)}
); } // every note track, drawn under the chords to show how they sit against them const noteTracks = p.tracks.filter((n): n is NoteTrack => n.kind === "notes"); inner.append( ...noteTracks.flatMap(n => n.notes.map(note => (
)), ), ); const voiced = voiceTrack(t, key); const current = activeTrackId.get() === t.id ? cursor.get() : undefined; voiced.forEach((v, i) => { const { start, dur } = v.event; const heard = sounding(t, v.notes); inner.append(
, ...v.notes.map(n => (
{label(n)}
)), ); }); roll.addEventListener("pointerdown", e => { if (e.button !== 0) return; activeTrackId.set(t.id); const { beat, pitch } = locate(e); const v = voiced.find(v => v.event.start <= beat && beat < v.event.start + v.event.dur); if (!v) return createChordWithNote(t.id, beat, pitch); cursor.set(v.event.start); toggleChordNote(t.id, v.event.id, pitch); }); return (
{chordStrip(t, voiced, ppb, p.grid, current)} {roll} {resizeHandle(t)}
); } function chordStrip( t: ChordTrack, voiced: VoicedChord[], ppb: number, grid: number, current?: number, ) { const selected = selectedIds(t.id); const blocks = voiced.map(v => { const a = v.analysis; // coloured by where it comes from, and tagged with it unless it's a secondary dominant const from = a && origin(a); const isVertex = from === "sharp" || from === "flat" || from === "inverse"; const tag = a?.secondary ?? (isVertex ? `${from} vertex` : from && from !== "home" ? MODES[from].label : ""); const cls = [ "chord", v.event.start === current ? "current" : "", selected.includes(v.event.id) ? "selected" : "", v.pinned ? "pinned" : "", originClass(a), v.chord ? "" : "unknown", ]; return (
{v.label}
{a ? prettyRoman(a.roman) : ""} {tag ? {tag} : undefined}
); }); const strip = (
{blocks}
) as HTMLDivElement; const beatAt = (e: MouseEvent) => (e.clientX - strip.getBoundingClientRect().left) / ppb; const snap = (pixels: number) => Math.round(pixels / ppb / grid) * grid; const hit = (e: Event) => { const el = (e.target as HTMLElement).closest(".chord"); return el ? voiced.find(v => v.event.id === el.dataset.id) : undefined; }; strip.addEventListener("pointerdown", e => { if (e.button !== 0) return; activeTrackId.set(t.id); const v = hit(e); if (!v) { selection.set(null); return cursor.set(Math.max(0, Math.floor(beatAt(e) / grid) * grid)); } const original = v.event; const selected = selectedIds(t.id); if (e.ctrlKey || e.metaKey) { const ids = selected.includes(original.id) ? selected.filter(id => id !== original.id) : [...selected, original.id]; selection.set({ trackId: t.id, ids }); chordAnchor = original.id; return cursor.set(original.start); } if (e.shiftKey) { // from the chord last clicked (or at the cursor) to this one const anchor = t.chords.find(c => c.id === chordAnchor) ?? t.chords.find(c => c.start === cursor.get()) ?? original; const [from, to] = [anchor.start, original.start].sort((a, b) => a - b); const ids = t.chords.filter(c => c.start >= from && c.start <= to).map(c => c.id); selection.set({ trackId: t.id, ids }); return cursor.set(original.start); } selection.set({ trackId: t.id, ids: [original.id] }); chordAnchor = original.id; cursor.set(original.start); const gesture = beginGesture(); const update = (chord: typeof original) => gesture.update(d => placeChord(findTrack(d, t.id, "chords")!, chord)); if ((e.target as HTMLElement).classList.contains("chord-handle")) { drag(e, m => { update({ ...original, dur: Math.max(grid, original.dur + snap(m.clientX - e.clientX)), }); }); return; } audition(v.notes, 0.9); drag(e, m => { const start = Math.max(0, original.start + snap(m.clientX - e.clientX)); if (start === cursor.get()) return; update({ ...original, start }); cursor.set(start); }); }); strip.addEventListener("dblclick", e => { const v = hit(e); const initial = v ? (v.event.symbol ?? (v.chord ? chordSymbol(v.chord) : "")) : ""; chordEdit.set({ trackId: t.id, initial, selectAll: true }); }); return strip; } function notePointer( e: PointerEvent, t: NoteTrack, p: Project, ppb: number, at: { beat: number; pitch: number }, ) { if (e.button !== 0 && e.button !== 2) return; activeTrackId.set(t.id); const grid = p.grid; const snap = (beats: number) => Math.round(beats / grid) * grid; const el = (e.target as HTMLElement).closest(".note"); const hit = el ? t.notes.find(n => n.id === el.dataset.id) : undefined; const edit = (change: (track: NoteTrack) => void) => (d: Project) => change(findTrack(d, t.id, "notes")!); if (e.button === 2 || e.altKey) { if (hit) commit(edit(track => void (track.notes = track.notes.filter(n => n.id !== hit.id)))); return; } const ctrl = e.ctrlKey || e.metaKey; // shift-drag selects with a box; with ctrl too, adding to what's selected if (!hit && e.shiftKey) return selectBox(e, t, ppb, ctrl); if (!hit) { // a note goes in the grid cell clicked const start = Math.floor(at.beat / grid) * grid; const gesture = beginGesture(); const note = { id: newId(), start, dur: lastNoteLength, pitch: at.pitch, vel: NOTE_VEL, }; selection.set({ trackId: t.id, ids: [note.id] }); audition([note.pitch], 0.4); gesture.update(edit(track => void track.notes.push(note))); drag(e, m => { const dur = Math.max(grid, note.dur + snap((m.clientX - e.clientX) / ppb)); gesture.update(edit(track => void track.notes.push({ ...note, dur }))); lastNoteLength = dur; }); return; } const gesture = beginGesture(); let ids = selectedIds(t.id); if (e.shiftKey) { ids = ids.includes(hit.id) ? ids.filter(id => id !== hit.id) : [...ids, hit.id]; selection.set({ trackId: t.id, ids }); if (!ids.includes(hit.id)) return; } else if (!ids.includes(hit.id)) { ids = [hit.id]; selection.set({ trackId: t.id, ids }); } const resizing = e.clientX > el!.getBoundingClientRect().right - 6; const originals = t.notes.filter(n => ids.includes(n.id)); let heard = 0; if (!resizing) audition([hit.pitch], 0.4); drag(e, m => { const beats = snap((m.clientX - e.clientX) / ppb); const pitch = resizing ? 0 : -Math.round((m.clientY - e.clientY) / ROW_H); gesture.update( edit(track => { for (const o of originals) { const n = track.notes.find(n => n.id === o.id)!; if (resizing) n.dur = Math.max(grid, o.dur + beats); else { n.start = Math.max(0, o.start + beats); n.pitch = Math.min(127, Math.max(0, o.pitch + pitch)); } } }), ); if (resizing) lastNoteLength = Math.max(grid, hit.dur + beats); if (pitch !== heard) audition([hit.pitch + (heard = pitch)], 0.3); }); } /** drag out a box and select the notes it touches; adding to the selection, or instead of it */ function selectBox(e: PointerEvent, t: NoteTrack, ppb: number, adding: boolean) { const roll = (e.target as HTMLElement) .closest(".roll")! .querySelector(".roll-inner")! .getBoundingClientRect(); const before = adding ? selectedIds(t.id) : []; // on the page rather than in the roll, which gets redrawn as the selection changes const box = (
) as HTMLDivElement; document.body.append(box); const update = (m: PointerEvent) => { const [x0, x1] = [Math.min(e.clientX, m.clientX), Math.max(e.clientX, m.clientX)]; const [y0, y1] = [Math.min(e.clientY, m.clientY), Math.max(e.clientY, m.clientY)]; Object.assign(box.style, { left: px(x0), top: px(y0), width: px(x1 - x0), height: px(y1 - y0), }); const [from, to] = [(x0 - roll.left) / ppb, (x1 - roll.left) / ppb]; const pitchAt = (y: number) => PITCHES - 1 - Math.floor((y - roll.top) / ROW_H); const [low, high] = [pitchAt(y1), pitchAt(y0)]; const inside = t.notes .filter(n => n.start < to && n.start + n.dur > from && n.pitch >= low && n.pitch <= high) .map(n => n.id); selection.set({ trackId: t.id, ids: [...new Set([...before, ...inside])] }); }; update(e); drag(e, update, () => box.remove()); }