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
+246
-66
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
|
||||
|
||||
```bash
|
||||
chmod +x Recorder-Tab-Converter-0.2.2.AppImage
|
||||
./Recorder-Tab-Converter-0.2.2.AppImage
|
||||
chmod +x Recorder-Tab-Converter-0.2.3.AppImage
|
||||
./Recorder-Tab-Converter-0.2.3.AppImage
|
||||
```
|
||||
|
||||
If the file will not start because FUSE is missing:
|
||||
|
||||
```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
|
||||
|
||||
@@ -30,7 +30,7 @@ From a checkout, one command:
|
||||
That installs Node packages, builds the app, and writes:
|
||||
|
||||
```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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "recorder-tab-converter",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"description": "Arrange soprano recorder fingering charts by dragging them onto a line.",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
+124
-46
@@ -6,20 +6,23 @@ import { DURATION_PRESETS } from "@/lib/duration";
|
||||
import { downloadBlob, renderLinePng } from "@/lib/export-png";
|
||||
import { CHART_NOTES, fingeringFor } from "@/lib/fingering";
|
||||
import {
|
||||
appendLine,
|
||||
clampLineBox,
|
||||
cloneLine,
|
||||
createNote,
|
||||
createRest,
|
||||
createSpace,
|
||||
dropIndexFromPoint,
|
||||
insertNote,
|
||||
LINE_EXAMPLES,
|
||||
moveNote,
|
||||
neighborDuration,
|
||||
placeOnLine,
|
||||
removeLine,
|
||||
removeNote,
|
||||
setDuration,
|
||||
transferNote,
|
||||
type LineKind,
|
||||
type LineNote,
|
||||
type ScoreLine,
|
||||
} from "@/lib/line";
|
||||
import { chartPixelHeight } from "@/lib/notation";
|
||||
import { pngFilename, PNG_CHART_WIDTH } from "@/lib/png-layout";
|
||||
@@ -33,6 +36,7 @@ type Drag = {
|
||||
kind: LineKind;
|
||||
pitch: string;
|
||||
id: string;
|
||||
lineId: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
@@ -41,17 +45,18 @@ type Drag = {
|
||||
armed: boolean;
|
||||
};
|
||||
|
||||
type DropTarget = { lineId: string; index: number };
|
||||
|
||||
export function Editor() {
|
||||
const [notes, setNotes] = useState<LineNote[]>([]);
|
||||
const [lines, setLines] = useState<ScoreLine[]>([{ id: "line-1", notes: [] }]);
|
||||
const [title, setTitle] = useState("Empty line");
|
||||
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 [boxHeight, setBoxHeight] = useState(520);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const dragRef = useRef<Drag | null>(null);
|
||||
const lineRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const paletteRef = useRef<HTMLDivElement>(null);
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
@@ -63,15 +68,23 @@ export function Editor() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function pointIndex(clientX: number, clientY: number) {
|
||||
const line = lineRef.current;
|
||||
if (!line) return -1;
|
||||
const rect = line.getBoundingClientRect();
|
||||
const centers = [...line.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
||||
function pointTarget(clientX: number, clientY: number): DropTarget | null {
|
||||
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
|
||||
let best: (DropTarget & { distance: number }) | null = null;
|
||||
for (const row of rows) {
|
||||
const lineId = row.dataset.lineId;
|
||||
if (!lineId) continue;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const centers = [...row.querySelectorAll<HTMLElement>("[data-note-id]")].map((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return box.left + box.width / 2;
|
||||
});
|
||||
return dropIndexFromPoint(clientX, clientY, rect, centers);
|
||||
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) {
|
||||
@@ -87,28 +100,30 @@ export function Editor() {
|
||||
Math.abs(dx) > Math.abs(dy);
|
||||
if (touchScroll) {
|
||||
updateDrag(null);
|
||||
setDropIndex(null);
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
if (Math.hypot(dx, dy) < 8) return;
|
||||
}
|
||||
window.getSelection()?.removeAllRanges();
|
||||
if (lineRef.current) {
|
||||
const rect = lineRef.current.getBoundingClientRect();
|
||||
if (event.clientX > rect.right - 28) lineRef.current.scrollLeft += 14;
|
||||
if (event.clientX < rect.left + 28) lineRef.current.scrollLeft -= 14;
|
||||
const rows = [...document.querySelectorAll<HTMLElement>("[data-line-id]")];
|
||||
for (const candidate of rows) {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
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 };
|
||||
updateDrag(next);
|
||||
const index = pointIndex(event.clientX, event.clientY);
|
||||
setDropIndex(index >= 0 ? index : null);
|
||||
setDropTarget(pointTarget(event.clientX, event.clientY));
|
||||
}
|
||||
|
||||
function onUp(event: PointerEvent) {
|
||||
const current = dragRef.current;
|
||||
if (!current || event.pointerId !== current.pointerId) return;
|
||||
const index = current.armed ? pointIndex(event.clientX, event.clientY) : -1;
|
||||
if (current.armed && index >= 0) {
|
||||
const target = current.armed ? pointTarget(event.clientX, event.clientY) : null;
|
||||
if (current.armed && target) {
|
||||
if (current.source === "palette") {
|
||||
const note =
|
||||
current.kind === "rest"
|
||||
@@ -116,14 +131,14 @@ export function Editor() {
|
||||
: current.kind === "space"
|
||||
? createSpace()
|
||||
: createNote(current.pitch);
|
||||
setNotes((existing) => insertNote(existing, index, note));
|
||||
setLines((existing) => placeOnLine(existing, target.lineId, target.index, note));
|
||||
setTitle("Your line");
|
||||
} else {
|
||||
setNotes((existing) => moveNote(existing, current.id, index));
|
||||
setLines((existing) => transferNote(existing, current.lineId, current.id, target.lineId, target.index));
|
||||
}
|
||||
}
|
||||
updateDrag(null);
|
||||
setDropIndex(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function onSelectStart(event: Event) {
|
||||
@@ -194,7 +209,8 @@ export function Editor() {
|
||||
}
|
||||
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
@@ -218,18 +234,19 @@ export function Editor() {
|
||||
<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>
|
||||
<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.
|
||||
The staff under the holes shows the pitch and the length. Resize the line box, then save the
|
||||
finished row as a PNG. The foot, with the double holes, stays at the top.
|
||||
Drag a chart, a rest, or a space onto a line. Drag a placed item sideways to reorder it, or
|
||||
onto another line. New line adds a row underneath. The staff under the holes shows the pitch
|
||||
and the length. Resize the box, then save every line as a PNG. The foot, with the double holes,
|
||||
stays at the top.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={notes.length === 0 ? "default" : "outline"}
|
||||
variant={lines.length === 1 && lines[0].notes.length === 0 ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setNotes([]);
|
||||
setLines([{ id: "line-1", notes: [] }]);
|
||||
setTitle("Empty line");
|
||||
}}
|
||||
>
|
||||
@@ -241,7 +258,7 @@ export function Editor() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setNotes(cloneLine([...example.notes]));
|
||||
setLines([{ id: "line-1", notes: cloneLine([...example.notes]) }]);
|
||||
setTitle(example.title);
|
||||
}}
|
||||
>
|
||||
@@ -273,6 +290,7 @@ export function Editor() {
|
||||
kind: "rest",
|
||||
pitch: "",
|
||||
id: "",
|
||||
lineId: "",
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
@@ -292,6 +310,7 @@ export function Editor() {
|
||||
kind: "space",
|
||||
pitch: "",
|
||||
id: "",
|
||||
lineId: "",
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
@@ -314,6 +333,7 @@ export function Editor() {
|
||||
kind: "note",
|
||||
pitch,
|
||||
id: "",
|
||||
lineId: "",
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
@@ -339,7 +359,15 @@ export function Editor() {
|
||||
{boxWidth ? `, ${boxWidth}px wide` : ""}.
|
||||
</p>
|
||||
</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 className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => nudgeBox(0, -48)}>
|
||||
@@ -357,28 +385,56 @@ export function Editor() {
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setBoxWidth(null)}>
|
||||
Fit width
|
||||
</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"}
|
||||
</Button>
|
||||
</div>
|
||||
{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={lineRef}
|
||||
data-line
|
||||
className={`h-full overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
|
||||
dropIndex !== null ? "border-primary" : "border-foreground"
|
||||
className={`flex h-full flex-col gap-4 overflow-auto rounded-none border-2 bg-card px-3 py-4 select-none ${
|
||||
dropTarget !== null ? "border-primary" : "border-foreground"
|
||||
}`}
|
||||
>
|
||||
{notes.length === 0 ? (
|
||||
<p className="px-2 py-16 text-center text-sm text-muted-foreground">
|
||||
{lines.map((line, lineIndex) => (
|
||||
<div key={line.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">Line {lineIndex + 1}</p>
|
||||
{lines.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label={`Remove line ${lineIndex + 1}`}
|
||||
onClick={() => setLines((existing) => removeLine(existing, line.id))}
|
||||
>
|
||||
<X />
|
||||
Remove line
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div data-line-id={line.id} className="overflow-x-auto">
|
||||
{line.notes.length === 0 ? (
|
||||
<p className="px-2 py-10 text-center text-sm text-muted-foreground">
|
||||
Drop a fingering, a rest, or a space here. The line is empty.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex w-max min-w-full items-end gap-2">
|
||||
{notes.map((note, index) => (
|
||||
{line.notes.map((note, index) => (
|
||||
<div key={note.id} className="flex items-end gap-2">
|
||||
{dropIndex === index ? <DropCaret /> : null}
|
||||
{dropTarget?.lineId === line.id && dropTarget.index === index ? <DropCaret /> : null}
|
||||
<PlacedItem
|
||||
note={note}
|
||||
dimmed={drag?.source === "line" && drag.armed && drag.id === note.id}
|
||||
@@ -387,6 +443,7 @@ export function Editor() {
|
||||
source: "line",
|
||||
kind: note.kind,
|
||||
id: note.id,
|
||||
lineId: line.id,
|
||||
pitch: note.pitch,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
@@ -399,16 +456,29 @@ export function Editor() {
|
||||
onDuration={(quarters) => {
|
||||
const preset = DURATION_PRESETS.find((item) => item.quarters === quarters);
|
||||
if (!preset) return;
|
||||
setNotes((existing) => setDuration(existing, note.id, preset));
|
||||
setLines((existing) =>
|
||||
existing.map((row) =>
|
||||
row.id === line.id ? { ...row, notes: setDuration(row.notes, note.id, preset) } : row,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onRemove={() => setNotes((existing) => removeNote(existing, note.id))}
|
||||
onRemove={() =>
|
||||
setLines((existing) =>
|
||||
existing.map((row) =>
|
||||
row.id === line.id ? { ...row, notes: removeNote(row.notes, note.id) } : row,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{dropIndex === notes.length ? <DropCaret /> : null}
|
||||
{dropTarget?.lineId === line.id && dropTarget.index === line.notes.length ? <DropCaret /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
@@ -457,7 +527,9 @@ export function Editor() {
|
||||
) : null}
|
||||
|
||||
<div ref={exportRef} className="pointer-events-none absolute h-px w-px overflow-hidden" aria-hidden>
|
||||
{notes.map((note) => (
|
||||
{lines.map((line) => (
|
||||
<div key={line.id} data-export-line={line.id}>
|
||||
{line.notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
data-export-slot
|
||||
@@ -478,6 +550,8 @@ export function Editor() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -597,9 +671,13 @@ function DropCaret() {
|
||||
function ScrollButtons({
|
||||
target,
|
||||
label,
|
||||
onLeft,
|
||||
onRight,
|
||||
}: {
|
||||
target: RefObject<HTMLDivElement | null>;
|
||||
target?: RefObject<HTMLDivElement | null>;
|
||||
label: string;
|
||||
onLeft?: () => void;
|
||||
onRight?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
@@ -608,7 +686,7 @@ function ScrollButtons({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Scroll ${label} left`}
|
||||
onClick={() => target.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
||||
onClick={() => onLeft?.() ?? target?.current?.scrollBy({ left: -180, behavior: "smooth" })}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
@@ -617,7 +695,7 @@ function ScrollButtons({
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Scroll ${label} right`}
|
||||
onClick={() => target.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
||||
onClick={() => onRight?.() ?? target?.current?.scrollBy({ left: 180, behavior: "smooth" })}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
|
||||
+32
-13
@@ -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 PAPER = "#fbf6ee";
|
||||
@@ -23,8 +23,8 @@ function loadSvg(svg: SVGElement): Promise<HTMLImageElement> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise<Blob> {
|
||||
const slots = [...exportRoot.querySelectorAll<HTMLElement>("[data-export-slot]")];
|
||||
async function readRow(line: HTMLElement): Promise<{ items: PngItem[]; images: Array<HTMLImageElement | null> } | null> {
|
||||
const slots = [...line.querySelectorAll<HTMLElement>(":scope > [data-export-slot]")];
|
||||
const items: PngItem[] = [];
|
||||
const images: Array<HTMLImageElement | null> = [];
|
||||
for (const slot of slots) {
|
||||
@@ -41,9 +41,24 @@ export async function renderLinePng(exportRoot: HTMLElement, title: string): Pro
|
||||
const svg = slot.querySelector("svg");
|
||||
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 canvas = document.createElement("canvas");
|
||||
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.fillText(title, layout.pad, layout.pad + 40);
|
||||
|
||||
const glyphTop = layout.pad + 56;
|
||||
let glyphTop = layout.pad + 56;
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const laid = layout.rows[rowIndex];
|
||||
let x = layout.pad;
|
||||
items.forEach((item, index) => {
|
||||
const slotWidth = layout.slots[index]?.width ?? item.glyphWidth;
|
||||
row.items.forEach((item, index) => {
|
||||
const slotWidth = laid?.slots[index]?.width ?? item.glyphWidth;
|
||||
if (item.kind === "space") {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = SPACE_STROKE;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), layout.glyphHeight - 16);
|
||||
ctx.strokeRect(x + 6, glyphTop + 8, Math.max(8, item.glyphWidth - 12), laid.glyphHeight - 16);
|
||||
ctx.restore();
|
||||
} else if (images[index]) {
|
||||
ctx.drawImage(images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
|
||||
} else if (row.images[index]) {
|
||||
ctx.drawImage(row.images[index], x, glyphTop, item.glyphWidth, item.glyphHeight);
|
||||
}
|
||||
const center = x + item.glyphWidth / 2;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = INK;
|
||||
ctx.font = "600 13px sans-serif";
|
||||
ctx.fillText(item.caption, center, glyphTop + layout.glyphHeight + 18);
|
||||
ctx.fillText(item.caption, center, glyphTop + laid.glyphHeight + 18);
|
||||
ctx.textAlign = "start";
|
||||
x += slotWidth + layout.gap;
|
||||
x += slotWidth + laid.gap;
|
||||
});
|
||||
glyphTop += laid.glyphHeight + 28 + layout.rowGap;
|
||||
});
|
||||
|
||||
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") }));
|
||||
}
|
||||
|
||||
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. */
|
||||
export function dropIndexFromPoint(
|
||||
pointerX: number,
|
||||
|
||||
@@ -35,6 +35,17 @@ export function pngSlotWidth(item: PngItem): number {
|
||||
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) {
|
||||
const slots = items.map((item) => ({ item, width: pngSlotWidth(item) }));
|
||||
const content =
|
||||
|
||||
+27
-1
@@ -10,10 +10,13 @@ import {
|
||||
dropIndexFromPoint,
|
||||
insertNote,
|
||||
LINE_EXAMPLES,
|
||||
appendLine,
|
||||
moveNote,
|
||||
neighborDuration,
|
||||
removeLine,
|
||||
removeNote,
|
||||
setDuration,
|
||||
transferNote,
|
||||
} from "@/lib/line";
|
||||
import {
|
||||
ledgerIndexes,
|
||||
@@ -23,7 +26,7 @@ import {
|
||||
stemDown,
|
||||
} from "@/lib/notation";
|
||||
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";
|
||||
|
||||
const BAROQUE: Record<string, string> = {
|
||||
@@ -167,6 +170,29 @@ describe("fingering line", () => {
|
||||
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", () => {
|
||||
expect(neighborDuration(1, 1)?.quarters).toBe(1.5);
|
||||
expect(neighborDuration(1, -1)?.quarters).toBe(0.75);
|
||||
|
||||
Reference in new issue
Block a user