char-slop/chordpad.vibed.software
git clone https://git.t4t.associates/char-slop/chordpad.vibed.software
2b249d4
main
1import type { ChordTrack , NoteTrack } from "../project/model.ts" ; 2import { project , redo , undo } from "../project/store.ts" ; 3import { invertVoicing } from "../theory/voicing.ts" ; 4import { 5chordsToEdit , 6deleteChords , 7pasteClip , 8pasteNoteClip , 9deleteNotes , 10moveChordCursor , 11nudgeNotes , 12resizeChord , 13revoice , 14unpin , 15togglePlay , 16} from "./actions.ts" ; 17import { activeTrackId , chordEdit , cursor , selectedIds , selection } from "./state.ts" ; 18import { chordSymbol } from "../theory/chord.ts" ; 19import { voiceTrack } from "../project/harmony.ts" ; 20import { 21CHORDS_MIME , 22chordsFromClipboard , 23chordsFromText , 24chordsToClipboard , 25NOTES_MIME , 26notesFromClipboard , 27notesToClipboard , 28} from "../project/clipboard.ts" ; 29import { projectKey } from "../project/model.ts" ; 30 31// anything that can begin a chord symbol, a roman numeral, or a scale degree 32const CHORD_START = / ^[A-Ga-g1-7#iIvV]$ / ; 33 34function chordKey ( e :KeyboardEvent , track :ChordTrack ) :boolean { 35const current = voiceTrack ( track , projectKey ( project . get ())). find ( 36v => v . event . start === cursor . get (), 37); 38const id = current ?. event . id ; 39const vertical = e . key === "ArrowUp" ?1 :e . key === "ArrowDown" ?- 1 :0 ; 40 41if ( e . ctrlKey || e . metaKey ) { 42const key = e . key . toLowerCase (); 43// copying and pasting come as clipboard events: see installClipboard 44if ( key === "a" ) selection . set ({ trackId :track . id , ids :track . chords . map ( c => c . id ) }); 45else return false ; 46return true ; 47} 48 49if ( e . key === "ArrowLeft" || e . key === "ArrowRight" ) { 50const direction = e . key === "ArrowRight" ?1 :- 1 ; 51if ( e . shiftKey ) id && resizeChord ( track . id , id , direction * project . get (). grid ); 52else moveChordCursor ( track , direction ); 53} else if ( vertical ) { 54if ( ! id ) return true ; 55if ( e . shiftKey ) 56revoice ( track . id , id , ([ bass , ...upper ]) => [ bass , ...upper . map ( n => n + 12 * vertical )]); 57else if ( e . altKey ) 58revoice ( track . id , id , ([ bass , ...upper ]) => [ bass + 12 * vertical , ...upper ]); 59else revoice ( track . id , id , notes => invertVoicing ( notes , vertical )); 60} else if ( e . key === "Delete" || e . key === "Backspace" ) { 61deleteChords ( track . id , chordsToEdit ( track )); 62} else if ( e . key === "Escape" ) { 63selection . set ( null ); 64} else if ( e . key === "Enter" || e . key === "F2" ) { 65const initial = current ?. event . symbol ?? ( current ?. chord ?chordSymbol ( current . chord ) :"" ); 66chordEdit . set ({ trackId :track . id , initial, selectAll :true }); 67} else if ( e . key === "p" ) { 68if ( id ) unpin ( track . id , id ); 69} else if ( CHORD_START . test ( e . key ) && ! e . ctrlKey && ! e . metaKey && ! e . altKey ) { 70chordEdit . set ({ trackId :track . id , initial :e . key , selectAll :false }); 71} else return false ; 72return true ; 73} 74 75function noteKey ( e :KeyboardEvent , track :NoteTrack ) :boolean { 76const ids = selectedIds ( track . id ); 77const grid = project . get (). grid ; 78const mod = e . ctrlKey || e . metaKey ; 79 80if ( mod && e . key === "a" ) 81selection . set ({ trackId :track . id , ids :track . notes . map ( n => n . id ) }); 82else if ( e . key === "Escape" ) selection . set ( null ); 83else if ( ! ids . length ) return false ; 84else if ( e . key === "Delete" || e . key === "Backspace" ) deleteNotes ( track . id , ids ); 85else if ( e . key === "ArrowUp" || e . key === "ArrowDown" ) { 86const direction = e . key === "ArrowUp" ?1 :- 1 ; 87// by a semitone, a scale degree (ctrl), or an octave (shift) 88if ( mod ) nudgeNotes ( track . id , ids , { degrees :direction }); 89else nudgeNotes ( track . id , ids , { pitch :direction * ( e . shiftKey ?12 :1 ) }); 90} else if ( e . key === "ArrowLeft" || e . key === "ArrowRight" ) { 91const step = ( e . key === "ArrowRight" ?1 :- 1 ) * grid ; 92nudgeNotes ( track . id , ids , e . shiftKey ?{ dur :step } :{ beats :step }); 93} else return false ; 94return true ; 95} 96 97export function installKeys () :void { 98// clicking a button shouldn't steal focus, or space would press it again 99addEventListener ( 100"mousedown" , 101e => { 102if (( e . target as Element ). closest ( "button" )) e . preventDefault (); 103}, 104true , 105); 106 107addEventListener ( "keydown" , e => { 108if (( e . target as Element ). closest ( "input, select, textarea, dialog" )) return ; 109const mod = e . ctrlKey || e . metaKey ; 110const key = e . key . toLowerCase (); 111 112if ( mod && key === "z" ) e . shiftKey ?redo () :undo (); 113else if ( mod && key === "y" ) redo (); 114else if ( e . key === " " ) togglePlay (); 115else if ( e . key === "Home" ) cursor . set ( 0 ); 116else { 117const track = project . get (). tracks . find ( t => t . id === activeTrackId . get ()); 118if ( ! track ) return ; 119if ( ! ( track . kind === "chords" ?chordKey ( e , track ) :noteKey ( e , track ))) return ; 120} 121e . preventDefault (); 122}); 123} 124 125/** 126* copy, cut and paste chords through the system clipboard, as the browser's own clipboard events 127* (so they come from the edit menu too). they carry the chords exactly, for this app, and as text 128* for anything else; pasted text is read as chord symbols 129*/ 130export function installClipboard () :void { 131const trackFor = ( e :ClipboardEvent ) => { 132// text boxes keep their own copy and paste 133if (( e . target as Element ). closest ?.( "input, select, textarea, dialog" )) return undefined ; 134return project . get (). tracks . find ( t => t . id === activeTrackId . get ()); 135}; 136 137const copy = ( e :ClipboardEvent , cut :boolean ) => { 138const track = trackFor ( e ); 139if ( ! track || ! e . clipboardData ) return ; 140const p = project . get (); 141const ids = track . kind === "chords" ?chordsToEdit ( track ) :selectedIds ( track . id ); 142const copied = 143track . kind === "chords" 144 ?chordsToClipboard ( track , ids , p . key ) 145 :notesToClipboard ( track , ids , projectKey ( p )); 146if ( ! copied ) return ; 147e . preventDefault (); 148e . clipboardData . setData ( track . kind === "chords" ?CHORDS_MIME :NOTES_MIME , copied . data ); 149e . clipboardData . setData ( "text/plain" , copied . text ); 150if ( cut ) ( track . kind === "chords" ?deleteChords :deleteNotes )( track . id , ids ); 151}; 152addEventListener ( "copy" , e => copy ( e , false )); 153addEventListener ( "cut" , e => copy ( e , true )); 154 155addEventListener ( "paste" , e => { 156const track = trackFor ( e ); 157if ( ! track || ! e . clipboardData ) return ; 158const p = project . get (); 159const key = projectKey ( p ); 160const data = ( type :string ) => e . clipboardData ! . getData ( type ); 161if ( track . kind === "notes" ) { 162const clip = notesFromClipboard ( data ( NOTES_MIME )); 163if ( ! clip ) return ; 164e . preventDefault (); 165return pasteNoteClip ( track . id , clip ); 166} 167const clip = 168chordsFromClipboard ( data ( CHORDS_MIME ), key ) ?? 169chordsFromText ( data ( "text/plain" ), key , p . chordLength ); 170if ( ! clip ) return ; 171e . preventDefault (); 172pasteClip ( track . id , clip ); 173}); 174}