Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e397b62757 | ||
|
|
837fcb2751 | ||
|
|
7c988e9347 |
No files matched your search
@@ -1,23 +1,23 @@
|
|||||||
# Recorder fingering editor
|
# Recorder fingering editor
|
||||||
|
|
||||||
A soprano recorder tool for arranging fingering charts. Drag a chart, a rest, or a space onto a line, reorder it, set how long it lasts, or take it off. Resize the line box, then save the finished row as a PNG. Baroque fingering is the chart. The foot, with the double holes, is at the top of each chart, and the mouthpiece is at the bottom.
|
A soprano recorder tool for arranging fingering charts. Drag a chart, a rest, or a space onto a line, reorder it, set how long it lasts, or take it off. Resize the line box, then save the finished row as a PNG. Baroque fingering is the chart. The foot, with the double holes, is at the top of each chart. The tube ends at the thumb hole.
|
||||||
|
|
||||||
The usual way to run it is one Linux AppImage. That file contains the window and the fingering charts. It does not read sheet music, and it does not include oemer, ONNX, or model files.
|
The usual way to run it is one Linux AppImage. That file contains the window and the fingering charts. It does not read sheet music, and it does not include oemer, ONNX, or model files.
|
||||||
|
|
||||||
## Run the AppImage
|
## Run the AppImage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x Recorder-Tab-Converter-0.2.2.AppImage
|
chmod +x Recorder-Tab-Converter-0.2.5.AppImage
|
||||||
./Recorder-Tab-Converter-0.2.2.AppImage
|
./Recorder-Tab-Converter-0.2.5.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
If the file will not start because FUSE is missing:
|
If the file will not start because FUSE is missing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.2.AppImage
|
APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.2.5.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
Open **Empty line**, or start from **Twinkle, opening** or **Ode, held note**. Drag any chart from C5 to D7, or drag **Rest** or **Space**, onto the line. Drag a placed item to move it. Choose a duration under it. Remove takes it off. **Shorter**, **Taller**, **Narrower**, **Wider**, and the corner grip change the line box. **Save PNG** downloads the finished row.
|
Open **Empty line**, or start from **Twinkle, opening** or **Ode, held note**. Drag any chart from C5 to D7, or drag **Rest** or **Space**, onto a line. Drag a placed item to move it, including onto another line. **New line** adds a row underneath. Plus and minus change the length. Remove takes an item off. **Shorter**, **Taller**, **Narrower**, **Wider**, and the corner grip change the box. **Save PNG** downloads every line.
|
||||||
|
|
||||||
## Build the AppImage
|
## Build the AppImage
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ From a checkout, one command:
|
|||||||
That installs Node packages, builds the app, and writes:
|
That installs Node packages, builds the app, and writes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/Recorder-Tab-Converter-0.2.2.AppImage
|
dist/Recorder-Tab-Converter-0.2.5.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
The AppImage is not committed. Keep it under 600MB.
|
The AppImage is not committed. Keep it under 600MB.
|
||||||
@@ -49,4 +49,4 @@ The dev server listens on [http://127.0.0.1:3847](http://127.0.0.1:3847).
|
|||||||
|
|
||||||
Baroque (English) fingerings are the first choice on the [Dolmetsch Online chart for recorders in C](https://dolmetsch.com/textfingeringchart.htm) (Brian Blood). The soprano is labeled in concert pitch: low C is C5, and the chart stops at D7. Double holes 6 and 7, forked F, and the second-octave thumb pinch are included. Alternate fingerings on that chart are not shown.
|
Baroque (English) fingerings are the first choice on the [Dolmetsch Online chart for recorders in C](https://dolmetsch.com/textfingeringchart.htm) (Brian Blood). The soprano is labeled in concert pitch: low C is C5, and the chart stops at D7. Double holes 6 and 7, forked F, and the second-octave thumb pinch are included. Alternate fingerings on that chart are not shown.
|
||||||
|
|
||||||
Each chart is drawn foot-up: holes 7 and 6 at the top, then 5 through 1, then the thumb, with the mouthpiece at the bottom. A filled hole is closed. A treble staff under the holes is written one octave below the sounding pitch. Low C, labeled C5, sits on the ledger line below the staff. Sounding C6 is written C5, and sounding C7 is written C6. Length is a notehead, stem, flags, dots, and rests. Labels keep the sounding names.
|
Each chart is drawn foot-up: holes 7 and 6 at the top, then 5 through 1, then the thumb. The tube ends there. A filled hole is closed. A treble staff under the holes is written one octave below the sounding pitch. The staff opens with a filled treble clef, spiral on the G line. Low C, labeled C5, sits on the ledger line below the staff. Sounding C6 is written C5, and sounding C7 is written C6. Length is a notehead, stem, flags, dots, and rests. Labels keep the sounding names.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "recorder-tab-converter",
|
"name": "recorder-tab-converter",
|
||||||
"version": "0.2.2",
|
"version": "0.2.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Arrange soprano recorder fingering charts by dragging them onto a line.",
|
"description": "Arrange soprano recorder fingering charts by dragging them onto a line.",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
+179
-101
@@ -6,22 +6,25 @@ import { DURATION_PRESETS } from "@/lib/duration";
|
|||||||
import { downloadBlob, renderLinePng } from "@/lib/export-png";
|
import { downloadBlob, renderLinePng } from "@/lib/export-png";
|
||||||
import { CHART_NOTES, fingeringFor } from "@/lib/fingering";
|
import { CHART_NOTES, fingeringFor } from "@/lib/fingering";
|
||||||
import {
|
import {
|
||||||
|
appendLine,
|
||||||
clampLineBox,
|
clampLineBox,
|
||||||
cloneLine,
|
cloneLine,
|
||||||
createNote,
|
createNote,
|
||||||
createRest,
|
createRest,
|
||||||
createSpace,
|
createSpace,
|
||||||
dropIndexFromPoint,
|
dropIndexFromPoint,
|
||||||
insertNote,
|
|
||||||
LINE_EXAMPLES,
|
LINE_EXAMPLES,
|
||||||
moveNote,
|
|
||||||
neighborDuration,
|
neighborDuration,
|
||||||
|
placeOnLine,
|
||||||
|
removeLine,
|
||||||
removeNote,
|
removeNote,
|
||||||
setDuration,
|
setDuration,
|
||||||
|
transferNote,
|
||||||
type LineKind,
|
type LineKind,
|
||||||
type LineNote,
|
type LineNote,
|
||||||
|
type ScoreLine,
|
||||||
} from "@/lib/line";
|
} from "@/lib/line";
|
||||||
import { chartPixelHeight } from "@/lib/notation";
|
import { chartOuterWidth, chartPixelHeight } from "@/lib/notation";
|
||||||
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
|
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
|
||||||
import { ChevronLeft, ChevronRight, Minus, Plus, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Minus, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
||||||
@@ -33,6 +36,7 @@ type Drag = {
|
|||||||
kind: LineKind;
|
kind: LineKind;
|
||||||
pitch: string;
|
pitch: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
lineId: string;
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
@@ -41,17 +45,18 @@ type Drag = {
|
|||||||
armed: boolean;
|
armed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DropTarget = { lineId: string; index: number };
|
||||||
|
|
||||||
export function Editor() {
|
export function Editor() {
|
||||||
const [notes, setNotes] = useState<LineNote[]>([]);
|
const [lines, setLines] = useState<ScoreLine[]>([{ id: "line-1", notes: [] }]);
|
||||||
const [title, setTitle] = useState("Empty line");
|
const [title, setTitle] = useState("Empty line");
|
||||||
const [drag, setDrag] = useState<Drag | null>(null);
|
const [drag, setDrag] = useState<Drag | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||||
const [boxWidth, setBoxWidth] = useState<number | null>(null);
|
const [boxWidth, setBoxWidth] = useState<number | null>(null);
|
||||||
const [boxHeight, setBoxHeight] = useState(520);
|
const [boxHeight, setBoxHeight] = useState(520);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const dragRef = useRef<Drag | null>(null);
|
const dragRef = useRef<Drag | null>(null);
|
||||||
const lineRef = useRef<HTMLDivElement>(null);
|
|
||||||
const frameRef = useRef<HTMLDivElement>(null);
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const paletteRef = useRef<HTMLDivElement>(null);
|
const paletteRef = useRef<HTMLDivElement>(null);
|
||||||
const exportRef = useRef<HTMLDivElement>(null);
|
const exportRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -63,15 +68,23 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function pointIndex(clientX: number, clientY: number) {
|
function pointTarget(clientX: number, clientY: number): DropTarget | null {
|
||||||
const line = lineRef.current;
|
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
|
||||||
if (!line) return -1;
|
let best: (DropTarget & { distance: number }) | null = null;
|
||||||
const rect = line.getBoundingClientRect();
|
for (const row of rows) {
|
||||||
const centers = [...line.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
const lineId = row.dataset.lineId;
|
||||||
const box = element.getBoundingClientRect();
|
if (!lineId) continue;
|
||||||
return box.left + box.width / 2;
|
const rect = row.getBoundingClientRect();
|
||||||
});
|
const centers = [...row.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
||||||
return dropIndexFromPoint(clientX, clientY, rect, centers);
|
const box = element.getBoundingClientRect();
|
||||||
|
return box.left + box.width / 2;
|
||||||
|
});
|
||||||
|
const index = dropIndexFromPoint(clientX, clientY, rect, centers);
|
||||||
|
if (index < 0) continue;
|
||||||
|
const distance = Math.abs(clientY - (rect.top + rect.bottom) / 2);
|
||||||
|
if (!best || distance < best.distance) best = { lineId, index, distance };
|
||||||
|
}
|
||||||
|
return best ? { lineId: best.lineId, index: best.index } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMove(event: PointerEvent) {
|
function onMove(event: PointerEvent) {
|
||||||
@@ -87,28 +100,30 @@ export function Editor() {
|
|||||||
Math.abs(dx) > Math.abs(dy);
|
Math.abs(dx) > Math.abs(dy);
|
||||||
if (touchScroll) {
|
if (touchScroll) {
|
||||||
updateDrag(null);
|
updateDrag(null);
|
||||||
setDropIndex(null);
|
setDropTarget(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Math.hypot(dx, dy) < 8) return;
|
if (Math.hypot(dx, dy) < 8) return;
|
||||||
}
|
}
|
||||||
window.getSelection()?.removeAllRanges();
|
window.getSelection()?.removeAllRanges();
|
||||||
if (lineRef.current) {
|
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
|
||||||
const rect = lineRef.current.getBoundingClientRect();
|
for (const candidate of rows) {
|
||||||
if (event.clientX > rect.right - 28) lineRef.current.scrollLeft += 14;
|
const rect = candidate.getBoundingClientRect();
|
||||||
if (event.clientX < rect.left + 28) lineRef.current.scrollLeft -= 14;
|
const verticallyNear = event.clientY >= rect.top - 36 && event.clientY <= rect.bottom + 36;
|
||||||
|
if (!verticallyNear) continue;
|
||||||
|
if (event.clientX > rect.right - 28) candidate.scrollLeft += 14;
|
||||||
|
if (event.clientX < rect.left + 28) candidate.scrollLeft -= 14;
|
||||||
}
|
}
|
||||||
const next = { ...current, x: event.clientX, y: event.clientY, armed: true };
|
const next = { ...current, x: event.clientX, y: event.clientY, armed: true };
|
||||||
updateDrag(next);
|
updateDrag(next);
|
||||||
const index = pointIndex(event.clientX, event.clientY);
|
setDropTarget(pointTarget(event.clientX, event.clientY));
|
||||||
setDropIndex(index >= 0 ? index : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUp(event: PointerEvent) {
|
function onUp(event: PointerEvent) {
|
||||||
const current = dragRef.current;
|
const current = dragRef.current;
|
||||||
if (!current || event.pointerId !== current.pointerId) return;
|
if (!current || event.pointerId !== current.pointerId) return;
|
||||||
const index = current.armed ? pointIndex(event.clientX, event.clientY) : -1;
|
const target = current.armed ? pointTarget(event.clientX, event.clientY) : null;
|
||||||
if (current.armed && index >= 0) {
|
if (current.armed && target) {
|
||||||
if (current.source === "palette") {
|
if (current.source === "palette") {
|
||||||
const note =
|
const note =
|
||||||
current.kind === "rest"
|
current.kind === "rest"
|
||||||
@@ -116,14 +131,14 @@ export function Editor() {
|
|||||||
: current.kind === "space"
|
: current.kind === "space"
|
||||||
? createSpace()
|
? createSpace()
|
||||||
: createNote(current.pitch);
|
: createNote(current.pitch);
|
||||||
setNotes((existing) => insertNote(existing, index, note));
|
setLines((existing) => placeOnLine(existing, target.lineId, target.index, note));
|
||||||
setTitle("Your line");
|
setTitle("Your line");
|
||||||
} else {
|
} else {
|
||||||
setNotes((existing) => moveNote(existing, current.id, index));
|
setLines((existing) => transferNote(existing, current.lineId, current.id, target.lineId, target.index));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateDrag(null);
|
updateDrag(null);
|
||||||
setDropIndex(null);
|
setDropTarget(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectStart(event: Event) {
|
function onSelectStart(event: Event) {
|
||||||
@@ -194,7 +209,8 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePng() {
|
async function savePng() {
|
||||||
if (!exportRef.current || notes.length === 0) {
|
const filled = lines.some((line) => line.notes.length > 0);
|
||||||
|
if (!exportRef.current || !filled) {
|
||||||
setSaveError("Add a note, rest, or space before saving a PNG.");
|
setSaveError("Add a note, rest, or space before saving a PNG.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -218,18 +234,19 @@ export function Editor() {
|
|||||||
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · Baroque</p>
|
<p className="text-sm tracking-[0.16em] text-[#6a4328] uppercase">Soprano in C · Baroque</p>
|
||||||
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder fingering editor</h1>
|
<h1 className="font-heading text-4xl leading-tight md:text-5xl">Recorder fingering editor</h1>
|
||||||
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
|
<p className="max-w-2xl text-base leading-7 text-muted-foreground">
|
||||||
Drag a chart, a rest, or a space onto the line. Drag a placed item sideways to reorder it.
|
Drag a chart, a rest, or a space onto a line. Drag a placed item sideways to reorder it, or
|
||||||
The staff under the holes shows the pitch and the length. Resize the line box, then save the
|
onto another line. New line adds a row underneath. The staff under the holes shows the pitch
|
||||||
finished row as a PNG. The foot, with the double holes, stays at the top.
|
and the length. Resize the box, then save every line as a PNG. The foot, with the double holes,
|
||||||
|
stays at the top.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={notes.length === 0 ? "default" : "outline"}
|
variant={lines.length === 1 && lines[0].notes.length === 0 ? "default" : "outline"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNotes([]);
|
setLines([{ id: "line-1", notes: [] }]);
|
||||||
setTitle("Empty line");
|
setTitle("Empty line");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -241,7 +258,7 @@ export function Editor() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNotes(cloneLine([...example.notes]));
|
setLines([{ id: "line-1", notes: cloneLine([...example.notes]) }]);
|
||||||
setTitle(example.title);
|
setTitle(example.title);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -273,6 +290,7 @@ export function Editor() {
|
|||||||
kind: "rest",
|
kind: "rest",
|
||||||
pitch: "",
|
pitch: "",
|
||||||
id: "",
|
id: "",
|
||||||
|
lineId: "",
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
@@ -292,6 +310,7 @@ export function Editor() {
|
|||||||
kind: "space",
|
kind: "space",
|
||||||
pitch: "",
|
pitch: "",
|
||||||
id: "",
|
id: "",
|
||||||
|
lineId: "",
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
@@ -301,7 +320,7 @@ export function Editor() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SpaceGlyph width={40} height={chartPixelHeight(40)} />
|
<SpaceGlyph width={chartOuterWidth(40)} height={chartPixelHeight(40)} />
|
||||||
</PaletteToken>
|
</PaletteToken>
|
||||||
{CHART_NOTES.map((pitch) => (
|
{CHART_NOTES.map((pitch) => (
|
||||||
<PaletteToken
|
<PaletteToken
|
||||||
@@ -314,6 +333,7 @@ export function Editor() {
|
|||||||
kind: "note",
|
kind: "note",
|
||||||
pitch,
|
pitch,
|
||||||
id: "",
|
id: "",
|
||||||
|
lineId: "",
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
@@ -339,7 +359,15 @@ export function Editor() {
|
|||||||
{boxWidth ? `, ${boxWidth}px wide` : ""}.
|
{boxWidth ? `, ${boxWidth}px wide` : ""}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ScrollButtons target={lineRef} label="line" />
|
<ScrollButtons
|
||||||
|
label="lines"
|
||||||
|
onLeft={() =>
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-line-id]").forEach((row) => row.scrollBy({ left: -180, behavior: "smooth" }))
|
||||||
|
}
|
||||||
|
onRight={() =>
|
||||||
|
document.querySelectorAll<HTMLElement>("[data-line-id]").forEach((row) => row.scrollBy({ left: 180, behavior: "smooth" }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, -48)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, -48)}>
|
||||||
@@ -357,57 +385,99 @@ export function Editor() {
|
|||||||
<Button type="button" variant="outline" size="sm" onClick={() => setBoxWidth(null)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => setBoxWidth(null)}>
|
||||||
Fit width
|
Fit width
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" onClick={savePng} disabled={notes.length === 0 || saving}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setLines((existing) => appendLine(existing));
|
||||||
|
setBoxHeight((height) => clampLineBox(400, height + 260, 960).height);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New line
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={savePng} disabled={lines.every((line) => line.notes.length === 0) || saving}>
|
||||||
{saving ? "Saving…" : "Save PNG"}
|
{saving ? "Saving…" : "Save PNG"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{saveError ? <p className="text-sm text-destructive">{saveError}</p> : null}
|
{saveError ? <p className="text-sm text-destructive">{saveError}</p> : null}
|
||||||
<div ref={frameRef} className="relative max-w-full" style={{ width: boxWidth ?? "100%", height: boxHeight }}>
|
<div ref={frameRef} className="relative max-w-full" style={{ width: boxWidth ?? "100%", height: boxHeight }}>
|
||||||
<div
|
<div
|
||||||
ref={lineRef}
|
|
||||||
data-line
|
data-line
|
||||||
className={`h-full overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
|
className={`flex h-full flex-col gap-4 overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
|
||||||
dropIndex !== null ? "border-primary" : "border-foreground"
|
dropTarget !== null ? "border-primary" : "border-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{notes.length === 0 ? (
|
{lines.map((line, lineIndex) => (
|
||||||
<p className="px-2 py-16 text-center text-sm text-muted-foreground">
|
<div key={line.id} className="space-y-2">
|
||||||
Drop a fingering, a rest, or a space here. The line is empty.
|
<div className="flex items-center justify-between gap-2">
|
||||||
</p>
|
<p className="text-sm font-medium">Line {lineIndex + 1}</p>
|
||||||
) : (
|
{lines.length > 1 ? (
|
||||||
<div className="flex w-max min-w-full items-end gap-2">
|
<Button
|
||||||
{notes.map((note, index) => (
|
type="button"
|
||||||
<div key={note.id} className="flex items-end gap-2">
|
variant="ghost"
|
||||||
{dropIndex === index ? <DropCaret /> : null}
|
size="xs"
|
||||||
<PlacedItem
|
aria-label={`Remove line ${lineIndex + 1}`}
|
||||||
note={note}
|
onClick={() => setLines((existing) => removeLine(existing, line.id))}
|
||||||
dimmed={drag?.source === "line" && drag.armed && drag.id === note.id}
|
>
|
||||||
onPointerDown={(event) =>
|
<X />
|
||||||
beginDrag(event, {
|
Remove line
|
||||||
source: "line",
|
</Button>
|
||||||
kind: note.kind,
|
) : null}
|
||||||
id: note.id,
|
</div>
|
||||||
pitch: note.pitch,
|
<div data-line-id={line.id} className="overflow-x-auto">
|
||||||
pointerId: event.pointerId,
|
{line.notes.length === 0 ? (
|
||||||
startX: event.clientX,
|
<p className="px-2 py-10 text-center text-sm text-muted-foreground">
|
||||||
startY: event.clientY,
|
Drop a fingering, a rest, or a space here. The line is empty.
|
||||||
x: event.clientX,
|
</p>
|
||||||
y: event.clientY,
|
) : (
|
||||||
armed: false,
|
<div className="flex w-max min-w-full items-end gap-2">
|
||||||
})
|
{line.notes.map((note, index) => (
|
||||||
}
|
<div key={note.id} className="flex items-end gap-2">
|
||||||
onDuration={(quarters) => {
|
{dropTarget?.lineId === line.id && dropTarget.index === index ? <DropCaret /> : null}
|
||||||
const preset = DURATION_PRESETS.find((item) => item.quarters === quarters);
|
<PlacedItem
|
||||||
if (!preset) return;
|
note={note}
|
||||||
setNotes((existing) => setDuration(existing, note.id, preset));
|
dimmed={drag?.source === "line" && drag.armed && drag.id === note.id}
|
||||||
}}
|
onPointerDown={(event) =>
|
||||||
onRemove={() => setNotes((existing) => removeNote(existing, note.id))}
|
beginDrag(event, {
|
||||||
/>
|
source: "line",
|
||||||
</div>
|
kind: note.kind,
|
||||||
))}
|
id: note.id,
|
||||||
{dropIndex === notes.length ? <DropCaret /> : null}
|
lineId: line.id,
|
||||||
|
pitch: note.pitch,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
armed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onDuration={(quarters) => {
|
||||||
|
const preset = DURATION_PRESETS.find((item) => item.quarters === quarters);
|
||||||
|
if (!preset) return;
|
||||||
|
setLines((existing) =>
|
||||||
|
existing.map((row) =>
|
||||||
|
row.id === line.id ? { ...row, notes: setDuration(row.notes, note.id, preset) } : row,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onRemove={() =>
|
||||||
|
setLines((existing) =>
|
||||||
|
existing.map((row) =>
|
||||||
|
row.id === line.id ? { ...row, notes: removeNote(row.notes, note.id) } : row,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dropTarget?.lineId === line.id && dropTarget.index === line.notes.length ? <DropCaret /> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="slider"
|
role="slider"
|
||||||
@@ -432,7 +502,7 @@ export function Editor() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<li>Foot at the top, mouthpiece at the bottom</li>
|
<li>Foot at the top. The tube ends at the thumb hole</li>
|
||||||
<li>Filled hole: closed</li>
|
<li>Filled hole: closed</li>
|
||||||
<li>Empty hole: open</li>
|
<li>Empty hole: open</li>
|
||||||
<li>One of a pair filled: half-hole</li>
|
<li>One of a pair filled: half-hole</li>
|
||||||
@@ -452,29 +522,33 @@ export function Editor() {
|
|||||||
<FingeringDiagram fingering={fingeringFor(ghost.pitch, "baroque")} pitch={ghost.pitch} size="sm" />
|
<FingeringDiagram fingering={fingeringFor(ghost.pitch, "baroque")} pitch={ghost.pitch} size="sm" />
|
||||||
) : null}
|
) : null}
|
||||||
{ghost.kind === "rest" ? <RestMark quarters={1} width={40} /> : null}
|
{ghost.kind === "rest" ? <RestMark quarters={1} width={40} /> : null}
|
||||||
{ghost.kind === "space" ? <SpaceGlyph width={40} height={chartPixelHeight(40)} /> : null}
|
{ghost.kind === "space" ? <SpaceGlyph width={chartOuterWidth(40)} height={chartPixelHeight(40)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div ref={exportRef} className="pointer-events-none absolute h-px w-px overflow-hidden" aria-hidden>
|
<div ref={exportRef} className="pointer-events-none absolute h-px w-px overflow-hidden" aria-hidden>
|
||||||
{notes.map((note) => (
|
{lines.map((line) => (
|
||||||
<div
|
<div key={line.id} data-export-line={line.id}>
|
||||||
key={note.id}
|
{line.notes.map((note) => (
|
||||||
data-export-slot
|
<div
|
||||||
data-kind={note.kind}
|
key={note.id}
|
||||||
data-quarters={note.quarters}
|
data-export-slot
|
||||||
data-duration={note.label}
|
data-kind={note.kind}
|
||||||
data-pitch={note.pitch}
|
data-quarters={note.quarters}
|
||||||
>
|
data-duration={note.label}
|
||||||
{note.kind === "note" ? (
|
data-pitch={note.pitch}
|
||||||
<FingeringDiagram
|
>
|
||||||
fingering={fingeringFor(note.pitch, "baroque")}
|
{note.kind === "note" ? (
|
||||||
pitch={note.pitch}
|
<FingeringDiagram
|
||||||
quarters={note.quarters}
|
fingering={fingeringFor(note.pitch, "baroque")}
|
||||||
width={PNG_CHART_WIDTH}
|
pitch={note.pitch}
|
||||||
/>
|
quarters={note.quarters}
|
||||||
) : null}
|
width={PNG_CHART_WIDTH}
|
||||||
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={PNG_CHART_WIDTH} /> : null}
|
/>
|
||||||
|
) : null}
|
||||||
|
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={PNG_CHART_WIDTH} /> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -536,7 +610,7 @@ function PlacedItem({
|
|||||||
) : null}
|
) : null}
|
||||||
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={CHART_WIDTH} /> : null}
|
{note.kind === "rest" ? <RestMark quarters={note.quarters} width={CHART_WIDTH} /> : null}
|
||||||
{note.kind === "space" ? (
|
{note.kind === "space" ? (
|
||||||
<SpaceGlyph width={Math.max(36, Math.round(CHART_WIDTH * note.quarters))} height={chartHeight} />
|
<SpaceGlyph width={Math.max(36, Math.round(chartOuterWidth(CHART_WIDTH) * note.quarters))} height={chartHeight} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] leading-none font-medium">{caption}</p>
|
<p className="text-[11px] leading-none font-medium">{caption}</p>
|
||||||
@@ -597,9 +671,13 @@ function DropCaret() {
|
|||||||
function ScrollButtons({
|
function ScrollButtons({
|
||||||
target,
|
target,
|
||||||
label,
|
label,
|
||||||
|
onLeft,
|
||||||
|
onRight,
|
||||||
}: {
|
}: {
|
||||||
target: RefObject<HTMLDivElement | null>;
|
target?: RefObject<HTMLDivElement | null>;
|
||||||
label: string;
|
label: string;
|
||||||
|
onLeft?: () => void;
|
||||||
|
onRight?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
@@ -608,7 +686,7 @@ function ScrollButtons({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label={`Scroll ${label} left`}
|
aria-label={`Scroll ${label} left`}
|
||||||
onClick={() => target.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
onClick={() => onLeft?.() ?? target?.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -617,7 +695,7 @@ function ScrollButtons({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label={`Scroll ${label} right`}
|
aria-label={`Scroll ${label} right`}
|
||||||
onClick={() => target.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
onClick={() => onRight?.() ?? target?.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
||||||
>
|
>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
CHART_VB_H,
|
CHART_VB_H,
|
||||||
CHART_VB_W,
|
CHART_VB_W,
|
||||||
|
chartOuterWidth,
|
||||||
chartPixelHeight,
|
chartPixelHeight,
|
||||||
|
CLEF_GUTTER,
|
||||||
dotIndex,
|
dotIndex,
|
||||||
ledgerIndexes,
|
ledgerIndexes,
|
||||||
noteValue,
|
noteValue,
|
||||||
@@ -24,8 +26,8 @@ const TUBE = "#9deaf3";
|
|||||||
const INK = "#1a1a1a";
|
const INK = "#1a1a1a";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foot at the top, mouthpiece at the bottom.
|
* Foot at the top. The tube ends at the thumb hole.
|
||||||
* The drawing is the reverse of a held recorder. Hole states are not changed.
|
* Hole states are not changed.
|
||||||
*/
|
*/
|
||||||
function Hole({
|
function Hole({
|
||||||
cx,
|
cx,
|
||||||
@@ -66,8 +68,9 @@ export function FingeringDiagram({
|
|||||||
width?: number;
|
width?: number;
|
||||||
quarters?: number;
|
quarters?: number;
|
||||||
}) {
|
}) {
|
||||||
const width = widthOverride ?? (size === "lead" ? 44 : 40);
|
const bodyWidth = widthOverride ?? (size === "lead" ? 44 : 40);
|
||||||
const height = chartPixelHeight(width);
|
const width = chartOuterWidth(bodyWidth);
|
||||||
|
const height = chartPixelHeight(bodyWidth);
|
||||||
if (!fingering) {
|
if (!fingering) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -83,7 +86,7 @@ export function FingeringDiagram({
|
|||||||
|
|
||||||
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
|
const [h1, h2, h3, h4, h5, h6a, h6b, h7a, h7b] = fingering.holes;
|
||||||
const value = noteValue(quarters);
|
const value = noteValue(quarters);
|
||||||
const label = `${pitch}, written one octave lower, ${durationLabel(quarters)} on the treble staff. Foot at the top, mouthpiece at the bottom. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
|
const label = `${pitch}, written one octave lower, ${durationLabel(quarters)} on the treble staff. Foot at the top. The tube ends at the thumb hole. ${fingeringProse(fingering)}. ${fingeringMarks(fingering).join(", ")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -94,8 +97,9 @@ export function FingeringDiagram({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
|
<g transform={`translate(${CLEF_GUTTER} 0)`}>
|
||||||
<path
|
<path
|
||||||
d="M9 3 H33 V148 H44 V184 H9 Z"
|
d="M9 3 H33 V150 H9 Z"
|
||||||
fill={TUBE}
|
fill={TUBE}
|
||||||
stroke={INK}
|
stroke={INK}
|
||||||
strokeWidth="1.4"
|
strokeWidth="1.4"
|
||||||
@@ -114,18 +118,18 @@ export function FingeringDiagram({
|
|||||||
<Hole cx={21} cy={118} r={4.7} state={h1} />
|
<Hole cx={21} cy={118} r={4.7} state={h1} />
|
||||||
<line x1="9" y1="130" x2="33" y2="130" stroke={INK} strokeWidth="1" />
|
<line x1="9" y1="130" x2="33" y2="130" stroke={INK} strokeWidth="1" />
|
||||||
<Hole cx={21} cy={140} r={4.5} state={fingering.thumb} />
|
<Hole cx={21} cy={140} r={4.5} state={fingering.thumb} />
|
||||||
<line x1="9" y1="156" x2="44" y2="156" stroke={INK} strokeWidth="1" />
|
</g>
|
||||||
<line x1="9" y1="166" x2="44" y2="166" stroke={INK} strokeWidth="1" />
|
|
||||||
<line x1="9" y1="176" x2="44" y2="176" stroke={INK} strokeWidth="1" />
|
|
||||||
<ellipse cx="26" cy="166" rx="3.2" ry="4.4" fill={TUBE} stroke={INK} strokeWidth="1.2" transform="rotate(-24 26 166)" />
|
|
||||||
<Staff pitch={pitch} />
|
<Staff pitch={pitch} />
|
||||||
<NoteOnStaff pitch={pitch} head={value.head} stem={value.stem} flags={value.flags} dots={value.dots} />
|
<g transform={`translate(${CLEF_GUTTER} 0)`}>
|
||||||
|
<NoteOnStaff pitch={pitch} head={value.head} stem={value.stem} flags={value.flags} dots={value.dots} />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RestMark({ quarters = 1, width = 44 }: { quarters?: number; width?: number }) {
|
export function RestMark({ quarters = 1, width: bodyWidth = 44 }: { quarters?: number; width?: number }) {
|
||||||
const height = chartPixelHeight(width);
|
const width = chartOuterWidth(bodyWidth);
|
||||||
|
const height = chartPixelHeight(bodyWidth);
|
||||||
const value = restValue(quarters);
|
const value = restValue(quarters);
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -139,7 +143,9 @@ export function RestMark({ quarters = 1, width = 44 }: { quarters?: number; widt
|
|||||||
data-dots={value.dots}
|
data-dots={value.dots}
|
||||||
>
|
>
|
||||||
<Staff />
|
<Staff />
|
||||||
<RestOnStaff rest={value.rest} dots={value.dots} />
|
<g transform={`translate(${CLEF_GUTTER} 0)`}>
|
||||||
|
<RestOnStaff rest={value.rest} dots={value.dots} />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -152,8 +158,8 @@ function Staff({ pitch }: { pitch?: string }) {
|
|||||||
{[0, 2, 4, 6, 8].map((index) => (
|
{[0, 2, 4, 6, 8].map((index) => (
|
||||||
<line
|
<line
|
||||||
key={index}
|
key={index}
|
||||||
x1={1.5}
|
x1={1}
|
||||||
x2={46.5}
|
x2={CHART_VB_W - 1.5}
|
||||||
y1={staffY(index)}
|
y1={staffY(index)}
|
||||||
y2={staffY(index)}
|
y2={staffY(index)}
|
||||||
stroke={INK}
|
stroke={INK}
|
||||||
@@ -165,25 +171,13 @@ function Staff({ pitch }: { pitch?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filled treble clef, drawn larger than the staff. The spiral is centered on the G line. */
|
||||||
function TrebleClef() {
|
function TrebleClef() {
|
||||||
const y = staffY(2);
|
const y = staffY(2);
|
||||||
return (
|
return (
|
||||||
<path
|
<g transform={`translate(0.7 ${y})`} data-clef="treble">
|
||||||
d={`M 7.4 ${y - 10.5}
|
<path d="M 9.53 -3.48 C 8.82 -3.28 8.18 -2.84 7.58 -2.16 C 6.99 -1.47 6.70 -0.71 6.70 0.11 C 6.70 0.62 6.87 1.20 7.21 1.82 C 7.55 2.45 8.06 2.91 8.73 3.20 C 8.95 3.25 9.06 3.37 9.06 3.53 C 9.06 3.59 8.97 3.65 8.78 3.69 C 7.71 3.42 6.84 2.85 6.16 2.00 C 5.48 1.13 5.13 0.15 5.11 -0.97 C 5.14 -2.17 5.50 -3.30 6.19 -4.32 C 6.89 -5.36 7.79 -6.10 8.89 -6.53 L 8.09 -10.68 C 6.29 -9.18 4.83 -7.63 3.69 -5.99 C 2.56 -4.37 1.97 -2.61 1.93 -0.70 C 1.95 0.15 2.13 0.98 2.45 1.78 C 2.78 2.58 3.27 3.31 3.93 3.97 C 5.25 5.29 6.96 5.98 9.06 6.05 C 9.77 6.01 10.53 5.88 11.35 5.67 L 9.53 -3.48 z M 10.38 -3.60 L 12.20 5.39 C 14.00 4.66 14.90 3.08 14.90 0.68 C 14.79 -0.13 14.56 -0.85 14.16 -1.50 C 13.78 -2.15 13.26 -2.66 12.61 -3.04 C 11.95 -3.41 11.22 -3.60 10.38 -3.60 z M 7.98 -15.75 C 8.37 -15.99 8.81 -16.39 9.29 -16.97 C 9.77 -17.53 10.24 -18.19 10.68 -18.94 C 11.14 -19.70 11.50 -20.47 11.77 -21.26 C 12.04 -22.03 12.16 -22.76 12.16 -23.44 C 12.16 -23.73 12.14 -24.02 12.08 -24.28 C 12.04 -24.70 11.91 -25.03 11.69 -25.25 C 11.46 -25.46 11.18 -25.58 10.83 -25.58 C 10.13 -25.58 9.50 -25.15 8.94 -24.28 C 8.51 -23.53 8.14 -22.65 7.89 -21.64 C 7.62 -20.62 7.47 -19.62 7.44 -18.60 C 7.50 -17.45 7.69 -16.50 7.98 -15.75 z M 7.24 -15.07 C 6.72 -16.97 6.43 -18.89 6.37 -20.86 C 6.38 -22.12 6.51 -23.30 6.75 -24.40 C 6.99 -25.50 7.33 -26.44 7.77 -27.26 C 8.20 -28.08 8.71 -28.70 9.27 -29.12 C 9.77 -29.49 10.13 -29.69 10.33 -29.69 C 10.48 -29.69 10.61 -29.63 10.73 -29.53 C 10.84 -29.42 11.00 -29.25 11.18 -29.01 C 12.57 -27.04 13.27 -24.66 13.27 -21.87 C 13.27 -20.55 13.10 -19.27 12.75 -17.98 C 12.41 -16.71 11.91 -15.49 11.24 -14.36 C 10.56 -13.22 9.77 -12.22 8.85 -11.37 L 9.79 -6.77 C 10.31 -6.82 10.66 -6.87 10.86 -6.87 C 11.74 -6.87 12.54 -6.68 13.27 -6.31 C 14.01 -5.94 14.64 -5.43 15.16 -4.79 C 15.67 -4.16 16.07 -3.44 16.35 -2.62 C 16.62 -1.80 16.77 -0.95 16.77 -0.06 C 16.77 1.32 16.41 2.58 15.68 3.72 C 14.96 4.85 13.87 5.68 12.41 6.22 C 12.50 6.79 12.67 7.62 12.91 8.68 C 13.15 9.76 13.32 10.61 13.44 11.24 C 13.55 11.87 13.60 12.48 13.60 13.08 C 13.60 14.00 13.38 14.82 12.94 15.54 C 12.48 16.27 11.87 16.83 11.10 17.22 C 10.34 17.62 9.50 17.82 8.59 17.82 C 7.30 17.82 6.18 17.46 5.22 16.74 C 4.27 16.02 3.75 15.05 3.70 13.81 C 3.74 13.26 3.87 12.75 4.10 12.26 C 4.34 11.77 4.65 11.37 5.06 11.07 C 5.46 10.75 5.94 10.59 6.49 10.55 C 6.94 10.55 7.37 10.68 7.78 10.93 C 8.18 11.18 8.51 11.52 8.75 11.95 C 8.99 12.39 9.11 12.87 9.11 13.38 C 9.11 14.07 8.88 14.65 8.41 15.13 C 7.95 15.61 7.35 15.86 6.64 15.86 L 6.37 15.86 C 6.82 16.56 7.57 16.92 8.61 16.92 C 9.14 16.92 9.68 16.80 10.21 16.59 C 10.76 16.37 11.22 16.08 11.60 15.70 C 11.99 15.33 12.25 14.93 12.35 14.51 C 12.55 14.03 12.64 13.37 12.64 12.54 C 12.64 11.98 12.58 11.42 12.48 10.86 C 12.37 10.31 12.21 9.57 11.99 8.66 C 11.77 7.76 11.60 7.06 11.51 6.58 C 10.81 6.75 10.08 6.85 9.32 6.85 C 8.05 6.85 6.85 6.59 5.71 6.06 C 4.58 5.54 3.59 4.81 2.72 3.88 C 1.87 2.94 1.20 1.89 0.72 0.70 C 0.26 -0.48 0.01 -1.72 0.00 -3.00 C 0.05 -4.19 0.27 -5.34 0.69 -6.42 C 1.11 -7.50 1.65 -8.53 2.31 -9.49 C 2.98 -10.45 3.67 -11.32 4.38 -12.11 C 5.11 -12.88 6.05 -13.87 7.24 -15.07 z" fill={INK} fillRule="evenodd" />
|
||||||
C 10.6 ${y - 12.2} 12.4 ${y - 8.2} 11.2 ${y - 5.2}
|
</g>
|
||||||
C 10 ${y - 2.4} 7.2 ${y - 1.6} 6.8 ${y + 1.2}
|
|
||||||
C 6.2 ${y + 4.6} 8.8 ${y + 6.2} 10.2 ${y + 3.6}
|
|
||||||
C 11.4 ${y + 1.2} 9.2 ${y - 0.6} 7.6 ${y + 0.8}
|
|
||||||
C 6.8 ${y + 1.6} 7 ${y + 4.2} 8.4 ${y + 7.4}
|
|
||||||
C 9.6 ${y + 11.2} 10.2 ${y + 15} 8.6 ${y + 17.6}
|
|
||||||
C 7 ${y + 20.2} 4.2 ${y + 18.6} 4.8 ${y + 15.4}
|
|
||||||
C 5.2 ${y + 13.2} 7.6 ${y + 13.6} 8 ${y + 15.8}`}
|
|
||||||
fill="none"
|
|
||||||
stroke={INK}
|
|
||||||
strokeWidth={1.45}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-25
@@ -1,4 +1,4 @@
|
|||||||
import { pngItemFromNote, pngLayout, type PngItem } from "@/lib/png-layout";
|
import { pngItemFromNote, pngStackLayout, type PngItem } from "@/lib/png-layout";
|
||||||
|
|
||||||
const INK = "#1a1a1a";
|
const INK = "#1a1a1a";
|
||||||
const PAPER = "#fbf6ee";
|
const PAPER = "#fbf6ee";
|
||||||
@@ -23,8 +23,8 @@ function loadSvg(svg: SVGElement): Promise<HTMLImageElement> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
|
async function readRow(line: HTMLElement): Promise<{ items: PngItem[]; images: Array<HTMLImageElement | null> } | null> {
|
||||||
const slots = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-slot]")];
|
const slots = [...line.querySelectorAll<HTMLElement>(":scope > [data-export-slot]")];
|
||||||
const items: PngItem[] = [];
|
const items: PngItem[] = [];
|
||||||
const images: Array<HTMLImageElement | null> = [];
|
const images: Array<HTMLImageElement | null> = [];
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
@@ -41,9 +41,24 @@ export async function renderLinePng(exportRoot: HTMLElement, title: string): Pro
|
|||||||
const svg = slot.querySelector("svg");
|
const svg = slot.querySelector("svg");
|
||||||
images.push(svg ? await loadSvg(svg) : null);
|
images.push(svg ? await loadSvg(svg) : null);
|
||||||
}
|
}
|
||||||
if (items.length === 0) throw new Error("Add a note, rest, or space before saving a PNG.");
|
if (items.length === 0) return null;
|
||||||
|
return { items, images };
|
||||||
|
}
|
||||||
|
|
||||||
const layout = pngLayout(items, title);
|
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
|
||||||
|
const lineNodes = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-line]")];
|
||||||
|
const sources = lineNodes.length > 0 ? lineNodes : [exportRoot];
|
||||||
|
const rows: Array<{ items: PngItem[]; images: Array<HTMLImageElement | null> }> = [];
|
||||||
|
for (const line of sources) {
|
||||||
|
const row = await readRow(line);
|
||||||
|
if (row) rows.push(row);
|
||||||
|
}
|
||||||
|
if (rows.length === 0) throw new Error("Add a note, rest, or space before saving a PNG.");
|
||||||
|
|
||||||
|
const layout = pngStackLayout(
|
||||||
|
rows.map((row) => row.items),
|
||||||
|
title,
|
||||||
|
);
|
||||||
const scale = 2;
|
const scale = 2;
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = Math.ceil(layout.width * scale);
|
canvas.width = Math.ceil(layout.width * scale);
|
||||||
@@ -60,26 +75,30 @@ export async function renderLinePng(exportRoot: HTMLElement, title: string): Pro
|
|||||||
ctx.font = "600 22px Georgia, serif";
|
ctx.font = "600 22px Georgia, serif";
|
||||||
ctx.fillText(title, layout.pad, layout.pad + 40);
|
ctx.fillText(title, layout.pad, layout.pad + 40);
|
||||||
|
|
||||||
const glyphTop = layout.pad + 56;
|
let glyphTop = layout.pad + 56;
|
||||||
let x = layout.pad;
|
rows.forEach((row, rowIndex) => {
|
||||||
items.forEach((item, index) => {
|
const laid = layout.rows[rowIndex];
|
||||||
const slotWidth = layout.slots[index]?.width ?? item.glyphWidth;
|
let x = layout.pad;
|
||||||
if (item.kind === "space") {
|
row.items.forEach((item, index) => {
|
||||||
ctx.save();
|
const slotWidth = laid?.slots[index]?.width ?? item.glyphWidth;
|
||||||
ctx.strokeStyle = SPACE_STROKE;
|
if (item.kind === "space") {
|
||||||
ctx.setLineDash([4, 4]);
|
ctx.save();
|
||||||
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), layout.glyphHeight - 16);
|
ctx.strokeStyle = SPACE_STROKE;
|
||||||
ctx.restore();
|
ctx.setLineDash([4, 4]);
|
||||||
} else if (images[index]) {
|
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), laid.glyphHeight - 16);
|
||||||
ctx.drawImage(images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
|
ctx.restore();
|
||||||
}
|
} else if (row.images[index]) {
|
||||||
const center = x + item.glyphWidth / 2;
|
ctx.drawImage(row.images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
|
||||||
ctx.textAlign = "center";
|
}
|
||||||
ctx.fillStyle = INK;
|
const center = x + item.glyphWidth / 2;
|
||||||
ctx.font = "600 13px sans-serif";
|
ctx.textAlign = "center";
|
||||||
ctx.fillText(item.caption, center, glyphTop + layout.glyphHeight + 18);
|
ctx.fillStyle = INK;
|
||||||
ctx.textAlign = "start";
|
ctx.font = "600 13px sans-serif";
|
||||||
x += slotWidth + layout.gap;
|
ctx.fillText(item.caption, center, glyphTop + laid.glyphHeight + 18);
|
||||||
|
ctx.textAlign = "start";
|
||||||
|
x += slotWidth + laid.gap;
|
||||||
|
});
|
||||||
|
glyphTop += laid.glyphHeight + 28 + layout.rowGap;
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||||
|
|||||||
@@ -86,6 +86,52 @@ export function cloneLine(notes: LineNote[]): LineNote[] {
|
|||||||
return notes.map((note) => ({ ...note, id: newId("n") }));
|
return notes.map((note) => ({ ...note, id: newId("n") }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScoreLine = {
|
||||||
|
id: string;
|
||||||
|
notes: LineNote[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function appendLine(lines: ScoreLine[], id = newId("line")): ScoreLine[] {
|
||||||
|
return [...lines, { id, notes: [] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave the last line in place. An empty score still has one row. */
|
||||||
|
export function removeLine(lines: ScoreLine[], id: string): ScoreLine[] {
|
||||||
|
if (lines.length <= 1) return lines;
|
||||||
|
const next = lines.filter((line) => line.id !== id);
|
||||||
|
return next.length > 0 ? next : lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLine(lines: ScoreLine[], id: string, update: (notes: LineNote[]) => LineNote[]): ScoreLine[] {
|
||||||
|
return lines.map((line) => (line.id === id ? { ...line, notes: update(line.notes) } : line));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placeOnLine(lines: ScoreLine[], lineId: string, index: number, note: LineNote): ScoreLine[] {
|
||||||
|
return mapLine(lines, lineId, (notes) => insertNote(notes, index, note));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a note onto a line. When it stays on the same line, `toIndex` counts the moving note.
|
||||||
|
* When it changes lines, `toIndex` is the slot in the destination.
|
||||||
|
*/
|
||||||
|
export function transferNote(
|
||||||
|
lines: ScoreLine[],
|
||||||
|
fromLineId: string,
|
||||||
|
noteId: string,
|
||||||
|
toLineId: string,
|
||||||
|
toIndex: number,
|
||||||
|
): ScoreLine[] {
|
||||||
|
const source = lines.find((line) => line.id === fromLineId);
|
||||||
|
const note = source?.notes.find((item) => item.id === noteId);
|
||||||
|
if (!source || !note) return lines;
|
||||||
|
if (fromLineId === toLineId) return mapLine(lines, fromLineId, (notes) => moveNote(notes, noteId, toIndex));
|
||||||
|
return lines.map((line) => {
|
||||||
|
if (line.id === fromLineId) return { ...line, notes: removeNote(line.notes, noteId) };
|
||||||
|
if (line.id === toLineId) return { ...line, notes: insertNote(line.notes, toIndex, note) };
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** -1 when the pointer is outside the line. Otherwise the insertion slot from left to right. */
|
/** -1 when the pointer is outside the line. Otherwise the insertion slot from left to right. */
|
||||||
export function dropIndexFromPoint(
|
export function dropIndexFromPoint(
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
|
|||||||
+13
-5
@@ -1,8 +1,11 @@
|
|||||||
import { quartersMatch } from "@/lib/duration";
|
import { quartersMatch } from "@/lib/duration";
|
||||||
|
|
||||||
/** Recorder drawing stays in a 48×188 box. The staff is added underneath. */
|
/** The tube ends just below the thumb hole. The staff is added underneath. */
|
||||||
export const CHART_VB_W = 48;
|
const CHART_BODY_W = 48;
|
||||||
const RECORDER_VB_H = 188;
|
/** Extra width on the left so the treble clef can sit on the G line. */
|
||||||
|
export const CLEF_GUTTER = 6;
|
||||||
|
export const CHART_VB_W = CHART_BODY_W + CLEF_GUTTER;
|
||||||
|
const RECORDER_VB_H = 152;
|
||||||
|
|
||||||
/** Distance between two staff lines. One diatonic step is half of this. */
|
/** Distance between two staff lines. One diatonic step is half of this. */
|
||||||
export const STAFF_SPACE = 4.6;
|
export const STAFF_SPACE = 4.6;
|
||||||
@@ -21,8 +24,13 @@ const BELOW = -LOW_INDEX * STEP + PAD_BOTTOM;
|
|||||||
export const BOTTOM_LINE_Y = RECORDER_VB_H + 3 + PAD_TOP + ABOVE;
|
export const BOTTOM_LINE_Y = RECORDER_VB_H + 3 + PAD_TOP + ABOVE;
|
||||||
export const CHART_VB_H = Math.ceil(BOTTOM_LINE_Y + BELOW);
|
export const CHART_VB_H = Math.ceil(BOTTOM_LINE_Y + BELOW);
|
||||||
|
|
||||||
export function chartPixelHeight(width: number): number {
|
/** Pixel width of a chart once the clef gutter is included. Holes keep their old size. */
|
||||||
return Math.round((width * CHART_VB_H) / CHART_VB_W);
|
export function chartOuterWidth(bodyWidth: number): number {
|
||||||
|
return Math.ceil((bodyWidth * CHART_VB_W) / CHART_BODY_W);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chartPixelHeight(bodyWidth: number): number {
|
||||||
|
return Math.round((chartOuterWidth(bodyWidth) * CHART_VB_H) / CHART_VB_W);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function staffY(index: number): number {
|
export function staffY(index: number): number {
|
||||||
|
|||||||
+14
-2
@@ -1,5 +1,5 @@
|
|||||||
import type { LineNote } from "@/lib/line";
|
import type { LineNote } from "@/lib/line";
|
||||||
import { chartPixelHeight } from "@/lib/notation";
|
import { chartOuterWidth, chartPixelHeight } from "@/lib/notation";
|
||||||
|
|
||||||
export const PNG_CHART_WIDTH = 88;
|
export const PNG_CHART_WIDTH = 88;
|
||||||
const GAP = 18;
|
const GAP = 18;
|
||||||
@@ -20,12 +20,13 @@ export type PngItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function pngItemFromNote(note: Pick<LineNote, "kind" | "pitch" | "quarters" | "label">, chartWidth = PNG_CHART_WIDTH): PngItem {
|
export function pngItemFromNote(note: Pick<LineNote, "kind" | "pitch" | "quarters" | "label">, chartWidth = PNG_CHART_WIDTH): PngItem {
|
||||||
|
const drawn = chartOuterWidth(chartWidth);
|
||||||
const glyphHeight = chartPixelHeight(chartWidth);
|
const glyphHeight = chartPixelHeight(chartWidth);
|
||||||
const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space";
|
const caption = note.kind === "note" ? note.pitch : note.kind === "rest" ? "Rest" : "Space";
|
||||||
return {
|
return {
|
||||||
kind: note.kind,
|
kind: note.kind,
|
||||||
caption,
|
caption,
|
||||||
glyphWidth: note.kind === "space" ? spacePixelWidth(note.quarters, chartWidth) : chartWidth,
|
glyphWidth: note.kind === "space" ? spacePixelWidth(note.quarters, drawn) : drawn,
|
||||||
glyphHeight,
|
glyphHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,17 @@ export function pngSlotWidth(item: PngItem): number {
|
|||||||
return Math.max(item.glyphWidth, text, 48);
|
return Math.max(item.glyphWidth, text, 48);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PNG_ROW_GAP = 36;
|
||||||
|
|
||||||
|
/** One header, then every fingering line stacked underneath. */
|
||||||
|
export function pngStackLayout(rows: PngItem[][], title: string) {
|
||||||
|
const laid = rows.map((items) => pngLayout(items, title));
|
||||||
|
const width = Math.max(1, ...laid.map((row) => row.width));
|
||||||
|
const blocks = laid.reduce((sum, row) => sum + row.glyphHeight + 28, 0);
|
||||||
|
const height = PAD + 44 + blocks + PNG_ROW_GAP * Math.max(0, laid.length - 1) + PAD;
|
||||||
|
return { width, height, rows: laid, pad: PAD, rowGap: PNG_ROW_GAP };
|
||||||
|
}
|
||||||
|
|
||||||
export function pngLayout(items: PngItem[], title: string) {
|
export function pngLayout(items: PngItem[], title: string) {
|
||||||
const slots = items.map((item) => ({ item, width: pngSlotWidth(item) }));
|
const slots = items.map((item) => ({ item, width: pngSlotWidth(item) }));
|
||||||
const content =
|
const content =
|
||||||
|
|||||||
+27
-1
@@ -10,10 +10,13 @@ import {
|
|||||||
dropIndexFromPoint,
|
dropIndexFromPoint,
|
||||||
insertNote,
|
insertNote,
|
||||||
LINE_EXAMPLES,
|
LINE_EXAMPLES,
|
||||||
|
appendLine,
|
||||||
moveNote,
|
moveNote,
|
||||||
neighborDuration,
|
neighborDuration,
|
||||||
|
removeLine,
|
||||||
removeNote,
|
removeNote,
|
||||||
setDuration,
|
setDuration,
|
||||||
|
transferNote,
|
||||||
} from "@/lib/line";
|
} from "@/lib/line";
|
||||||
import {
|
import {
|
||||||
ledgerIndexes,
|
ledgerIndexes,
|
||||||
@@ -23,7 +26,7 @@ import {
|
|||||||
stemDown,
|
stemDown,
|
||||||
} from "@/lib/notation";
|
} from "@/lib/notation";
|
||||||
import { parsePitch } from "@/lib/pitch";
|
import { parsePitch } from "@/lib/pitch";
|
||||||
import { pngFilename, pngItemFromNote, pngLayout } from "@/lib/png-layout";
|
import { pngFilename, pngItemFromNote, pngLayout, pngStackLayout } from "@/lib/png-layout";
|
||||||
import { extraBeatArrows } from "@/lib/score";
|
import { extraBeatArrows } from "@/lib/score";
|
||||||
|
|
||||||
const BAROQUE: Record<string, string> = {
|
const BAROQUE: Record<string, string> = {
|
||||||
@@ -167,6 +170,29 @@ describe("fingering line", () => {
|
|||||||
expect(pngFilename(" ")).toBe("line.png");
|
expect(pngFilename(" ")).toBe("line.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("appends a line and can move a note onto it", () => {
|
||||||
|
const top = { id: "a", notes: [createNote("C5", quarter, "c"), createNote("E5", quarter, "e")] };
|
||||||
|
const next = appendLine([top], "b");
|
||||||
|
expect(next.map((line) => line.id)).toEqual(["a", "b"]);
|
||||||
|
expect(next[1].notes).toEqual([]);
|
||||||
|
expect(next[0].notes.map((note) => note.pitch)).toEqual(["C5", "E5"]);
|
||||||
|
const moved = transferNote(next, "a", "e", "b", 0);
|
||||||
|
expect(moved[0].notes.map((note) => note.pitch)).toEqual(["C5"]);
|
||||||
|
expect(moved[1].notes.map((note) => note.pitch)).toEqual(["E5"]);
|
||||||
|
expect(removeLine(moved, "b").map((line) => line.id)).toEqual(["a"]);
|
||||||
|
expect(removeLine([{ id: "a", notes: [] }], "a")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks every fingering line in the PNG", () => {
|
||||||
|
const top = [pngItemFromNote(createNote("C5", quarter, "c"))];
|
||||||
|
const bottom = [pngItemFromNote(createNote("G5", quarter, "g"))];
|
||||||
|
const one = pngStackLayout([top], "Your line");
|
||||||
|
const two = pngStackLayout([top, bottom], "Your line");
|
||||||
|
expect(two.rows).toHaveLength(2);
|
||||||
|
expect(two.height).toBeGreaterThan(one.height);
|
||||||
|
expect(pngLayout(top, "Your line").glyphHeight).toBe(one.rows[0].glyphHeight);
|
||||||
|
});
|
||||||
|
|
||||||
it("steps duration without skipping a preset", () => {
|
it("steps duration without skipping a preset", () => {
|
||||||
expect(neighborDuration(1, 1)?.quarters).toBe(1.5);
|
expect(neighborDuration(1, 1)?.quarters).toBe(1.5);
|
||||||
expect(neighborDuration(1, -1)?.quarters).toBe(0.75);
|
expect(neighborDuration(1, -1)?.quarters).toBe(0.75);
|
||||||
|
|||||||
Reference in new issue
Block a user