/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const { debounce } = require("resource://devtools/shared/debounce.js"); const { lerp } = require("resource://devtools/client/memory/utils.js"); const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const LERP_SPEED = 0.5; const ZOOM_SPEED = 0.01; const TRANSLATE_EPSILON = 1; const ZOOM_EPSILON = 0.001; const LINE_SCROLL_MODE = 1; const SCROLL_LINE_SIZE = 15; /** * DragZoom is a constructor that contains the state of the current dragging and * zooming behavior. It sets the scrolling and zooming behaviors. * * @param {HTMLElement} container description * The container for the canvases */ function DragZoom(container, debounceRate, requestAnimationFrame) { EventEmitter.decorate(this); this.isDragging = false; // The current mouse position this.mouseX = container.offsetWidth / 2; this.mouseY = container.offsetHeight / 2; // The total size of the visualization after being zoomed, in pixels this.zoomedWidth = container.offsetWidth; this.zoomedHeight = container.offsetHeight; // How much the visualization has been zoomed in this.zoom = 0; // The offset of visualization from the container. This is applied after // the zoom, and the visualization by default is centered this.translateX = 0; this.translateY = 0; // The size of the offset between the top/left of the container, and the // top/left of the containing element. This value takes into account // the device pixel ratio for canvas draws. this.offsetX = 0; this.offsetY = 0; // The smoothed values that are animated and eventually match the target // values. The values are updated by the update loop this.smoothZoom = 0; this.smoothTranslateX = 0; this.smoothTranslateY = 0; // Add the constant values for testing purposes this.ZOOM_SPEED = ZOOM_SPEED; this.ZOOM_EPSILON = ZOOM_EPSILON; const update = createUpdateLoop(container, this, requestAnimationFrame); this.destroy = setHandlers(this, container, update, debounceRate); } module.exports = DragZoom; /** * Returns an update loop. This loop smoothly updates the visualization when * actions are performed. Once the animations have reached their target values * the animation loop is stopped. * * Any value in the `dragZoom` object that starts with "smooth" is the * smoothed version of a value that is interpolating toward the target value. * For instance `dragZoom.smoothZoom` approaches `dragZoom.zoom` on each * iteration of the update loop until it's sufficiently close as defined by * the epsilon values. * * Only these smoothed values and the container CSS are updated by the loop. * * @param {HTMLDivElement} container * @param {Object} dragZoom * The values that represent the current dragZoom state * @param {Function} requestAnimationFrame */ function createUpdateLoop(container, dragZoom, requestAnimationFrame) { let isLooping = false; function update() { const isScrollChanging = Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON; const isTranslateChanging = Math.abs(dragZoom.smoothTranslateX - dragZoom.translateX) > TRANSLATE_EPSILON || Math.abs(dragZoom.smoothTranslateY - dragZoom.translateY) > TRANSLATE_EPSILON; isLooping = isScrollChanging || isTranslateChanging; if (isScrollChanging) { dragZoom.smoothZoom = lerp( dragZoom.smoothZoom, dragZoom.zoom, LERP_SPEED ); } else { dragZoom.smoothZoom = dragZoom.zoom; } if (isTranslateChanging) { dragZoom.smoothTranslateX = lerp( dragZoom.smoothTranslateX, dragZoom.translateX, LERP_SPEED ); dragZoom.smoothTranslateY = lerp( dragZoom.smoothTranslateY, dragZoom.translateY, LERP_SPEED ); } else { dragZoom.smoothTranslateX = dragZoom.translateX; dragZoom.smoothTranslateY = dragZoom.translateY; } const zoom = 1 + dragZoom.smoothZoom; const x = dragZoom.smoothTranslateX; const y = dragZoom.smoothTranslateY; container.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`; if (isLooping) { requestAnimationFrame(update); } } // Go ahead and start the update loop update(); return function restartLoopingIfStopped() { if (!isLooping) { update(); } }; } /** * Set the various event listeners and return a function to remove them * * @param {Object} dragZoom * @param {HTMLElement} container * @param {Function} update * @return {Function} The function to remove the handlers */ function setHandlers(dragZoom, container, update, debounceRate) { const emitChanged = debounce(() => dragZoom.emit("change"), debounceRate); const removeDragHandlers = setDragHandlers( container, dragZoom, emitChanged, update ); const removeScrollHandlers = setScrollHandlers( container, dragZoom, emitChanged, update ); return function removeHandlers() { removeDragHandlers(); removeScrollHandlers(); }; } /** * Sets handlers for when the user drags on the canvas. It will update dragZoom * object with new translate and offset values. * * @param {HTMLElement} container * @param {Object} dragZoom * @param {Function} changed * @param {Function} update */ function setDragHandlers(container, dragZoom, emitChanged, update) { const parentEl = container.parentElement; function startDrag() { dragZoom.isDragging = true; container.style.cursor = "grabbing"; } function stopDrag() { dragZoom.isDragging = false; container.style.cursor = "grab"; } function drag(event) { const prevMouseX = dragZoom.mouseX; const prevMouseY = dragZoom.mouseY; dragZoom.mouseX = event.clientX - parentEl.offsetLeft; dragZoom.mouseY = event.clientY - parentEl.offsetTop; if (!dragZoom.isDragging) { return; } dragZoom.translateX += dragZoom.mouseX - prevMouseX; dragZoom.translateY += dragZoom.mouseY - prevMouseY; keepInView(container, dragZoom); emitChanged(); update(); } parentEl.addEventListener("mousedown", startDrag); parentEl.addEventListener("mouseup", stopDrag); parentEl.addEventListener("mouseout", stopDrag); parentEl.addEventListener("mousemove", drag); return function removeListeners() { parentEl.removeEventListener("mousedown", startDrag); parentEl.removeEventListener("mouseup", stopDrag); parentEl.removeEventListener("mouseout", stopDrag); parentEl.removeEventListener("mousemove", drag); }; } /** * Sets the handlers for when the user scrolls. It updates the dragZoom object * and keeps the canvases all within the view. After changing values update * loop is called, and the changed event is emitted. * * @param {HTMLDivElement} container * @param {Object} dragZoom * @param {Function} changed * @param {Function} update */ function setScrollHandlers(container, dragZoom, emitChanged, update) { const window = container.ownerDocument.defaultView; function handleWheel(event) { event.preventDefault(); if (dragZoom.isDragging) { return; } // Update the zoom level const scrollDelta = getScrollDelta(event, window); const prevZoom = dragZoom.zoom; dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED); // Calculate the updated width and height const prevZoomedWidth = container.offsetWidth * (1 + prevZoom); const prevZoomedHeight = container.offsetHeight * (1 + prevZoom); dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom); dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom); const deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth; const deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight; const mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2; const mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2; // The ratio of where the center of the mouse is in regards to the total // zoomed width/height const ratioZoomX = (prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) / prevZoomedWidth; const ratioZoomY = (prevZoomedHeight / 2 + mouseOffsetY - dragZoom.translateY) / prevZoomedHeight; // Distribute the change in width and height based on the above ratio dragZoom.translateX -= lerp(-deltaWidth / 2, deltaWidth / 2, ratioZoomX); dragZoom.translateY -= lerp(-deltaHeight / 2, deltaHeight / 2, ratioZoomY); // Keep the canvas in range of the container keepInView(container, dragZoom); emitChanged(); update(); } container.addEventListener("wheel", handleWheel); return function removeListener() { container.removeEventListener("wheel", handleWheel); }; } /** * Account for the various mouse wheel event types, per pixel or per line * * @param {WheelEvent} event * @return {Number} The scroll size in pixels */ function getScrollDelta(event) { if (event.deltaMode === LINE_SCROLL_MODE) { // Update by a fixed arbitrary value to normalize scroll types return event.deltaY * SCROLL_LINE_SIZE; } return event.deltaY; } /** * Keep the dragging and zooming within the view by updating the values in the * `dragZoom` object. * * @param {HTMLDivElement} container * @param {Object} dragZoom */ function keepInView(container, dragZoom) { const { devicePixelRatio } = container.ownerDocument.defaultView; const overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2; const overdrawY = (dragZoom.zoomedHeight - container.offsetHeight) / 2; dragZoom.translateX = Math.max( -overdrawX, Math.min(overdrawX, dragZoom.translateX) ); dragZoom.translateY = Math.max( -overdrawY, Math.min(overdrawY, dragZoom.translateY) ); dragZoom.offsetX = devicePixelRatio * ((dragZoom.zoomedWidth - container.offsetWidth) / 2 - dragZoom.translateX); dragZoom.offsetY = devicePixelRatio * ((dragZoom.zoomedHeight - container.offsetHeight) / 2 - dragZoom.translateY); }