Files
Recorder-Tabber/src/components/note-editor.tsx
T
Cursor Agentandchen 59d65333e3 Add a soprano recorder tab converter as a Linux AppImage.
The app turns a lead line into Baroque fingering tabs with rhythm kept, ships Twinkle and Ode to Joy, and reads photos locally with oemer when the bundled Python is present.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
2026-10-09 03:35:16 +00:00

286 lines
9.2 KiB
TypeScript

"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DURATION_PRESETS, quartersMatch } from "@/lib/duration";
import { CHART_NOTES } from "@/lib/fingering";
import {
barStatus,
newId,
parseSignature,
signatureKey,
TIME_SIGNATURES,
type Score,
type ScoreEvent,
} from "@/lib/score";
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
const fieldClass =
"h-10 min-w-0 rounded-lg border border-input bg-card px-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
export function NoteEditor({
score,
onChange,
}: {
score: Score;
onChange: (score: Score) => void;
}) {
const signatures = TIME_SIGNATURES.some(
(item) =>
item.beats === score.timeSignature.beats && item.beatType === score.timeSignature.beatType,
)
? TIME_SIGNATURES
: [score.timeSignature, ...TIME_SIGNATURES];
function updateEvent(measureId: string, eventId: string, patch: Partial<ScoreEvent>) {
onChange({
...score,
measures: score.measures.map((measure) =>
measure.id !== measureId
? measure
: {
...measure,
events: measure.events.map((event) =>
event.id === eventId ? { ...event, ...patch } : event,
),
},
),
});
}
function move(measureIndex: number, eventIndex: number, direction: -1 | 1) {
const measures = score.measures.map((measure) => ({ ...measure, events: [...measure.events] }));
const measure = measures[measureIndex];
const target = eventIndex + direction;
if (target >= 0 && target < measure.events.length) {
const [item] = measure.events.splice(eventIndex, 1);
measure.events.splice(target, 0, item);
} else if (direction === -1 && measureIndex > 0) {
const [item] = measure.events.splice(eventIndex, 1);
measures[measureIndex - 1].events.push(item);
} else if (direction === 1 && measureIndex < measures.length - 1) {
const [item] = measure.events.splice(eventIndex, 1);
measures[measureIndex + 1].events.unshift(item);
} else {
return;
}
onChange({ ...score, measures });
}
function add(measureId: string, kind: "note" | "rest") {
const measure = score.measures.find((item) => item.id === measureId);
const previous = [...(measure?.events ?? [])].reverse().find((event) => event.kind === "note");
const event: ScoreEvent = {
id: newId("e"),
kind,
pitch: kind === "note" ? (previous?.pitch ?? "C5") : null,
quarters: 1,
label: "quarter",
tied: false,
};
onChange({
...score,
measures: score.measures.map((item) =>
item.id === measureId ? { ...item, events: [...item.events, event] } : item,
),
});
}
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="score-title">Title</Label>
<Input
id="score-title"
value={score.title}
onChange={(event) => onChange({ ...score, title: event.target.value })}
className="h-10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="time-signature">Time signature</Label>
<Select
value={signatureKey(score.timeSignature)}
onValueChange={(value) => {
if (!value) return;
onChange({ ...score, timeSignature: parseSignature(value) });
}}
>
<SelectTrigger id="time-signature" className="w-full data-[size=default]:h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{signatures.map((signature) => (
<SelectItem key={signatureKey(signature)} value={signatureKey(signature)}>
{signatureKey(signature)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{score.measures.map((measure, measureIndex) => {
const status = barStatus(measure, score.timeSignature);
return (
<fieldset key={measure.id} className="space-y-3 rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
<legend className="px-1 font-medium">Bar {measureIndex + 1}</legend>
<p className={status.ok ? "text-xs text-muted-foreground" : "text-xs text-[#8a4b12]"}>
{status.text}
</p>
{measure.events.map((event, eventIndex) => (
<EventRow
key={event.id}
event={event}
onPitch={(value) => {
if (value === "rest") {
updateEvent(measure.id, event.id, { kind: "rest", pitch: null });
} else {
updateEvent(measure.id, event.id, { kind: "note", pitch: value });
}
}}
onDuration={(quarters) => {
const preset = DURATION_PRESETS.find((item) => quartersMatch(item.quarters, quarters));
updateEvent(measure.id, event.id, {
quarters,
label: preset?.label ?? event.label,
});
}}
onDelete={() =>
onChange({
...score,
measures: score.measures.map((item) =>
item.id === measure.id
? { ...item, events: item.events.filter((row) => row.id !== event.id) }
: item,
),
})
}
onUp={() => move(measureIndex, eventIndex, -1)}
onDown={() => move(measureIndex, eventIndex, 1)}
/>
))}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "note")}>
<Plus />
Note
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "rest")}>
<Plus />
Rest
</Button>
{score.measures.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
onChange({
...score,
measures: score.measures.filter((item) => item.id !== measure.id),
})
}
>
<Trash2 />
Delete bar
</Button>
) : null}
</div>
</fieldset>
);
})}
<Button
type="button"
variant="secondary"
onClick={() =>
onChange({
...score,
measures: [...score.measures, { id: newId("m"), events: [] }],
})
}
>
<Plus />
Add bar
</Button>
</div>
);
}
function EventRow({
event,
onPitch,
onDuration,
onDelete,
onUp,
onDown,
}: {
event: ScoreEvent;
onPitch: (value: string) => void;
onDuration: (quarters: number) => void;
onDelete: () => void;
onUp: () => void;
onDown: () => void;
}) {
const pitches = [...CHART_NOTES] as string[];
if (event.pitch && !pitches.includes(event.pitch)) pitches.unshift(event.pitch);
const durations = [...DURATION_PRESETS];
if (!durations.some((item) => quartersMatch(item.quarters, event.quarters))) {
durations.push({ quarters: event.quarters, label: event.label });
}
return (
<div className="flex flex-wrap items-center gap-2">
<label className="sr-only" htmlFor={`${event.id}-pitch`}>
Pitch or rest
</label>
<select
id={`${event.id}-pitch`}
className={`${fieldClass} w-[7.5rem]`}
value={event.kind === "rest" ? "rest" : (event.pitch ?? "C5")}
onChange={(change) => onPitch(change.target.value)}
>
<option value="rest">rest</option>
{pitches.map((pitch) => (
<option key={pitch} value={pitch}>
{pitch}
</option>
))}
</select>
<label className="sr-only" htmlFor={`${event.id}-duration`}>
Duration
</label>
<select
id={`${event.id}-duration`}
className={`${fieldClass} w-[9.5rem]`}
value={String(event.quarters)}
onChange={(change) => onDuration(Number(change.target.value))}
>
{durations.map((duration) => (
<option key={`${duration.label}-${duration.quarters}`} value={String(duration.quarters)}>
{duration.label}
</option>
))}
</select>
<div className="ml-auto flex gap-1">
<Button type="button" variant="ghost" size="icon" onClick={onUp} aria-label="Move earlier">
<ChevronUp />
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onDown} aria-label="Move later">
<ChevronDown />
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onDelete} aria-label="Delete note">
<Trash2 />
</Button>
</div>
</div>
);
}