Add a New line button for another fingering row.
AppImage release / appimage (push) Successful in 49s
AppImage release / appimage (push) Successful in 49s
Each line keeps its own charts and staff. Save PNG stacks every line. Version 0.2.3. Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
7 files changed
+308
-128
No files matched your search
@@ -7,17 +7,17 @@ The usual way to run it is one Linux AppImage. That file contains the window and
|
|||||||
## Run the AppImage
|
## Run the AppImage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chmod +x Recorder-Tab-Converter-0.2.2.AppImage
|
chmod +x Recorder-Tab-Converter-0.2.3.AppImage
|
||||||
./Recorder-Tab-Converter-0.2.2.AppImage
|
./Recorder-Tab-Converter-0.2.3.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.3.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.3.AppImage
|
||||||
```
|
```
|
||||||
|
|
||||||
The AppImage is not committed. Keep it under 600MB.
|
The AppImage is not committed. Keep it under 600MB.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "recorder-tab-converter",
|
"name": "recorder-tab-converter",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"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",
|
||||||
|
|||||||
+174
-96
@@ -6,20 +6,23 @@ 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 { chartPixelHeight } from "@/lib/notation";
|
||||||
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
|
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
|
||||||
@@ -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,
|
||||||
@@ -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"
|
||||||
@@ -457,24 +527,28 @@ export function Editor() {
|
|||||||
) : 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
@@ -35,6 +35,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