Files
Recorder-Tabber/src/lib/line.ts
T
Cursor Agentandchen dcca7f5e83
AppImage release / appimage (push) Successful in 50s
Show length on a staff under each fingering chart.
Dragging no longer selects text. The line box resizes, rests and spaces can be added, and Save PNG writes the row. Each chart keeps the foot at the top, and the treble staff shows the pitch with a notehead, stem, flags, and dots. Version 0.2.1.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
2026-10-11 09:49:17 +00:00

140 lines
4.7 KiB
TypeScript

import { DURATION_PRESETS, type DurationPreset } from "@/lib/duration";
import { newId } from "@/lib/score";
export type LineKind = "note" | "rest" | "space";
export type LineNote = {
id: string;
kind: LineKind;
pitch: string;
quarters: number;
label: string;
};
export const LINE_BOX = {
minWidth: 280,
minHeight: 200,
maxHeight: 880,
};
const QUARTER = DURATION_PRESETS.find((preset) => preset.quarters === 1) ?? DURATION_PRESETS[3];
export function createNote(pitch: string, preset: DurationPreset = QUARTER, id = newId("n")): LineNote {
return { id, kind: "note", pitch, quarters: preset.quarters, label: preset.label };
}
export function createRest(preset: DurationPreset = QUARTER, id = newId("r")): LineNote {
return { id, kind: "rest", pitch: "", quarters: preset.quarters, label: preset.label };
}
export function createSpace(preset: DurationPreset = QUARTER, id = newId("s")): LineNote {
return { id, kind: "space", pitch: "", quarters: preset.quarters, label: preset.label };
}
/** Keep a resized line box on the page. `maxWidth` is the room the page actually has. */
export function clampLineBox(
width: number,
height: number,
maxWidth: number,
): { width: number; height: number } {
const limit = Math.max(160, maxWidth);
const minWidth = Math.min(LINE_BOX.minWidth, limit);
return {
width: Math.round(Math.min(limit, Math.max(minWidth, width))),
height: Math.round(Math.min(LINE_BOX.maxHeight, Math.max(LINE_BOX.minHeight, height))),
};
}
export function insertNote(notes: LineNote[], index: number, note: LineNote): LineNote[] {
const next = notes.slice();
const at = Math.max(0, Math.min(index, next.length));
next.splice(at, 0, note);
return next;
}
/** `toIndex` is the slot in the current list, counting the note that is moving. */
export function moveNote(notes: LineNote[], id: string, toIndex: number): LineNote[] {
const from = notes.findIndex((note) => note.id === id);
if (from < 0) return notes;
const next = notes.filter((note) => note.id !== id);
const adjusted = toIndex > from ? toIndex - 1 : toIndex;
const at = Math.max(0, Math.min(adjusted, next.length));
next.splice(at, 0, notes[from]);
return next;
}
export function removeNote(notes: LineNote[], id: string): LineNote[] {
return notes.filter((note) => note.id !== id);
}
const DURATION_ORDER = [...DURATION_PRESETS].sort((a, b) => a.quarters - b.quarters);
/** The next longer or shorter preset. Null at either end of the list. */
export function neighborDuration(quarters: number, direction: -1 | 1): DurationPreset | null {
const index = DURATION_ORDER.findIndex((preset) => Math.abs(preset.quarters - quarters) < 0.001);
if (index < 0) return null;
return DURATION_ORDER[index + direction] ?? null;
}
export function setDuration(notes: LineNote[], id: string, preset: DurationPreset): LineNote[] {
return notes.map((note) =>
note.id === id ? { ...note, quarters: preset.quarters, label: preset.label } : note,
);
}
export function cloneLine(notes: LineNote[]): LineNote[] {
return notes.map((note) => ({ ...note, id: newId("n") }));
}
/** -1 when the pointer is outside the line. Otherwise the insertion slot from left to right. */
export function dropIndexFromPoint(
pointerX: number,
pointerY: number,
line: { left: number; right: number; top: number; bottom: number },
noteCenters: number[],
slack = 36,
): number {
const inside =
pointerX >= line.left - slack &&
pointerX <= line.right + slack &&
pointerY >= line.top - slack &&
pointerY <= line.bottom + slack;
if (!inside) return -1;
for (let index = 0; index < noteCenters.length; index += 1) {
if (pointerX < noteCenters[index]) return index;
}
return noteCenters.length;
}
function preset(quarters: number, label: string): DurationPreset {
return { quarters, label };
}
/** Two short lines to edit. They are not a full tune. */
export const LINE_EXAMPLES = [
{
id: "twinkle",
title: "Twinkle, opening",
blurb: "Seven notes. The last one is held.",
notes: [
createNote("C5", preset(1, "quarter"), "twinkle-1"),
createNote("C5", preset(1, "quarter"), "twinkle-2"),
createNote("G5", preset(1, "quarter"), "twinkle-3"),
createNote("G5", preset(1, "quarter"), "twinkle-4"),
createNote("A5", preset(1, "quarter"), "twinkle-5"),
createNote("A5", preset(1, "quarter"), "twinkle-6"),
createNote("G5", preset(2, "half"), "twinkle-7"),
],
},
{
id: "ode",
title: "Ode, held note",
blurb: "A dotted quarter, an eighth, and a half note.",
notes: [
createNote("E5", preset(1.5, "dotted quarter"), "ode-1"),
createNote("D5", preset(0.5, "eighth"), "ode-2"),
createNote("D5", preset(2, "half"), "ode-3"),
],
},
] as const;