538 lines
22 KiB
JavaScript
538 lines
22 KiB
JavaScript
/* score-reader.js — Page navigation, zoom and pan for the full score reader.
|
|
|
|
Configuration is read from #score-reader-stage data attributes:
|
|
data-page-count — total number of score pages
|
|
data-pages — comma-separated list of absolute page SVG URLs
|
|
|
|
Pages are fetched and inlined as live SVG rather than pointed at with an
|
|
<img>. Three things follow from that, and they are the reason for it:
|
|
the notation inherits the theme's `color` (an <img>-loaded SVG resolves
|
|
`currentColor` against its own root, so it can only ever draw black);
|
|
zooming is a width change on a vector, not a rescaled bitmap; and the
|
|
elements are in the DOM, which is what any later staff- or measure-level
|
|
feature will need. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
var stage = document.getElementById('score-reader-stage');
|
|
var viewport = document.getElementById('score-reader-viewport');
|
|
var pageEl = document.getElementById('score-page');
|
|
var folio = document.getElementById('score-folio');
|
|
var prevBtn = document.getElementById('score-prev');
|
|
var nextBtn = document.getElementById('score-next');
|
|
var bar = document.getElementById('score-reader-bar');
|
|
|
|
if (!stage || !viewport || !pageEl || !folio || !prevBtn || !nextBtn) return;
|
|
|
|
var pages = (stage.dataset.pages || '').split(',').filter(function (p) {
|
|
return p.length > 0;
|
|
});
|
|
var pageCount = pages.length;
|
|
if (pageCount === 0) return; /* nothing to display */
|
|
|
|
var currentPage = 1;
|
|
var ratio = null; /* page width / height, learned from page 1 */
|
|
var naturalW = null; /* page width in CSS px at 100 % */
|
|
var cache = new Map();
|
|
|
|
/* ------------------------------------------------------------------
|
|
Fetching and inlining
|
|
------------------------------------------------------------------ */
|
|
|
|
/* An ordinary web link, a fragment, or a relative path. Mirrors the
|
|
convention Catalog.safeHref applies on the Haskell side. */
|
|
var SAFE_HREF = /^(https?:|\/|#|\.)/i;
|
|
|
|
/* innerHTML and adoptNode never execute a <script>, but inline `on*`
|
|
handlers and SVG anchors survive both — <a xlink:href="javascript:…">
|
|
around a notehead is clickable. Strip them here, with the browser's
|
|
own parser, rather than in a build-time text pass: a hand-rolled
|
|
scanner over a sixty-page orchestral score risks corrupting valid
|
|
markup, which is a worse failure than the one it would prevent.
|
|
|
|
Odd hrefs are the common case, not the exotic one. LilyPond emits
|
|
point-and-click anchors by default — hundreds per page, each holding
|
|
the absolute path of the source .ly — so this also keeps a local
|
|
filesystem layout from reaching a reader, and stops every notehead
|
|
from swallowing drags that should be panning the page. A `#`
|
|
fragment survives, because an engraver referencing shared glyphs
|
|
through <use href="#…"> depends on it. */
|
|
function sanitize(svg) {
|
|
Array.prototype.forEach.call(svg.querySelectorAll('script'), function (n) {
|
|
n.parentNode.removeChild(n);
|
|
});
|
|
var walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
|
|
var el = svg;
|
|
while (el) {
|
|
Array.prototype.slice.call(el.attributes).forEach(function (a) {
|
|
if (/^on/i.test(a.name)) {
|
|
el.removeAttribute(a.name);
|
|
} else if (a.localName === 'href'
|
|
&& !SAFE_HREF.test(a.value.trim())) {
|
|
el.removeAttribute(a.name);
|
|
}
|
|
});
|
|
el = walker.nextNode();
|
|
}
|
|
return svg;
|
|
}
|
|
|
|
function fetchPage(index) {
|
|
if (index < 1 || index > pageCount) return Promise.resolve(null);
|
|
if (cache.has(index)) return Promise.resolve(cache.get(index));
|
|
|
|
var p = fetch(pages[index - 1], { credentials: 'same-origin' })
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return r.text();
|
|
})
|
|
.then(function (text) {
|
|
var doc = new DOMParser().parseFromString(text, 'image/svg+xml');
|
|
if (doc.querySelector('parsererror')) throw new Error('malformed SVG');
|
|
var svg = doc.documentElement;
|
|
if (!svg || svg.localName !== 'svg') throw new Error('not an SVG');
|
|
return sanitize(svg);
|
|
})
|
|
.catch(function (err) {
|
|
cache.delete(index); /* a failure must not be cached */
|
|
throw err;
|
|
});
|
|
|
|
cache.set(index, p);
|
|
return p;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Geometry — read the page's own dimensions off the SVG
|
|
------------------------------------------------------------------ */
|
|
|
|
var UNIT_PX = {
|
|
'': 1, px: 1, pt: 96 / 72, pc: 16,
|
|
mm: 96 / 25.4, cm: 96 / 2.54, 'in': 96
|
|
};
|
|
|
|
function aspectOf(svg) {
|
|
var vb = svg.getAttribute('viewBox');
|
|
if (vb) {
|
|
var p = vb.trim().split(/[\s,]+/).map(Number);
|
|
if (p.length === 4 && p[2] > 0 && p[3] > 0) return p[2] / p[3];
|
|
}
|
|
/* width and height carry the same unit, so the ratio needs no
|
|
conversion even when that unit is millimetres. */
|
|
var w = parseFloat(svg.getAttribute('width'));
|
|
var h = parseFloat(svg.getAttribute('height'));
|
|
if (w > 0 && h > 0) return w / h;
|
|
return null;
|
|
}
|
|
|
|
function naturalWidthOf(svg) {
|
|
var raw = svg.getAttribute('width');
|
|
if (!raw) return null;
|
|
var m = /^\s*([0-9.]+)\s*([a-z%]*)\s*$/i.exec(raw);
|
|
if (!m) return null;
|
|
var unit = (m[2] || '').toLowerCase();
|
|
if (!(unit in UNIT_PX)) return null; /* %, em, … are not resolvable here */
|
|
return parseFloat(m[1]) * UNIT_PX[unit];
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Zoom
|
|
|
|
Zoom sets the sheet's width in pixels and lets the SVG scale into
|
|
it, so the notation stays vector-crisp at every level. Panning is
|
|
the viewport's native scrolling — no transform bookkeeping, and
|
|
touch momentum comes free.
|
|
------------------------------------------------------------------ */
|
|
|
|
var ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6];
|
|
var mode = 'fit-height'; /* 'fit-height' | 'fit-width' | 'zoom' */
|
|
var zoomFactor = 1;
|
|
|
|
var modeBtn = document.querySelector('[data-action="zoom-cycle"]');
|
|
var inBtn = document.querySelector('[data-action="zoom-in"]');
|
|
var outBtn = document.querySelector('[data-action="zoom-out"]');
|
|
|
|
function available() {
|
|
var cs = getComputedStyle(viewport);
|
|
return {
|
|
w: viewport.clientWidth
|
|
- parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight),
|
|
h: viewport.clientHeight
|
|
- parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
|
};
|
|
}
|
|
|
|
function applySize() {
|
|
if (!ratio) return;
|
|
var avail = available();
|
|
var w;
|
|
if (mode === 'fit-width') w = avail.w;
|
|
else if (mode === 'fit-height') w = avail.h * ratio;
|
|
else w = (naturalW || avail.h * ratio) * zoomFactor;
|
|
|
|
pageEl.style.setProperty('--score-page-width',
|
|
Math.max(80, Math.round(w)) + 'px');
|
|
updateZoomUi();
|
|
updateGutter();
|
|
requestAnimationFrame(updatePanState);
|
|
}
|
|
|
|
/* At fit, the folio sits in the page's own bottom margin and reads as a
|
|
printed page number. Zoomed in it floats over the staves instead, so
|
|
it needs a ground of its own to stop reading as ink on the music. */
|
|
function updatePanState() {
|
|
document.body.classList.toggle('is-panning',
|
|
viewport.scrollWidth > viewport.clientWidth + 1 ||
|
|
viewport.scrollHeight > viewport.clientHeight + 1);
|
|
}
|
|
|
|
/* The zoom level the sheet is currently at, whatever mode produced it —
|
|
so stepping out of a fit mode continues from what is on screen rather
|
|
than jumping back to whatever the last explicit zoom was. */
|
|
function effectiveFactor() {
|
|
var w = pageEl.getBoundingClientRect().width;
|
|
if (!naturalW || !w) return zoomFactor;
|
|
return w / naturalW;
|
|
}
|
|
|
|
function updateZoomUi() {
|
|
if (!modeBtn) return;
|
|
if (mode === 'fit-height') modeBtn.textContent = 'Fit';
|
|
else if (mode === 'fit-width') modeBtn.textContent = 'Width';
|
|
else modeBtn.textContent = Math.round(effectiveFactor() * 100) + '%';
|
|
|
|
var f = effectiveFactor();
|
|
if (inBtn) inBtn.disabled = f >= ZOOM_STEPS[ZOOM_STEPS.length - 1] - 0.001;
|
|
if (outBtn) outBtn.disabled = f <= ZOOM_STEPS[0] + 0.001;
|
|
}
|
|
|
|
function step(direction) {
|
|
var f = effectiveFactor();
|
|
var next = null;
|
|
if (direction > 0) {
|
|
for (var i = 0; i < ZOOM_STEPS.length; i++) {
|
|
if (ZOOM_STEPS[i] > f + 0.001) { next = ZOOM_STEPS[i]; break; }
|
|
}
|
|
} else {
|
|
for (var j = ZOOM_STEPS.length - 1; j >= 0; j--) {
|
|
if (ZOOM_STEPS[j] < f - 0.001) { next = ZOOM_STEPS[j]; break; }
|
|
}
|
|
}
|
|
if (next === null) return;
|
|
mode = 'zoom';
|
|
zoomFactor = next;
|
|
applySize();
|
|
}
|
|
|
|
if (inBtn) inBtn.addEventListener('click', function () { step(1); });
|
|
if (outBtn) outBtn.addEventListener('click', function () { step(-1); });
|
|
if (modeBtn) modeBtn.addEventListener('click', function () {
|
|
mode = (mode === 'fit-height') ? 'fit-width' : 'fit-height';
|
|
applySize();
|
|
});
|
|
|
|
window.addEventListener('resize', applySize);
|
|
viewport.addEventListener('scroll', function () {
|
|
updatePanState();
|
|
updateGutter();
|
|
}, { passive: true });
|
|
|
|
/* ------------------------------------------------------------------
|
|
Navigation
|
|
------------------------------------------------------------------ */
|
|
|
|
/* Keep the canonical URL clean on a plain load: ?p= is only written
|
|
back once one was already present, or the reader has been navigated. */
|
|
var qs = new URLSearchParams(window.location.search);
|
|
var syncUrl = qs.has('p');
|
|
var initial = parseInt(qs.get('p'), 10);
|
|
if (!isNaN(initial) && initial >= 1 && initial <= pageCount) currentPage = initial;
|
|
|
|
function render(index, svg) {
|
|
if (index !== currentPage) return; /* a later turn already won */
|
|
|
|
var node = svg.cloneNode(true);
|
|
node.setAttribute('aria-hidden', 'true');
|
|
node.removeAttribute('width');
|
|
node.removeAttribute('height');
|
|
if (!node.getAttribute('preserveAspectRatio')) {
|
|
node.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
}
|
|
|
|
if (ratio === null) {
|
|
ratio = aspectOf(svg);
|
|
naturalW = naturalWidthOf(svg);
|
|
/* Page shape picks the opening mode. Fit-height shows a whole
|
|
landscape page and stays readable; on a portrait page it
|
|
leaves most of a landscape window empty and, for a large
|
|
ensemble, shrinks the staves past reading. Only the initial
|
|
mode — a later Fit/Width press is the reader's own. */
|
|
if (ratio !== null && ratio < 1) mode = 'fit-width';
|
|
if (ratio) pageEl.style.setProperty('--score-aspect-num', String(ratio));
|
|
}
|
|
|
|
pageEl.replaceChildren(node);
|
|
pageEl.classList.remove('is-loading');
|
|
|
|
/* Size before measuring: the gutter is a fraction of the laid-out
|
|
page, and every page carries its own — a first page prints full
|
|
instrument names where later ones abbreviate. */
|
|
applySize();
|
|
buildGutter(node);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Instrument-label gutter
|
|
|
|
Zoomed in far enough to read a large-ensemble page, the left column
|
|
of names scrolls off and there is no way to tell which staff is
|
|
which. That is what makes a 33-stave score unreadable rather than
|
|
merely awkward, so a second copy of the page — clipped to that
|
|
column and pinned to the viewport's left edge — keeps the names in
|
|
view at any zoom.
|
|
|
|
Where the column ends is measured, not declared. The leftmost long
|
|
horizontal rule on a page is where the staves begin; everything left
|
|
of it is names and brackets. Measuring through getBoundingClientRect
|
|
rather than parsing coordinates keeps this independent of each
|
|
engraver's units and nested transforms: MuseScore draws staff lines
|
|
as <polyline> in page coordinates and LilyPond as <line> inside
|
|
translated groups, and both measure the same way.
|
|
------------------------------------------------------------------ */
|
|
|
|
var gutterEl = null;
|
|
var gutterInner = null;
|
|
var gutterFrac = 0;
|
|
|
|
function measureGutter(svgEl) {
|
|
var box = svgEl.getBoundingClientRect();
|
|
if (!box.width) return 0;
|
|
|
|
/* A staff line runs the width of its system. Stems, ledger lines
|
|
and barlines do not, which is the whole filter. */
|
|
var minRun = box.width * 0.2;
|
|
var left = Infinity;
|
|
var rules = svgEl.querySelectorAll('line, polyline');
|
|
|
|
for (var i = 0; i < rules.length; i++) {
|
|
var r = rules[i].getBoundingClientRect();
|
|
if (r.height > 4 || r.width < minRun) continue;
|
|
if (r.left < left) left = r.left;
|
|
}
|
|
if (left === Infinity) return 0;
|
|
|
|
var frac = (left - box.left) / box.width;
|
|
/* Reject a page with no label column and a measurement that has
|
|
swallowed most of the page; neither deserves a gutter. */
|
|
return (frac > 0.02 && frac < 0.4) ? frac : 0;
|
|
}
|
|
|
|
function buildGutter(node) {
|
|
if (gutterEl && gutterEl.parentNode) gutterEl.parentNode.removeChild(gutterEl);
|
|
gutterEl = null;
|
|
gutterFrac = measureGutter(node);
|
|
if (!gutterFrac) return;
|
|
|
|
gutterInner = document.createElement('div');
|
|
gutterInner.className = 'score-gutter-inner';
|
|
gutterInner.appendChild(node.cloneNode(true));
|
|
|
|
gutterEl = document.createElement('div');
|
|
gutterEl.className = 'score-gutter';
|
|
gutterEl.setAttribute('aria-hidden', 'true'); /* the page already says this */
|
|
gutterEl.appendChild(gutterInner);
|
|
pageEl.appendChild(gutterEl);
|
|
updateGutter();
|
|
}
|
|
|
|
function updateGutter() {
|
|
if (!gutterEl) return;
|
|
var scrollLeft = viewport.scrollLeft;
|
|
var pageW = pageEl.getBoundingClientRect().width;
|
|
|
|
/* Both widths come from the laid-out sheet rather than from
|
|
--score-page-width. The copy has to be exactly as wide as the
|
|
page for its labels to land where the originals do, and reading
|
|
the box directly means that holds however the width was arrived
|
|
at — a fit expression, a zoom step, or a stylesheet override. */
|
|
gutterInner.style.width = pageW + 'px';
|
|
|
|
/* The label column is a fixed share of the page, so the deeper the
|
|
zoom the more of the window it would claim — 42% of a 1600px
|
|
viewport at 4600px wide, which is absurd for a row of names.
|
|
Past a third of the window it is condensed horizontally instead
|
|
of being allowed to grow or being clipped: scaleX leaves every y
|
|
coordinate alone, so the names stay level with their own staves,
|
|
and condensed type is still readable where a truncated name is
|
|
not. */
|
|
var natural = pageW * gutterFrac;
|
|
var cap = viewport.clientWidth * 0.33;
|
|
var shown = Math.min(natural, cap);
|
|
var squeeze = natural > 0 ? shown / natural : 1;
|
|
|
|
gutterInner.style.transformOrigin = '0 0';
|
|
gutterInner.style.transform =
|
|
squeeze < 1 ? 'scaleX(' + squeeze + ')' : '';
|
|
gutterEl.style.setProperty('--score-gutter-w',
|
|
Math.round(shown) + 'px');
|
|
gutterEl.style.transform = 'translateX(' + scrollLeft + 'px)';
|
|
/* Only once the real labels have actually scrolled out from under it;
|
|
before that the gutter would be an exact copy of what is already
|
|
on screen, and its edge rule would be the only visible difference. */
|
|
gutterEl.classList.toggle('is-visible', scrollLeft > 1);
|
|
}
|
|
|
|
function fail(index, err) {
|
|
if (index !== currentPage) return;
|
|
pageEl.classList.remove('is-loading');
|
|
var msg = document.createElement('p');
|
|
msg.className = 'score-page-error';
|
|
msg.textContent = 'Could not load page ' + index + '.';
|
|
pageEl.replaceChildren(msg);
|
|
if (window.console) console.error('[score-reader]', err);
|
|
}
|
|
|
|
function navigate(page) {
|
|
if (page < 1 || page > pageCount || page === currentPage) return;
|
|
show(page);
|
|
}
|
|
|
|
function show(page) {
|
|
currentPage = page;
|
|
|
|
folio.textContent = page + ' / ' + pageCount;
|
|
pageEl.setAttribute('aria-label',
|
|
'Score page ' + page + ' of ' + pageCount);
|
|
prevBtn.disabled = (page === 1);
|
|
nextBtn.disabled = (page === pageCount);
|
|
updateActiveMovement();
|
|
|
|
if (syncUrl) history.replaceState(null, '', '?p=' + page);
|
|
|
|
pageEl.classList.add('is-loading');
|
|
fetchPage(page)
|
|
.then(function (svg) { render(page, svg); })
|
|
.catch(function (err) { fail(page, err); });
|
|
|
|
/* Warm the neighbours so a turn in either direction is instant. */
|
|
if (page > 1) fetchPage(page - 1).catch(function () {});
|
|
if (page < pageCount) fetchPage(page + 1).catch(function () {});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------
|
|
Movement buttons — highlight the movement containing the page
|
|
------------------------------------------------------------------ */
|
|
|
|
var mvtButtons = Array.prototype.slice.call(
|
|
document.querySelectorAll('.score-reader-mvt'));
|
|
|
|
function updateActiveMovement() {
|
|
var active = null;
|
|
mvtButtons.forEach(function (btn) {
|
|
var p = parseInt(btn.dataset.page, 10);
|
|
if (!isNaN(p) && p <= currentPage) active = btn;
|
|
});
|
|
mvtButtons.forEach(function (btn) {
|
|
btn.classList.toggle('is-active', btn === active);
|
|
});
|
|
}
|
|
|
|
mvtButtons.forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var p = parseInt(btn.dataset.page, 10);
|
|
if (!isNaN(p)) navigate(p);
|
|
});
|
|
});
|
|
|
|
prevBtn.addEventListener('click', function () { navigate(currentPage - 1); });
|
|
nextBtn.addEventListener('click', function () { navigate(currentPage + 1); });
|
|
|
|
/* ------------------------------------------------------------------
|
|
Keyboard
|
|
------------------------------------------------------------------ */
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
var panel = document.querySelector('.settings-panel');
|
|
if (panel && panel.classList.contains('is-open')) return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight': case 'ArrowDown': case 'PageDown': case ' ':
|
|
navigate(currentPage + 1); e.preventDefault(); break;
|
|
case 'ArrowLeft': case 'ArrowUp': case 'PageUp':
|
|
navigate(currentPage - 1); e.preventDefault(); break;
|
|
case 'Home':
|
|
navigate(1); e.preventDefault(); break;
|
|
case 'End':
|
|
navigate(pageCount); e.preventDefault(); break;
|
|
case '+': case '=':
|
|
step(1); e.preventDefault(); break;
|
|
case '-': case '_':
|
|
step(-1); e.preventDefault(); break;
|
|
case '0':
|
|
mode = 'fit-height'; applySize(); e.preventDefault(); break;
|
|
case 'Escape':
|
|
history.back(); break;
|
|
}
|
|
});
|
|
|
|
/* ------------------------------------------------------------------
|
|
Touch — swipe to turn, but only when there is nothing to pan.
|
|
Once the sheet is wider than the viewport a horizontal drag is the
|
|
reader panning across the system, and stealing it to turn the page
|
|
would make an orchestral score unreadable.
|
|
------------------------------------------------------------------ */
|
|
|
|
var touchX = 0, touchY = 0;
|
|
|
|
viewport.addEventListener('touchstart', function (e) {
|
|
touchX = e.changedTouches[0].clientX;
|
|
touchY = e.changedTouches[0].clientY;
|
|
}, { passive: true });
|
|
|
|
viewport.addEventListener('touchend', function (e) {
|
|
if (viewport.scrollWidth > viewport.clientWidth + 1) return; /* pannable */
|
|
var dx = e.changedTouches[0].clientX - touchX;
|
|
var dy = e.changedTouches[0].clientY - touchY;
|
|
if (Math.abs(dx) < 50 || Math.abs(dy) > 30) return;
|
|
navigate(currentPage + (dx < 0 ? 1 : -1));
|
|
}, { passive: true });
|
|
|
|
/* ------------------------------------------------------------------
|
|
Idle — let the chrome recede so the sheet is the page
|
|
------------------------------------------------------------------ */
|
|
|
|
var IDLE_MS = 3000;
|
|
var idleTimer = null;
|
|
|
|
function poke() {
|
|
document.body.classList.remove('is-idle');
|
|
clearTimeout(idleTimer);
|
|
idleTimer = setTimeout(function () {
|
|
var panel = document.querySelector('.settings-panel');
|
|
if (panel && panel.classList.contains('is-open')) return poke();
|
|
if (bar && bar.matches(':hover')) return poke();
|
|
if (bar && bar.contains(document.activeElement)) return poke();
|
|
document.body.classList.add('is-idle');
|
|
}, IDLE_MS);
|
|
}
|
|
|
|
['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel', 'focusin']
|
|
.forEach(function (evt) {
|
|
document.addEventListener(evt, poke, { passive: true });
|
|
});
|
|
|
|
/* ------------------------------------------------------------------
|
|
Init
|
|
------------------------------------------------------------------ */
|
|
|
|
/* Hands sizing over to applySize(); until this lands, the stylesheet's
|
|
own fit-height rules hold the sheet at the right size. */
|
|
document.body.classList.add('js-ready');
|
|
|
|
show(currentPage);
|
|
applySize();
|
|
poke();
|
|
syncUrl = true; /* every later navigate() is a user action */
|
|
}());
|