diff --git a/README.md b/README.md index 17902f9..9cf8498 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 73b8675..ceefc94 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 4c5bfc0..600e762 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -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([]); + const [lines, setLines] = useState([{ id: "line-1", notes: [] }]); const [title, setTitle] = useState("Empty line"); const [drag, setDrag] = useState(null); - const [dropIndex, setDropIndex] = useState(null); + const [dropTarget, setDropTarget] = useState(null); const [boxWidth, setBoxWidth] = useState(null); const [boxHeight, setBoxHeight] = useState(520); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const dragRef = useRef(null); - const lineRef = useRef(null); const frameRef = useRef(null); const paletteRef = useRef(null); const exportRef = useRef(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("[data-note-id]")].map((element) => { - const box = element.getBoundingClientRect(); - return box.left + box.width / 2; - }); - return dropIndexFromPoint(clientX, clientY, rect, centers); + function pointTarget(clientX: number, clientY: number): DropTarget | null { + const rows = [...document.querySelectorAll("[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("[data-note-id]")].map((element) => { + 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) { @@ -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("[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() {

Soprano in C · Baroque

Recorder fingering editor

- 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.

- + + document.querySelectorAll("[data-line-id]").forEach((row) => row.scrollBy({ left: -180, behavior: "smooth" })) + } + onRight={() => + document.querySelectorAll("[data-line-id]").forEach((row) => row.scrollBy({ left: 180, behavior: "smooth" })) + } + />
- +
{saveError ?

{saveError}

: null}
- {notes.length === 0 ? ( -

- Drop a fingering, a rest, or a space here. The line is empty. -

- ) : ( -
- {notes.map((note, index) => ( -
- {dropIndex === index ? : null} - - beginDrag(event, { - source: "line", - kind: note.kind, - id: note.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; - setNotes((existing) => setDuration(existing, note.id, preset)); - }} - onRemove={() => setNotes((existing) => removeNote(existing, note.id))} - /> -
- ))} - {dropIndex === notes.length ? : null} + {lines.map((line, lineIndex) => ( +
+
+

Line {lineIndex + 1}

+ {lines.length > 1 ? ( + + ) : null} +
+
+ {line.notes.length === 0 ? ( +

+ Drop a fingering, a rest, or a space here. The line is empty. +

+ ) : ( +
+ {line.notes.map((note, index) => ( +
+ {dropTarget?.lineId === line.id && dropTarget.index === index ? : null} + + beginDrag(event, { + source: "line", + kind: note.kind, + id: note.id, + 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, + ), + ) + } + /> +
+ ))} + {dropTarget?.lineId === line.id && dropTarget.index === line.notes.length ? : null} +
+ )} +
- )} + ))}
- {notes.map((note) => ( -
- {note.kind === "note" ? ( - - ) : null} - {note.kind === "rest" ? : null} + {lines.map((line) => ( +
+ {line.notes.map((note) => ( +
+ {note.kind === "note" ? ( + + ) : null} + {note.kind === "rest" ? : null} +
+ ))}
))}
@@ -597,9 +671,13 @@ function DropCaret() { function ScrollButtons({ target, label, + onLeft, + onRight, }: { - target: RefObject; + target?: RefObject; label: string; + onLeft?: () => void; + onRight?: () => void; }) { return (
@@ -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" })} > @@ -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" })} > diff --git a/src/lib/export-png.ts b/src/lib/export-png.ts index 5ec09dd..6f2e549 100644 --- a/src/lib/export-png.ts +++ b/src/lib/export-png.ts @@ -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 { }); } -export async function renderLinePng(exportRoot: HTMLElement, title: string): Promise { - const slots = [...exportRoot.querySelectorAll("[data-export-slot]")]; +async function readRow(line: HTMLElement): Promise<{ items: PngItem[]; images: Array } | null> { + const slots = [...line.querySelectorAll(":scope > [data-export-slot]")]; const items: PngItem[] = []; const images: Array = []; 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 { + const lineNodes = [...exportRoot.querySelectorAll("[data-export-line]")]; + const sources = lineNodes.length > 0 ? lineNodes : [exportRoot]; + const rows: Array<{ items: PngItem[]; images: Array }> = []; + 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 x = layout.pad; - items.forEach((item, index) => { - const slotWidth = layout.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.restore(); - } else if (images[index]) { - ctx.drawImage(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.textAlign = "start"; - x += slotWidth + layout.gap; + let glyphTop = layout.pad + 56; + rows.forEach((row, rowIndex) => { + const laid = layout.rows[rowIndex]; + let x = layout.pad; + 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), laid.glyphHeight - 16); + ctx.restore(); + } 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 + laid.glyphHeight + 18); + ctx.textAlign = "start"; + x += slotWidth + laid.gap; + }); + glyphTop += laid.glyphHeight + 28 + layout.rowGap; }); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); diff --git a/src/lib/line.ts b/src/lib/line.ts index c20a5b9..b020f84 100644 --- a/src/lib/line.ts +++ b/src/lib/line.ts @@ -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, diff --git a/src/lib/png-layout.ts b/src/lib/png-layout.ts index 04936ac..1965714 100644 --- a/src/lib/png-layout.ts +++ b/src/lib/png-layout.ts @@ -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 = diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 4b9b0d6..24547d6 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -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 = { @@ -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);