char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
66d16f5
main
1import type { JSX } from "@char/aftercare/jsx-runtime" ; 2import { playing } from "../audio/transport.ts" ; 3import { type Project , TONICS , UNTITLED } from "../project/model.ts" ; 4import { 5commit , 6createProject , 7deleteProject , 8history , 9openProject , 10project , 11projectIndex , 12redo , 13undo , 14} from "../project/store.ts" ; 15import { MODES } from "../theory/key.ts" ; 16import { rekey } from "../project/harmony.ts" ; 17import { 18exportMidi , 19exportProject , 20importingTheoryTab , 21importMidi , 22importProject , 23importTheoryTab , 24togglePlay , 25} from "./actions.ts" ; 26import { chooseFile , watch } from "./dom.tsx" ; 27 28const pretty = ( s :string ) => s . replace ( "#" , "♯" ). replace ( "b" , "♭" ); 29const MODE_OPTIONS = Object . entries ( MODES ). map (([ id , { label}]) :[ string , string ] => [ 30id , 31label , 32]); 33 34const options = ( entries :[ value :string , label :string ][]) => 35entries . map (([ value , label ]) => < option value = { value }>{ label }</ option >); 36 37/** a control bound to a project field: shows the current value, commits changes */ 38function bind < E extends HTMLInputElement | HTMLSelectElement >( 39el :E , 40read :( p :Project ) => string , 41write :( d :Project , value :string ) => void , 42{ history= true } = {}, 43) :E { 44watch ( el , [ project ], () => { 45if ( document . activeElement !== el ) el . value = read ( project . get ()); 46}); 47el . addEventListener ( "change" , () => { 48commit ( d => write ( d , el . value ), { history}); 49if ( el instanceof HTMLSelectElement ) el . blur (); 50}); 51return el ; 52} 53 54/** the things you do to a project as a whole, in a menu under a button */ 55function projectMenu () { 56const item = ( label :string , run :() => void ) => ( 57< button 58type = "button" 59role = "menuitem" 60_onclick = {() => { 61menu . hidePopover (); 62run (); 63}} 64> 65{ label } 66</ button > 67); 68const menu = ( 69< div class = "menu" popover = "auto" role = "menu" > 70{ item ( "new project" , createProject )} 71{ item ( "rename…" , () => { 72const name = prompt ( "project name" , project . get (). name ); 73if ( name !== null ) 74commit ( d => void ( d . name = name . trim () || UNTITLED ), { history : false }); 75})} 76{ item ( "delete…" , () => { 77if ( confirm ( `delete " ${ project . get (). name } "?` )) deleteProject ( project . get (). id ); 78})} 79< hr / > 80{ item ( "open project file…" , () => 81chooseFile ( ".chordpad,.json" , file => void importProject ( file )) , 82)} 83{ item ( "save project file" , exportProject )} 84< hr / > 85{ item ( "import midi…" , () => 86chooseFile ( ".mid,.midi,audio/midi" , file => void importMidi ( file )) , 87)} 88{ item ( "export midi" , exportMidi )} 89{ item ( "import theorytab…" , () => { 90const url = prompt ( "TheoryTab link (hooktheory.com/theorytab/view/…)" ); 91if ( url ) void importTheoryTab ( url ); 92})} 93</ div > 94) as HTMLDivElement ; 95 96const toggle = ( 97< button 98type = "button" 99class = "tb-menu" 100aria - haspopup = "menu" 101disabled = { importingTheoryTab } 102_also = { el => el . setAttribute ( "popovertarget" , "project-menu" )} 103> 104{ importingTheoryTab . derive ( busy => ( busy ? "importing…" : "project ▾" ))} 105</ button > 106) as HTMLButtonElement ; 107menu . id = "project-menu" ; 108// opens under its button 109menu . addEventListener ( "beforetoggle" , e => { 110if (( e as ToggleEvent ). newState !== "open" ) return ; 111const at = toggle . getBoundingClientRect (); 112Object . assign ( menu . style , { left : ` ${ at . left } px` , top : ` ${ at . bottom + 4 } px` }); 113}); 114// a press outside only closes it. the browser would close it too, but not when the press 115// redraws what's under it (as clicking the timeline does), and the press would still edit 116addEventListener ( 117"pointerdown" , 118e => { 119const target = e . target as Node ; 120if ( ! menu . matches ( ":popover-open" ) || menu . contains ( target ) || toggle . contains ( target )) 121return ; 122e . stopPropagation (); 123e . preventDefault (); 124menu . hidePopover (); 125}, 126true , 127); 128return [ toggle , menu ]; 129} 130 131export function Toolbar () : JSX . Element { 132const projects = ( 133< select 134class = "tb-projects" 135title = "projects" 136_onchange = {() => openProject ( projects . value )} 137/ > 138) as HTMLSelectElement ; 139watch ( projects , [ projectIndex , project . derive ( p => ` ${ p . id } ${ p . name } ` )] , () => { 140// a blank project isn't saved, so isn't in the list: show it anyway while it's open 141const current = project . get (); 142const saved = projectIndex . get (); 143const listed = saved . some ( e => e . id === current . id ) ? saved : [ current , ... saved ]; 144projects . replaceChildren ( ... options ( listed . map ( e => [ e . id , e . name ]))); 145projects . value = current . id ; 146}); 147 148const loop = ( 149< input 150type = "checkbox" 151_onchange = {() => commit ( d => void ( d . loop = loop . checked ), { history : false })} 152/> 153) as HTMLInputElement ; 154watch ( loop , [ project ], () => ( loop . checked = project . get (). loop )); 155 156return ( 157< header class = "toolbar" > 158< div class = "tb-section tb-left" > 159< div class = "tb-group" > 160{ projects } 161{ projectMenu ()} 162< / div> 163 164< div class = "tb-group" > 165< label > key </ label > 166{ bind ( 167(< select >{ options ( TONICS . map ( t => [ t , pretty ( t )]))}</ select >) as HTMLSelectElement , 168p => p . key . tonic , 169( d , v ) => rekey ( d , { ... d . key , tonic : v }) , 170)} 171{ bind ( 172(< select >{ options ( MODE_OPTIONS )}</ select >) as HTMLSelectElement , 173p => p . key . mode , 174( d , v ) => rekey ( d , { ... d . key , mode : v as Project [ "key" ][ "mode" ] }), 175)} 176< /div> 177</ div > 178 179< div class = "tb-section tb-middle" > 180< div class = "tb-group" > 181< button type = "button" class = "tb-play" _onclick = { togglePlay }> 182{ playing . derive ( p => ( p ? "■ stop" : "▶ play" ))} 183</ button > 184< label >{ loop } loop </ label > 185< label > 186{ bind ( 187(< input type = "number" class = "tb-bpm" min = "30" max = "300" / >) as HTMLInputElement , 188p => String ( p . bpm ), 189( d , v ) => ( d . bpm = Math . min ( 300 , Math . max ( 30 , Number ( v ) || d . bpm ))), 190)} 191bpm 192< /label> 193</ div > 194< / div> 195 196< div class = "tb-section tb-right" > 197< div class = "tb-group" > 198< label > 199chords 200{ bind ( 201( 202< select > 203{ options ([ 204[ "0.25" , "1/16" ], 205[ "0.5" , "1/8" ], 206[ "1" , "1/4" ], 207[ "2" , "1/2" ], 208[ "4" , "1 bar" ], 209[ "8" , "2 bars" ], 210])} 211</ select > 212) as HTMLSelectElement , 213p => String ( p . chordLength ), 214( d , v ) => ( d . chordLength = Number ( v )), 215{ history : false }, 216)} 217</ label > 218< label > 219grid 220{ bind ( 221( 222< select > 223{ options ([ 224[ "1" , "1/4" ], 225[ "0.5" , "1/8" ], 226[ "0.25" , "1/16" ], 227[ String ( 1 / 3 ), "1/8T" ], 228])} 229</ select > 230) as HTMLSelectElement , 231p => String ( p . grid ) , 232( d , v ) => ( d . grid = Number ( v )) , 233{ history : false } , 234)} 235</ label > 236</ div > 237 238< div class =" tb - group " > 239< button 240type =" button " 241title= "undo (ctrl+z)" 242disabled = {history. derive ( h => ! h . undo )} 243_onclick = {undo} 244> 245undo 246< / button> 247< button 248type = "button" 249title = "redo (ctrl+shift+z)" 250disabled = { history . derive ( h => ! h . redo )} 251_onclick = { redo } 252> 253redo 254</ button > 255< / div> 256</ div > 257< / header> 258); 259}