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();