UNPKG

photo-sphere-viewer

Version:

A JavaScript library to display Photo Sphere panoramas

819 lines (724 loc) 23.7 kB
import { MathUtils, SplineCurve, Vector2 } from 'three'; import { ACTIONS, CTRLZOOM_TIMEOUT, DBLCLICK_DELAY, EVENTS, IDS, INERTIA_WINDOW, KEY_CODES, LONGTOUCH_DELAY, MESH_USER_DATA, MOVE_THRESHOLD, OBJECT_EVENTS, TWOFINGERSOVERLAY_DELAY } from '../data/constants'; import { SYSTEM } from '../data/system'; import gestureIcon from '../icons/gesture.svg'; import mousewheelIcon from '../icons/mousewheel.svg'; import { clone, distance, each, getClosest, getPosition, hasParent, isEmpty, isFullscreenEnabled, normalizeWheel, throttle } from '../utils'; import { Animation } from '../utils/Animation'; import { PressHandler } from '../utils/PressHandler'; import { AbstractService } from './AbstractService'; const IDLE = 0; const MOVING = 1; const INERTIA = 2; /** * @summary Events handler * @extends PSV.services.AbstractService * @memberof PSV.services */ export class EventsHandler extends AbstractService { /** * @param {PSV.Viewer} psv */ constructor(psv) { super(psv); /** * @summary Internal properties * @member {Object} * @property {number} moveThreshold - computed threshold based on device pixel ratio * @property {number} step * @property {boolean} mousedown - before moving past the threshold * @property {number} startMouseX - start x position of the click/touch * @property {number} startMouseY - start y position of the click/touch * @property {number} mouseX - current x position of the cursor * @property {number} mouseY - current y position of the cursor * @property {number[][]} mouseHistory - list of latest positions of the cursor, [time, x, y] * @property {number} pinchDist - distance between fingers when zooming * @property {PressHandler} keyHandler * @property {boolean} ctrlKeyDown - when the Ctrl key is pressed * @property {PSV.ClickData} dblclickData - temporary storage of click data between two clicks * @property {number} dblclickTimeout - timeout id for double click * @property {number} twofingersTimeout - timeout id for "two fingers" overlay * @property {number} ctrlZoomTimeout - timeout id for "ctrol zoom" overlay * @protected */ this.state = { moveThreshold : MOVE_THRESHOLD * SYSTEM.pixelRatio, keyboardEnabled : false, step : IDLE, mousedown : false, startMouseX : 0, startMouseY : 0, mouseX : 0, mouseY : 0, mouseHistory : [], pinchDist : 0, keyHandler : new PressHandler(), ctrlKeyDown : false, dblclickData : null, dblclickTimeout : null, longtouchTimeout : null, twofingersTimeout: null, ctrlZoomTimeout : null, }; /** * @summary Throttled wrapper of {@link PSV.Viewer#autoSize} * @type {Function} * @private */ this.__onResize = throttle(() => this.psv.autoSize(), 50); } /** * @summary Initializes event handlers * @protected */ init() { window.addEventListener('resize', this); window.addEventListener('keydown', this, { passive: false }); window.addEventListener('keyup', this); this.psv.container.addEventListener('mousedown', this); window.addEventListener('mousemove', this, { passive: false }); window.addEventListener('mouseup', this); this.psv.container.addEventListener('touchstart', this, { passive: false }); window.addEventListener('touchmove', this, { passive: false }); window.addEventListener('touchend', this, { passive: false }); this.psv.container.addEventListener(SYSTEM.mouseWheelEvent, this, { passive: false }); if (SYSTEM.fullscreenEvent) { document.addEventListener(SYSTEM.fullscreenEvent, this); } } /** * @override */ destroy() { window.removeEventListener('resize', this); window.removeEventListener('keydown', this); window.removeEventListener('keyup', this); this.psv.container.removeEventListener('mousedown', this); window.removeEventListener('mousemove', this); window.removeEventListener('mouseup', this); this.psv.container.removeEventListener('touchstart', this); window.removeEventListener('touchmove', this); window.removeEventListener('touchend', this); this.psv.container.removeEventListener(SYSTEM.mouseWheelEvent, this); if (SYSTEM.fullscreenEvent) { document.removeEventListener(SYSTEM.fullscreenEvent, this); } clearTimeout(this.state.dblclickTimeout); clearTimeout(this.state.longtouchTimeout); clearTimeout(this.state.twofingersTimeout); clearTimeout(this.state.ctrlZoomTimeout); delete this.state; super.destroy(); } /** * @summary Handles events * @param {Event} evt * @private */ handleEvent(evt) { /* eslint-disable */ switch (evt.type) { // @formatter:off case 'resize': this.__onResize(); break; case 'keydown': this.__onKeyDown(evt); break; case 'keyup': this.__onKeyUp(); break; case 'mousemove': this.__onMouseMove(evt); break; case 'mouseup': this.__onMouseUp(evt); break; case 'touchmove': this.__onTouchMove(evt); break; case 'touchend': this.__onTouchEnd(evt); break; case SYSTEM.fullscreenEvent: this.__fullscreenToggled(); break; // @formatter:on } /* eslint-enable */ if (!getClosest(evt.target, '.psv--capture-event')) { /* eslint-disable */ switch (evt.type) { // @formatter:off case 'mousedown': this.__onMouseDown(evt); break; case 'touchstart': this.__onTouchStart(evt); break; case SYSTEM.mouseWheelEvent: this.__onMouseWheel(evt); break; // @formatter:on } /* eslint-enable */ } } /** * @summary Enables the keyboard controls * @protected */ enableKeyboard() { this.state.keyboardEnabled = true; } /** * @summary Disables the keyboard controls * @protected */ disableKeyboard() { this.state.keyboardEnabled = false; } /** * @summary Handles keyboard events * @param {KeyboardEvent} e * @private */ __onKeyDown(e) { if (this.config.mousewheelCtrlKey) { this.state.ctrlKeyDown = e.key === KEY_CODES.Control; if (this.state.ctrlKeyDown) { clearTimeout(this.state.ctrlZoomTimeout); this.psv.overlay.hide(IDS.CTRL_ZOOM); } } const e2 = this.psv.trigger(EVENTS.KEY_PRESS, e.key); if (e2.isDefaultPrevented()) { return; } if (!this.state.keyboardEnabled) { return; } const action = this.config.keyboard[e.key]; if (action === ACTIONS.TOGGLE_AUTOROTATE) { this.psv.toggleAutorotate(); e.preventDefault(); } else if (action && !this.state.keyHandler.time) { if (action !== ACTIONS.ZOOM_IN && action !== ACTIONS.ZOOM_OUT) { this.psv.__stopAll(); } /* eslint-disable */ switch (action) { // @formatter:off case ACTIONS.ROTATE_LAT_UP: this.psv.dynamics.position.roll({latitude: false}); break; case ACTIONS.ROTATE_LAT_DOWN: this.psv.dynamics.position.roll({latitude: true}); break; case ACTIONS.ROTATE_LONG_RIGHT: this.psv.dynamics.position.roll({longitude: false}); break; case ACTIONS.ROTATE_LONG_LEFT: this.psv.dynamics.position.roll({longitude: true}); break; case ACTIONS.ZOOM_IN: this.psv.dynamics.zoom.roll(false); break; case ACTIONS.ZOOM_OUT: this.psv.dynamics.zoom.roll(true); break; // @formatter:on } /* eslint-enable */ this.state.keyHandler.down(); e.preventDefault(); } } /** * @summary Handles keyboard events * @private */ __onKeyUp() { this.state.ctrlKeyDown = false; if (!this.state.keyboardEnabled) { return; } this.state.keyHandler.up(() => { this.psv.dynamics.position.stop(); this.psv.dynamics.zoom.stop(); this.psv.resetIdleTimer(); }); } /** * @summary Handles mouse down events * @param {MouseEvent} evt * @private */ __onMouseDown(evt) { this.state.mousedown = true; this.state.startMouseX = evt.clientX; this.state.startMouseY = evt.clientY; } /** * @summary Handles mouse up events * @param {MouseEvent} evt * @private */ __onMouseUp(evt) { if (this.state.mousedown || this.state.step === MOVING) { this.__stopMove(evt.clientX, evt.clientY, evt.target, evt.button === 2); } } /** * @summary Handles mouse move events * @param {MouseEvent} evt * @private */ __onMouseMove(evt) { if (this.config.mousemove && (this.state.mousedown || this.state.step === MOVING)) { evt.preventDefault(); this.__move(evt.clientX, evt.clientY); } if (!isEmpty(this.prop.objectsObservers) && hasParent(evt.target, this.psv.container)) { const viewerPos = getPosition(this.psv.container); const viewerPoint = { x: evt.clientX - viewerPos.left, y: evt.clientY - viewerPos.top, }; const intersections = this.psv.dataHelper.getIntersections(viewerPoint); const emit = (observer, key, type) => { observer.listener.handleEvent(new CustomEvent(type, { detail: { originalEvent: evt, object : observer.object, data : observer.object.userData[key], viewerPoint : viewerPoint, }, })); }; each(this.prop.objectsObservers, (observer, key) => { const intersection = intersections.find(i => i.object.userData[key]); if (intersection) { if (observer.object && intersection.object !== observer.object) { emit(observer, key, OBJECT_EVENTS.LEAVE_OBJECT); delete observer.object; } if (!observer.object) { observer.object = intersection.object; emit(observer, key, OBJECT_EVENTS.ENTER_OBJECT); } else { emit(observer, key, OBJECT_EVENTS.HOVER_OBJECT); } } else if (observer.object) { emit(observer, key, OBJECT_EVENTS.LEAVE_OBJECT); delete observer.object; } }); } } /** * @summary Handles touch events * @param {TouchEvent} evt * @private */ __onTouchStart(evt) { if (evt.touches.length === 1) { this.state.mousedown = true; this.state.startMouseX = evt.touches[0].clientX; this.state.startMouseY = evt.touches[0].clientY; if (!this.prop.longtouchTimeout) { this.prop.longtouchTimeout = setTimeout(() => { const touch = evt.touches[0]; this.__stopMove(touch.clientX, touch.clientY, touch.target, true); this.prop.longtouchTimeout = null; }, LONGTOUCH_DELAY); } } else if (evt.touches.length === 2) { this.state.mousedown = false; this.__cancelLongTouch(); if (this.config.mousemove) { this.__cancelTwoFingersOverlay(); this.__startMoveZoom(evt); evt.preventDefault(); } } } /** * @summary Handles touch events * @param {TouchEvent} evt * @private */ __onTouchEnd(evt) { this.__cancelLongTouch(); if (this.state.mousedown || this.state.step === MOVING) { evt.preventDefault(); this.__cancelTwoFingersOverlay(); if (evt.touches.length === 1) { this.__stopMove(this.state.mouseX, this.state.mouseY); } else if (evt.touches.length === 0) { const touch = evt.changedTouches[0]; this.__stopMove(touch.clientX, touch.clientY, touch.target); } } } /** * @summary Handles touch move events * @param {TouchEvent} evt * @private */ __onTouchMove(evt) { this.__cancelLongTouch(); if (!this.config.mousemove) { return; } if (evt.touches.length === 1) { if (this.config.touchmoveTwoFingers) { if (this.state.mousedown && !this.prop.twofingersTimeout) { this.prop.twofingersTimeout = setTimeout(() => { this.psv.overlay.show({ id : IDS.TWO_FINGERS, image: gestureIcon, text : this.config.lang.twoFingers, }); }, TWOFINGERSOVERLAY_DELAY); } } else if (this.state.mousedown || this.state.step === MOVING) { evt.preventDefault(); const touch = evt.touches[0]; this.__move(touch.clientX, touch.clientY); } } else { this.__moveZoom(evt); this.__cancelTwoFingersOverlay(); } } /** * @summary Cancel the long touch timer if any * @private */ __cancelLongTouch() { if (this.prop.longtouchTimeout) { clearTimeout(this.prop.longtouchTimeout); this.prop.longtouchTimeout = null; } } /** * @summary Cancel the two fingers overlay timer if any * @private */ __cancelTwoFingersOverlay() { if (this.config.touchmoveTwoFingers) { if (this.prop.twofingersTimeout) { clearTimeout(this.prop.twofingersTimeout); this.prop.twofingersTimeout = null; } this.psv.overlay.hide(IDS.TWO_FINGERS); } } /** * @summary Handles mouse wheel events * @param {WheelEvent} evt * @private */ __onMouseWheel(evt) { if (!this.config.mousewheel) { return; } if (this.config.mousewheelCtrlKey && !this.state.ctrlKeyDown) { this.psv.overlay.show({ id : IDS.CTRL_ZOOM, image: mousewheelIcon, text : this.config.lang.ctrlZoom, }); clearTimeout(this.state.ctrlZoomTimeout); this.state.ctrlZoomTimeout = setTimeout(() => this.psv.overlay.hide(IDS.CTRL_ZOOM), CTRLZOOM_TIMEOUT); return; } evt.preventDefault(); evt.stopPropagation(); const delta = normalizeWheel(evt).spinY * 5 * this.config.zoomSpeed; if (delta !== 0) { this.psv.dynamics.zoom.step(-delta, 5); } } /** * @summary Handles fullscreen events * @param {boolean} [force] force state * @fires PSV.fullscreen-updated * @package */ __fullscreenToggled(force) { this.prop.fullscreen = force !== undefined ? force : isFullscreenEnabled(this.psv.container); if (this.config.keyboard) { if (this.prop.fullscreen) { this.psv.startKeyboardControl(); } else { this.psv.stopKeyboardControl(); } } this.psv.trigger(EVENTS.FULLSCREEN_UPDATED, this.prop.fullscreen); } /** * @summary Resets all state variables * @private */ __resetMove() { this.state.step = IDLE; this.state.mousedown = false; this.state.mouseX = 0; this.state.mouseY = 0; this.state.startMouseX = 0; this.state.startMouseY = 0; this.state.mouseHistory.length = 0; } /** * @summary Initializes the combines move and zoom * @param {TouchEvent} evt * @private */ __startMoveZoom(evt) { this.psv.__stopAll(); this.__resetMove(); const p1 = { x: evt.touches[0].clientX, y: evt.touches[0].clientY }; const p2 = { x: evt.touches[1].clientX, y: evt.touches[1].clientY }; this.state.step = MOVING; this.state.pinchDist = distance(p1, p2); this.state.mouseX = (p1.x + p2.x) / 2; this.state.mouseY = (p1.y + p2.y) / 2; this.__logMouseMove(this.state.mouseX, this.state.mouseY); } /** * @summary Stops the movement * @description If the move threshold was not reached a click event is triggered, otherwise an animation is launched to simulate inertia * @param {int} clientX * @param {int} clientY * @param {EventTarget} [target] * @param {boolean} [rightclick=false] * @private */ __stopMove(clientX, clientY, target = null, rightclick = false) { if (this.state.step === MOVING) { if (this.config.moveInertia) { this.__logMouseMove(clientX, clientY); this.__stopMoveInertia(clientX, clientY); } else { this.__resetMove(); this.psv.resetIdleTimer(); } } else if (this.state.mousedown) { this.psv.stopAnimation(); this.__click(clientX, clientY, target, rightclick); this.__resetMove(); this.psv.resetIdleTimer(); } } /** * @summary Performs an animation to simulate inertia when the movement stops * @param {int} clientX * @param {int} clientY * @private */ __stopMoveInertia(clientX, clientY) { // get direction at end of movement const curve = new SplineCurve(this.state.mouseHistory.map(([, x, y]) => new Vector2(x, y))); const direction = curve.getTangent(1); // average speed const speed = this.state.mouseHistory.slice(1).reduce(({ total, prev }, curr) => { return { total: total + distance({ x: prev[1], y: prev[2] }, { x: curr[1], y: curr[2] }) / (curr[0] - prev[0]), prev : curr, }; }, { total: 0, prev : this.state.mouseHistory[0], }).total / this.state.mouseHistory.length; if (!speed) { this.__resetMove(); this.psv.resetIdleTimer(); return; } this.state.step = INERTIA; let currentClientX = clientX; let currentClientY = clientY; this.prop.animationPromise = new Animation({ properties: { speed: { start: speed, end: 0 }, }, duration : 1000, easing : 'outQuad', onTick : (properties) => { // 3 is a magic number currentClientX += properties.speed * direction.x * 3 * SYSTEM.pixelRatio; currentClientY += properties.speed * direction.y * 3 * SYSTEM.pixelRatio; this.__applyMove(currentClientX, currentClientY); }, }); this.prop.animationPromise .then((done) => { this.prop.animationPromise = null; if (done) { this.__resetMove(); this.psv.resetIdleTimer(); } }); } /** * @summary Triggers an event with all coordinates when a simple click is performed * @param {int} clientX * @param {int} clientY * @param {EventTarget} target * @param {boolean} [rightclick=false] * @fires PSV.click * @fires PSV.dblclick * @private */ __click(clientX, clientY, target, rightclick = false) { const boundingRect = this.psv.container.getBoundingClientRect(); /** * @type {PSV.ClickData} */ const data = { rightclick: rightclick, target : target, clientX : clientX, clientY : clientY, viewerX : clientX - boundingRect.left, viewerY : clientY - boundingRect.top, }; const intersections = this.psv.dataHelper.getIntersections({ x: data.viewerX, y: data.viewerY, }); const sphereIntersection = intersections.find(i => i.object.userData[MESH_USER_DATA]); if (sphereIntersection) { const sphericalCoords = this.psv.dataHelper.vector3ToSphericalCoords(sphereIntersection.point); data.longitude = sphericalCoords.longitude; data.latitude = sphericalCoords.latitude; data.objects = intersections.map(i => i.object).filter(o => !o.userData[MESH_USER_DATA]); try { const textureCoords = this.psv.dataHelper.sphericalCoordsToTextureCoords(data); data.textureX = textureCoords.x; data.textureY = textureCoords.y; } catch (e) { data.textureX = NaN; data.textureY = NaN; } if (!this.state.dblclickTimeout) { this.psv.trigger(EVENTS.CLICK, data); this.state.dblclickData = clone(data); this.state.dblclickTimeout = setTimeout(() => { this.state.dblclickTimeout = null; this.state.dblclickData = null; }, DBLCLICK_DELAY); } else { if (Math.abs(this.state.dblclickData.clientX - data.clientX) < this.state.moveThreshold && Math.abs(this.state.dblclickData.clientY - data.clientY) < this.state.moveThreshold) { this.psv.trigger(EVENTS.DOUBLE_CLICK, this.state.dblclickData); } clearTimeout(this.state.dblclickTimeout); this.state.dblclickTimeout = null; this.state.dblclickData = null; } } } /** * @summary Starts moving when crossing moveThreshold and performs movement * @param {int} clientX * @param {int} clientY * @private */ __move(clientX, clientY) { if (this.state.mousedown && (Math.abs(clientX - this.state.startMouseX) >= this.state.moveThreshold || Math.abs(clientY - this.state.startMouseY) >= this.state.moveThreshold)) { this.psv.__stopAll(); this.__resetMove(); this.state.step = MOVING; this.state.mouseX = clientX; this.state.mouseY = clientY; this.__logMouseMove(clientX, clientY); } else if (this.state.step === MOVING) { this.__applyMove(clientX, clientY); this.__logMouseMove(clientX, clientY); } } /** * @summary Raw method for movement, called from mouse event and move inertia * @param {int} clientX * @param {int} clientY * @private */ __applyMove(clientX, clientY) { const rotation = { longitude: (clientX - this.state.mouseX) / this.prop.size.width * this.config.moveSpeed * MathUtils.degToRad(this.prop.littlePlanet ? 90 : this.prop.hFov), latitude : (clientY - this.state.mouseY) / this.prop.size.height * this.config.moveSpeed * MathUtils.degToRad(this.prop.littlePlanet ? 90 : this.prop.vFov), }; const currentPosition = this.psv.getPosition(); this.psv.rotate({ longitude: currentPosition.longitude - rotation.longitude, latitude : currentPosition.latitude + rotation.latitude, }); this.state.mouseX = clientX; this.state.mouseY = clientY; } /** * @summary Perfoms combined move and zoom * @param {TouchEvent} evt * @private */ __moveZoom(evt) { if (this.state.step === MOVING) { evt.preventDefault(); const p1 = { x: evt.touches[0].clientX, y: evt.touches[0].clientY }; const p2 = { x: evt.touches[1].clientX, y: evt.touches[1].clientY }; const p = distance(p1, p2); const delta = (p - this.state.pinchDist) / SYSTEM.pixelRatio * this.config.zoomSpeed; this.psv.zoom(this.psv.getZoomLevel() + delta); this.__move((p1.x + p2.x) / 2, (p1.y + p2.y) / 2); this.state.pinchDist = p; } } /** * @summary Stores each mouse position during a mouse move * @description Positions older than "INERTIA_WINDOW" are removed<br> * Positions before a pause of "INERTIA_WINDOW" / 10 are removed * @param {int} clientX * @param {int} clientY * @private */ __logMouseMove(clientX, clientY) { const now = Date.now(); const last = this.state.mouseHistory.length ? this.state.mouseHistory[this.state.mouseHistory.length - 1] : [0, -1, -1]; // avoid duplicates if (last[1] === clientX && last[2] === clientY) { last[0] = now; } else if (now === last[0]) { last[1] = clientX; last[2] = clientY; } else { this.state.mouseHistory.push([now, clientX, clientY]); } let previous = null; for (let i = 0; i < this.state.mouseHistory.length;) { if (this.state.mouseHistory[i][0] < now - INERTIA_WINDOW) { this.state.mouseHistory.splice(i, 1); } else if (previous && this.state.mouseHistory[i][0] - previous > INERTIA_WINDOW / 10) { this.state.mouseHistory.splice(0, i); i = 0; previous = this.state.mouseHistory[i][0]; } else { previous = this.state.mouseHistory[i][0]; i++; } } } }