Files
scores/app.js
T
2026-08-28 18:25:04 +02:00

1066 lines
32 KiB
JavaScript

const {
Accidental,
Articulation,
Formatter,
Renderer,
Stave,
StaveHairpin,
StaveNote,
Voice
} = Vex.Flow;
const scoreEl = document.getElementById("score");
const removeBtn = document.getElementById("remove-note");
const clearBtn = document.getElementById("clear-score");
const exportBtn = document.getElementById("export-json");
const importBtn = document.getElementById("import-json");
const tsNumButton = document.getElementById("ts-num-button");
const tsDenButton = document.getElementById("ts-den-button");
const tsNumSelect = document.getElementById("ts-num-select");
const tsDenSelect = document.getElementById("ts-den-select");
const clefValueContainer = document.getElementById("clef-values");
const systemTabs = document.getElementById("system-tabs");
const addSystemBtn = document.getElementById("add-system");
const removeSystemBtn = document.getElementById("remove-system");
const noteValueContainer = document.getElementById("note-values");
const restValueContainer = document.getElementById("rest-values");
const symbolValueContainer = document.getElementById("symbol-values");
const CLEF_PITCHES = {
treble: [
"g/5", "f/5", "e/5", "d/5", "c/5", "b/4", "a/4", "g/4", "f/4", "e/4", "d/4", "c/4", "b/3", "a/3", "g/3", "f/3", "e/3", "d/3", "c/3"
],
bass: [
"b/4", "a/4", "g/4", "f/4", "e/4", "d/4", "c/4", "b/3", "a/3", "g/3", "f/3", "e/3", "d/3", "c/3", "b/2", "a/2", "g/2", "f/2", "e/2"
],
alto: [
"e/5", "d/5", "c/5", "b/4", "a/4", "g/4", "f/4", "e/4", "d/4", "c/4", "b/3", "a/3", "g/3", "f/3", "e/3", "d/3", "c/3", "b/2", "a/2"
]
};
const DYNAMIC_LABELS = {
"dyn-pp": "pp",
"dyn-p": "p",
"dyn-mp": "mp",
"dyn-mf": "mf",
"dyn-f": "f",
"dyn-ff": "ff"
};
const STAVE_X = 30;
const STAVE_TOP = 60;
const STAVE_GAP_Y = 145;
const STAVE_MIN_WIDTH = 860;
const STAVE_SIDE_PADDING = 30;
const FIXED_NOTE_START_X = 300;
const STAVE_RIGHT_PADDING = 40;
const MIN_NOTE_SPACING = 54;
const BEAT_EPSILON = 0.001;
const DURATION_TO_DENOMINATOR = {
w: 1,
h: 2,
q: 4,
"8": 8,
"16": 16
};
const ALLOWED_DENOMINATORS = [1, 2, 4, 8, 16];
let systemCounter = 1;
function createDefaultSystem(name, clef = "treble") {
systemCounter += 1;
return {
id: `sys-${systemCounter}`,
name,
clef,
events: [
{ key: "c/4", duration: "q", rest: false, accidental: "", articulation: "", dynamic: "" },
{ key: "e/4", duration: "q", rest: false, accidental: "", articulation: "", dynamic: "" },
{ key: "g/4", duration: "q", rest: false, accidental: "", articulation: "", dynamic: "" }
],
symbols: [],
voltas: [],
pickupBeats: null,
selectedIndex: -1
};
}
const initialSystem = createDefaultSystem("Systeem 1", "treble");
const STATE = {
systems: [initialSystem],
activeSystemId: initialSystem.id,
timeSignature: { num: 4, den: 4 },
mode: "note",
selectedDuration: "w",
selectedSymbol: "",
dragging: null,
render: {
rows: [],
hitboxes: []
}
};
function getSystemById(systemId) {
return STATE.systems.find((system) => system.id === systemId) || null;
}
function getActiveSystem() {
return getSystemById(STATE.activeSystemId) || STATE.systems[0] || null;
}
function ensureSystemSelection() {
if (!getActiveSystem() && STATE.systems.length) {
STATE.activeSystemId = STATE.systems[0].id;
}
}
function renderSystemTabs() {
systemTabs.innerHTML = "";
STATE.systems.forEach((system) => {
const button = document.createElement("button");
button.type = "button";
button.className = `system-tab ${system.id === STATE.activeSystemId ? "active" : ""}`;
button.textContent = system.name;
button.addEventListener("click", () => {
STATE.activeSystemId = system.id;
highlightChips();
drawScore();
});
systemTabs.appendChild(button);
});
removeSystemBtn.disabled = STATE.systems.length <= 1;
}
function addSystem() {
const newIndex = STATE.systems.length + 1;
const newSystem = createDefaultSystem(`Systeem ${newIndex}`, "treble");
newSystem.events = [];
STATE.systems.push(newSystem);
STATE.activeSystemId = newSystem.id;
renderSystemTabs();
syncClefUI();
drawScore();
}
function removeActiveSystem() {
if (STATE.systems.length <= 1) {
return;
}
const index = STATE.systems.findIndex((system) => system.id === STATE.activeSystemId);
if (index < 0) {
return;
}
STATE.systems.splice(index, 1);
const fallback = STATE.systems[Math.max(0, index - 1)] || STATE.systems[0];
STATE.activeSystemId = fallback.id;
renderSystemTabs();
syncClefUI();
drawScore();
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function moveItem(array, from, to) {
if (from === to || from < 0 || to < 0 || from >= array.length || to >= array.length) {
return;
}
const [item] = array.splice(from, 1);
array.splice(to, 0, item);
}
function getRowForY(y) {
const rows = STATE.render.rows;
if (!rows.length) {
return null;
}
const inRange = rows.find((row) => y >= row.top && y <= row.bottom);
if (inRange) {
return inRange;
}
if (y < rows[0].top) {
return rows[0];
}
if (y > rows[rows.length - 1].bottom) {
return rows[rows.length - 1];
}
return rows.reduce((closest, row) => {
const rowMiddle = (row.top + row.bottom) / 2;
const closeMiddle = (closest.top + closest.bottom) / 2;
return Math.abs(y - rowMiddle) < Math.abs(y - closeMiddle) ? row : closest;
});
}
function yToPitch(y) {
const row = getRowForY(y);
if (!row) {
return "c/4";
}
const system = getSystemById(row.systemId) || getActiveSystem();
const pitches = CLEF_PITCHES[system?.clef || "treble"] || CLEF_PITCHES.treble;
const top = row.top;
const bottom = row.bottom;
const ratio = clamp((y - top) / (bottom - top), 0, 1);
const idx = Math.round(ratio * (pitches.length - 1));
return pitches[idx];
}
function xToInsertIndex(x, y, preferredSystemId = null) {
const system = preferredSystemId ? getSystemById(preferredSystemId) : null;
const activeSystem = system || getActiveSystem();
if (!activeSystem || !activeSystem.events.length) {
return 0;
}
const row = getRowForY(y);
const effectiveSystemId = preferredSystemId || row?.systemId || activeSystem.id;
if (!row && !effectiveSystemId) {
return activeSystem.events.length;
}
const hitboxes = STATE.render.hitboxes
.filter((box) => box.systemId === effectiveSystemId && (!row || box.rowIndex === row.rowIndex))
.sort((a, b) => a.centerX - b.centerX);
if (!hitboxes.length) {
if (!row) {
return activeSystem.events.length;
}
return row.startIndex < 0 ? 0 : row.startIndex;
}
for (let i = 0; i < hitboxes.length; i += 1) {
if (x < hitboxes[i].centerX) {
return hitboxes[i].index;
}
}
return row ? row.endIndex + 1 : activeSystem.events.length;
}
function symbolToAccidental(symbol) {
if (symbol === "flat") {
return "b";
}
if (symbol === "sharp") {
return "#";
}
if (symbol === "natural") {
return "n";
}
return "";
}
function sanitizeTimeSignature(num, den) {
const safeNum = clamp(Math.round(Number(num) || 4), 1, 16);
const parsedDen = Math.round(Number(den) || 4);
const safeDen = ALLOWED_DENOMINATORS.includes(parsedDen) ? parsedDen : 4;
return { num: safeNum, den: safeDen };
}
function sanitizeClef(value) {
if (["treble", "bass", "alto"].includes(value)) {
return value;
}
return "treble";
}
function getMeasureBeats() {
return STATE.timeSignature.num;
}
function eventToBeats(event) {
const durationDen = DURATION_TO_DENOMINATOR[event.duration] || 4;
return STATE.timeSignature.den / durationDen;
}
function totalBeats(system) {
return (system?.events || []).reduce((sum, event) => sum + eventToBeats(event), 0);
}
function getPickupBeats(system) {
const measureBeats = getMeasureBeats();
if (typeof system?.pickupBeats === "number" && system.pickupBeats >= 0 && system.pickupBeats < measureBeats) {
return system.pickupBeats;
}
const total = totalBeats(system);
const remainder = total % measureBeats;
if (Math.abs(remainder) < BEAT_EPSILON) {
return 0;
}
// If the total does not end on a full measure, assume complementary opmaat/neermaat.
return measureBeats - remainder;
}
function getMeasureLayout(system) {
const boundaries = new Set();
const measures = [];
const events = system?.events || [];
if (!events.length) {
return { boundaries, measures };
}
const pickup = getPickupBeats(system);
const measureBeats = getMeasureBeats();
let nextBoundary = pickup > BEAT_EPSILON ? pickup : measureBeats;
let cursor = 0;
let measureStart = 0;
let measureNumber = 1;
events.forEach((event, index) => {
const eventEnd = cursor + eventToBeats(event);
// If a boundary falls inside a note/rest duration, snap it to the current note end.
if (eventEnd >= nextBoundary - BEAT_EPSILON) {
boundaries.add(index);
measures.push({ number: measureNumber, startIndex: measureStart, endIndex: index });
measureNumber += 1;
measureStart = index + 1;
nextBoundary = eventEnd + measureBeats;
}
cursor = eventEnd;
});
if (measureStart <= events.length - 1) {
measures.push({ number: measureNumber, startIndex: measureStart, endIndex: events.length - 1 });
}
return { boundaries, measures };
}
function applySymbol(index) {
const system = getActiveSystem();
if (!system || index < 0 || index >= system.events.length || !STATE.selectedSymbol) {
return;
}
const event = system.events[index];
const measureLayout = getMeasureLayout(system);
if (["volta-1", "volta-2"].includes(STATE.selectedSymbol)) {
const targetMeasure = measureLayout.measures.find((measure) => index >= measure.startIndex && index <= measure.endIndex);
if (!targetMeasure) {
return;
}
const label = STATE.selectedSymbol === "volta-1" ? "1." : "2.";
const filtered = system.voltas.filter((volta) => volta.measureNumber !== targetMeasure.number);
filtered.push({ measureNumber: targetMeasure.number, label });
system.voltas = filtered;
return;
}
if (event.rest) {
return;
}
if (STATE.selectedSymbol.startsWith("dyn-") || STATE.selectedSymbol === "forte") {
event.dynamic = STATE.selectedSymbol === "forte" ? "dyn-f" : STATE.selectedSymbol;
return;
}
if (["flat", "sharp", "natural"].includes(STATE.selectedSymbol)) {
event.accidental = symbolToAccidental(STATE.selectedSymbol);
return;
}
if (["staccato", "tenuto"].includes(STATE.selectedSymbol)) {
event.articulation = STATE.selectedSymbol;
return;
}
if (["crescendo", "decrescendo"].includes(STATE.selectedSymbol)) {
const endIndex = clamp(index + 1, 0, system.events.length - 1);
system.symbols.push({
type: STATE.selectedSymbol,
from: index,
to: endIndex
});
}
}
function createStaveNote(event, clef) {
const duration = event.rest ? `${event.duration}r` : event.duration;
const staveNote = new StaveNote({
clef,
keys: [event.rest ? "b/4" : event.key],
duration
});
if (!event.rest && event.accidental) {
staveNote.addModifier(new Accidental(event.accidental), 0);
}
if (!event.rest && event.articulation === "staccato") {
staveNote.addModifier(new Articulation("a.").setPosition(3), 0);
}
if (!event.rest && event.articulation === "tenuto") {
staveNote.addModifier(new Articulation("a-").setPosition(3), 0);
}
return staveNote;
}
function drawScore() {
scoreEl.innerHTML = "";
STATE.render.hitboxes = [];
STATE.render.rows = [];
ensureSystemSelection();
const width = Math.max(STAVE_MIN_WIDTH, scoreEl.clientWidth || STAVE_MIN_WIDTH);
const staveWidth = width - STAVE_X - STAVE_SIDE_PADDING;
const fixedStartX = clamp(
FIXED_NOTE_START_X,
STAVE_X + 120,
STAVE_X + staveWidth - 120
);
const availableNoteWidth = Math.max(
180,
STAVE_X + staveWidth - STAVE_RIGHT_PADDING - fixedStartX
);
const notesPerRow = Math.max(1, Math.floor(availableNoteWidth / MIN_NOTE_SPACING));
const systemRowCounts = STATE.systems.map((system) => Math.max(1, Math.ceil(Math.max(1, system.events.length) / notesPerRow)));
const maxSystemRows = Math.max(1, ...systemRowCounts);
const totalRows = Math.max(1, maxSystemRows * Math.max(1, STATE.systems.length));
const height = Math.max(320, STAVE_TOP + totalRows * STAVE_GAP_Y + 40);
const renderer = new Renderer(scoreEl, Renderer.Backends.SVG);
renderer.resize(width, height);
const context = renderer.getContext();
const timeSignatureLabel = `${STATE.timeSignature.num}/${STATE.timeSignature.den}`;
const svg = scoreEl.querySelector("svg");
const systemRenderData = new Map();
STATE.systems.forEach((system) => {
systemRenderData.set(system.id, {
noteRefs: [],
hitboxByIndex: new Map(),
measureLayout: getMeasureLayout(system)
});
});
for (let rowLayer = 0; rowLayer < maxSystemRows; rowLayer += 1) {
for (let systemIndex = 0; systemIndex < STATE.systems.length; systemIndex += 1) {
const system = STATE.systems[systemIndex];
const drawData = systemRenderData.get(system.id);
const globalRowIndex = rowLayer * STATE.systems.length + systemIndex;
const startIndex = rowLayer * notesPerRow;
const endIndex = Math.min(startIndex + notesPerRow, system.events.length);
const rowEvents = system.events.slice(startIndex, endIndex);
const y = STAVE_TOP + globalRowIndex * STAVE_GAP_Y;
const stave = new Stave(STAVE_X, y, staveWidth);
stave.addClef(system.clef).addTimeSignature(timeSignatureLabel);
if (typeof stave.setNoteStartX === "function") {
stave.setNoteStartX(fixedStartX);
}
stave.setContext(context).draw();
STATE.render.rows.push({
rowIndex: globalRowIndex,
rowLayer,
systemId: system.id,
stave,
startIndex: rowEvents.length ? startIndex : -1,
endIndex: rowEvents.length ? endIndex - 1 : -1,
top: stave.getYForLine(0) - 26,
bottom: stave.getYForLine(4) + 28
});
const renderEvents = rowEvents.length
? rowEvents
: [{ key: "b/4", duration: "w", rest: true, accidental: "", articulation: "", dynamic: "" }];
const staveNotes = renderEvents.map((event) => createStaveNote(event, system.clef));
const voice = new Voice({ num_beats: STATE.timeSignature.num, beat_value: STATE.timeSignature.den });
voice.setMode(Voice.Mode.SOFT);
voice.addTickables(staveNotes);
new Formatter().joinVoices([voice]).format([voice], availableNoteWidth);
voice.draw(context, stave);
staveNotes.forEach((note, localIndex) => {
const globalIndex = startIndex + localIndex;
if (!rowEvents[localIndex]) {
return;
}
drawData.noteRefs[globalIndex] = { note, rowIndex: globalRowIndex };
const box = note.getBoundingBox();
if (!box) {
return;
}
STATE.render.hitboxes.push({
index: globalIndex,
systemId: system.id,
rowIndex: globalRowIndex,
left: box.getX() - 8,
right: box.getX() + box.getW() + 8,
top: box.getY() - 8,
bottom: box.getY() + box.getH() + 8,
centerX: box.getX() + box.getW() / 2,
note
});
drawData.hitboxByIndex.set(globalIndex, STATE.render.hitboxes[STATE.render.hitboxes.length - 1]);
if (globalIndex === system.selectedIndex && system.id === STATE.activeSystemId) {
const selectedRect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
selectedRect.setAttribute("x", String(box.getX() - 10));
selectedRect.setAttribute("y", String(box.getY() - 10));
selectedRect.setAttribute("width", String(box.getW() + 20));
selectedRect.setAttribute("height", String(box.getH() + 20));
selectedRect.setAttribute("fill", "none");
selectedRect.setAttribute("stroke", "#2a83bf");
selectedRect.setAttribute("stroke-width", "2");
selectedRect.setAttribute("rx", "6");
svg.appendChild(selectedRect);
}
});
}
}
for (let rowLayer = 0; rowLayer < maxSystemRows; rowLayer += 1) {
const layerRows = STATE.render.rows
.filter((row) => row.rowLayer === rowLayer)
.sort((a, b) => a.rowIndex - b.rowIndex);
if (layerRows.length <= 1) {
continue;
}
const topRow = layerRows[0];
const bottomRow = layerRows[layerRows.length - 1];
const x = STAVE_X + 2;
const yTop = topRow.stave.getYForLine(0) - 10;
const yBottom = bottomRow.stave.getYForLine(4) + 10;
context.save();
context.setStrokeStyle("#22364f");
context.setLineWidth(2.2);
context.beginPath();
context.moveTo(x, yTop);
context.lineTo(x, yBottom);
context.stroke();
context.restore();
}
STATE.systems.forEach((system) => {
const drawData = systemRenderData.get(system.id);
const noteRefs = drawData.noteRefs;
const hitboxByIndex = drawData.hitboxByIndex;
const measureLayout = drawData.measureLayout;
const barlineAfter = measureLayout.boundaries;
barlineAfter.forEach((index) => {
const current = hitboxByIndex.get(index);
if (!current) {
return;
}
const row = STATE.render.rows[current.rowIndex];
if (!row) {
return;
}
const next = hitboxByIndex.get(index + 1);
let x = current.right + 10;
if (next && next.rowIndex === current.rowIndex) {
x = (current.right + next.left) / 2;
} else {
x = row.stave.getX() + row.stave.getWidth() - 8;
}
const yTop = row.stave.getYForLine(0) - 10;
const yBottom = row.stave.getYForLine(4) + 10;
context.save();
context.setStrokeStyle("#355170");
context.setLineWidth(1.4);
context.beginPath();
context.moveTo(x, yTop);
context.lineTo(x, yBottom);
context.stroke();
context.restore();
});
measureLayout.measures.forEach((measure) => {
const startBox = hitboxByIndex.get(measure.startIndex);
if (!startBox || measure.number <= 1) {
return;
}
const row = STATE.render.rows[startBox.rowIndex];
if (!row) {
return;
}
const number = document.createElementNS("http://www.w3.org/2000/svg", "text");
number.setAttribute("x", String(startBox.left - 6));
number.setAttribute("y", String(row.stave.getYForLine(0) - 16));
number.setAttribute("fill", "#3c5874");
number.setAttribute("font-size", "11");
number.setAttribute("font-family", "Space Grotesk, sans-serif");
number.textContent = String(measure.number);
svg.appendChild(number);
});
system.events.forEach((event, index) => {
if (!event.dynamic || !DYNAMIC_LABELS[event.dynamic]) {
return;
}
const box = hitboxByIndex.get(index);
if (!box) {
return;
}
const row = STATE.render.rows[box.rowIndex];
if (!row) {
return;
}
const dynamicText = document.createElementNS("http://www.w3.org/2000/svg", "text");
dynamicText.setAttribute("x", String(box.left));
dynamicText.setAttribute("y", String(row.stave.getYForLine(4) + 30));
dynamicText.setAttribute("fill", "#1f2a44");
dynamicText.setAttribute("font-size", "14");
dynamicText.setAttribute("font-family", "Fraunces, serif");
dynamicText.textContent = DYNAMIC_LABELS[event.dynamic];
svg.appendChild(dynamicText);
});
system.symbols.forEach((symbol) => {
const fromRef = noteRefs[symbol.from];
const toRef = noteRefs[symbol.to];
if (!fromRef || !toRef || fromRef.rowIndex !== toRef.rowIndex) {
return;
}
if (symbol.type === "crescendo" || symbol.type === "decrescendo") {
const type = symbol.type === "crescendo"
? StaveHairpin.type.CRESC
: StaveHairpin.type.DECRESC;
const hairpin = new StaveHairpin({ first_note: fromRef.note, last_note: toRef.note }, type);
hairpin.setRenderOptions({ y_shift: 24, height: 18 });
hairpin.setContext(context).draw();
}
});
system.voltas.forEach((volta) => {
const measure = measureLayout.measures.find((item) => item.number === volta.measureNumber);
if (!measure) {
return;
}
const startBox = hitboxByIndex.get(measure.startIndex);
const endBox = hitboxByIndex.get(measure.endIndex);
if (!startBox || !endBox || startBox.rowIndex !== endBox.rowIndex) {
return;
}
const row = STATE.render.rows[startBox.rowIndex];
if (!row) {
return;
}
const y = row.stave.getYForLine(0) - 30;
const x1 = startBox.left - 6;
const x2 = endBox.right + 6;
const bracket = document.createElementNS("http://www.w3.org/2000/svg", "path");
bracket.setAttribute("d", `M ${x1} ${y + 12} L ${x1} ${y} L ${x2} ${y} L ${x2} ${y + 12}`);
bracket.setAttribute("fill", "none");
bracket.setAttribute("stroke", "#2d4b6a");
bracket.setAttribute("stroke-width", "1.5");
svg.appendChild(bracket);
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
label.setAttribute("x", String(x1 + 3));
label.setAttribute("y", String(y - 2));
label.setAttribute("fill", "#2d4b6a");
label.setAttribute("font-size", "12");
label.setAttribute("font-family", "Space Grotesk, sans-serif");
label.textContent = volta.label;
svg.appendChild(label);
});
});
}
function syncTimeSignatureUI() {
tsNumButton.textContent = String(STATE.timeSignature.num);
tsDenButton.textContent = String(STATE.timeSignature.den);
tsNumSelect.value = String(STATE.timeSignature.num);
tsDenSelect.value = String(STATE.timeSignature.den);
}
function syncClefUI() {
const activeSystem = getActiveSystem();
clefValueContainer.querySelectorAll("[data-clef]").forEach((button) => {
button.classList.toggle("active", button.dataset.clef === activeSystem?.clef);
});
}
function hideTimeSignatureLists() {
tsNumSelect.hidden = true;
tsDenSelect.hidden = true;
}
function applyTimeSignatureChange(num, den) {
STATE.timeSignature = sanitizeTimeSignature(num, den);
STATE.systems.forEach((system) => {
if (typeof system.pickupBeats === "number") {
system.pickupBeats = clamp(system.pickupBeats, 0, getMeasureBeats() - BEAT_EPSILON);
}
});
syncTimeSignatureUI();
drawScore();
}
function highlightChips() {
document.querySelectorAll(".chip").forEach((chip) => chip.classList.remove("active"));
document.querySelectorAll(`[data-kind="${STATE.mode}"][data-duration="${STATE.selectedDuration}"]`)
.forEach((el) => el.classList.add("active"));
if (STATE.selectedSymbol) {
document.querySelectorAll(`[data-symbol="${STATE.selectedSymbol}"]`)
.forEach((el) => el.classList.add("active"));
}
syncClefUI();
}
function addEventAtPosition(x, y) {
const row = getRowForY(y);
const targetSystem = getSystemById(row?.systemId) || getActiveSystem();
if (!targetSystem) {
return;
}
STATE.activeSystemId = targetSystem.id;
const insertIndex = xToInsertIndex(x, y, targetSystem.id);
const pitch = yToPitch(y);
const event = {
key: pitch,
duration: STATE.selectedDuration,
rest: STATE.mode === "rest",
accidental: "",
articulation: "",
dynamic: ""
};
if (event.rest) {
event.key = "b/4";
}
targetSystem.events.splice(insertIndex, 0, event);
targetSystem.selectedIndex = insertIndex;
syncClefUI();
renderSystemTabs();
drawScore();
}
function detectHitbox(x, y) {
return STATE.render.hitboxes.find((box) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom);
}
function handleScorePointerDown(ev) {
const rect = scoreEl.getBoundingClientRect();
const x = ev.clientX - rect.left + scoreEl.scrollLeft;
const y = ev.clientY - rect.top + scoreEl.scrollTop;
const hit = detectHitbox(x, y);
if (hit) {
const hitSystem = getSystemById(hit.systemId);
if (!hitSystem) {
return;
}
STATE.activeSystemId = hit.systemId;
hitSystem.selectedIndex = hit.index;
if (STATE.selectedSymbol) {
applySymbol(hit.index);
renderSystemTabs();
drawScore();
return;
}
STATE.dragging = { index: hit.index, systemId: hit.systemId };
syncClefUI();
renderSystemTabs();
drawScore();
return;
}
if (!STATE.selectedSymbol) {
addEventAtPosition(x, y);
}
}
function handleScorePointerMove(ev) {
if (!STATE.dragging) {
return;
}
const rect = scoreEl.getBoundingClientRect();
const x = ev.clientX - rect.left + scoreEl.scrollLeft;
const y = ev.clientY - rect.top + scoreEl.scrollTop;
const currentIndex = STATE.dragging.index;
const dragSystem = getSystemById(STATE.dragging.systemId) || getActiveSystem();
if (!dragSystem) {
return;
}
const current = dragSystem.events[currentIndex];
if (!current) {
return;
}
if (!current.rest) {
current.key = yToPitch(y);
}
const targetIndex = clamp(xToInsertIndex(x, y, dragSystem.id), 0, dragSystem.events.length - 1);
if (targetIndex !== currentIndex) {
moveItem(dragSystem.events, currentIndex, targetIndex);
STATE.dragging.index = targetIndex;
dragSystem.selectedIndex = targetIndex;
}
drawScore();
}
function handleScorePointerUp() {
STATE.dragging = null;
}
function removeSelected() {
const system = getActiveSystem();
if (!system || system.selectedIndex < 0) {
return;
}
const removedIndex = system.selectedIndex;
system.events.splice(removedIndex, 1);
system.symbols = system.symbols
.filter((symbol) => symbol.from !== removedIndex && symbol.to !== removedIndex)
.map((symbol) => ({
...symbol,
from: symbol.from > removedIndex ? symbol.from - 1 : symbol.from,
to: symbol.to > removedIndex ? symbol.to - 1 : symbol.to
}));
system.selectedIndex = clamp(system.selectedIndex, 0, system.events.length - 1);
drawScore();
}
function clearScore() {
const system = getActiveSystem();
if (!system) {
return;
}
system.events = [];
system.symbols = [];
system.voltas = [];
system.selectedIndex = -1;
drawScore();
}
function exportJson() {
const payload = JSON.stringify(
{
systems: STATE.systems,
activeSystemId: STATE.activeSystemId,
timeSignature: STATE.timeSignature
},
null,
2
);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(payload)
.then(() => {
alert("JSON is gekopieerd naar je klembord.");
})
.catch(() => {
window.prompt("Kopieer deze JSON:", payload);
});
return;
}
window.prompt("Kopieer deze JSON:", payload);
}
function importJson() {
try {
const input = window.prompt("Plak hier de JSON om te importeren:");
if (input === null) {
return;
}
const parsed = JSON.parse(input);
if (!parsed || (!Array.isArray(parsed.events) && !Array.isArray(parsed.systems))) {
throw new Error("Gebruik JSON formaat met systems of events.");
}
if (Array.isArray(parsed.systems)) {
STATE.systems = parsed.systems
.filter((system) => system && Array.isArray(system.events))
.map((system, index) => ({
id: typeof system.id === "string" ? system.id : `sys-import-${index + 1}`,
name: typeof system.name === "string" ? system.name : `Systeem ${index + 1}`,
clef: sanitizeClef(system.clef),
events: system.events
.filter((event) => event && event.duration && (event.rest || event.key))
.map((event) => ({ ...event, dynamic: typeof event.dynamic === "string" ? event.dynamic : "" })),
symbols: Array.isArray(system.symbols) ? system.symbols : [],
voltas: Array.isArray(system.voltas) ? system.voltas : [],
pickupBeats: typeof system.pickupBeats === "number" ? system.pickupBeats : null,
selectedIndex: typeof system.selectedIndex === "number" ? system.selectedIndex : -1
}));
if (!STATE.systems.length) {
STATE.systems = [createDefaultSystem("Systeem 1", "treble")];
}
STATE.activeSystemId = typeof parsed.activeSystemId === "string" ? parsed.activeSystemId : STATE.systems[0].id;
} else {
const legacyEvents = parsed.events.filter((event) => event && event.duration && (event.rest || event.key));
STATE.systems = [
{
id: "sys-legacy",
name: "Systeem 1",
clef: sanitizeClef(parsed.clef),
events: legacyEvents.map((event) => ({ ...event, dynamic: typeof event.dynamic === "string" ? event.dynamic : "" })),
symbols: Array.isArray(parsed.symbols) ? parsed.symbols : [],
voltas: Array.isArray(parsed.voltas) ? parsed.voltas : [],
pickupBeats: typeof parsed.pickupBeats === "number" ? parsed.pickupBeats : null,
selectedIndex: -1
}
];
STATE.activeSystemId = STATE.systems[0].id;
}
if (parsed.timeSignature && typeof parsed.timeSignature === "object") {
STATE.timeSignature = sanitizeTimeSignature(parsed.timeSignature.num, parsed.timeSignature.den);
} else {
STATE.timeSignature = { num: 4, den: 4 };
}
ensureSystemSelection();
syncTimeSignatureUI();
syncClefUI();
renderSystemTabs();
drawScore();
} catch (error) {
alert(error.message || "Kon JSON niet importeren.");
}
}
noteValueContainer.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", () => {
STATE.mode = "note";
STATE.selectedDuration = button.dataset.duration;
STATE.selectedSymbol = "";
highlightChips();
});
});
restValueContainer.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", () => {
STATE.mode = "rest";
STATE.selectedDuration = button.dataset.duration;
STATE.selectedSymbol = "";
highlightChips();
});
});
symbolValueContainer.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", () => {
const symbol = button.dataset.symbol;
STATE.selectedSymbol = STATE.selectedSymbol === symbol ? "" : symbol;
highlightChips();
});
});
clefValueContainer.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", () => {
const system = getActiveSystem();
if (!system) {
return;
}
system.clef = sanitizeClef(button.dataset.clef);
syncClefUI();
renderSystemTabs();
drawScore();
});
});
tsNumButton.addEventListener("click", () => {
tsNumSelect.hidden = false;
tsDenSelect.hidden = true;
tsNumSelect.focus();
});
tsDenButton.addEventListener("click", () => {
tsDenSelect.hidden = false;
tsNumSelect.hidden = true;
tsDenSelect.focus();
});
tsNumSelect.addEventListener("change", () => {
applyTimeSignatureChange(Number(tsNumSelect.value), STATE.timeSignature.den);
hideTimeSignatureLists();
});
tsDenSelect.addEventListener("change", () => {
applyTimeSignatureChange(STATE.timeSignature.num, Number(tsDenSelect.value));
hideTimeSignatureLists();
});
function isTargetInsideTimeSignaturePicker(target) {
return (
tsNumButton.contains(target) ||
tsDenButton.contains(target) ||
tsNumSelect.contains(target) ||
tsDenSelect.contains(target)
);
}
window.addEventListener("pointerdown", (event) => {
const target = event.target;
if (!isTargetInsideTimeSignaturePicker(target)) {
hideTimeSignatureLists();
}
});
removeBtn.addEventListener("click", removeSelected);
clearBtn.addEventListener("click", clearScore);
exportBtn.addEventListener("click", exportJson);
importBtn.addEventListener("click", importJson);
addSystemBtn.addEventListener("click", addSystem);
removeSystemBtn.addEventListener("click", removeActiveSystem);
scoreEl.addEventListener("pointerdown", handleScorePointerDown);
scoreEl.addEventListener("pointermove", handleScorePointerMove);
window.addEventListener("pointerup", handleScorePointerUp);
highlightChips();
syncTimeSignatureUI();
syncClefUI();
renderSystemTabs();
drawScore();