char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Somallow copy and pasting notes2b249d4

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