import type { ChordTrack, NoteTrack } from "../project/model.ts"; import { project, redo, undo } from "../project/store.ts"; import { invertVoicing } from "../theory/voicing.ts"; import { chordsToEdit, deleteChords, pasteClip, pasteNoteClip, deleteNotes, moveChordCursor, nudgeNotes, resizeChord, revoice, unpin, togglePlay, } from "./actions.ts"; import { activeTrackId, chordEdit, cursor, selectedIds, selection } from "./state.ts"; import { chordSymbol } from "../theory/chord.ts"; import { voiceTrack } from "../project/harmony.ts"; import { CHORDS_MIME, chordsFromClipboard, chordsFromText, chordsToClipboard, NOTES_MIME, notesFromClipboard, notesToClipboard, } from "../project/clipboard.ts"; import { projectKey } from "../project/model.ts"; // anything that can begin a chord symbol, a roman numeral, or a scale degree const CHORD_START = /^[A-Ga-g1-7#iIvV]$/; function chordKey(e: KeyboardEvent, track: ChordTrack): boolean { const current = voiceTrack(track, projectKey(project.get())).find( v => v.event.start === cursor.get(), ); const id = current?.event.id; const vertical = e.key === "ArrowUp" ? 1 : e.key === "ArrowDown" ? -1 : 0; if (e.ctrlKey || e.metaKey) { const key = e.key.toLowerCase(); // copying and pasting come as clipboard events: see installClipboard if (key === "a") selection.set({ trackId: track.id, ids: track.chords.map(c => c.id) }); else return false; return true; } if (e.key === "ArrowLeft" || e.key === "ArrowRight") { const direction = e.key === "ArrowRight" ? 1 : -1; if (e.shiftKey) id && resizeChord(track.id, id, direction * project.get().grid); else moveChordCursor(track, direction); } else if (vertical) { if (!id) return true; if (e.shiftKey) revoice(track.id, id, ([bass, ...upper]) => [bass, ...upper.map(n => n + 12 * vertical)]); else if (e.altKey) revoice(track.id, id, ([bass, ...upper]) => [bass + 12 * vertical, ...upper]); else revoice(track.id, id, notes => invertVoicing(notes, vertical)); } else if (e.key === "Delete" || e.key === "Backspace") { deleteChords(track.id, chordsToEdit(track)); } else if (e.key === "Escape") { selection.set(null); } else if (e.key === "Enter" || e.key === "F2") { const initial = current?.event.symbol ?? (current?.chord ? chordSymbol(current.chord) : ""); chordEdit.set({ trackId: track.id, initial, selectAll: true }); } else if (e.key === "p") { if (id) unpin(track.id, id); } else if (CHORD_START.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) { chordEdit.set({ trackId: track.id, initial: e.key, selectAll: false }); } else return false; return true; } function noteKey(e: KeyboardEvent, track: NoteTrack): boolean { const ids = selectedIds(track.id); const grid = project.get().grid; const mod = e.ctrlKey || e.metaKey; if (mod && e.key === "a") selection.set({ trackId: track.id, ids: track.notes.map(n => n.id) }); else if (e.key === "Escape") selection.set(null); else if (!ids.length) return false; else if (e.key === "Delete" || e.key === "Backspace") deleteNotes(track.id, ids); else if (e.key === "ArrowUp" || e.key === "ArrowDown") { const direction = e.key === "ArrowUp" ? 1 : -1; // by a semitone, a scale degree (ctrl), or an octave (shift) if (mod) nudgeNotes(track.id, ids, { degrees: direction }); else nudgeNotes(track.id, ids, { pitch: direction * (e.shiftKey ? 12 : 1) }); } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { const step = (e.key === "ArrowRight" ? 1 : -1) * grid; nudgeNotes(track.id, ids, e.shiftKey ? { dur: step } : { beats: step }); } else return false; return true; } export function installKeys(): void { // clicking a button shouldn't steal focus, or space would press it again addEventListener( "mousedown", e => { if ((e.target as Element).closest("button")) e.preventDefault(); }, true, ); addEventListener("keydown", e => { if ((e.target as Element).closest("input, select, textarea, dialog")) return; const mod = e.ctrlKey || e.metaKey; const key = e.key.toLowerCase(); if (mod && key === "z") e.shiftKey ? redo() : undo(); else if (mod && key === "y") redo(); else if (e.key === " ") togglePlay(); else if (e.key === "Home") cursor.set(0); else { const track = project.get().tracks.find(t => t.id === activeTrackId.get()); if (!track) return; if (!(track.kind === "chords" ? chordKey(e, track) : noteKey(e, track))) return; } e.preventDefault(); }); } /** * copy, cut and paste chords through the system clipboard, as the browser's own clipboard events * (so they come from the edit menu too). they carry the chords exactly, for this app, and as text * for anything else; pasted text is read as chord symbols */ export function installClipboard(): void { const trackFor = (e: ClipboardEvent) => { // text boxes keep their own copy and paste if ((e.target as Element).closest?.("input, select, textarea, dialog")) return undefined; return project.get().tracks.find(t => t.id === activeTrackId.get()); }; const copy = (e: ClipboardEvent, cut: boolean) => { const track = trackFor(e); if (!track || !e.clipboardData) return; const p = project.get(); const ids = track.kind === "chords" ? chordsToEdit(track) : selectedIds(track.id); const copied = track.kind === "chords" ? chordsToClipboard(track, ids, p.key) : notesToClipboard(track, ids, projectKey(p)); if (!copied) return; e.preventDefault(); e.clipboardData.setData(track.kind === "chords" ? CHORDS_MIME : NOTES_MIME, copied.data); e.clipboardData.setData("text/plain", copied.text); if (cut) (track.kind === "chords" ? deleteChords : deleteNotes)(track.id, ids); }; addEventListener("copy", e => copy(e, false)); addEventListener("cut", e => copy(e, true)); addEventListener("paste", e => { const track = trackFor(e); if (!track || !e.clipboardData) return; const p = project.get(); const key = projectKey(p); const data = (type: string) => e.clipboardData!.getData(type); if (track.kind === "notes") { const clip = notesFromClipboard(data(NOTES_MIME)); if (!clip) return; e.preventDefault(); return pasteNoteClip(track.id, clip); } const clip = chordsFromClipboard(data(CHORDS_MIME), key) ?? chordsFromText(data("text/plain"), key, p.chordLength); if (!clip) return; e.preventDefault(); pasteClip(track.id, clip); }); }