char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
4c8871a
main
1import type { JSX } from "@char/aftercare/jsx-runtime" ; 2import { audition , play , playhead , playing } from "../audio/transport.ts" ; 3import { sounding , type VoicedChord , voiceTrack } from "../project/harmony.ts" ; 4import { 5type ChordTrack , 6findTrack , 7moveTrack , 8newId , 9NOTE_VEL , 10type NoteTrack , 11placeChord , 12type Project , 13projectEnd , 14projectKey , 15type Track , 16} from "../project/model.ts" ; 17import { beginGesture , commit , project } from "../project/store.ts" ; 18import { origin , prettyRoman } from "../theory/analysis.ts" ; 19import { chordSymbol } from "../theory/chord.ts" ; 20import { type Key , MODES } from "../theory/key.ts" ; 21import { formatNote , mod , pcOf , spellPc } from "../theory/pitch.ts" ; 22import { addTrack , createChordWithNote , removeTrack , toggleChordNote } from "./actions.ts" ; 23import { ChordEditor } from "./chord-editor.tsx" ; 24import { originClass } from "./origin.ts" ; 25import { drag , px , setChildren , watch } from "./dom.tsx" ; 26import { 27activeTrackId , 28chordEdit , 29cursor , 30HEADER_W , 31laneHeight , 32pxPerBeat , 33ROW_H , 34rollHeight , 35selectedIds , 36selection , 37STRIP_H , 38} from "./state.ts" ; 39 40let lastNoteLength = 1 ; 41/** where a shift-click range of chords starts from */ 42let chordAnchor :string | undefined ; 43 44export function Timeline () :JSX . Element { 45const rows = (< div class = "tl-rows" / >) as HTMLDivElement ; 46const cursorLine = (< div class = "tl-line tl-cursor" / >) as HTMLDivElement ; 47const playLine = (< div class = "tl-line tl-playhead" / >) as HTMLDivElement ; 48const content = ( 49< div class = "tl-content" > 50{ rows} 51{ cursorLine} 52{ playLine} 53< ChordEditor / > 54</ div > 55) as HTMLDivElement ; 56 57const corner = < div class = "tl-corner" / >; 58const addRow = ( 59< div class = "tl-add" > 60< button type = "button" _onclick = {() => addTrack ( "chords" )}> 61+ chords 62</ button > 63< button type = "button" _onclick = {() => addTrack ( "notes" )}> 64+ notes 65< / button> 66</ div > 67); 68// the ruler and headers take clicks and double-clicks, so they're only rebuilt when what they 69// show has changed: otherwise a re-render mid-click would swallow the click 70const headers = new Map < string , { shows : string ; el : JSX . Element }>(); 71let cachedRuler: { shows : string ; el : JSX . Element } | undefined ; 72const rulerFor = ( p: Project , beats: number , ppb: number ) => { 73const shows = JSON . stringify ([ beats , ppb , p . beatsPerBar , p . grid , p . loop , p . loopRegion ]); 74if ( cachedRuler ?. shows !== shows ) cachedRuler = { shows , el : ruler ( p , beats , ppb ) }; 75return cachedRuler . el ; 76}; 77const header = ( t: Track , p: Project ) => { 78const bass = t . kind === "chords" && t . bass ; 79const shows = JSON . stringify ([ t . name , t . muted , t . kind , bass , t . view , p . key . tonic ]); 80const cached = headers . get ( t . id ); 81if ( cached ?. shows === shows ) return cached . el ; 82const el = trackHeader ( t , p ); 83headers . set ( t . id , { shows , el }); 84return el ; 85}; 86 87watch ( rows , [ project , selection , cursor , activeTrackId , pxPerBeat ], () => { 88const p = project . get (); 89const ppb = pxPerBeat . get (); 90const bar = p . beatsPerBar ; 91const beats = Math . max ( 9216 * bar , 93Math . ceil (( Math . max ( projectEnd ( p ), cursor . get ()) + 4 * bar ) / bar ) * bar , 94); 95content . style . width = px ( HEADER_W + beats * ppb ); 96rows . style . gridTemplateColumns = ` ${ HEADER_W } px ${ beats * ppb } px` ; 97for ( const id of headers . keys ()) if ( ! p . tracks . some ( t => t . id === id )) headers . delete ( id ); 98setChildren ( rows , [ 99corner , 100rulerFor ( p , beats , ppb ), 101...p . tracks . flatMap ( t => [ header ( t , p ), lane ( t , p , ppb )]), 102addRow , 103]); 104for ( const [ id , { el }] of headers ) 105el . classList . toggle ( "active" , id === activeTrackId . get ()); 106for ( const el of rows . querySelectorAll < HTMLElement >( ".lane" )) 107el . classList . toggle ( "active" , el . dataset . lane === activeTrackId . get ()); 108cursorLine . style . translate = px ( HEADER_W + cursor . get () * ppb ); 109}); 110 111watch ( playLine , [ playhead , pxPerBeat ], () => { 112const beat = playhead . get (); 113playLine . hidden = beat === null ; 114if ( beat !== null ) playLine . style . translate = px ( HEADER_W + beat * pxPerBeat . get ()); 115}); 116 117const scroller = (< div class = "timeline" >{ content }< / div>) as HTMLDivElement; 118 119// ctrl-click moves the cursor to the nearest grid line, as clicking the ruler does 120rows . addEventListener ( 121"pointerdown" , 122e => { 123if ( e . button !== 0 || ! ( e . ctrlKey || e . metaKey ) || e . shiftKey || e . altKey ) return ; 124const surface = ( e . target as Element ). closest < HTMLElement >( ".roll, .strip" ); 125const lane = surface ?. closest < HTMLElement >( "[data-lane]" ); 126if ( ! surface || ! lane ) return ; 127e . preventDefault (); 128e . stopPropagation (); 129const grid = project . get (). grid ; 130const at = ( e . clientX - surface . getBoundingClientRect (). left ) / pxPerBeat . get (); 131const beat = Math . max ( 0 , Math . round ( at / grid ) * grid ); 132activeTrackId . set ( lane . dataset . lane ! ); 133cursor . set ( beat ); 134if ( playing . get ()) play ( beat ); 135}, 136{ capture : true }, 137); 138 139// middle-dragging pans, with the content following the pointer: sideways through the song, and 140// up and down through the pitches of the track the drag started on 141scroller . addEventListener ( "pointerdown" , e => { 142if ( e . button !== 1 ) return ; 143e . preventDefault (); 144const at = ( e . target as Element ). closest < HTMLElement >( "[data-lane], [data-track]" )?. dataset ; 145const track = project . get (). tracks . find ( t => t . id === ( at ?. lane ?? at ?. track )); 146const left = scroller . scrollLeft ; 147const top = track && viewOf ( track ). high ; 148document . body . classList . add ( "panning" ); 149drag ( 150e , 151m => { 152scroller . scrollLeft = left - ( m . clientX - e . clientX ); 153if ( track ) panTo ( track , top ! + Math . round (( m . clientY - e . clientY ) / ROW_H )); 154}, 155() => { 156document . body . classList . remove ( "panning" ); 157savePans (); 158}, 159); 160}); 161// firefox (and chrome on windows) would start autoscrolling 162scroller . addEventListener ( "mousedown" , e => e . button === 1 && e . preventDefault ()); 163 164// zoom around the pointer: the beat under it stays under it 165scroller . addEventListener ( "wheel" , e => { 166if ( ! e . ctrlKey ) return ; 167e . preventDefault (); 168const old = pxPerBeat . get (); 169// a trackpad pinch comes as many small deltas, a mouse wheel as a few big ones (a notch is 170// about 100): zoom with the pinch, but no more than about 15% a notch 171const pixels = e . deltaY * ( e . deltaMode === 1 ? 16 : 1 ); 172const step = Math . max ( - 0.15 , Math . min ( 0.15 , pixels * 0.01 )); 173const ppb = Math . min ( 160 , Math . max ( 8 , old * Math . exp ( - step ))); 174if ( ppb === old ) return ; 175 176// measured from the scroller's left edge, not counting the track headers pinned there 177const x = Math . max ( HEADER_W , e . clientX - scroller . getBoundingClientRect (). left ); 178const beat = ( scroller . scrollLeft + x - HEADER_W ) / old ; 179pxPerBeat . set ( ppb ); 180// queued behind the re-render the zoom causes, so the timeline is already wide enough 181queueMicrotask (() => ( scroller . scrollLeft = HEADER_W + beat * ppb - x )); 182}); 183return scroller ; 184} 185 186const gridLines = ( ppb: number , bar: number ) => 187[ 188`repeating-linear-gradient(to right, var(--line-bar) 0 1px, transparent 1px ${ ppb * bar } px)` , 189`repeating-linear-gradient(to right, var(--line-beat) 0 1px, transparent 1px ${ ppb } px)` , 190]. join ( "," ); 191 192function ruler ( p: Project , beats: number , ppb: number ) { 193const labels = []; 194for ( let b = 0 ; b < beats ; b += p . beatsPerBar ) { 195labels . push (< span style = {{ left : px ( b * ppb + 4 ) }}>{ String ( b / p . beatsPerBar + 1 )}</ span >); 196} 197// measured as a drag starts: the ruler is redrawn as the loop changes under it 198const beatsFrom = () => { 199const left = el . getBoundingClientRect (). left ; 200return ( x: number ) => Math . max ( 0 , Math . round (( x - left ) / ppb / p . grid ) * p . grid ); 201}; 202const setLoop = ( a: number , b: number ) => { 203const loopRegion = { start : Math . min ( a , b ), end : Math . max ( a , b ) }; 204commit ( d => Object . assign ( d , { loop : true , loopRegion }), { history : false }); 205}; 206 207/** a loop's end, to drag it somewhere else (past the other end, if need be) */ 208const edge = ( moving: "start" | "end" ) => ( 209< div 210class = { `loop-edge ${ moving } ` } 211_onpointerdown = { e => { 212if ( e . button !== 0 || ! p . loopRegion ) return ; 213e . stopPropagation (); 214const beatAt = beatsFrom (); 215const fixed = moving === "start" ? p . loopRegion . end : p . loopRegion . start ; 216document . body . classList . add ( "resizing-loop" ); 217drag ( 218e , 219m => { 220const to = beatAt ( m . clientX ); 221if ( to !== fixed ) setLoop ( fixed , to ); 222} , 223() => document . body . classList . remove ( "resizing-loop" ) , 224); 225}} 226/ > 227); 228const region = p.loopRegion && ( 229< div 230class = { `loop-region ${ p . loop ? "" : " off" } ` } 231style = {{ 232left : px ( p . loopRegion . start * ppb ), 233width : px (( p . loopRegion . end - p . loopRegion . start ) * ppb ), 234}} 235title = "drag its ends to move them; double-click to loop the whole song" 236_ondblclick = {() => commit ( d => void delete d . loopRegion , { history : false })} 237> 238{ edge ( "start" )} 239{ edge ( "end" )} 240</ div > 241); 242 243const el = ( 244< div 245class =" ruler " 246style = {{ backgroundImage : gridLines ( ppb , p . beatsPerBar ) }} 247title = "click to move the cursor, drag to set the loop" 248_onpointerdown = { e => { 249if ( e . button !== 0 ) return ; 250const beatAt = beatsFrom (); 251const from = beatAt ( e . clientX ); 252let dragging = false ; 253drag ( 254e , 255m => { 256dragging ||= Math . abs ( m . clientX - e . clientX ) > 3 ; 257const to = beatAt ( m . clientX ); 258if ( dragging && to !== from ) setLoop ( from , to ); 259}, 260() => { 261if ( dragging ) return ; 262cursor . set ( from ); 263if ( playing . get ()) play ( from ); 264}, 265); 266}} 267> 268{ region } 269{ labels } 270</ div > 271); 272return el; 273} 274 275function trackHeader ( t : Track , p : Project ) { 276const name = ( 277< input 278class = "th-name" 279value = { t . name } 280spellcheck = { false } 281_onchange = {() => commit ( d => void ( d . tracks . find ( x => x . id === t . id ) ! . name = name . value ))} 282_onkeydown = { e => e . key === "Enter" && name . blur ()} 283/> 284) as HTMLInputElement ; 285return ( 286< div 287class = "track-head" 288dataset = {{ track : t . id }} 289style= {{ height : px ( laneHeight ( t )) }} 290title="drag to move the track" 291_onpointerdown= { e => { 292activeTrackId . set ( t . id ); 293const control = "input, button, label, .keys, .track-resize" ; 294if ( e . button === 0 && ! ( e . target as Element ). closest ( control )) reorder ( e , t ); 295}} 296> 297< div class =" th-top " > 298{ name } 299< button 300type = "button" 301class = { `th-btn ${ t . muted ? " on" : "" } ` } 302title = "mute" 303_onclick = {() => 304commit ( d => void ( d . tracks . find ( x => x . id === t . id ) ! . muted = ! t . muted )) 305} 306> 307M 308</ button > 309< button 310type = "button" 311class =" th-btn " 312title = "delete track" 313_onclick = {() => removeTrack ( t . id )} 314> 315× 316</ button > 317</ div > 318{ gainSlider ( t )} 319{ t . kind === "chords" ? ( 320< label class = "th-bass" title = "off when another track carries the bass" > 321< input 322type = "checkbox" 323checked = { t . bass } 324_onchange = { e => 325commit ( 326d => 327void ( findTrack ( d , t . id , "chords" ) ! . bass = ( 328e . target as HTMLInputElement 329). checked ), 330) 331} 332/> 333bass 334< / label> 335) : undefined } 336{ pianoKeys ( t , projectKey ( p ))} 337{ resizeHandle ( t )} 338</ div > 339); 340} 341 342/** drag a track's header up or down to move it, with a line showing where it'll go */ 343function reorder ( e : PointerEvent , t : Track ) { 344const heads = [ ... document . querySelectorAll ( ".track-head" )]. map ( h => 345h . getBoundingClientRect (), 346); 347const timeline = document . querySelector ( ".timeline" ) ! . getBoundingClientRect (); 348const line = (< div class = "reorder-line" / >) as HTMLDivElement ; 349let to : number | undefined ; 350drag ( 351e , 352m => { 353if ( to === undefined && Math . abs ( m . clientY - e . clientY ) < 4 ) return ; 354if ( to === undefined ) { 355document . body . append ( line ); 356document . body . classList . add ( "reordering" ); 357} 358// the gap between tracks nearest the pointer 359to = heads . filter ( h => m . clientY > h . top + h . height / 2 ). length ; 360const y = to < heads . length ? heads [ to ]. top : heads . at ( - 1 ) ! . bottom ; 361Object . assign ( line . style , { 362top : px ( y - 1 ), 363left : px ( timeline . left ), 364width : px ( timeline . width ), 365}); 366}, 367() => { 368line . remove (); 369document . body . classList . remove ( "reordering" ); 370const from = project . get (). tracks . findIndex ( x => x . id === t . id ); 371if ( to !== undefined && to !== from && to !== from + 1 ) 372commit ( d => moveTrack ( d , t . id , to ! )); 373}, 374); 375} 376 377/** a track's volume trim, in dB: one drag is one undo step, and a double click resets it */ 378function gainSlider ( t : Track ) { 379const set = ( gain : number ) => ( d : Project ) => 380void ( d . tracks . find ( x => x . id === t . id ) ! . gain = gain ); 381let gesture : ReturnType < typeof beginGesture > | undefined ; 382const slider = ( 383< input 384type = "range" 385min = "-24" 386max = "6" 387step = "0.5" 388title = "volume (double-click for 0 dB)" 389_oninput = {() => ( gesture ??= beginGesture ()). update ( set ( slider . valueAsNumber ))} 390_onchange = {() => ( gesture = undefined )} 391// let go when the mouse does, or space would nudge the slider rather than play 392_onpointerdown = {() => addEventListener ( "pointerup" , () => slider . blur (), { once : true })} 393_ondblclick = {() => commit ( set ( 0 ))} 394/> 395) as HTMLInputElement ; 396const readout = (< span />) as HTMLSpanElement ; 397// not rebuilt with the header, which would swap the slider out from under a drag 398watch ( slider , [ project ], () => { 399const gain = project . get (). tracks . find ( x => x . id === t . id )?. gain ?? 0 ; 400slider . valueAsNumber = gain ; 401readout . textContent = gain ? ` ${ gain > 0 ? "+" : "−" } ${ Math . abs ( gain )} dB` : "0 dB" ; 402}); 403return ( 404< label class = "th-gain" > 405{ slider } 406{ readout } 407< / label> 408); 409} 410 411const MIN_ROWS = 12 ; 412 413/** dragging a track's bottom edge shows more or fewer rows below, keeping the top in place */ 414function resizeHandle ( t : Track ) { 415return ( 416< div 417class = "track-resize" 418title = "drag to resize" 419_onpointerdown = { e => { 420if ( e . button !== 0 ) return ; 421e . preventDefault (); 422const { low , high } = viewOf ( t ); 423savePans (); 424let rows = high - low + 1 ; 425document . body . classList . add ( "resizing-track" ); 426drag ( 427e , 428m => { 429const want = high - low + 1 + Math . round (( m . clientY - e . clientY ) / ROW_H ); 430const next = Math . max ( MIN_ROWS , Math . min ( high + 1 , want )); 431if ( next === rows ) return ; 432rows = next ; 433commit ( 434d => 435void ( d . tracks . find ( x => x . id === t . id ) ! . view = { low : high - rows + 1 , high }), 436{ history : false }, 437); 438} , 439() => document . body . classList . remove ( "resizing-track" ) , 440); 441}} 442/ > 443); 444} 445 446const PITCHES = 128 ; 447/** where the rows for pitches from `high` down start, in a roll or keyboard that holds them all */ 448const offset = ( high : number ) => `0 ${ px ( - ( PITCHES - 1 - high ) * ROW_H )} `; 449 450/** 451* pitch ranges moved but not yet saved, by their top pitch. rolls and keyboards hold every pitch, 452* so moving through them is only a transform; saving (and so re-rendering) waits for the moving 453* to stop, as doing it every row would drop frames 454*/ 455const panned = new Map<string, number>(); 456let panSave: ReturnType<typeof setTimeout> | undefined; 457 458const viewOf = (t: Track): Track["view"] => { 459const high = panned.get(t.id); 460return high === undefined ? t.view : { low: t.view.low + high - t.view.high, high }; 461}; 462 463function panTo(t: Track, high: number) { 464high = Math.max(t.view.high - t.view.low, Math.min(PITCHES - 1, high)); 465panned.set(t.id, high); 466for (const el of document.querySelectorAll<HTMLElement>(` [ data - pan = "${t.id}" ] `)) 467el.style.translate = offset(high); 468clearTimeout(panSave); 469panSave = setTimeout(savePans, 250); 470} 471 472function savePans() { 473clearTimeout(panSave); 474if (!panned.size) return; 475const views = new Map(project.get().tracks.map(t => [t.id, viewOf(t)])); 476panned.clear(); 477commit(d => d.tracks.forEach(t => (t.view = views.get(t.id)!)), { history: false }); 478} 479 480// fractions of a row scrolled so far 481const scrolledRows = new Map<string, number>(); 482 483/** vertical wheel moves a track's pitch range like scrolling a document */ 484function scrollPitches(t: Track) { 485return (e: WheelEvent) => { 486const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? rollHeight(t) : 1; 487// shift+wheel scrolls the timeline sideways. some browsers turn it into a horizontal scroll 488// themselves, but others (firefox) leave it vertical, which would land here as pitch 489if (e.shiftKey) { 490e.preventDefault(); 491const timeline = (e.currentTarget as Element).closest(".timeline")!; 492timeline.scrollLeft += 493(Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * unit; 494return; 495} 496// leave zooming and horizontal scrolling to the timeline 497if (e.ctrlKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; 498// a mouse wheel's notch moves one key, whether it comes as lines (firefox, sometimes) or as 499// one big jump in pixels; a trackpad's stream of small ones moves the rows with the fingers 500const notches = Math.max(1, Math.round(Math.abs(e.deltaY) / 100)) * Math.sign(e.deltaY); 501const moved = 502e.deltaMode === 2 503? e.deltaY * (t.view.high - t.view.low + 1) 504: e.deltaMode === 1 505? e.deltaY / 3 506: Math.abs(e.deltaY) >= 40 507? notches 508: e.deltaY / ROW_H; 509const total = (scrolledRows.get(t.id) ?? 0) + moved; 510const rows = Math.trunc(total); 511const { low, high } = viewOf(t); 512const shift = Math.max(-low, Math.min(PITCHES - 1 - high, -rows)); 513// at the end of the range, let the timeline scroll instead 514if (rows && !shift) return scrolledRows.delete(t.id); 515 516e.preventDefault(); 517scrolledRows.set(t.id, total - rows); 518if (shift) panTo(t, high + shift); 519}; 520} 521 522const BLACK_KEYS = [1, 3, 6, 8, 10]; 523 524function pianoKeys(t: Track, key: Key) { 525const keys = []; 526for (let m = PITCHES - 1; m >= 0; m--) { 527const cls = [ 528"key", 529BLACK_KEYS.includes(mod(m, 12)) ? "black" : "", 530mod(m - pcOf(key.tonic), 12) === 0 ? "tonic" : "", 531]; 532keys.push(<div class={cls.join(" ")}>{mod(m, 12) === 0 ? ` C$ { m / 12 - 1 } ` : ""}</div>); 533} 534const inner = ( 535<div 536class="keys-inner" 537dataset={{ pan: t.id }} 538style={{ translate: offset(viewOf(t).high) }} 539> 540{keys} 541</div> 542) as HTMLDivElement; 543return ( 544<div 545class="keys" 546style={{ height: px(rollHeight(t)) }} 547title="scroll or middle-drag to see other notes" 548_onpointerdown={e => { 549if (e.button !== 0) return; 550const row = Math.floor((e.clientY - inner.getBoundingClientRect().top) / ROW_H); 551audition([PITCHES - 1 - row], 0.5); 552}} 553_onwheel={scrollPitches(t)} 554> 555{inner} 556</div> 557); 558} 559 560function rollBackground(key: Key, ppb: number, bar: number) { 561const tonic = pcOf(key.tonic); 562const scale = MODES[key.mode].steps; 563const stops = []; 564for (let i = 0, m = PITCHES - 1; m >= 0; m--, i++) { 565const rel = mod(m - tonic, 12); 566const color = 567rel === 0 568? "var(--row-tonic)" 569: scale.includes(rel) 570? "var(--row-scale)" 571: "var(--row-off)"; 572stops.push(` $ { color } ${ i * ROW_H } px $ {( i + 1 ) * ROW_H } px `); 573} 574return [ 575gridLines(ppb, bar), 576` repeating - linear - gradient ( to bottom , transparent 0 ${ ROW_H - 1 } px , var ( -- line - row ) $ { ROW_H - 1 } px $ { ROW_H } px ) `, 577` linear - gradient ( to bottom , $ { stops . join ( "," )}) `, 578].join(","); 579} 580 581function lane(t: Track, p: Project, ppb: number) { 582const key = projectKey(p); 583// every pitch, of which the roll shows the track's range 584const inner = ( 585<div 586class="roll-inner" 587dataset={{ pan: t.id }} 588style={{ 589height: px(PITCHES * ROW_H), 590translate: offset(viewOf(t).high), 591backgroundImage: rollBackground(key, ppb, p.beatsPerBar), 592}} 593/> 594) as HTMLDivElement; 595const roll = ( 596<div 597class="roll" 598style={{ height: px(rollHeight(t)) }} 599_oncontextmenu={e => e.preventDefault()} 600_onwheel={scrollPitches(t)} 601> 602{inner} 603</div> 604) as HTMLDivElement; 605const locate = (e: PointerEvent) => { 606const r = inner.getBoundingClientRect(); 607return { 608beat: (e.clientX - r.left) / ppb, 609pitch: PITCHES - 1 - Math.floor((e.clientY - r.top) / ROW_H), 610}; 611}; 612const noteBox = (start: number, dur: number, pitch: number) => ({ 613left: px(start * ppb), 614width: px(Math.max(3, dur * ppb - 1)), 615top: px((PITCHES - 1 - pitch) * ROW_H), 616height: px(ROW_H - 1), 617}); 618const label = (pitch: number) => formatNote(spellPc(mod(pitch, 12), key.tonic)); 619const inScale = (pitch: number) => 620MODES[key.mode].steps.includes(mod(pitch - pcOf(key.tonic), 12)); 621 622if (t.kind === "notes") { 623const selected = selectedIds(t.id); 624inner.append( 625...t.notes.map(n => ( 626<div 627class={` note$ { selected . includes ( n . id ) ? " selected" : "" } `} 628style={noteBox(n.start, n.dur, n.pitch)} 629dataset={{ id: n.id }} 630> 631{label(n.pitch)} 632</div> 633)), 634); 635roll.addEventListener("pointerdown", e => notePointer(e, t, p, ppb, locate(e))); 636return ( 637<div class="lane notes" dataset={{ lane: t.id }}> 638{roll} 639{resizeHandle(t)} 640</div> 641); 642} 643 644// every note track, drawn under the chords to show how they sit against them 645const noteTracks = p.tracks.filter((n): n is NoteTrack => n.kind === "notes"); 646inner.append( 647...noteTracks.flatMap(n => 648n.notes.map(note => ( 649<div class="note ghost" style={noteBox(note.start, note.dur, note.pitch)} /> 650)), 651), 652); 653 654const voiced = voiceTrack(t, key); 655const current = activeTrackId.get() === t.id ? cursor.get() : undefined; 656voiced.forEach((v, i) => { 657const { start, dur } = v.event; 658const heard = sounding(t, v.notes); 659inner.append( 660<div 661class={` chord - col$ { i % 2 ? " alt" : "" } $ { start === current ? " current" : "" } `} 662style={{ left: px(start * ppb), width: px(dur * ppb) }} 663/>, 664...v.notes.map(n => ( 665<div 666class={` note$ { v . pinned ? "" : " auto" } ${ inScale ( n ) ? "" : " outside" } ${ heard . includes ( n ) ? "" : " silent" } `} 667style={noteBox(start, dur, n)} 668> 669{label(n)} 670</div> 671)), 672); 673}); 674roll.addEventListener("pointerdown", e => { 675if (e.button !== 0) return; 676activeTrackId.set(t.id); 677const { beat, pitch } = locate(e); 678const v = voiced.find(v => v.event.start <= beat && beat < v.event.start + v.event.dur); 679if (!v) return createChordWithNote(t.id, beat, pitch); 680cursor.set(v.event.start); 681toggleChordNote(t.id, v.event.id, pitch); 682}); 683 684return ( 685<div class="lane chords" dataset={{ lane: t.id }}> 686{chordStrip(t, voiced, ppb, p.grid, current)} 687{roll} 688{resizeHandle(t)} 689</div> 690); 691} 692 693function chordStrip( 694t: ChordTrack, 695voiced: VoicedChord[], 696ppb: number, 697grid: number, 698current?: number, 699) { 700const selected = selectedIds(t.id); 701const blocks = voiced.map(v => { 702const a = v.analysis; 703// coloured by where it comes from, and tagged with it unless it's a secondary dominant 704const from = a && origin(a); 705const isVertex = from === "sharp" || from === "flat" || from === "inverse"; 706const tag = 707a?.secondary ?? 708(isVertex ? ` $ { from } vertex ` : from && from !== "home" ? MODES[from].label : ""); 709const cls = [ 710"chord", 711v.event.start === current ? "current" : "", 712selected.includes(v.event.id) ? "selected" : "", 713v.pinned ? "pinned" : "", 714originClass(a), 715v.chord ? "" : "unknown", 716]; 717return ( 718<div 719class={cls.filter(Boolean).join(" ")} 720style={{ left: px(v.event.start * ppb), width: px(v.event.dur * ppb) }} 721dataset={{ id: v.event.id }} 722title={[ 723v.pinned ? "hand-voiced (p to unpin)" : "voiced from its neighbours", 724...(v.alternatives.length 725? [` could also be read as $ { v . alternatives . join ( ", " )} `] 726: []), 727].join("\n" )} 728> 729< div class = "chord-name" >{ v . label }< / div> 730< div class = "chord-roman" > 731{ a ? prettyRoman ( a . roman ) : "" } 732{ tag ? < span class = "chord-tag" >{ tag }< /span> : undefined} 733</ div > 734< div class = "chord-handle" / > 735< /div> 736); 737}); 738 739const strip = ( 740< div class = "strip" style = {{ height : px ( STRIP_H ) }}> 741{ blocks } 742< / div> 743) as HTMLDivElement ; 744const beatAt = ( e : MouseEvent ) => ( e . clientX - strip . getBoundingClientRect (). left ) / ppb ; 745const snap = ( pixels : number ) => Math . round ( pixels / ppb / grid ) * grid ; 746const hit = ( e : Event ) => { 747const el = ( e . target as HTMLElement ). closest < HTMLElement >( ".chord" ); 748return el ? voiced . find ( v => v . event . id === el . dataset . id ) : undefined ; 749}; 750 751strip . addEventListener ( "pointerdown" , e => { 752if ( e . button !== 0 ) return ; 753activeTrackId . set ( t . id ); 754const v = hit ( e ); 755if ( ! v ) { 756selection . set ( null ); 757return cursor . set ( Math . max ( 0 , Math . floor ( beatAt ( e ) / grid ) * grid )); 758} 759 760const original = v . event ; 761const selected = selectedIds ( t . id ); 762if ( e . ctrlKey || e . metaKey ) { 763const ids = selected . includes ( original . id ) 764? selected . filter ( id => id !== original . id ) 765: [ ... selected , original . id ]; 766selection . set ({ trackId : t . id , ids }); 767chordAnchor = original . id ; 768return cursor . set ( original . start ); 769} 770if ( e . shiftKey ) { 771// from the chord last clicked (or at the cursor) to this one 772const anchor = 773t . chords . find ( c => c . id === chordAnchor ) ?? 774t . chords . find ( c => c . start === cursor . get ()) ?? 775original ; 776const [ from , to ] = [ anchor . start , original . start ]. sort (( a , b ) => a - b ); 777const ids = t . chords . filter ( c => c . start >= from && c . start <= to ). map ( c => c . id ); 778selection . set ({ trackId : t . id , ids }); 779return cursor . set ( original . start ); 780} 781selection . set ({ trackId : t . id , ids : [ original . id ] }); 782chordAnchor = original . id ; 783cursor . set ( original . start ); 784const gesture = beginGesture (); 785const update = ( chord : typeof original ) => 786gesture . update ( d => placeChord ( findTrack ( d , t . id , "chords" ) ! , chord )); 787 788if (( e . target as HTMLElement ). classList . contains ( "chord-handle" )) { 789drag ( e , m => { 790update ({ 791... original , 792dur : Math . max ( grid , original . dur + snap ( m . clientX - e . clientX )), 793}); 794}); 795return ; 796} 797 798audition ( v . notes , 0.9 ); 799drag ( e , m => { 800const start = Math . max ( 0 , original . start + snap ( m . clientX - e . clientX )); 801if ( start === cursor . get ()) return ; 802update ({ ... original , start }); 803cursor . set ( start ); 804}); 805}); 806 807strip . addEventListener ( "dblclick" , e => { 808const v = hit ( e ); 809const initial = v ? ( v . event . symbol ?? ( v . chord ? chordSymbol ( v . chord ) : "" )) : "" ; 810chordEdit . set ({ trackId : t . id , initial , selectAll : true }); 811}); 812 813return strip ; 814} 815 816function notePointer ( 817e : PointerEvent , 818t : NoteTrack , 819p : Project , 820ppb : number , 821at : { beat : number ; pitch : number }, 822) { 823if ( e . button !== 0 && e . button !== 2 ) return ; 824activeTrackId . set ( t . id ); 825const grid = p . grid ; 826const snap = ( beats : number ) => Math . round ( beats / grid ) * grid ; 827const el = ( e . target as HTMLElement ). closest < HTMLElement >( ".note" ); 828const hit = el ? t . notes . find ( n => n . id === el . dataset . id ) : undefined ; 829const edit = ( change : ( track : NoteTrack ) => void ) => ( d : Project ) => 830change ( findTrack ( d , t . id , "notes" ) ! ); 831 832if ( e . button === 2 || e . altKey ) { 833if ( hit ) 834commit ( edit ( track => void ( track . notes = track . notes . filter ( n => n . id !== hit . id )))); 835return ; 836} 837 838const ctrl = e . ctrlKey || e . metaKey ; 839// shift-drag selects with a box; with ctrl too, adding to what's selected 840if ( ! hit && e . shiftKey ) return selectBox ( e , t , ppb , ctrl ); 841 842if ( ! hit ) { 843// a note goes in the grid cell clicked 844const start = Math . floor ( at . beat / grid ) * grid ; 845const gesture = beginGesture (); 846const note = { 847id : newId (), 848start , 849dur : lastNoteLength , 850pitch : at . pitch , 851vel : NOTE_VEL , 852}; 853selection . set ({ trackId : t . id , ids : [ note . id ] }); 854audition ([ note . pitch ], 0.4 ); 855gesture . update ( edit ( track => void track . notes . push ( note ))); 856drag ( e , m => { 857const dur = Math . max ( grid , note . dur + snap (( m . clientX - e . clientX ) / ppb )); 858gesture . update ( edit ( track => void track . notes . push ({ ... note , dur }))); 859lastNoteLength = dur ; 860}); 861return ; 862} 863 864const gesture = beginGesture (); 865let ids = selectedIds ( t . id ); 866if ( e . shiftKey ) { 867ids = ids . includes ( hit . id ) ? ids . filter ( id => id !== hit . id ) : [ ... ids , hit . id ]; 868selection . set ({ trackId : t . id , ids }); 869if ( ! ids . includes ( hit . id )) return ; 870} else if ( ! ids . includes ( hit . id )) { 871ids = [ hit . id ]; 872selection . set ({ trackId : t . id , ids }); 873} 874 875const resizing = e . clientX > el ! . getBoundingClientRect (). right - 6 ; 876const originals = t . notes . filter ( n => ids . includes ( n . id )); 877let heard = 0 ; 878if ( ! resizing ) audition ([ hit . pitch ], 0.4 ); 879 880drag ( e , m => { 881const beats = snap (( m . clientX - e . clientX ) / ppb ); 882const pitch = resizing ? 0 : - Math . round (( m . clientY - e . clientY ) / ROW_H ); 883gesture . update ( 884edit ( track => { 885for ( const o of originals ) { 886const n = track . notes . find ( n => n . id === o . id ) ! ; 887if ( resizing ) n . dur = Math . max ( grid , o . dur + beats ); 888else { 889n . start = Math . max ( 0 , o . start + beats ); 890n . pitch = Math . min ( 127 , Math . max ( 0 , o . pitch + pitch )); 891} 892} 893}), 894); 895if ( resizing ) lastNoteLength = Math . max ( grid , hit . dur + beats ); 896if ( pitch !== heard ) audition ([ hit . pitch + ( heard = pitch )], 0.3 ); 897}); 898} 899 900/** drag out a box and select the notes it touches; adding to the selection, or instead of it */ 901function selectBox ( e : PointerEvent , t : NoteTrack , ppb : number , adding : boolean ) { 902const roll = ( e . target as HTMLElement ) 903. closest ( ".roll" ) ! 904. querySelector ( ".roll-inner" ) ! 905. getBoundingClientRect (); 906const before = adding ? selectedIds ( t . id ) : []; 907// on the page rather than in the roll, which gets redrawn as the selection changes 908const box = (< div class = "select-box" / >) as HTMLDivElement ; 909document . body . append ( box ); 910 911const update = ( m : PointerEvent ) => { 912const [ x0 , x1 ] = [ Math . min ( e . clientX , m . clientX ), Math . max ( e . clientX , m . clientX )]; 913const [ y0 , y1 ] = [ Math . min ( e . clientY , m . clientY ), Math . max ( e . clientY , m . clientY )]; 914Object . assign ( box . style , { 915left : px ( x0 ), 916top : px ( y0 ), 917width : px ( x1 - x0 ), 918height : px ( y1 - y0 ), 919} ); 920 921const [ from , to ] = [( x0 - roll . left ) / ppb , ( x1 - roll . left ) / ppb ]; 922const pitchAt = ( y : number ) => PITCHES - 1 - Math . floor (( y - roll . top ) / ROW_H ); 923const [ low , high ] = [ pitchAt ( y1 ), pitchAt ( y0 )]; 924const inside = t . notes 925. filter ( n => n . start < to && n . start + n . dur > from && n . pitch >= low && n . pitch <= high ) 926. map ( n => n . id ); 927selection . set ({ trackId : t . id , ids : [ ... new Set ([ ... before , ... inside ])] }); 928}; 929update ( e ); 930drag ( e , update , () => box . remove ()); 931}