1066 lines
32 KiB
JavaScript
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();
|