diff --git a/app.js b/app.js index e69de29..b957167 100644 --- a/app.js +++ b/app.js @@ -0,0 +1,1065 @@ +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();