abcjs
Version:
Renderer for abc music notation
450 lines (407 loc) • 15.5 kB
JavaScript
var spacing = require('../helpers/spacing');
function setupSelection(engraver, svgs) {
engraver.rangeHighlight = rangeHighlight;
if (engraver.dragging) {
for (var h = 0; h < engraver.selectables.length; h++) {
var hist = engraver.selectables[h];
if (hist.svgEl.getAttribute("selectable") === "true") {
hist.svgEl.setAttribute("tabindex", 0);
hist.svgEl.setAttribute("data-index", h);
hist.svgEl.addEventListener("keydown", keyboardDown.bind(engraver));
hist.svgEl.addEventListener("keyup", keyboardSelection.bind(engraver));
hist.svgEl.addEventListener("focus", elementFocused.bind(engraver));
}
}
}
for (var i = 0; i < svgs.length; i++) {
svgs[i].addEventListener('touchstart', mouseDown.bind(engraver), { passive: true });
svgs[i].addEventListener('touchmove', mouseMove.bind(engraver), { passive: true });
svgs[i].addEventListener('touchend', mouseUp.bind(engraver), { passive: true });
svgs[i].addEventListener('mousedown', mouseDown.bind(engraver));
svgs[i].addEventListener('mousemove', mouseMove.bind(engraver));
svgs[i].addEventListener('mouseup', mouseUp.bind(engraver));
}
}
function getCoord(ev) {
var scaleX = 1;
var scaleY = 1;
var svg = ev.target.closest('svg')
var yOffset = 0
// when renderer.options.responsive === 'resize' the click coords are in relation to the HTML
// element, we need to convert to the SVG viewBox coords
if (svg && svg.viewBox && svg.viewBox.baseVal) { // Firefox passes null to this when no viewBox is given
// Chrome makes these values null when no viewBox is given.
if (svg.viewBox.baseVal.width !== 0)
scaleX = svg.viewBox.baseVal.width / svg.clientWidth
if (svg.viewBox.baseVal.height !== 0)
scaleY = svg.viewBox.baseVal.height / svg.clientHeight
yOffset = svg.viewBox.baseVal.y
}
var svgClicked = ev.target && ev.target.tagName === "svg";
var x;
var y;
if (svgClicked) {
x = ev.offsetX;
y = ev.offsetY;
} else {
x = ev.layerX;
y = ev.layerY;
}
x = x * scaleX;
y = y * scaleY;
//console.log(x, y)
return [x, y + yOffset];
}
function elementFocused(ev) {
// If there had been another element focused and is being dragged, then report that before setting the new element up.
if (this.dragMechanism === "keyboard" && this.dragYStep !== 0 && this.dragTarget)
notifySelect.bind(this)(this.dragTarget, this.dragYStep, this.selectables.length, this.dragIndex, ev);
this.dragYStep = 0;
}
function keyboardDown(ev) {
// Swallow the up and down arrow events - they will be used for dragging with the keyboard
switch (ev.keyCode) {
case 38:
case 40:
ev.preventDefault();
}
}
function keyboardSelection(ev) {
// "this" is the EngraverController because of the bind(this) when setting the event listener.
var handled = false;
var index = ev.target.dataset.index;
switch (ev.keyCode) {
case 13:
case 32:
handled = true;
this.dragTarget = this.selectables[index];
this.dragIndex = index;
this.dragMechanism = "keyboard";
mouseUp.bind(this)(ev);
break;
case 38: // arrow up
handled = true;
this.dragTarget = this.selectables[index];
this.dragIndex = index;
if (this.dragTarget && this.dragTarget.isDraggable) {
if (this.dragging && this.dragTarget.isDraggable)
this.dragTarget.absEl.highlight(undefined, this.dragColor);
this.dragYStep--;
this.dragTarget.svgEl.setAttribute("transform", "translate(0," + (this.dragYStep * spacing.STEP) + ")");
}
break;
case 40: // arrow down
handled = true;
this.dragTarget = this.selectables[index];
this.dragIndex = index;
this.dragMechanism = "keyboard";
if (this.dragTarget && this.dragTarget.isDraggable) {
if (this.dragging && this.dragTarget.isDraggable)
this.dragTarget.absEl.highlight(undefined, this.dragColor);
this.dragYStep++;
this.dragTarget.svgEl.setAttribute("transform", "translate(0," + (this.dragYStep * spacing.STEP) + ")");
}
break;
case 9: // tab
// This is losing focus - if there had been dragging, then do the callback
if (this.dragYStep !== 0) {
mouseUp.bind(this)(ev);
}
break;
default:
//console.log(ev);
break;
}
if (handled)
ev.preventDefault();
}
function findElementInHistory(selectables, el) {
for (var i = 0; i < selectables.length; i++) {
if (el.dataset.index === selectables[i].svgEl.dataset.index)
return i;
}
return -1;
}
function findElementByCoord(self, x, y) {
var minDistance = 9999999;
var closestIndex = -1;
for (var i = 0; i < self.selectables.length && minDistance > 0; i++) {
var el = self.selectables[i];
self.getDim(el);
if (el.dim.left < x && el.dim.right > x && el.dim.top < y && el.dim.bottom > y) {
// See if it is a direct hit on an element - if so, definitely take it (there are no overlapping elements)
closestIndex = i;
minDistance = 0;
} else if (el.dim.top < y && el.dim.bottom > y) {
// See if it is the same vertical as the element. Then the distance is the x difference
var horiz = Math.min(Math.abs(el.dim.left - x), Math.abs(el.dim.right - x));
if (horiz < minDistance) {
minDistance = horiz;
closestIndex = i;
}
} else if (el.dim.left < x && el.dim.right > x) {
// See if it is the same horizontal as the element. Then the distance is the y difference
var vert = Math.min(Math.abs(el.dim.top - y), Math.abs(el.dim.bottom - y));
if (vert < minDistance) {
minDistance = vert;
closestIndex = i;
}
} else {
// figure out the distance to this element.
var dx = Math.abs(x - el.dim.left) > Math.abs(x - el.dim.right) ? Math.abs(x - el.dim.right) : Math.abs(x - el.dim.left);
var dy = Math.abs(y - el.dim.top) > Math.abs(y - el.dim.bottom) ? Math.abs(y - el.dim.bottom) : Math.abs(y - el.dim.top);
var hypotenuse = Math.sqrt(dx * dx + dy * dy);
if (hypotenuse < minDistance) {
minDistance = hypotenuse;
closestIndex = i;
}
}
}
return (closestIndex >= 0 && minDistance <= 12) ? closestIndex : -1;
}
function getBestMatchCoordinates(dim, ev, scale) {
// Different browsers have conflicting meanings for the coordinates that are returned.
// If the item we want is clicked on directly, then we will just see what is the best match.
// This seems like less of a hack than browser sniffing.
if (dim.x <= ev.offsetX && dim.x + dim.width >= ev.offsetX &&
dim.y <= ev.offsetY && dim.y + dim.height >= ev.offsetY)
return [ev.offsetX, ev.offsetY];
// Firefox returns a weird value for offset, but layer is correct.
// Safari and Chrome return the correct value for offset, but layer is multiplied by the scale (that is, if it were rendered with { scale: 2 })
// For instance (if scale is 2):
// Firefox: { offsetY: 5, layerY: 335 }
// Others: {offsetY: 335, layerY: 670} (there could be a little rounding, so the number might not be exactly 2x)
// So, if layerY/scale is approx. offsetY, then use offsetY, otherwise use layerY
var epsilon = Math.abs(ev.layerY / scale - ev.offsetY);
if (epsilon < 3)
return [ev.offsetX, ev.offsetY];
else
return [ev.layerX, ev.layerY];
}
function getTarget(target) {
// This searches up the dom for the first item containing the attribute "selectable", or stopping at the SVG.
if (target.tagName === "svg")
return target;
var found = target.getAttribute("selectable");
while (!found) {
if (!target.parentElement)
found = true;
else {
target = target.parentElement;
if (target.tagName === "svg")
found = true;
else
found = target.getAttribute("selectable");
}
}
return target;
}
function getMousePosition(self, ev) {
// if the user clicked exactly on an element that we're interested in, then we already have the answer.
// This is more reliable than the calculations because firefox returns different coords for offsetX, offsetY
var x;
var y;
var box;
var clickedOn = findElementInHistory(self.selectables, getTarget(ev.target));
if (clickedOn >= 0) {
// There was a direct hit on an element.
box = getBestMatchCoordinates(self.selectables[clickedOn].svgEl.getBBox(), ev, self.scale);
x = box[0];
y = box[1];
//console.log("clicked on", clickedOn, x, y, self.selectables[clickedOn].svgEl.getBBox(), ev.target.getBBox());
} else {
// See if they clicked close to an element.
box = getCoord(ev);
x = box[0];
y = box[1];
clickedOn = findElementByCoord(self, x, y);
//console.log("clicked near", clickedOn, x, y, printEl(ev.target));
}
return { x: x, y: y, clickedOn: clickedOn };
}
function attachMissingTouchEventAttributes(touchEv) {
if (!touchEv || !touchEv.target || !touchEv.touches || touchEv.touches.length < 1)
return
var rect = touchEv.target.getBoundingClientRect();
var offsetX = touchEv.touches[0].pageX - rect.left;
var offsetY = touchEv.touches[0].pageY - rect.top;
touchEv.touches[0].offsetX = offsetX;
touchEv.touches[0].offsetY = offsetY;
touchEv.touches[0].layerX = touchEv.touches[0].pageX;
touchEv.touches[0].layerY = touchEv.touches[0].pageY;
}
function mouseDown(ev) {
// "this" is the EngraverController because of the bind(this) when setting the event listener.
var _ev = ev;
if (ev.type === 'touchstart') {
attachMissingTouchEventAttributes(ev);
if (ev.touches.length > 0)
_ev = ev.touches[0];
}
var positioning = getMousePosition(this, _ev);
// Only start dragging if the user clicked close enough to an element and clicked with the main mouse button.
if (positioning.clickedOn >= 0 && (ev.type === 'touchstart' || ev.button === 0) && this.selectables[positioning.clickedOn]) {
this.dragTarget = this.selectables[positioning.clickedOn];
this.dragIndex = positioning.clickedOn;
this.dragMechanism = "mouse";
this.dragMouseStart = { x: positioning.x, y: positioning.y };
if (this.dragging && this.dragTarget.isDraggable) {
addGlobalClass(this.renderer.paper, "abcjs-dragging-in-progress");
this.dragTarget.absEl.highlight(undefined, this.dragColor);
}
}
}
function mouseMove(ev) {
var _ev = ev;
if (ev.type === 'touchmove') {
attachMissingTouchEventAttributes(ev);
if (ev.touches.length > 0)
_ev = ev.touches[0];
}
this.lastTouchMove = ev;
// "this" is the EngraverController because of the bind(this) when setting the event listener.
if (!this.dragTarget || !this.dragging || !this.dragTarget.isDraggable || this.dragMechanism !== 'mouse' || !this.dragMouseStart)
return;
var positioning = getMousePosition(this, _ev);
var yDist = Math.round((positioning.y - this.dragMouseStart.y) / spacing.STEP);
if (yDist !== this.dragYStep) {
this.dragYStep = yDist;
this.dragTarget.svgEl.setAttribute("transform", "translate(0," + (yDist * spacing.STEP) + ")");
}
}
function mouseUp(ev) {
// "this" is the EngraverController because of the bind(this) when setting the event listener.
var _ev = ev;
if (ev.type === 'touchend' && this.lastTouchMove) {
attachMissingTouchEventAttributes(this.lastTouchMove);
if (this.lastTouchMove && this.lastTouchMove.touches && this.lastTouchMove.touches.length > 0)
_ev = this.lastTouchMove.touches[0];
}
if (!this.dragTarget)
return;
clearSelection.bind(this)();
if (this.dragTarget.absEl && this.dragTarget.absEl.highlight) {
this.selected = [this.dragTarget.absEl];
this.dragTarget.absEl.highlight(undefined, this.selectionColor);
}
notifySelect.bind(this)(this.dragTarget, this.dragYStep, this.selectables.length, this.dragIndex, _ev);
if (this.dragTarget.svgEl && this.dragTarget.svgEl.focus) {
this.dragTarget.svgEl.focus();
this.dragTarget = null;
this.dragIndex = -1;
}
removeGlobalClass(this.renderer.svg, "abcjs-dragging-in-progress");
}
function setSelection(dragIndex) {
if (dragIndex >= 0 && dragIndex < this.selectables.length) {
this.dragTarget = this.selectables[dragIndex];
this.dragIndex = dragIndex;
this.dragMechanism = "keyboard";
mouseUp.bind(this)({ target: this.dragTarget.svgEl });
}
}
function notifySelect(target, dragStep, dragMax, dragIndex, ev) {
var classes = [];
if (target.absEl.elemset) {
var classObj = {};
for (var j = 0; j < target.absEl.elemset.length; j++) {
var es = target.absEl.elemset[j];
if (es) {
var klass = es.getAttribute("class").split(' ');
for (var k = 0; k < klass.length; k++)
classObj[klass[k]] = true;
}
}
for (var kk = 0; kk < Object.keys(classObj).length; kk++)
classes.push(Object.keys(classObj)[kk]);
}
var analysis = {};
for (var ii = 0; ii < classes.length; ii++) {
findNumber(classes[ii], "abcjs-v", analysis, "voice");
findNumber(classes[ii], "abcjs-l", analysis, "line");
findNumber(classes[ii], "abcjs-m", analysis, "measure");
}
if (target.staffPos)
analysis.staffPos = target.staffPos;
var closest = ev.target;
while (closest && closest.dataset && !closest.dataset.name && closest.tagName.toLowerCase() !== 'svg')
closest = closest.parentNode;
var parent = ev.target;
while (parent && parent.dataset && !parent.dataset.index && parent.tagName.toLowerCase() !== 'svg')
parent = parent.parentNode;
analysis.name = parent.dataset.name;
analysis.clickedName = closest.dataset.name;
analysis.parentClasses = parent.classList;
analysis.clickedClasses = closest.classList;
analysis.selectableElement = target.svgEl;
for (var i = 0; i < this.listeners.length; i++) {
this.listeners[i](target.absEl.abcelem, target.absEl.tuneNumber, classes.join(' '), analysis, { step: dragStep, max: dragMax, index: dragIndex, setSelection: setSelection.bind(this) }, ev);
}
}
function findNumber(klass, match, target, name) {
if (klass.indexOf(match) === 0) {
var value = klass.replace(match, '');
var num = parseInt(value, 10);
if ('' + num === value)
target[name] = num;
}
}
function clearSelection() {
for (var i = 0; i < this.selected.length; i++) {
this.selected[i].unhighlight(undefined, this.renderer.foregroundColor);
}
this.selected = [];
}
function rangeHighlight(start, end) {
clearSelection.bind(this)();
for (var line = 0; line < this.staffgroups.length; line++) {
var voices = this.staffgroups[line].voices;
for (var voice = 0; voice < voices.length; voice++) {
var elems = voices[voice].children;
for (var elem = 0; elem < elems.length; elem++) {
// Since the user can highlight more than an element, or part of an element, a hit is if any of the endpoints
// is inside the other range.
var elStart = elems[elem].abcelem.startChar;
var elEnd = elems[elem].abcelem.endChar;
if ((end > elStart && start < elEnd) || ((end === start) && end === elEnd)) {
// if (elems[elem].abcelem.startChar>=start && elems[elem].abcelem.endChar<=end) {
this.selected[this.selected.length] = elems[elem];
elems[elem].highlight(undefined, this.selectionColor);
}
}
}
}
}
function getClassSet(el) {
var oldClass = el.getAttribute('class');
if (!oldClass)
oldClass = "";
var klasses = oldClass.split(" ");
var obj = {};
for (var i = 0; i < klasses.length; i++)
obj[klasses[i]] = true;
return obj;
}
function setClassSet(el, klassSet) {
var klasses = [];
for (var key in klassSet) {
if (klassSet.hasOwnProperty(key))
klasses.push(key);
}
el.setAttribute('class', klasses.join(' '));
}
function addGlobalClass(svg, klass) {
if (svg) {
var obj = getClassSet(svg.svg);
obj[klass] = true;
setClassSet(svg.svg, obj);
}
}
function removeGlobalClass(svg, klass) {
if (svg) {
var obj = getClassSet(svg.svg);
delete obj[klass];
setClassSet(svg.svg, obj);
}
}
module.exports = setupSelection;