/* 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/. */
/** @module */
/** Max time in ms to accept a touch move as quick swipe gesture
*
* @readonly
* @default
* @type {number} */
const MAX_FLICK_TIME = 100;
/** Min distance to accept a touch move as quick swipe gesture.
*
* Threshhold to prevent any touch to be interpreted as swipe.
*
* @readonly
* @default
* @type {number} */
const MIN_FLICK_TRAVEL = 20;
/** Minimum distance to accept a touch move as slow swipe gesture in horizontal direction.
*
* Value depends on screen size and therefore is (re)calculated
*
* @default
* @type {number} */
let MIN_SLOW_TRAVEL_X;
/** Minimum distance to accept a touch move as slow swipe gesture in vertical direction.
*
* Value depends on screen size and therefore is (re)calculated
*
* @default
* @type {number} */
let MIN_SLOW_TRAVEL_Y; // minimum distance to accept a touch move as slow swipe gesture in vertical direction
/** Tolerance of slight rotation gesture movement assumed to be unintentional.
*
* The Threshhold adds some visual stability to pan movements.
* Must be exceeded once to accept a rotating gesture.
*
* @readonly
* @default
* @type {number} */
const ROTATE_THRESHHOLD = 10;
/** Upper tolerance of slight zoom gesture movement assumed to be unintentional.
*
* The Threshhold adds some visual stability to pan movements.
* Must be exceeded once to accept a zoom gesture.
*
* @readonly
* @default
* @type {number} */
const ZOOM_UP_THRESHHOLD = 1.5;
/** Lower tolerance of slight zoom gesture movement assumed to be unintentional.
*
* The Threshhold adds some visual stability to pan movements.
* Must be exceeded once to accept a zoom gesture.
*
* @readonly
* @default
* @type {number} */
const ZOOM_LOW_THRESHHOLD = 1/ZOOM_UP_THRESHHOLD;
/** The current Sozi player.
*
* @type {module:player/Player.Player} */
let player;
/** The current Sozi presentation.
*
* @type {module:model/Presentation.Presentation} */
let presentation;
/** When playing the presentation, are touch gestures enabled?
*
* Inferred from
* {@link module:model/Presentation.Presentation#enableMouseZoom|enableMouseZoom},
* {@link module:model/Presentation.Presentation#enableMouseTranslation|enableMouseTranslation},
* or {@link module:model/Presentation.Presentation#enableMouseRotation|enableMouseRotation}
* in the current presentation.
*
* @type {boolean} */
let interactionGestureEnabled;
/** The currently active gesture handler depending on the amount of touchpoints.
*
* `null`, if no touches on the screen.
*
* @type {module:player/TouchGestures.Gesture} */
let currentGesture;
/** Helper class defining a line for geometric calculations.
*
* It is used to interpret changes in two finger gestures.
*/
class Line {
/** Creates a new line defined by 2 points.
*
* @param {number} x1 - The X coordinate of the first point.
* @param {number} y1 - The Y coordinate of the first point.
* @param {number} x2 - The X coordinate of the second point.
* @param {number} y2 - The Y coordinate of the second point.
*/
constructor(x1, y1, x2, y2) {
this.x1 = x1;
this.y1 = y1;
this.x2 = x2;
this.y2 = y2;
}
/** Calculate the angle between this line and another.
*
* @param {module:player/TouchGestures.Line} otherLine - The line defining the second leg of the angle.
* @returns {number} the angle in degree
*
*/
getAngle(otherLine) {
const dAx = this.x2 - this.x1;
const dAy = this.y2 - this.y1;
const dBx = otherLine.x2 - otherLine.x1;
const dBy = otherLine.y2 - otherLine.y1;
const angle = Math.atan2(dAx * dBy - dAy * dBx, dAx * dBx + dAy * dBy);
const degree_angle = angle * (180 / Math.PI);
return degree_angle;
}
/** Calculates the average horizontal distance to another line according to the two definition points.
*
* @param {module:player/TouchGestures.Line} otherLine - The line to calculate the distance to.
* @returns {number} the average horizontal distance.
*/
getXDist(otherLine) {
return (this.x1 - otherLine.x1 + this.x2 - otherLine.x2) / 2;
}
/** Calculates the average vertical distance to another line according to the two definition points.
*
* @param {module:player/TouchGestures.Line} otherLine - The line to calculate the distance to.
* @returns {number} the average vertical distance.
*/
getYDist(otherLine) {
return (this.y1 - otherLine.y1 + this.y2 - otherLine.y2) / 2;
}
/** Calculates the square of the line's length.
*
* @returns {number} the square to the line's length.
*/
getSqrLength() {
return Math.pow(this.x1 - this.x2, 2) + Math.pow(this.y1 - this.y2, 2);
}
/** The Midpoint of this line in x and y coordinate
*
* @returns {object} - X and Y coordinate of the midpoint.
*
*/
getMidpoint() {
return {
x: (this.x1 + this.x2) / 2,
y: (this.y1 + this.y2) / 2
};
}
}
/** abstract class for touch gesture handlers.
*/
class Gesture {
/** Touchpoints have been moved.
*
* Specific behaviour to be implemented by derived classes
*
* @param {object[]} touches - Array of touchpoints from the touch event.
*/
move(touches) {
// Not implemented
}
/** Checks whether the given touches (e.g. by number) cannot be processed by the specific gesture object.
*
* Must be implemented by derived classes.
*
* @param {object[]} touches - Array of touchpoints from the touch event.
* @returns {boolean} - `true`, if touches are rejected, `false` if accepted
*
*/
rejects(touches) {
// Not implemented
return true;
}
/** Gesture has been completed successfully.
*
* Specific behaviour to be implemented by derived classes
*/
finish() {
// Not implemented
}
/** Execute a swipe.
*
* Must be implemented by derived class.
*/
doSwipe() {
// Not implemented
}
/** Tests for vertical or horizontal swipes according to a given minimum movement.
*
* Performs the swipe in the dominant direction (longer absolute move)
*
* @param {number} distX - Touch movement in horizontal direction
* @param {number} distY - Touch movement in vertical direction
* @param {number} minX - Minimum distance in horizontal direction to accept as swipe
* @param {number} minY - Minimum distance in vertical direction to accept as swipe
* @returns {boolean} - `true`, if a swipe was ececuted
*/
checkSwipe(distX, distY, minX, minY) {
const dXAbs = Math.abs(distX);
const dYAbs = Math.abs(distY);
if (dXAbs > dYAbs) { //potential horizontal swipe
if (dXAbs >= minX) {
this.doSwipe((distX < 0) ? "left" : "right");
return true;
}
}
else { // potental vertical swipe
if (dYAbs>= minY) {
this.doSwipe((distY < 0) ? "up" : "down");
return true;
}
}
return false;
}
}
/** Handles single touch gestures.
*
* Single touch gestures are swipes controlling navigation.
* A Swipe is detected either as a quick flick (any/very short minimum length within restricted time
* or a slow movement across the screen (any time but minimum length of movement).
* Swipes are accepted in both vertical and horizontal direction.
* Only one swipe is performed however, determined by the dominant direction (longer absolute length in px).
*
* @extends module:player/TouchGestures-Gesture
*/
class SingleGesture extends Gesture {
/** Creates a new single touch gesture handler.
*
* @param {object[]} touches - The initial touchpoint objects when the gesture is first detected
*/
constructor(touches) {
super();
this.flickTime = Date.now(); // start time for quick swipes
this.firstTouch = { // first touched point for long swipes
x: touches[0].clientX,
y: touches[0].clientY
};
this.lastTouch = this.firstTouch; // last touched point
this.prevTouch = this.firstTouch; // point touched before the last
this.swipeDone = false; // swipe performed while moving
}
/** Performs a swipe.
*
* @param {string} direction - Currently recognized "up", "down", "left", "right"
* @override
*/
doSwipe(direction) {
switch (direction) {
case "down":
case "left":
player.moveToNext();
break;
case "up":
case "right":
player.moveToPrevious();
break;
}
}
/** Checks for long swipes when the touch point moves.
*
* Also updates the last touch value to check quick swipes when gesture is finished.
*
* @param {object[]} touches - The touchpoints.
* @override
*/
move(touches) {
const current = {
x: touches[0].clientX,
y: touches[0].clientY
};
// check slow swipe at every movement
// slow swipe only needs a touch to travel a certain distance
// without releasing the touchpoint.
// the distance is about 1/2 of the display width to prevent accidental swipes
if (this.checkSwipe(current.x - this.firstTouch.x,
current.y - this.firstTouch.y,
MIN_SLOW_TRAVEL_X,
MIN_SLOW_TRAVEL_Y)) {
this.swipeDone = true;
}
else {
this.flickTime = Date.now();
this.prevTouch = this.lastTouch;
this.lastTouch = current;
}
}
rejects(touches) {
return this.swipeDone || touches.length != 1;
}
/** Check quick swipe when the touchpint is released from the screen.
*
* A quick swipe happend, when the last movement before release was a quick flick in one direction.
* The touchpoint has to be moved for a certain distance within a short timeframe right before release.
*
* @override
*/
finish() {
// Do not swipe again, if long swipe already fired.
// this prevents double swipe glitches for long AND fast swipe gestures.
if (!this.swipeDone) {
const travelTime = Date.now() - this.flickTime;
if (travelTime < MAX_FLICK_TIME) {
this.checkSwipe(this.lastTouch.x - this.prevTouch.x,
this.lastTouch.y - this.prevTouch.y,
MIN_FLICK_TRAVEL,
MIN_FLICK_TRAVEL);
}
}
}
}
/** Handles double touch gestures.
*
* Double touch gestures control screen interactions like zoom, rotate and panning (translate).
* To interpret changes the handler uses line objects which
* virtuallyvirtually connect two touchpoints.
*
* @extends Gesture
*/
class DoubleGesture extends Gesture {
/** Creates a new double touch gesture handler.
*
* @param {object[]} touches - The initial touchpoint objects when the gesture is first detected
*/
constructor(touches) {
super();
// initial line connecting the very first touchpoints as reference for all upcomming gestures
this.startLine = new Line(
touches[0].clientX,
touches[0].clientY,
touches[1].clientX,
touches[1].clientY
);
// buffer for the previous touchpoints.
this.lastLine = this.startLine;
// mark if rotation and zoom threshholds are reached at least once during gesture.
this.rotateEnabled = false;
this.zoomEnabled = false;
}
/** Checks zoom threshhold and performs the actual zoom.
*
* @param {module:player/TouchGestures.Line} actLine - The currently processed line.
*/
zoom(actLine) {
if (this.zoomEnabled) {
const zoom = (actLine.getSqrLength() / this.lastLine.getSqrLength());
const mid = actLine.getMidpoint();
player.viewport.zoom(zoom, mid.x, mid.y);
}
else {
// Check threshhold to enable zoom.
const zoom = Math.abs(actLine.getSqrLength() / this.startLine.getSqrLength());
if (zoom > ZOOM_UP_THRESHHOLD || zoom < ZOOM_LOW_THRESHHOLD) {
this.zoomEnabled = true;
}
}
}
/** Checks roatation threshhold and performs the actual rotation.
*
* @param {module:player/TouchGestures.Line} actLine - The currently processed line.
*/
rotate(actLine) {
if (this.rotateEnabled) {
const rotate = actLine.getAngle(this.lastLine);
player.viewport.rotate(rotate);
}
else {
// Check threshhold to enable rotation.
if (Math.abs(actLine.getAngle(this.startLine)) >= ROTATE_THRESHHOLD) {
this.rotateEnabled = true;
}
}
}
/** Performs the actual translation/pan.
*
* @param {module:player/TouchGestures.Line} actLine - The currently processed line.
*/
translate(actLine) {
const panX = actLine.getXDist(this.lastLine);
const panY = actLine.getYDist(this.lastLine);
player.viewport.translate(panX, panY);
}
/** Processes touchpoint moves.
*
* Checks whether each of translate, zoom or rotate are allowed by presentation's
* mouse enabled policy and delegates the interaction to helper classes.
*
* @param {object[]} touches - Array of touchpoints.
* @override
*/
move(touches) {
const actLine = new Line(
touches[0].clientX,
touches[0].clientY,
touches[1].clientX,
touches[1].clientY
);
if (presentation.enableMouseZoom) {
this.zoom(actLine);
}
if (presentation.enableMouseRotation) {
this.rotate(actLine);
}
if (presentation.enableMouseTranslation) {
this.translate(actLine);
}
this.lastLine = actLine;
}
/** Rejects any number of touchpoints but two.
*
* @param {object[]} touches - Array of touchpoints.
* @override
*/
rejects(touches) {
return touches.length != 2;
}
}
/** A dummy gesture handler used when a gesture is restricted by mouse configuration.
*
* The dummy pretends to handle a given number of touchpoints, but does nothing in effect.
*
* @extends Gesture
*/
class DummyGesture extends Gesture {
/** Constructs the dummy gesture handler.
*
* @param {number} touchNum - The number of touches this dummy should pretend to handle.
*/
constructor(touchNum) {
super();
this.touchNum = touchNum;
}
/** Rejects touch events that do not exaclty match the initial number of touches to be handled.
*
* @param {object[]} touches - Array of touch objects from the touch event
* @override
*/
rejects(touches) {
return touches.length != this.touchNum;
}
}
/** Updates all parameters depending on screen dimensions.
*
* @listens resize
*/
function updateScreenValues() {
MIN_SLOW_TRAVEL_X = Math.floor(window.innerWidth/2);
MIN_SLOW_TRAVEL_Y = Math.floor(window.innerHeight/2);
}
/** Creates a new gesture handler according to the number of currently applied touches.
*
* If no appropriate handler can be identified according to number of touch points or the presentation's mouse enabled policy,
* a dummy handler is returned.
* The dummy does nothing in effect, but avoids the createGesture function to be called repeatedly.
*
* @param {object[]} touches - Array of currently active touches
* @returns {module:player/TouchGestures.Gesture} - A new gesture handler matching the number of touches, or `null` if no handler matches.
*/
function createGesture(touches) {
switch (touches.length) {
case 1: return presentation.enableMouseNavigation ? new SingleGesture(touches) : new DummyGesture(1);
case 2: return interactionGestureEnabled ? new DoubleGesture(touches) : new DummyGesture(2);
default: return new DummyGesture(touches.length);
}
}
/** Checks, if the current gesture handler is appropriate for the given touches.
*
* If not, a new gesture handler is put in place.
*
* @param {object[]} touches - Array of currently active touches.
*/
function updateGesture(touches) {
if (currentGesture == null || currentGesture.rejects(touches)) {
currentGesture = createGesture(touches);
}
}
/** Processes a touch start event.
*
* Initializes a gesture handler.
*
* @param {TouchEvent} evt - The DOM event to process.
*
* @listens touchstart
*/
function onTouchStart(evt) {
evt.preventDefault();
updateGesture(evt.touches);
}
/** Processes a touch move event.
*
* @param {TouchEvent} evt - The DOM event to process.
*
* @listens touchmove
*/
function onTouchMove(evt) {
updateGesture(evt.touches);
currentGesture.move(evt.touches);
}
/** Processes a touch end event.
*
* @param {TouchEvent} evt - The DOM event to process.
*
* @listens touchend
*/
function onTouchEnd(evt) {
if (currentGesture) {
currentGesture.finish(evt);
}
currentGesture = null;
}
/** Initializes touch gestures.
*
* This function adds touch listeners to the given parent.
*
* @param {module:player/Player.Player} pl - The current Player.
* @param {module:model/Presentation.Presentation} pr - The presentation to play.
*/
export function init(pl, pr) {
player = pl;
presentation = pr;
interactionGestureEnabled = presentation.enableMouseRotation ||
presentation.enableMouseZoom || presentation.enableMouseTranslation;
if (presentation.enableMouseNavigation || interactionGestureEnabled) {
const root = player.viewport.svgRoot;
updateScreenValues();
window.addEventListener("resize", updateScreenValues);
root.addEventListener("touchstart", onTouchStart, false);
root.addEventListener("touchend", onTouchEnd, false);
root.addEventListener("touchcancel", onTouchEnd, false);
root.addEventListener("touchmove", onTouchMove, false);
}
}