char-slop/chordpad.vibed.software

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

Charlotte Somimprove interchange detection66d16f5

main
7.7 KiB259 linesraw
1import type { JSX } from "@char/aftercare/jsx-runtime";
2import { playing } from "../audio/transport.ts";
3import { type Project, TONICS, UNTITLED } from "../project/model.ts";
4import {
5  commit,
6  createProject,
7  deleteProject,
8  history,
9  openProject,
10  project,
11  projectIndex,
12  redo,
13  undo,
14} from "../project/store.ts";
15import { MODES } from "../theory/key.ts";
16import { rekey } from "../project/harmony.ts";
17import {
18  exportMidi,
19  exportProject,
20  importingTheoryTab,
21  importMidi,
22  importProject,
23  importTheoryTab,
24  togglePlay,
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] => [
30  id,
31  label,
32]);
33
34const options = (entries: [value: string, label: string][]) =>
35  entries.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>(
39  el: E,
40  read: (p: Project) => string,
41  write: (d: Project, value: string) => void,
42  { history = true } = {},
43): E {
44  watch(el, [project], () => {
45    if (document.activeElement !== el) el.value = read(project.get());
46  });
47  el.addEventListener("change", () => {
48    commit(d => write(d, el.value), { history });
49    if (el instanceof HTMLSelectElement) el.blur();
50  });
51  return el;
52}
53
54/** the things you do to a project as a whole, in a menu under a button */
55function projectMenu() {
56  const item = (label: string, run: () => void) => (
57    <button
58      type="button"
59      role="menuitem"
60      _onclick={() => {
61        menu.hidePopover();
62        run();
63      }}
64    >
65      {label}
66    </button>
67  );
68  const menu = (
69    <div class="menu" popover="auto" role="menu">
70      {item("new project", createProject)}
71      {item("rename…", () => {
72        const name = prompt("project name", project.get().name);
73        if (name !== null)
74          commit(d => void (d.name = name.trim() || UNTITLED), { history: false });
75      })}
76      {item("delete…", () => {
77        if (confirm(`delete "${project.get().name}"?`)) deleteProject(project.get().id);
78      })}
79      <hr />
80      {item("open project file…", () =>
81        chooseFile(".chordpad,.json", file => void importProject(file)),
82      )}
83      {item("save project file", exportProject)}
84      <hr />
85      {item("import midi…", () =>
86        chooseFile(".mid,.midi,audio/midi", file => void importMidi(file)),
87      )}
88      {item("export midi", exportMidi)}
89      {item("import theorytab…", () => {
90        const url = prompt("TheoryTab link (hooktheory.com/theorytab/view/…)");
91        if (url) void importTheoryTab(url);
92      })}
93    </div>
94  ) as HTMLDivElement;
95
96  const toggle = (
97    <button
98      type="button"
99      class="tb-menu"
100      aria-haspopup="menu"
101      disabled={importingTheoryTab}
102      _also={el => el.setAttribute("popovertarget", "project-menu")}
103    >
104      {importingTheoryTab.derive(busy => (busy ? "importing…" : "project ▾"))}
105    </button>
106  ) as HTMLButtonElement;
107  menu.id = "project-menu";
108  // opens under its button
109  menu.addEventListener("beforetoggle", e => {
110    if ((e as ToggleEvent).newState !== "open") return;
111    const at = toggle.getBoundingClientRect();
112    Object.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
116  addEventListener(
117    "pointerdown",
118    e => {
119      const target = e.target as Node;
120      if (!menu.matches(":popover-open") || menu.contains(target) || toggle.contains(target))
121        return;
122      e.stopPropagation();
123      e.preventDefault();
124      menu.hidePopover();
125    },
126    true,
127  );
128  return [toggle, menu];
129}
130
131export function Toolbar(): JSX.Element {
132  const projects = (
133    <select
134      class="tb-projects"
135      title="projects"
136      _onchange={() => openProject(projects.value)}
137    />
138  ) as HTMLSelectElement;
139  watch(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
141    const current = project.get();
142    const saved = projectIndex.get();
143    const listed = saved.some(e => e.id === current.id) ? saved : [current, ...saved];
144    projects.replaceChildren(...options(listed.map(e => [e.id, e.name])));
145    projects.value = current.id;
146  });
147
148  const loop = (
149    <input
150      type="checkbox"
151      _onchange={() => commit(d => void (d.loop = loop.checked), { history: false })}
152    />
153  ) as HTMLInputElement;
154  watch(loop, [project], () => (loop.checked = project.get().loop));
155
156  return (
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,
168            p => p.key.tonic,
169            (d, v) => rekey(d, { ...d.key, tonic: v }),
170          )}
171          {bind(
172            (<select>{options(MODE_OPTIONS)}</select>) as HTMLSelectElement,
173            p => 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,
188              p => String(p.bpm),
189              (d, v) => (d.bpm = Math.min(300, Math.max(30, Number(v) || d.bpm))),
190            )}
191            bpm
192          </label>
193        </div>
194      </div>
195
196      <div class="tb-section tb-right">
197        <div class="tb-group">
198          <label>
199            chords
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,
213              p => String(p.chordLength),
214              (d, v) => (d.chordLength = Number(v)),
215              { history: false },
216            )}
217          </label>
218          <label>
219            grid
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,
231              p => 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
240            type="button"
241            title="undo (ctrl+z)"
242            disabled={history.derive(h => !h.undo)}
243            _onclick={undo}
244          >
245            undo
246          </button>
247          <button
248            type="button"
249            title="redo (ctrl+shift+z)"
250            disabled={history.derive(h => !h.redo)}
251            _onclick={redo}
252          >
253            redo
254          </button>
255        </div>
256      </div>
257    </header>
258  );
259}