char-slop/chordpad.vibed.software

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

Charlotte Sominitial commit3c7bc6e

main
2.8 KiB89 linesraw
1import type { JSX } from "@char/aftercare/jsx-runtime";
2import { findTrack, projectKey } from "../project/model.ts";
3import { project } from "../project/store.ts";
4import { prettyRoman, romanOf } from "../theory/analysis.ts";
5import { chordName, parseChord } from "../theory/chord.ts";
6import { moveChordCursor, writeChord } from "./actions.ts";
7import { px, watch } from "./dom.tsx";
8import { type ChordEdit, chordEdit, cursor, HEADER_W, pxPerBeat } from "./state.ts";
9
10/** inline chord entry at the cursor: space or tab writes and moves on, enter writes and stops */
11export function ChordEditor(): JSX.Element {
12  let edit: ChordEdit | null = null;
13
14  const parsed = () => parseChord(input.value, projectKey(project.get()));
15  const refresh = () => {
16    const chord = parsed();
17    input.classList.toggle("invalid", !!input.value.trim() && !chord);
18    hint.textContent = chord
19      ? `${chordName(chord)} · ${prettyRoman(romanOf(chord, projectKey(project.get())))}`
20      : "";
21  };
22
23  const submit = () => {
24    if (!edit || !input.value.trim()) return true;
25    if (!parsed()) {
26      box.animate([{ translate: "-3px 0" }, { translate: "3px 0" }, { translate: "0 0" }], 150);
27      return false;
28    }
29    writeChord(edit.trackId, input.value.trim());
30    return true;
31  };
32
33  const close = () => {
34    chordEdit.set(null);
35    input.blur();
36  };
37
38  const onKey = (e: KeyboardEvent) => {
39    if (e.key === "Escape") close();
40    else if (e.key === "Enter") submit() && close();
41    else if (e.key === " " || e.key === "Tab") {
42      const track = edit && findTrack(project.get(), edit.trackId, "chords");
43      if (!input.value.trim() && track) moveChordCursor(track, e.shiftKey ? -1 : 1);
44      else if (submit()) input.value = "";
45      refresh();
46    } else return;
47    e.preventDefault();
48  };
49
50  const input = (
51    <input
52      class="chord-input"
53      spellcheck={false}
54      autocomplete="off"
55      _oninput={refresh}
56      _onkeydown={onKey}
57      _onblur={() => chordEdit.set(null)}
58    />
59  ) as HTMLInputElement;
60  const hint = <span class="chord-hint" />;
61  const box = (
62    <div class="chord-editor" hidden>
63      {input}
64      {hint}
65    </div>
66  ) as HTMLDivElement;
67
68  const position = () => {
69    if (!edit) return;
70    const lane = document.querySelector<HTMLElement>(`.lane[data-lane="${edit.trackId}"]`);
71    box.style.left = px(HEADER_W + cursor.get() * pxPerBeat.get());
72    box.style.top = px(lane?.offsetTop ?? 0);
73    box.scrollIntoView({ block: "nearest", inline: "nearest" });
74  };
75  watch(box, [cursor, pxPerBeat, project], position);
76
77  chordEdit.subscribe(next => {
78    edit = next;
79    box.hidden = !next;
80    if (!next) return;
81    input.value = next.initial;
82    refresh();
83    position();
84    input.focus();
85    if (next.selectAll) input.select();
86  });
87
88  return box;
89}