/* global document window */ const isDragSymbol = Symbol('dragging'); const isOutSymbol = Symbol('isOut'); const windowListeners = Symbol('windowListeners'); const listeners = Symbol('listener'); /** * A class to enable touch & mouse interactions on SVGs. Translates browser coordinates to * SVG coordinates. * * Call SVGInteraction.makeInteractive(svg), then override this class's methods to use. */ class SVGInteraction { constructor(svg, svgPt) { if (svgPt) this.svgPt = svgPt; this.svg = svg; this.makeInteractive(); } addEventListener(type, callback) { this[listeners].push([type, callback]); } removeEventListener(type, callback) { const index = this[listeners].findIndex(([listenerType, listenerCallback]) => { return type === listenerType && callback === listenerCallback; }); if (index !== -1) this[listeners].splice(index, 1); return index !== -1; } callListeners(type, e, coords) { console.log('calling listeners ' + type); this[listeners].forEach(([listenerType, callback]) => { console.log('checking listener ', listenerType); if (type === listenerType) { console.log('calling!'); callback(e, coords); } }); } /* eslint-disable no-unused-vars */ // These are here as holders -- override whichever you need when you inherit this class. touchStart(e, coords) { this.callListeners('touchStart', e, coords); } touchEnd(e, coords) { this.callListeners('touchEnd', e, coords); } drag(e, coords) { this.callListeners('drag', e, coords); } hover(e, coords) { this.callListeners('hover', e, coords); } mouseOut(e, coords) { this.callListeners('mouseOut', e, coords); } /* eslint-enable no-unused-vars */ makeInteractive(svg = this.svg) { console.log('making interactive'); this[listeners] = []; // We will add listeners to the SVG bounding box itself: svg.style.pointerEvents = 'bounding-box'; // An SVG point is used to translate div space to SVG space. See Alexander Jank's solution at: // https://stackoverflow.com/questions/29261304/how-to-get-the-click-coordinates-relative-to-svg-element-holding-the-onclick-lis this.svgPt = this.svgPt || svg.createSVGPoint(); /** ==== Condition Testing & Setting Functions ==== * * These functions are used to translate UI events from the browser into more helpful * events from a programatic perspective. They take care of tracking mousestate & * touchstate (down or up) to fire drag vs. hover. If a user drags outside of the SVG, * they continue to fire the drag event, so that (if desired) things like drag & drop * among elements can be implemented. * */ // A not-combinator const not = condition => () => !condition(); // Get whether we're dragging const isDragging = () => this[isDragSymbol]; // Set whether the mouse is down or up. const down = () => { this[isDragSymbol] = true; return true; }; const up = () => { this[isDragSymbol] = false; return true; }; // Get whether we're outside of the SVG bounds const isOutside = () => this[isOutSymbol]; // Set whether we're in our out; if we move out while dragging we need window listeners briefly. const inside = () => { this[isOutSymbol] = false; this[windowListeners].forEach(([eventType, listener]) => { window.removeEventListener(eventType, listener, false); }); return true; }; const outside = () => { this[isOutSymbol] = true; this[windowListeners].forEach(([eventType, listener]) => { window.addEventListener(eventType, listener); }); return true; }; // We'll hold the window listeners here so we can add & remove them as needed. this[windowListeners] = []; // Utility function to check event conditions & fire class events if needed const addListener = ([eventType, callback, ifTrue, el]) => { el = el || svg; const listener = (evt) => { if (ifTrue()) { const coords = getCoords(evt, svg, this.svgPt, this); callback.call(this, evt, coords); } }; if (el !== window) el.addEventListener(eventType, listener); else this[windowListeners].push([eventType, listener]); }; // [ type, listener, testFunction (fire listener if true), EventTarget (default this.svg)] [ /* events occuring within SVG */ ['mousedown', this.touchStart, down], // Touch is started ['touchstart', this.touchStart, down], // Touch is started ['mouseup', this.touchEnd, up], // Touch ends or mouse up ['touchend', this.touchEnd, up], // Touch ends or mouse up ['touchmove', this.drag, isDragging], // Dragging ['mousemove', this.drag, isDragging], // Dragging ['mousemove', this.hover, not(isDragging)], // Hover ['mouseout', this.mouseOut, not(isDragging)], // Mouseout ['touchcancel', this.mouseOut, not(isDragging)], // Mouseout (touch interrupt) ['mouseout', () => {}, () => isDragging() && outside()], // goes out of bounds isDown & set ['touchcancel', () => {}, () => isDragging() && outside()], // goes out of bounds isDown & set ['touchmove', inside, isOutside], // comes back inside ['mousemove', inside, isOutside], // comes back inside ['mousedown', inside, isOutside], // comes back inside, this shouldn't happen, but just in case ['touchstart', inside, isOutside], // comes back inside, this shouldn't happen, but just in case /* out of bounds events */ ['touchmove', this.drag, () => isOutside() && isDragging(), window], // if outside in window & dragging ['mousemove', this.drag, () => isOutside() && isDragging(), window], // if outside in window & dragging ['mouseup', this.touchEnd, () => isOutside() && isDragging() && up() && inside(), window], // if outside in window & dragging & released ['touchend', this.touchEnd, () => isOutside() && isDragging() && up() && inside(), window], // if outside in window & dragging & released ] .forEach(addListener); } } /** * Returns coordinates for an event * @param {Event} e touch or mouse event * @param {SVGPoint} svgPt an SVG point * @param {SVGElement} svg the SVG's client bounding rectangle * * @returns {Object} { x, y, [touches] } */ function getCoords(e, svg, svgPt) { if ('changedTouches' in e) { const length = e.changedTouches.length; const touches = []; for (let i = 0; i < length; i++) { touches.push(getCoords(e.changedTouches.item(i), svg, svgPt)); } return { x: touches[0].x, y: touches[0].y, touches }; } svgPt.x = e.clientX; svgPt.y = e.clientY; const svgCoords = svgPt.matrixTransform(svg.getScreenCTM().inverse()); return { x: svgCoords.x, y: svgCoords.y }; } function drawMusic() { // Basic setup boilerplate for using VexFlow with the SVG rendering context: VF = Vex.Flow; // Create an SVG renderer and attach it to the DIV element named "boo". var div = document.getElementById("boo") var renderer = new VF.Renderer(div, VF.Renderer.Backends.SVG); // Configure the rendering context. renderer.resize(500, 500); var context = renderer.getContext(); var notes = [ // A quarter-note C. new VF.StaveNote({clef: "treble", keys: ["c/4"], duration: "q" }), // A quarter-note D. new VF.StaveNote({clef: "treble", keys: ["d/4"], duration: "q" }), // A quarter-note rest. Note that the key (b/4) specifies the vertical // position of the rest. new VF.StaveNote({clef: "treble", keys: ["b/4"], duration: "qr" }), // A C-Major chord. new VF.StaveNote({clef: "treble", keys: ["c/4", "e/4", "g/4"], duration: "q" }) ]; // Create a voice in 4/4 and add above notes var voice = new VF.Voice({num_beats: 4, beat_value: 4}); voice.addTickables(notes); // Format and justify the notes to 400 pixels. var formatter = new VF.Formatter().joinVoices([voice]).format([voice], 400); // Create a stave of width 400 at position 10, 40 on the canvas. var stave = new VF.Stave(10, 40, 400); // Add a clef and time signature. stave.addClef("treble").addTimeSignature("4/4"); // Connect it to the rendering context and draw! stave.setContext(context).draw(); // Render voice voice.draw(context, stave); return { notes, context } } const { notes, context } = drawMusic(); context.resize(500, 200); context.setViewBox(0, 0, 500, 200); const svg = context.svg; svg.removeAttribute('width'); svg.removeAttribute('height'); svg.style.border = "black solid 1px"; const interaction = new SVGInteraction(svg); const eventsDiv = document.getElementById('eventsDiv'); const noteEventsDiv = document.getElementById('noteEventsDiv'); const events = [ 'touchStart', 'touchEnd', 'drag', 'hover', 'mouseOut' ]; events.forEach((type) => { interaction.addEventListener(type, (e, coords) => { update(type, coords); }) }) notes.forEach( (note, index) => { const noteInteraction = new SVGInteraction(note.attrs.el, interaction.svgPt); events.forEach((type) => noteInteraction.addEventListener(type, (e, coords) => { updateNote(`Note ${index}: ${type}`, e, coords); }) ); }) const eventsBuffer = []; function update(type, { x, y }) { x = Math.floor(x); y = Math.floor(y); eventsBuffer.unshift(`${type}: (${x}, ${y})`); eventsDiv.innerHTML = eventsBuffer.join('
'); } const noteEventsBuffer = []; function updateNote(type, { x, y }) { x = Math.floor(x); y = Math.floor(y); noteEventsBuffer.unshift(`${type}: (${x}, ${y})`); noteEventsDiv.innerHTML = noteEventsBuffer.join('
'); }