<commit_before>"use strict";

import { CityTourMath } from "./../math";

const HALF_PI = Math.PI * 0.5;

var GestureProcessor = function(sceneView, mapCamera, terrain) {
  var PAN = "pan";
  var TILT = "tilt";
  var ROTATE = "rotate";
  var PINCH_ZOOM = "pinch zoom";

  var MIN_ROTATION_ANGLE =  0.01745329;  // 1 degree
  var MIN_ZOOM_DELTA = 2.0;
  var ALLOWABLE_DELTA_FOR_TILT_GESTURE = Math.PI / 16;
  var MIN_TILT_GESTURE_START_ANGLE = HALF_PI - ALLOWABLE_DELTA_FOR_TILT_GESTURE;
  var MAX_TILT_GESTURE_START_ANGLE = HALF_PI + ALLOWABLE_DELTA_FOR_TILT_GESTURE;

  var terrainBoundingBox = new THREE.Box3(new THREE.Vector3(terrain.minX(), Number.NEGATIVE_INFINITY, terrain.minZ()),
                                          new THREE.Vector3(terrain.maxX(), Number.POSITIVE_INFINITY, terrain.maxZ()));

  var currentGesture;
  var previousTouches;

  // Adapted from https://stackoverflow.com/questions/13055214/mouse-canvas-x-y-to-three-js-world-x-y-z#13091694
  //
  // Instead of using the camera's current position to unproject from the screen coordinate to the world coordinate,
  // this always uses the equivalent of a camera looking straight down on the center of orbit, from the zoom distance away.
  //
  // The reason for doing this is so that the rate of movement is uniform regardless of the camera's tilt angle.
  // I.e., at the same zoom level, moving your mouse/finger 10 pixels on the screen will result in the same amount
  // of world movement if the camera is looking straight down, or straight forward. It also means that the location on
  // the screen (i.e. top-left vs. center vs. bottom-right) won't affect the amount of world movement when the camera is
  // tilted at an angle other than straight down.
  var screenCoordinateToWorldCoordinateStraightDown = function(normalizedScreenVector) {
    var straightDownEuler, straightDownPosition, straightDownQuaternion, straightDownScale, straightDownMatrix;
    var matrix;
    var direction, distanceToXZPlane, worldPosition;
    var camera = sceneView.camera();

    // Similar camera world matrix from a "looking straight down on center of orbit" position/rotation
    straightDownEuler = new THREE.Euler(-HALF_PI, camera.rotation.y, 0.0, 'YXZ');
    straightDownPosition = new THREE.Vector3(mapCamera.positionX(), mapCamera.positionY(), mapCamera.positionZ());
    straightDownQuaternion = new THREE.Quaternion();
    straightDownQuaternion.setFromEuler(straightDownEuler);
    straightDownScale = camera.scale.clone();
    straightDownMatrix = new THREE.Matrix4().compose(straightDownPosition, straightDownQuaternion, straightDownScale);

    // Unproject from the simulated camera position
    matrix = new THREE.Matrix4();
    matrix.multiplyMatrices(straightDownMatrix, matrix.getInverse(camera.projectionMatrix));
    normalizedScreenVector.applyMatrix4(matrix);

    direction = normalizedScreenVector.sub(straightDownPosition).normalize();
    distanceToXZPlane = -(straightDownPosition.y / direction.y);
    worldPosition = straightDownPosition.clone().add(direction.multiplyScalar(distanceToXZPlane));

    return worldPosition;
  };

  var panCamera = function(currentTouches) {
    var normalizedScreenDragDistance = new THREE.Vector3(currentTouches.normalizedScreenMidpoint().x - previousTouches.normalizedScreenMidpoint().x,
                                                         currentTouches.normalizedScreenMidpoint().y - previousTouches.normalizedScreenMidpoint().y,
                                                         0.0);

    var worldDragStart = new THREE.Vector3(mapCamera.positionX(), 0.0, mapCamera.positionZ());
    var worldDragEnd = screenCoordinateToWorldCoordinateStraightDown(normalizedScreenDragDistance);
    var worldDragDistance = new THREE.Vector3(worldDragEnd.x - worldDragStart.x,
                                              worldDragEnd.y - worldDragStart.y,
                                              worldDragEnd.z - worldDragStart.z);

    mapCamera.pan(worldDragDistance.x, worldDragDistance.z);
  };

  var determineMultiTouchGesture = function(currentTouches) {
    var screenAngleBetweenTouches = Math.abs(currentTouches.angleBetweenTouches());
    var touchPointsAreHorizontal = screenAngleBetweenTouches >= MIN_TILT_GESTURE_START_ANGLE &&
                                   screenAngleBetweenTouches <= MAX_TILT_GESTURE_START_ANGLE;

    if (previousTouches.count() !== 2) {
      return undefined;
    }

    if (currentGesture === TILT) {
      return TILT;
    }
    else if (currentGesture === undefined && touchPointsAreHorizontal) {
      return TILT;
    }
    else if (Math.abs(previousTouches.angleBetweenTouches() - currentTouches.angleBetweenTouches()) >= MIN_ROTATION_ANGLE) {
      return ROTATE;
    }
    else if (Math.abs(currentTouches.distanceInScreenPixels() - previousTouches.distanceInScreenPixels()) >= MIN_ZOOM_DELTA) {
      return PINCH_ZOOM;
    }
    else {
      return currentGesture;
    }
  };

  var processSingleTouchGestures = function(currentTouches, isShiftKey, isAltKey) {
    if (previousTouches.count() !== 1) {
      mapCamera.setIsVelocityEnabled(false);
      return;
    }

    if (isAltKey) {
      var previousGesture = currentGesture;
      var distanceBetweenTouchesDeltaX = currentTouches.normalizedScreenMidpoint().x - previousTouches.normalizedScreenMidpoint().x;
      var distanceBetweenTouchesDeltaY = currentTouches.normalizedScreenMidpoint().y - previousTouches.normalizedScreenMidpoint().y;

      if (previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      if (Math.abs(distanceBetweenTouchesDeltaX) > Math.abs(distanceBetweenTouchesDeltaY)) {
        currentGesture = ROTATE;

        var azimuthAngleDelta = CityTourMath.lerp(0, Math.PI, (currentTouches.normalizedScreenMidpoint().x - previousTouches.normalizedScreenMidpoint().x));
        var newAzimuthAngle = mapCamera.azimuthAngle() + azimuthAngleDelta;
        mapCamera.rotateAzimuthAroundCenterOfAction(newAzimuthAngle - mapCamera.azimuthAngle());
      }
      else if (Math.abs(distanceBetweenTouchesDeltaX) < Math.abs(distanceBetweenTouchesDeltaY)) {
        currentGesture = TILT;

        var tiltAngleDelta = -CityTourMath.lerp(distanceBetweenTouchesDeltaY, 0, mapCamera.maxTiltAngle() - mapCamera.minTiltAngle());
        mapCamera.tiltCamera(tiltAngleDelta);
      }
      else {
        // Mouse didn't move, so do nothing
      }
    }
    else if (isShiftKey) {
      var previousGesture = currentGesture;
      currentGesture = PINCH_ZOOM;

      if (previousGesture !== PINCH_ZOOM) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      var distanceBetweenTouchesDelta = currentTouches.normalizedScreenMidpoint().y - previousTouches.normalizedScreenMidpoint().y;
      var baseZoomDistanceDelta = 0.025;
      var zoomDistanceDelta;
      if (distanceBetweenTouchesDelta > 0) {
        zoomDistanceDelta = -baseZoomDistanceDelta;
      }
      else if (distanceBetweenTouchesDelta < 0) {
        zoomDistanceDelta = baseZoomDistanceDelta;
      }
      else {
        zoomDistanceDelta = 0.0;
      }

      if (zoomDistanceDelta !== 0.0) {
        mapCamera.zoomTowardCenterOfAction(zoomDistanceDelta);
      }
    }
    else {
      currentGesture = PAN;
      panCamera(currentTouches);

      sceneView.touchPoint1MarkerMesh().position.set(currentTouches.touches()[0].worldX(),
                                                     currentTouches.touches()[0].worldY(),
                                                     currentTouches.touches()[0].worldZ());
      sceneView.touchPoint2MarkerMesh().position.set(0.0, 0.0, 0.0);
    }
  };

  var processMultiTouchGestures = function(currentTouches) {
    var yDistanceDelta, tiltAngleDelta;
    var distanceBetweenTouchesDelta, baseZoomDistanceDelta, zoomDistanceDelta;

    var previousGesture = currentGesture;
    currentGesture = determineMultiTouchGesture(currentTouches);

    if (currentGesture === undefined) {
      return;
    }

    if (currentGesture === TILT) {
      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      yDistanceDelta = currentTouches.touches()[0].normalizedScreenY() - previousTouches.touches()[0].normalizedScreenY();
      tiltAngleDelta = -yDistanceDelta * (mapCamera.minTiltAngle() - mapCamera.maxTiltAngle());
      mapCamera.tiltCamera(tiltAngleDelta);
    }
    else if (currentGesture === ROTATE) {
      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      mapCamera.rotateAzimuthAroundCenterOfAction(previousTouches.angleBetweenTouches() - currentTouches.angleBetweenTouches());
    }
    else if (currentGesture === PINCH_ZOOM) {
      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      distanceBetweenTouchesDelta = currentTouches.distanceInScreenPixels() - previousTouches.distanceInScreenPixels();

      baseZoomDistanceDelta = 0.025;
      zoomDistanceDelta = (distanceBetweenTouchesDelta > 0) ? baseZoomDistanceDelta : -baseZoomDistanceDelta;

      mapCamera.zoomTowardCenterOfAction(zoomDistanceDelta);
    }
  };

  var clampedCenterOfAction = function(currentTouches) {
    var cameraPosition = new THREE.Vector3(mapCamera.positionX(), mapCamera.positionY(), mapCamera.positionZ());
    var direction = currentTouches.worldMidpoint().clone().sub(cameraPosition).normalize();
    var ray = new THREE.Ray(cameraPosition, direction);
    var intersectionPoint = ray.intersectBox(terrainBoundingBox, new THREE.Vector3());

    if (intersectionPoint === null) {
      return currentTouches.worldMidpoint();
    }

    // If camera is outside the terrain bounds, raycast again from the edge of the
    // terrain bounding box so that we choose the farthest edge of the bounding box.
    if (terrain.isPointInBounds(mapCamera.positionX(), mapCamera.positionZ()) === false) {
      ray.origin = intersectionPoint;
      ray.recast(0.00000001);
      intersectionPoint = ray.intersectBox(terrainBoundingBox, new THREE.Vector3());
    }

    return intersectionPoint;
  };

  var processGesture = function(currentTouches, isShiftKey, isAltKey) {
    if (currentTouches === undefined) {
      currentGesture = undefined;
      mapCamera.setIsVelocityEnabled(true);
      sceneView.touchPoint1MarkerMesh().position.set(0.0, 0.0, 0.0);
      sceneView.touchPoint2MarkerMesh().position.set(0.0, 0.0, 0.0);
    }
    else if (previousTouches === undefined) {
      mapCamera.setIsVelocityEnabled(false);
    }
    else if (currentTouches.count() === 1) {
      processSingleTouchGestures(currentTouches, isShiftKey, isAltKey);
    }
    else if (currentTouches.count() === 2) {
      sceneView.touchPoint1MarkerMesh().position.set(currentTouches.touches()[0].worldX(),
                                                     currentTouches.touches()[0].worldY(),
                                                     currentTouches.touches()[0].worldZ());
      sceneView.touchPoint2MarkerMesh().position.set(currentTouches.touches()[1].worldX(),
                                                     currentTouches.touches()[1].worldY(),
                                                     currentTouches.touches()[1].worldZ());

      processMultiTouchGestures(currentTouches);
    }

    previousTouches = currentTouches;
  };

  var setTerrain = function(newTerrain) {
    terrain = newTerrain;
    terrainBoundingBox = new THREE.Box3(new THREE.Vector3(terrain.minX(), Number.NEGATIVE_INFINITY, terrain.minZ()),
                                        new THREE.Vector3(terrain.maxX(), Number.POSITIVE_INFINITY, terrain.maxZ()));
  };

  return {
    processGesture: processGesture,
    previousTouches: function() { return previousTouches; },
    setTerrain: setTerrain,
  };
};

export { GestureProcessor };
<commit_msg>Zoom/rotate/tilt gestures re-use center of action<commit_after>"use strict";

import { CityTourMath } from "./../math";

const HALF_PI = Math.PI * 0.5;

var GestureProcessor = function(sceneView, mapCamera, terrain) {
  var PAN = "pan";
  var TILT = "tilt";
  var ROTATE = "rotate";
  var PINCH_ZOOM = "pinch zoom";

  var MIN_ROTATION_ANGLE =  0.01745329;  // 1 degree
  var MIN_ZOOM_DELTA = 2.0;
  var ALLOWABLE_DELTA_FOR_TILT_GESTURE = Math.PI / 16;
  var MIN_TILT_GESTURE_START_ANGLE = HALF_PI - ALLOWABLE_DELTA_FOR_TILT_GESTURE;
  var MAX_TILT_GESTURE_START_ANGLE = HALF_PI + ALLOWABLE_DELTA_FOR_TILT_GESTURE;

  var terrainBoundingBox = new THREE.Box3(new THREE.Vector3(terrain.minX(), Number.NEGATIVE_INFINITY, terrain.minZ()),
                                          new THREE.Vector3(terrain.maxX(), Number.POSITIVE_INFINITY, terrain.maxZ()));

  var currentGesture;
  var previousTouches;

  // Adapted from https://stackoverflow.com/questions/13055214/mouse-canvas-x-y-to-three-js-world-x-y-z#13091694
  //
  // Instead of using the camera's current position to unproject from the screen coordinate to the world coordinate,
  // this always uses the equivalent of a camera looking straight down on the center of orbit, from the zoom distance away.
  //
  // The reason for doing this is so that the rate of movement is uniform regardless of the camera's tilt angle.
  // I.e., at the same zoom level, moving your mouse/finger 10 pixels on the screen will result in the same amount
  // of world movement if the camera is looking straight down, or straight forward. It also means that the location on
  // the screen (i.e. top-left vs. center vs. bottom-right) won't affect the amount of world movement when the camera is
  // tilted at an angle other than straight down.
  var screenCoordinateToWorldCoordinateStraightDown = function(normalizedScreenVector) {
    var straightDownEuler, straightDownPosition, straightDownQuaternion, straightDownScale, straightDownMatrix;
    var matrix;
    var direction, distanceToXZPlane, worldPosition;
    var camera = sceneView.camera();

    // Similar camera world matrix from a "looking straight down on center of orbit" position/rotation
    straightDownEuler = new THREE.Euler(-HALF_PI, camera.rotation.y, 0.0, 'YXZ');
    straightDownPosition = new THREE.Vector3(mapCamera.positionX(), mapCamera.positionY(), mapCamera.positionZ());
    straightDownQuaternion = new THREE.Quaternion();
    straightDownQuaternion.setFromEuler(straightDownEuler);
    straightDownScale = camera.scale.clone();
    straightDownMatrix = new THREE.Matrix4().compose(straightDownPosition, straightDownQuaternion, straightDownScale);

    // Unproject from the simulated camera position
    matrix = new THREE.Matrix4();
    matrix.multiplyMatrices(straightDownMatrix, matrix.getInverse(camera.projectionMatrix));
    normalizedScreenVector.applyMatrix4(matrix);

    direction = normalizedScreenVector.sub(straightDownPosition).normalize();
    distanceToXZPlane = -(straightDownPosition.y / direction.y);
    worldPosition = straightDownPosition.clone().add(direction.multiplyScalar(distanceToXZPlane));

    return worldPosition;
  };

  var panCamera = function(currentTouches) {
    var normalizedScreenDragDistance = new THREE.Vector3(currentTouches.normalizedScreenMidpoint().x - previousTouches.normalizedScreenMidpoint().x,
                                                         currentTouches.normalizedScreenMidpoint().y - previousTouches.normalizedScreenMidpoint().y,
                                                         0.0);

    var worldDragStart = new THREE.Vector3(mapCamera.positionX(), 0.0, mapCamera.positionZ());
    var worldDragEnd = screenCoordinateToWorldCoordinateStraightDown(normalizedScreenDragDistance);
    var worldDragDistance = new THREE.Vector3(worldDragEnd.x - worldDragStart.x,
                                              worldDragEnd.y - worldDragStart.y,
                                              worldDragEnd.z - worldDragStart.z);

    mapCamera.pan(worldDragDistance.x, worldDragDistance.z);
  };

  var determineMultiTouchGesture = function(currentTouches) {
    var screenAngleBetweenTouches = Math.abs(currentTouches.angleBetweenTouches());
    var touchPointsAreHorizontal = screenAngleBetweenTouches >= MIN_TILT_GESTURE_START_ANGLE &&
                                   screenAngleBetweenTouches <= MAX_TILT_GESTURE_START_ANGLE;

    if (previousTouches.count() !== 2) {
      return undefined;
    }

    if (currentGesture === TILT) {
      return TILT;
    }
    else if (currentGesture === undefined && touchPointsAreHorizontal) {
      return TILT;
    }
    else if (Math.abs(previousTouches.angleBetweenTouches() - currentTouches.angleBetweenTouches()) >= MIN_ROTATION_ANGLE) {
      return ROTATE;
    }
    else if (Math.abs(currentTouches.distanceInScreenPixels() - previousTouches.distanceInScreenPixels()) >= MIN_ZOOM_DELTA) {
      return PINCH_ZOOM;
    }
    else {
      return currentGesture;
    }
  };

  var processSingleTouchGestures = function(currentTouches, isShiftKey, isAltKey) {
    if (previousTouches.count() !== 1) {
      mapCamera.setIsVelocityEnabled(false);
      return;
    }

    if (isAltKey) {
      var previousGesture = currentGesture;
      var distanceBetweenTouchesDeltaX = currentTouches.normalizedScreenMidpoint().x - previousTouches.normalizedScreenMidpoint().x;
      var distanceBetweenTouchesDeltaY = currentTouches.normalizedScreenMidpoint().y - previousTouches.normalizedScreenMidpoint().y;

      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      if (Math.abs(distanceBetweenTouchesDeltaX) > Math.abs(distanceBetweenTouchesDeltaY)) {
        currentGesture = ROTATE;

        var azimuthAngleDelta = CityTourMath.lerp(0, Math.PI, (currentTouches.normalizedScreenMidpoint().x - previousTouches.normalizedScreenMidpoint().x));
        var newAzimuthAngle = mapCamera.azimuthAngle() + azimuthAngleDelta;
        mapCamera.rotateAzimuthAroundCenterOfAction(newAzimuthAngle - mapCamera.azimuthAngle());
      }
      else if (Math.abs(distanceBetweenTouchesDeltaX) < Math.abs(distanceBetweenTouchesDeltaY)) {
        currentGesture = TILT;

        var tiltAngleDelta = -CityTourMath.lerp(distanceBetweenTouchesDeltaY, 0, mapCamera.maxTiltAngle() - mapCamera.minTiltAngle());
        mapCamera.tiltCamera(tiltAngleDelta);
      }
      else {
        // Mouse didn't move, so do nothing
      }
    }
    else if (isShiftKey) {
      var previousGesture = currentGesture;
      currentGesture = PINCH_ZOOM;

      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      var distanceBetweenTouchesDelta = currentTouches.normalizedScreenMidpoint().y - previousTouches.normalizedScreenMidpoint().y;
      var baseZoomDistanceDelta = 0.025;
      var zoomDistanceDelta;
      if (distanceBetweenTouchesDelta > 0) {
        zoomDistanceDelta = -baseZoomDistanceDelta;
      }
      else if (distanceBetweenTouchesDelta < 0) {
        zoomDistanceDelta = baseZoomDistanceDelta;
      }
      else {
        zoomDistanceDelta = 0.0;
      }

      if (zoomDistanceDelta !== 0.0) {
        mapCamera.zoomTowardCenterOfAction(zoomDistanceDelta);
      }
    }
    else {
      currentGesture = PAN;
      panCamera(currentTouches);

      sceneView.touchPoint1MarkerMesh().position.set(currentTouches.touches()[0].worldX(),
                                                     currentTouches.touches()[0].worldY(),
                                                     currentTouches.touches()[0].worldZ());
      sceneView.touchPoint2MarkerMesh().position.set(0.0, 0.0, 0.0);
    }
  };

  var processMultiTouchGestures = function(currentTouches) {
    var yDistanceDelta, tiltAngleDelta;
    var distanceBetweenTouchesDelta, baseZoomDistanceDelta, zoomDistanceDelta;

    var previousGesture = currentGesture;
    currentGesture = determineMultiTouchGesture(currentTouches);

    if (currentGesture === undefined) {
      return;
    }

    if (currentGesture === TILT) {
      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      yDistanceDelta = currentTouches.touches()[0].normalizedScreenY() - previousTouches.touches()[0].normalizedScreenY();
      tiltAngleDelta = -yDistanceDelta * (mapCamera.minTiltAngle() - mapCamera.maxTiltAngle());
      mapCamera.tiltCamera(tiltAngleDelta);
    }
    else if (currentGesture === ROTATE) {
      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      mapCamera.rotateAzimuthAroundCenterOfAction(previousTouches.angleBetweenTouches() - currentTouches.angleBetweenTouches());
    }
    else if (currentGesture === PINCH_ZOOM) {
      if (previousGesture !== PINCH_ZOOM && previousGesture !== ROTATE && previousGesture !== TILT) {
        if (terrain.isPointInBounds(currentTouches.worldMidpoint().x, currentTouches.worldMidpoint().z)) {
          mapCamera.setCenterOfAction(currentTouches.worldMidpoint());
        }
        else {
          mapCamera.setCenterOfAction(clampedCenterOfAction(currentTouches));
        }
      }

      distanceBetweenTouchesDelta = currentTouches.distanceInScreenPixels() - previousTouches.distanceInScreenPixels();

      baseZoomDistanceDelta = 0.025;
      zoomDistanceDelta = (distanceBetweenTouchesDelta > 0) ? baseZoomDistanceDelta : -baseZoomDistanceDelta;

      mapCamera.zoomTowardCenterOfAction(zoomDistanceDelta);
    }
  };

  var clampedCenterOfAction = function(currentTouches) {
    var cameraPosition = new THREE.Vector3(mapCamera.positionX(), mapCamera.positionY(), mapCamera.positionZ());
    var direction = currentTouches.worldMidpoint().clone().sub(cameraPosition).normalize();
    var ray = new THREE.Ray(cameraPosition, direction);
    var intersectionPoint = ray.intersectBox(terrainBoundingBox, new THREE.Vector3());

    if (intersectionPoint === null) {
      return currentTouches.worldMidpoint();
    }

    // If camera is outside the terrain bounds, raycast again from the edge of the
    // terrain bounding box so that we choose the farthest edge of the bounding box.
    if (terrain.isPointInBounds(mapCamera.positionX(), mapCamera.positionZ()) === false) {
      ray.origin = intersectionPoint;
      ray.recast(0.00000001);
      intersectionPoint = ray.intersectBox(terrainBoundingBox, new THREE.Vector3());
    }

    return intersectionPoint;
  };

  var processGesture = function(currentTouches, isShiftKey, isAltKey) {
    if (currentTouches === undefined) {
      currentGesture = undefined;
      mapCamera.setIsVelocityEnabled(true);
      sceneView.touchPoint1MarkerMesh().position.set(0.0, 0.0, 0.0);
      sceneView.touchPoint2MarkerMesh().position.set(0.0, 0.0, 0.0);
    }
    else if (previousTouches === undefined) {
      mapCamera.setIsVelocityEnabled(false);
    }
    else if (currentTouches.count() === 1) {
      processSingleTouchGestures(currentTouches, isShiftKey, isAltKey);
    }
    else if (currentTouches.count() === 2) {
      sceneView.touchPoint1MarkerMesh().position.set(currentTouches.touches()[0].worldX(),
                                                     currentTouches.touches()[0].worldY(),
                                                     currentTouches.touches()[0].worldZ());
      sceneView.touchPoint2MarkerMesh().position.set(currentTouches.touches()[1].worldX(),
                                                     currentTouches.touches()[1].worldY(),
                                                     currentTouches.touches()[1].worldZ());

      processMultiTouchGestures(currentTouches);
    }

    previousTouches = currentTouches;
  };

  var setTerrain = function(newTerrain) {
    terrain = newTerrain;
    terrainBoundingBox = new THREE.Box3(new THREE.Vector3(terrain.minX(), Number.NEGATIVE_INFINITY, terrain.minZ()),
                                        new THREE.Vector3(terrain.maxX(), Number.POSITIVE_INFINITY, terrain.maxZ()));
  };

  return {
    processGesture: processGesture,
    previousTouches: function() { return previousTouches; },
    setTerrain: setTerrain,
  };
};

export { GestureProcessor };
<|endoftext|>


<commit_before>const Plugin = require('../../core/Plugin')
const ServiceWorkerStore = require('./ServiceWorkerStore')
const IndexedDBStore = require('./IndexedDBStore')
const MetaDataStore = require('./MetaDataStore')

/**
* The Golden Retriever plugin — restores selected files and resumes uploads
* after a closed tab or a browser crash!
*
* Uses localStorage, IndexedDB and ServiceWorker to do its magic, read more:
* https://uppy.io/blog/2017/07/golden-retriever/
*/
module.exports = class GoldenRetriever extends Plugin {
  constructor (uppy, opts) {
    super(uppy, opts)
    this.type = 'debugger'
    this.id = 'GoldenRetriever'
    this.title = 'Golden Retriever'

    const defaultOptions = {
      expires: 24 * 60 * 60 * 1000, // 24 hours
      serviceWorker: false
    }

    this.opts = Object.assign({}, defaultOptions, opts)

    this.MetaDataStore = new MetaDataStore({
      expires: this.opts.expires,
      storeName: uppy.getID()
    })
    this.ServiceWorkerStore = null
    if (this.opts.serviceWorker) {
      this.ServiceWorkerStore = new ServiceWorkerStore({ storeName: uppy.getID() })
    }
    this.IndexedDBStore = new IndexedDBStore(Object.assign(
      { expires: this.opts.expires },
      opts.indexedDB || {},
      { storeName: uppy.getID() }))

    this.saveFilesStateToLocalStorage = this.saveFilesStateToLocalStorage.bind(this)
    this.loadFilesStateFromLocalStorage = this.loadFilesStateFromLocalStorage.bind(this)
    this.loadFileBlobsFromServiceWorker = this.loadFileBlobsFromServiceWorker.bind(this)
    this.loadFileBlobsFromIndexedDB = this.loadFileBlobsFromIndexedDB.bind(this)
    this.onBlobsLoaded = this.onBlobsLoaded.bind(this)
  }

  loadFilesStateFromLocalStorage () {
    const savedState = this.MetaDataStore.load()

    if (savedState) {
      this.uppy.log('Recovered some state from Local Storage')
      this.uppy.setState({
        currentUploads: savedState.currentUploads || {},
        files: savedState.files || {}
      })

      this.savedPluginData = savedState.pluginData
    }
  }

  /**
   * Get file objects that are currently waiting: they've been selected,
   * but aren't yet being uploaded.
   */
  getWaitingFiles () {
    const waitingFiles = {}

    const allFiles = this.uppy.state.files
    Object.keys(allFiles).forEach((fileID) => {
      const file = this.uppy.getFile(fileID)
      if (!file.progress || !file.progress.uploadStarted) {
        waitingFiles[fileID] = file
      }
    })

    return waitingFiles
  }

  /**
   * Get file objects that are currently being uploaded. If a file has finished
   * uploading, but the other files in the same batch have not, the finished
   * file is also returned.
   */
  getUploadingFiles () {
    const uploadingFiles = {}

    const { currentUploads } = this.uppy.state
    if (currentUploads) {
      const uploadIDs = Object.keys(currentUploads)
      uploadIDs.forEach((uploadID) => {
        const filesInUpload = currentUploads[uploadID].fileIDs
        filesInUpload.forEach((fileID) => {
          uploadingFiles[fileID] = this.uppy.getFile(fileID)
        })
      })
    }

    return uploadingFiles
  }

  saveFilesStateToLocalStorage () {
    const filesToSave = Object.assign(
      this.getWaitingFiles(),
      this.getUploadingFiles()
    )

    const pluginData = {}
    this.uppy.emit('restore:get-data', (data) => {
      Object.assign(pluginData, data)
    })

    this.MetaDataStore.save({
      currentUploads: this.uppy.state.currentUploads,
      files: filesToSave,
      pluginData: pluginData
    })
  }

  loadFileBlobsFromServiceWorker () {
    this.ServiceWorkerStore.list().then((blobs) => {
      const numberOfFilesRecovered = Object.keys(blobs).length
      const numberOfFilesTryingToRecover = Object.keys(this.uppy.state.files).length
      if (numberOfFilesRecovered === numberOfFilesTryingToRecover) {
        this.uppy.log(`Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`)
        this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
        this.onBlobsLoaded(blobs)
      } else {
        this.uppy.log('Failed to recover blobs from Service Worker, trying IndexedDB now...')
        this.loadFileBlobsFromIndexedDB()
      }
    })
  }

  loadFileBlobsFromIndexedDB () {
    this.IndexedDBStore.list().then((blobs) => {
      const numberOfFilesRecovered = Object.keys(blobs).length

      if (numberOfFilesRecovered > 0) {
        this.uppy.log(`Successfully recovered ${numberOfFilesRecovered} blobs from Indexed DB!`)
        this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
        return this.onBlobsLoaded(blobs)
      }
      this.uppy.log('Couldn’t recover anything from IndexedDB :(')
    })
  }

  onBlobsLoaded (blobs) {
    const obsoleteBlobs = []
    const updatedFiles = Object.assign({}, this.uppy.state.files)
    Object.keys(blobs).forEach((fileID) => {
      const originalFile = this.uppy.getFile(fileID)
      if (!originalFile) {
        obsoleteBlobs.push(fileID)
        return
      }

      const cachedData = blobs[fileID]

      const updatedFileData = {
        data: cachedData,
        isRestored: true
      }
      const updatedFile = Object.assign({}, originalFile, updatedFileData)
      updatedFiles[fileID] = updatedFile

      this.uppy.generatePreview(updatedFile)
    })
    this.uppy.setState({
      files: updatedFiles
    })

    // Files have changed--make sure the progress is accurate.
    this.uppy.calculateTotalProgress()

    this.uppy.emit('restored', this.savedPluginData)

    if (obsoleteBlobs.length) {
      this.deleteBlobs(obsoleteBlobs).then(() => {
        this.uppy.log(`[GoldenRetriever] cleaned up ${obsoleteBlobs.length} old files`)
      })
    }
  }

  deleteBlobs (fileIDs) {
    const promises = []
    fileIDs.forEach((id) => {
      if (this.ServiceWorkerStore) {
        promises.push(this.ServiceWorkerStore.delete(id))
      }
      if (this.IndexedDBStore) {
        promises.push(this.IndexedDBStore.delete(id))
      }
    })
    return Promise.all(promises)
  }

  install () {
    this.loadFilesStateFromLocalStorage()

    if (Object.keys(this.uppy.state.files).length > 0) {
      if (this.ServiceWorkerStore) {
        this.uppy.log('Attempting to load files from Service Worker...')
        this.loadFileBlobsFromServiceWorker()
      } else {
        this.uppy.log('Attempting to load files from Indexed DB...')
        this.loadFileBlobsFromIndexedDB()
      }
    }

    this.uppy.on('file-added', (file) => {
      if (file.isRemote) return

      if (this.ServiceWorkerStore) {
        this.ServiceWorkerStore.put(file).catch((err) => {
          this.uppy.log('Could not store file', 'error')
          this.uppy.log(err)
        })
      }

      this.IndexedDBStore.put(file).catch((err) => {
        this.uppy.log('Could not store file', 'error')
        this.uppy.log(err)
      })
    })

    this.uppy.on('file-removed', (fileID) => {
      if (this.ServiceWorkerStore) this.ServiceWorkerStore.delete(fileID)
      this.IndexedDBStore.delete(fileID)
    })

    this.uppy.on('complete', ({ successful }) => {
      const fileIDs = successful.map((file) => file.id)
      this.deleteBlobs(fileIDs).then(() => {
        this.uppy.log(`[GoldenRetriever] removed ${successful.length} files that finished uploading`)
      })
    })

    this.uppy.on('state-update', this.saveFilesStateToLocalStorage)

    this.uppy.on('restored', () => {
      // start all uploads again when file blobs are restored
      const { currentUploads } = this.uppy.getState()
      if (currentUploads) {
        Object.keys(currentUploads).forEach((uploadId) => {
          this.uppy.restore(uploadId, currentUploads[uploadId])
        })
      }
    })
  }
}
<commit_msg>restore: Also restore if there are uploads without files.<commit_after>const Plugin = require('../../core/Plugin')
const ServiceWorkerStore = require('./ServiceWorkerStore')
const IndexedDBStore = require('./IndexedDBStore')
const MetaDataStore = require('./MetaDataStore')

/**
* The Golden Retriever plugin — restores selected files and resumes uploads
* after a closed tab or a browser crash!
*
* Uses localStorage, IndexedDB and ServiceWorker to do its magic, read more:
* https://uppy.io/blog/2017/07/golden-retriever/
*/
module.exports = class GoldenRetriever extends Plugin {
  constructor (uppy, opts) {
    super(uppy, opts)
    this.type = 'debugger'
    this.id = 'GoldenRetriever'
    this.title = 'Golden Retriever'

    const defaultOptions = {
      expires: 24 * 60 * 60 * 1000, // 24 hours
      serviceWorker: false
    }

    this.opts = Object.assign({}, defaultOptions, opts)

    this.MetaDataStore = new MetaDataStore({
      expires: this.opts.expires,
      storeName: uppy.getID()
    })
    this.ServiceWorkerStore = null
    if (this.opts.serviceWorker) {
      this.ServiceWorkerStore = new ServiceWorkerStore({ storeName: uppy.getID() })
    }
    this.IndexedDBStore = new IndexedDBStore(Object.assign(
      { expires: this.opts.expires },
      opts.indexedDB || {},
      { storeName: uppy.getID() }))

    this.saveFilesStateToLocalStorage = this.saveFilesStateToLocalStorage.bind(this)
    this.loadFilesStateFromLocalStorage = this.loadFilesStateFromLocalStorage.bind(this)
    this.loadFileBlobsFromServiceWorker = this.loadFileBlobsFromServiceWorker.bind(this)
    this.loadFileBlobsFromIndexedDB = this.loadFileBlobsFromIndexedDB.bind(this)
    this.onBlobsLoaded = this.onBlobsLoaded.bind(this)
  }

  loadFilesStateFromLocalStorage () {
    const savedState = this.MetaDataStore.load()

    if (savedState) {
      this.uppy.log('Recovered some state from Local Storage')
      this.uppy.setState({
        currentUploads: savedState.currentUploads || {},
        files: savedState.files || {}
      })

      this.savedPluginData = savedState.pluginData
    }
  }

  /**
   * Get file objects that are currently waiting: they've been selected,
   * but aren't yet being uploaded.
   */
  getWaitingFiles () {
    const waitingFiles = {}

    const allFiles = this.uppy.state.files
    Object.keys(allFiles).forEach((fileID) => {
      const file = this.uppy.getFile(fileID)
      if (!file.progress || !file.progress.uploadStarted) {
        waitingFiles[fileID] = file
      }
    })

    return waitingFiles
  }

  /**
   * Get file objects that are currently being uploaded. If a file has finished
   * uploading, but the other files in the same batch have not, the finished
   * file is also returned.
   */
  getUploadingFiles () {
    const uploadingFiles = {}

    const { currentUploads } = this.uppy.state
    if (currentUploads) {
      const uploadIDs = Object.keys(currentUploads)
      uploadIDs.forEach((uploadID) => {
        const filesInUpload = currentUploads[uploadID].fileIDs
        filesInUpload.forEach((fileID) => {
          uploadingFiles[fileID] = this.uppy.getFile(fileID)
        })
      })
    }

    return uploadingFiles
  }

  saveFilesStateToLocalStorage () {
    const filesToSave = Object.assign(
      this.getWaitingFiles(),
      this.getUploadingFiles()
    )

    const pluginData = {}
    this.uppy.emit('restore:get-data', (data) => {
      Object.assign(pluginData, data)
    })

    this.MetaDataStore.save({
      currentUploads: this.uppy.state.currentUploads,
      files: filesToSave,
      pluginData: pluginData
    })
  }

  loadFileBlobsFromServiceWorker () {
    this.ServiceWorkerStore.list().then((blobs) => {
      const numberOfFilesRecovered = Object.keys(blobs).length
      const numberOfFilesTryingToRecover = Object.keys(this.uppy.state.files).length
      if (numberOfFilesRecovered === numberOfFilesTryingToRecover) {
        this.uppy.log(`Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`)
        this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
        this.onBlobsLoaded(blobs)
      } else {
        this.uppy.log('Failed to recover blobs from Service Worker, trying IndexedDB now...')
        this.loadFileBlobsFromIndexedDB()
      }
    })
  }

  loadFileBlobsFromIndexedDB () {
    this.IndexedDBStore.list().then((blobs) => {
      const numberOfFilesRecovered = Object.keys(blobs).length

      if (numberOfFilesRecovered > 0) {
        this.uppy.log(`Successfully recovered ${numberOfFilesRecovered} blobs from Indexed DB!`)
        this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
        return this.onBlobsLoaded(blobs)
      }
      this.uppy.log('Couldn’t recover anything from IndexedDB :(')
    })
  }

  onBlobsLoaded (blobs) {
    const obsoleteBlobs = []
    const updatedFiles = Object.assign({}, this.uppy.state.files)
    Object.keys(blobs).forEach((fileID) => {
      const originalFile = this.uppy.getFile(fileID)
      if (!originalFile) {
        obsoleteBlobs.push(fileID)
        return
      }

      const cachedData = blobs[fileID]

      const updatedFileData = {
        data: cachedData,
        isRestored: true
      }
      const updatedFile = Object.assign({}, originalFile, updatedFileData)
      updatedFiles[fileID] = updatedFile

      this.uppy.generatePreview(updatedFile)
    })
    this.uppy.setState({
      files: updatedFiles
    })

    // Files have changed--make sure the progress is accurate.
    this.uppy.calculateTotalProgress()

    this.uppy.emit('restored', this.savedPluginData)

    if (obsoleteBlobs.length) {
      this.deleteBlobs(obsoleteBlobs).then(() => {
        this.uppy.log(`[GoldenRetriever] cleaned up ${obsoleteBlobs.length} old files`)
      })
    }
  }

  deleteBlobs (fileIDs) {
    const promises = []
    fileIDs.forEach((id) => {
      if (this.ServiceWorkerStore) {
        promises.push(this.ServiceWorkerStore.delete(id))
      }
      if (this.IndexedDBStore) {
        promises.push(this.IndexedDBStore.delete(id))
      }
    })
    return Promise.all(promises)
  }

  install () {
    this.loadFilesStateFromLocalStorage()

    if (Object.keys(this.uppy.state.files).length > 0) {
      if (this.ServiceWorkerStore) {
        this.uppy.log('Attempting to load files from Service Worker...')
        this.loadFileBlobsFromServiceWorker()
      } else {
        this.uppy.log('Attempting to load files from Indexed DB...')
        this.loadFileBlobsFromIndexedDB()
      }
    } else {
      this.core.log('No files need to be loaded, only restoring processing state...')
      this.onBlobsLoaded([])
    }

    this.uppy.on('file-added', (file) => {
      if (file.isRemote) return

      if (this.ServiceWorkerStore) {
        this.ServiceWorkerStore.put(file).catch((err) => {
          this.uppy.log('Could not store file', 'error')
          this.uppy.log(err)
        })
      }

      this.IndexedDBStore.put(file).catch((err) => {
        this.uppy.log('Could not store file', 'error')
        this.uppy.log(err)
      })
    })

    this.uppy.on('file-removed', (fileID) => {
      if (this.ServiceWorkerStore) this.ServiceWorkerStore.delete(fileID)
      this.IndexedDBStore.delete(fileID)
    })

    this.uppy.on('complete', ({ successful }) => {
      const fileIDs = successful.map((file) => file.id)
      this.deleteBlobs(fileIDs).then(() => {
        this.uppy.log(`[GoldenRetriever] removed ${successful.length} files that finished uploading`)
      })
    })

    this.uppy.on('state-update', this.saveFilesStateToLocalStorage)

    this.uppy.on('restored', () => {
      // start all uploads again when file blobs are restored
      const { currentUploads } = this.uppy.getState()
      if (currentUploads) {
        Object.keys(currentUploads).forEach((uploadId) => {
          this.uppy.restore(uploadId, currentUploads[uploadId])
        })
      }
    })
  }
}
<|endoftext|>


<commit_before>/* eslint-disable no-unused-expressions */

import sinon from 'sinon';

import { build } from '../../builder';
import { compose, map, toIdMap, values } from '../../fp';
import { subscriber as plugin } from '.';

const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t));
const toMiniUser = ({ id, name }) => ({ id, name });

const createUserApi = (container) => {
  const getUser = (id) => Promise.resolve(container[id]);
  getUser.operation = 'READ';
  getUser.byId = true;
  const getUsers = () => Promise.resolve(values(container));
  getUsers.operation = 'READ';

  const updateUser = (nextUser) => {
    const { id } = nextUser;
    const user = container[id];
    container[id] = { ...user, ...nextUser };
    return Promise.resolve(container[id]);
  };
  updateUser.operation = 'UPDATE';

  const createUser = (user) => {
    container[user.id] = user;
    return Promise.resolve(user);
  };
  createUser.operation = 'CREATE';

  const removeUser = (id) => {
    delete container[id];
    return Promise.resolve();
  };
  removeUser.operation = 'DELETE';

  return { getUser, getUsers, createUser, updateUser, removeUser };
};

const createConfig = () => {
  const peter = { id: 'peter', name: 'peter', location: 'gothenburg' };
  const gernot = { id: 'gernot', name: 'gernot', location: 'graz' };
  const robin = { id: 'robin', name: 'robin', location: 'berlin' };

  const list = [peter, gernot, robin];
  const users = toIdMap(list);
  const miniUsers = compose(toIdMap, map(toMiniUser))(list);

  const getActivities = () => Promise.resolve([]);
  getActivities.operation = 'READ';

  return {
    user: {
      api: createUserApi(users)
    },
    mediumUser: {
      api: createUserApi(users),
      viewOf: 'user',
      invalidates: ['activity']
    },
    miniUser: {
      api: createUserApi(miniUsers),
      viewOf: 'mediumUser'
    },
    activity: {
      api: { getActivities }
    }
  };
};

describe('subscriber plugin', () => {
  it('patches fns so that a subscriber can be created on READ operations', () => {
    const api = build(createConfig(), [plugin()]);
    expect(api.user.getUsers.createSubscriber).to.be.a('function');
    expect(api.user.getUser.createSubscriber).to.be.a('function');
  });

  it('does not patch other operations than read', () => {
    const api = build(createConfig(), [plugin()]);
    expect(api.user.removeUser.createSubscriber).not.to.be;
    expect(api.user.updateUser.createSubscriber).not.to.be;
  });

  describe('createSubscriber()', () => {
    it('returns a subscriber shape', () => {
      const api = build(createConfig(), [plugin()]);
      const subscriber = api.user.getUsers.createSubscriber();
      expect(subscriber.destroy).to.be.a('function');
      expect(subscriber.subscribe).to.be.a('function');
      expect(subscriber.alive).to.be.true;
    });
  });

  describe('subscriber', () => {
    describe('destroy', () => {
      it('removes all subscriptions', () => {
        const spy1 = sinon.spy();
        const spy2 = sinon.spy();
        const api = build(createConfig(), [plugin()]);

        const subscriber = api.user.getUsers.createSubscriber();
        subscriber.subscribe(spy1);
        subscriber.subscribe(spy2);

        return delay().then(() => {
          const initialCallCount = spy1.callCount;

          return api.user.updateUser({ id: 'peter', name: 'Peter' }).then(() => {
            expect(spy1.callCount).to.equal(initialCallCount + 1);
            expect(spy2.callCount).to.equal(initialCallCount + 1);

            subscriber.destroy();

            return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              expect(spy1.callCount).to.equal(initialCallCount + 1);
              expect(spy2.callCount).to.equal(initialCallCount + 1);
            });
          });
        });
      });

      it('marks a subscriber as destroyed', () => {
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();
        expect(subscriber.alive).to.be.true;

        subscriber.destroy();
        expect(subscriber.alive).to.be.false;
      });
    });

    describe('subscribe', () => {
      it('immediately invokes for the first time', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;
        });
      });

      it('returns an unsuscribe function', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        const unsubscribe = subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;
          unsubscribe();

          return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
            expect(spy).to.have.been.calledOnce;
          });
        });
      });

      it('calls the callback again when a relevant change happens', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;

          return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
            expect(spy).to.have.been.calledTwice;
            return api.user.updateUser({ id: 'peter', name: 'PETer' }).then(() => {
              expect(spy).to.have.been.calledThrice;
            });
          });
        });
      });

      it('takes an optional second callback invoked on error', () => {
        const spy = sinon.spy();
        const errSpy = sinon.spy();
        const error = { err: 'x' };
        const config = createConfig();
        config.user.api.getUsers = () => Promise.reject(error);
        config.user.api.getUsers.operation = 'READ';

        const api = build(config, [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy, errSpy);

        return delay().then(() => {
          expect(spy).not.to.have.been.called;
          expect(errSpy).to.have.been.calledOnce;
          expect(errSpy).to.have.been.calledWith(error);
        });
      });

      // eslint-disable-next-line max-len
      it('several parallel subscriptions guarantee to call each subscription only once initially', () => {
        const spies = [sinon.spy(), sinon.spy(), sinon.spy()];
        const api = build(createConfig(), [plugin()]);

        const subscriber = api.user.getUsers.createSubscriber();
        spies.forEach((spy) => subscriber.subscribe(spy));

        return delay().then(() => {
          spies.forEach((spy) => expect(spy.callCount).to.equal(1));
        });
      });

      it('invokes the callback with no arguments by default', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;

          return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
            expect(spy).to.have.been.calledTwice;
          });
        });
      });

      it('takes the arguments of the create call and passes them to the api call', () => {
        const config = createConfig();
        const stub = sinon.stub();
        const spy = sinon.spy();
        stub.returns(Promise.resolve([]));
        stub.operation = 'READ';
        config.user.api.getUsers = stub;
        const api = build(config, [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber(1, 2, 3);
        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(stub).to.have.been.calledWith(1, 2, 3);
        });
      });

      describe('with views', () => {
        it('notices changes to a child view', () => {
          const spy = sinon.spy();
          const api = build(createConfig(), [plugin()]);
          const subscriber = api.user.getUsers.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            return api.miniUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              expect(spy).to.have.been.calledTwice;
            });
          });
        });

        it('notices changes to a parent view', () => {
          const spy = sinon.spy();
          const api = build(createConfig(), [plugin()]);
          const subscriber = api.miniUser.getUsers.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              expect(spy).to.have.been.calledTwice;
            });
          });
        });

        it('notices direct invalidations', () => {
          const spy = sinon.spy();
          const api = build(createConfig(), [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              return delay().then(() => {
                expect(spy).to.have.been.calledThrice;
              });
            });
          });
        });

        it('calls api fns only AFTER they have been invalidated (on update)', () => {
          const config = createConfig();
          const state = {
            activities: [{ id: 'a' }]
          };
          config.activity.api.getActivities = () => Promise.resolve(state.activities);
          config.activity.api.getActivities.operation = 'READ';

          const spy = sinon.spy();
          const api = build(config, [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            const firstArgs = spy.args[0];
            expect(firstArgs.length).to.equal(1);

            state.activities = [...state.activities, { id: 'b' }];

            return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              return delay().then(() => {
                // TODO
                // Ideally this should be called only twice:
                // 1. The initial subscription
                // 2. The call after the invalidation by updateUser
                //
                // The third call happens after 2. - as this call also creates
                // an update event. The subscription is consuming its own event.
                // Not ideal, but not trivial to fix.
                expect(spy).to.have.been.calledThrice;
                const secondArgs = spy.args[1];
                expect(secondArgs[0].length).to.equal(2);
              });
            });
          });
        });

        it('calls api fns only AFTER they have been invalidated (on create)', () => {
          const config = createConfig();
          const state = {
            activities: [{ id: 'a' }]
          };
          config.activity.api.getActivities = () => Promise.resolve(state.activities);
          config.activity.api.getActivities.operation = 'READ';

          const spy = sinon.spy();
          const api = build(config, [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            const firstArgs = spy.args[0];
            expect(firstArgs.length).to.equal(1);

            state.activities = [...state.activities, { id: 'b' }];

            return api.mediumUser.createUser({ id: 'timur', name: 'Timur' }).then(() => {
              return delay().then(() => {
                expect(spy).to.have.been.calledThrice;
                const secondArgs = spy.args[1];
                expect(secondArgs[0].length).to.equal(2);
              });
            });
          });
        });

        it('calls api fns only AFTER they have been invalidated (on remove)', () => {
          const config = createConfig();
          const state = {
            activities: [{ id: 'a' }]
          };
          config.activity.api.getActivities = () => Promise.resolve(state.activities);
          config.activity.api.getActivities.operation = 'READ';

          const spy = sinon.spy();
          const api = build(config, [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          // fill the cache so that we can remove items later
          api.user.getUsers().then(() => {
            subscriber.subscribe(spy);

            return delay().then(() => {
              expect(spy).to.have.been.calledOnce;

              const firstArgs = spy.args[0];
              expect(firstArgs.length).to.equal(1);

              state.activities = [...state.activities, { id: 'b' }];

              return api.mediumUser.removeUser('peter').then(() => {
                return delay().then(() => {
                  expect(spy).to.have.been.calledThrice;
                  const secondArgs = spy.args[1];
                  expect(secondArgs[0].length).to.equal(2);
                });
              });
            });
          });
        });
      });
    });
  });
});
<commit_msg>Improve test coverage for subscriber<commit_after>/* eslint-disable no-unused-expressions */

import sinon from 'sinon';

import { build } from '../../builder';
import { compose, map, toIdMap, values } from '../../fp';
import { subscriber as plugin } from '.';

const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t));
const toMiniUser = ({ id, name }) => ({ id, name });

const createUserApi = (container) => {
  const getUser = (id) => Promise.resolve(container[id]);
  getUser.operation = 'READ';
  getUser.byId = true;
  const getUsers = () => Promise.resolve(values(container));
  getUsers.operation = 'READ';

  const updateUser = (nextUser) => {
    const { id } = nextUser;
    const user = container[id];
    container[id] = { ...user, ...nextUser };
    return Promise.resolve(container[id]);
  };
  updateUser.operation = 'UPDATE';

  const createUser = (user) => {
    container[user.id] = user;
    return Promise.resolve(user);
  };
  createUser.operation = 'CREATE';

  const removeUser = (id) => {
    delete container[id];
    return Promise.resolve();
  };
  removeUser.operation = 'DELETE';

  return { getUser, getUsers, createUser, updateUser, removeUser };
};

const createConfig = () => {
  const peter = { id: 'peter', name: 'peter', location: 'gothenburg' };
  const gernot = { id: 'gernot', name: 'gernot', location: 'graz' };
  const robin = { id: 'robin', name: 'robin', location: 'berlin' };

  const list = [peter, gernot, robin];
  const users = toIdMap(list);
  const miniUsers = compose(toIdMap, map(toMiniUser))(list);

  const getActivities = () => Promise.resolve([]);
  getActivities.operation = 'READ';

  return {
    user: {
      api: createUserApi(users)
    },
    mediumUser: {
      api: createUserApi(users),
      viewOf: 'user',
      invalidates: ['activity']
    },
    miniUser: {
      api: createUserApi(miniUsers),
      viewOf: 'mediumUser'
    },
    activity: {
      api: { getActivities }
    }
  };
};

describe('subscriber plugin', () => {
  it('patches fns so that a subscriber can be created on READ operations', () => {
    const api = build(createConfig(), [plugin()]);
    expect(api.user.getUsers.createSubscriber).to.be.a('function');
    expect(api.user.getUser.createSubscriber).to.be.a('function');
  });

  it('does not patch other operations than read', () => {
    const api = build(createConfig(), [plugin()]);
    expect(api.user.removeUser.createSubscriber).not.to.be;
    expect(api.user.updateUser.createSubscriber).not.to.be;
  });

  describe('createSubscriber()', () => {
    it('returns a subscriber shape', () => {
      const api = build(createConfig(), [plugin()]);
      const subscriber = api.user.getUsers.createSubscriber();
      expect(subscriber.destroy).to.be.a('function');
      expect(subscriber.subscribe).to.be.a('function');
      expect(subscriber.alive).to.be.true;
    });
  });

  describe('subscriber', () => {
    describe('destroy', () => {
      it('removes all subscriptions', () => {
        const spy1 = sinon.spy();
        const spy2 = sinon.spy();
        const api = build(createConfig(), [plugin()]);

        const subscriber = api.user.getUsers.createSubscriber();
        subscriber.subscribe(spy1);
        subscriber.subscribe(spy2);

        return delay().then(() => {
          const initialCallCount = spy1.callCount;

          return api.user.updateUser({ id: 'peter', name: 'Peter' }).then(() => {
            expect(spy1.callCount).to.equal(initialCallCount + 1);
            expect(spy2.callCount).to.equal(initialCallCount + 1);

            subscriber.destroy();

            return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              expect(spy1.callCount).to.equal(initialCallCount + 1);
              expect(spy2.callCount).to.equal(initialCallCount + 1);
            });
          });
        });
      });

      it('marks a subscriber as destroyed', () => {
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();
        expect(subscriber.alive).to.be.true;

        subscriber.destroy();
        expect(subscriber.alive).to.be.false;
      });
    });

    describe('subscribe', () => {
      it('immediately invokes for the first time', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;
        });
      });

      it('returns an unsuscribe function', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        const unsubscribe = subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;
          unsubscribe();

          return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
            expect(spy).to.have.been.calledOnce;
          });
        });
      });

      it('calls the callback again when a relevant change happens', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;

          return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
            expect(spy).to.have.been.calledTwice;
            return api.user.updateUser({ id: 'peter', name: 'PETer' }).then(() => {
              expect(spy).to.have.been.calledThrice;
            });
          });
        });
      });

      it('takes an optional second callback invoked on error', () => {
        const spy = sinon.spy();
        const errSpy = sinon.spy();
        const error = { err: 'x' };
        const config = createConfig();
        config.user.api.getUsers = () => Promise.reject(error);
        config.user.api.getUsers.operation = 'READ';

        const api = build(config, [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy, errSpy);

        return delay().then(() => {
          expect(spy).not.to.have.been.called;
          expect(errSpy).to.have.been.calledOnce;
          expect(errSpy).to.have.been.calledWith(error);
        });
      });

      it('also invokes error callback in later stages of the subscription\'s lifecycle', () => {
        const spy = sinon.spy();
        const errSpy = sinon.spy();
        const error = { err: 'x' };
        const config = createConfig();
        config.user.api.getUsers = () => Promise.reject(error);
        config.user.api.getUsers.operation = 'READ';

        const api = build(config, [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy, errSpy);

        return delay().then(() => {
          expect(spy).not.to.have.been.called;
          expect(errSpy).to.have.been.calledOnce;
          expect(errSpy).to.have.been.calledWith(error);

          return api.user.createUser({ id: 'x', name: 'y' }).then(() => {
            return delay().then(() => {
              expect(spy).not.to.have.been.called;
              expect(errSpy).to.have.been.calledTwice;
            });
          });
        });
      });

      // eslint-disable-next-line max-len
      it('several parallel subscriptions guarantee to call each subscription only once initially', () => {
        const spies = [sinon.spy(), sinon.spy(), sinon.spy()];
        const api = build(createConfig(), [plugin()]);

        const subscriber = api.user.getUsers.createSubscriber();
        spies.forEach((spy) => subscriber.subscribe(spy));

        return delay().then(() => {
          spies.forEach((spy) => expect(spy.callCount).to.equal(1));
        });
      });

      it('invokes the callback with no arguments by default', () => {
        const spy = sinon.spy();
        const api = build(createConfig(), [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber();

        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(spy).to.have.been.calledOnce;

          return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
            expect(spy).to.have.been.calledTwice;
          });
        });
      });

      it('takes the arguments of the create call and passes them to the api call', () => {
        const config = createConfig();
        const stub = sinon.stub();
        const spy = sinon.spy();
        stub.returns(Promise.resolve([]));
        stub.operation = 'READ';
        config.user.api.getUsers = stub;
        const api = build(config, [plugin()]);
        const subscriber = api.user.getUsers.createSubscriber(1, 2, 3);
        subscriber.subscribe(spy);

        return delay().then(() => {
          expect(stub).to.have.been.calledWith(1, 2, 3);
        });
      });

      describe('with views', () => {
        it('notices changes to a child view', () => {
          const spy = sinon.spy();
          const api = build(createConfig(), [plugin()]);
          const subscriber = api.user.getUsers.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            return api.miniUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              expect(spy).to.have.been.calledTwice;
            });
          });
        });

        it('notices changes to a parent view', () => {
          const spy = sinon.spy();
          const api = build(createConfig(), [plugin()]);
          const subscriber = api.miniUser.getUsers.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              expect(spy).to.have.been.calledTwice;
            });
          });
        });

        it('notices direct invalidations', () => {
          const spy = sinon.spy();
          const api = build(createConfig(), [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              return delay().then(() => {
                expect(spy).to.have.been.calledThrice;
              });
            });
          });
        });

        it('calls api fns only AFTER they have been invalidated (on update)', () => {
          const config = createConfig();
          const state = {
            activities: [{ id: 'a' }]
          };
          config.activity.api.getActivities = () => Promise.resolve(state.activities);
          config.activity.api.getActivities.operation = 'READ';

          const spy = sinon.spy();
          const api = build(config, [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            const firstArgs = spy.args[0];
            expect(firstArgs.length).to.equal(1);

            state.activities = [...state.activities, { id: 'b' }];

            return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => {
              return delay().then(() => {
                // TODO
                // Ideally this should be called only twice:
                // 1. The initial subscription
                // 2. The call after the invalidation by updateUser
                //
                // The third call happens after 2. - as this call also creates
                // an update event. The subscription is consuming its own event.
                // Not ideal, but not trivial to fix.
                expect(spy).to.have.been.calledThrice;
                const secondArgs = spy.args[1];
                expect(secondArgs[0].length).to.equal(2);
              });
            });
          });
        });

        it('calls api fns only AFTER they have been invalidated (on create)', () => {
          const config = createConfig();
          const state = {
            activities: [{ id: 'a' }]
          };
          config.activity.api.getActivities = () => Promise.resolve(state.activities);
          config.activity.api.getActivities.operation = 'READ';

          const spy = sinon.spy();
          const api = build(config, [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          subscriber.subscribe(spy);

          return delay().then(() => {
            expect(spy).to.have.been.calledOnce;

            const firstArgs = spy.args[0];
            expect(firstArgs.length).to.equal(1);

            state.activities = [...state.activities, { id: 'b' }];

            return api.mediumUser.createUser({ id: 'timur', name: 'Timur' }).then(() => {
              return delay().then(() => {
                expect(spy).to.have.been.calledThrice;
                const secondArgs = spy.args[1];
                expect(secondArgs[0].length).to.equal(2);
              });
            });
          });
        });

        it('calls api fns only AFTER they have been invalidated (on remove)', () => {
          const config = createConfig();
          const state = {
            activities: [{ id: 'a' }]
          };
          config.activity.api.getActivities = () => Promise.resolve(state.activities);
          config.activity.api.getActivities.operation = 'READ';

          const spy = sinon.spy();
          const api = build(config, [plugin()]);
          const subscriber = api.activity.getActivities.createSubscriber();

          // fill the cache so that we can remove items later
          api.user.getUsers().then(() => {
            subscriber.subscribe(spy);

            return delay().then(() => {
              expect(spy).to.have.been.calledOnce;

              const firstArgs = spy.args[0];
              expect(firstArgs.length).to.equal(1);

              state.activities = [...state.activities, { id: 'b' }];

              return api.mediumUser.removeUser('peter').then(() => {
                return delay().then(() => {
                  expect(spy).to.have.been.calledThrice;
                  const secondArgs = spy.args[1];
                  expect(secondArgs[0].length).to.equal(2);
                });
              });
            });
          });
        });
      });
    });
  });
});
<|endoftext|>


<commit_before>var Lightense = function () {
  'use strict';

  // save some bytes

  var w = window,
      d = document;

  // global config object
  var config = {
    time: 300,
    padding: 40,
    cubicBezier: 'cubic-bezier(.2, 0, .1, 1)',
    zIndex: 2147483647
  };

  function startTracking(passedElements) {
    // if passed an array of elements, assign tracking to all
    var len = passedElements.length;
    if (len) {
      // loop and assign
      for (var i = 0; i < len; i++) {
        track(passedElements[i]);
      }
    } else {
      track(passedElements);
    }
  }

  function track(element) {
    var imageSource = element.getAttribute('data-image') || element.src;
    if (imageSource) {

      element.classList.add('lightense-target');

      element.addEventListener('click', function (event) {

        // if Command (OS X) or Ctrl (Windows) key pressed, stop processing and
        // open the image in new tab
        if (event.metaKey || event.ctrlKey) {
          return w.open(imageSource, '_blank');
        }

        // init instance
        init(this);
      }, false);
    }
  }

  function createStyle() {
    var css = '\n      .lightense-backdrop {\n        box-sizing: border-box;\n        width: 100%;\n        height: 100%;\n        position: fixed;\n        top: 0;\n        left: 0;\n        overflow: hidden;\n        z-index: ' + (config.zIndex - 1) + ';\n        padding: 0;\n        margin: 0;\n        transition: opacity ' + config.time + 'ms ease;\n        cursor: zoom-out;\n        opacity: 0;\n        background-color: rgba(255, 255, 255, .98);\n        visibility: hidden;\n      }\n\n      @supports (-webkit-backdrop-filter: blur(30px)) {\n        .lightense-backdrop {\n          background-color: rgba(255, 255, 255, .6);\n          -webkit-backdrop-filter: blur(30px);\n          backdrop-filter: blur(30px);\n        }\n      }\n\n      .lightense-wrap {\n        position: relative;\n        transition: transform ' + config.time + 'ms ' + config.cubicBezier + ';\n        z-index: ' + config.zIndex + ';\n        -webkit-user-select: none;\n        user-select: none;\n      }\n\n      .lightense-target {\n        cursor: zoom-in;\n        transition: transform ' + config.time + 'ms ' + config.cubicBezier + ';\n      }\n\n      .lightense-open {\n        cursor: zoom-out;\n      }\n\n      .lightense-transitioning {\n        pointer-events: none;\n      }\n    ';

    var head = d.head || d.getElementsByTagName('head')[0];
    var style = d.createElement('style');
    if (style.styleSheet) {
      style.styleSheet.cssText = css;
    } else {
      style.appendChild(d.createTextNode(css));
    }
    head.appendChild(style);
  }

  function createBackdrop() {
    config.container = d.createElement('div');
    config.container.className = 'lightense-backdrop';
    d.body.appendChild(config.container);
  }

  function createTransform(img) {
    // get original image size
    var naturalWidth = img.width;
    var naturalHeight = img.height;

    // calc zoom ratio
    var scrollTop = w.pageYOffset || d.documentElement.scrollTop || 0;
    var scrollLeft = w.pageXOffset || d.documentElement.scrollLeft || 0;
    var targetImage = config.target.getBoundingClientRect();
    var maxScaleFactor = naturalWidth / targetImage.width;
    var viewportWidth = w.innerWidth || d.documentElement.clientWidth || 0;
    var viewportHeight = w.innerHeight || d.documentElement.clientHeight || 0;
    var viewportWidthOffset = viewportWidth - config.padding;
    var viewportHeightOffset = viewportHeight - config.padding;
    var imageRatio = naturalWidth / naturalHeight;
    var viewportRatio = viewportWidthOffset / viewportHeightOffset;

    if (naturalWidth < viewportWidthOffset && naturalHeight < viewportHeightOffset) {
      config.scaleFactor = maxScaleFactor;
    } else if (imageRatio < viewportRatio) {
      config.scaleFactor = viewportHeightOffset / naturalHeight * maxScaleFactor;
    } else {
      config.scaleFactor = viewportWidthOffset / naturalWidth * maxScaleFactor;
    }

    // calc animation
    var translateX, translateY;
    var viewportX = viewportWidth / 2;
    var viewportY = scrollTop + viewportHeight / 2;
    var imageCenterX = targetImage.left + scrollLeft + targetImage.width / 2;
    var imageCenterY = targetImage.top + scrollTop + targetImage.height / 2;

    config.translateX = viewportX - imageCenterX;
    config.translateY = viewportY - imageCenterY;
  }

  function createViewer() {
    config.target.classList.add('lightense-open');

    // create wrapper element
    config.wrap = d.createElement('div');
    config.wrap.className = 'lightense-wrap';

    // apply zoom ratio to target image
    setTimeout(function () {
      config.target.style.transform = 'scale(' + config.scaleFactor + ')';
    });

    // apply animation to outer wrapper
    config.target.parentNode.insertBefore(config.wrap, config.target);
    config.wrap.appendChild(config.target);
    setTimeout(function () {
      config.wrap.style.transform = 'translate3d(' + config.translateX + 'px, ' + config.translateY + 'px, 0)';
    });

    // show backdrop
    if (config.background) config.container.style.backgroundColor = config.background;
    config.container.style.visibility = 'visible';
    setTimeout(function () {
      config.container.style.opacity = '1';
    });
  }

  function removeViewer() {
    unbindEvents();

    config.target.classList.remove('lightense-open');

    // remove transform styles
    config.wrap.style.transform = '';
    config.target.style.transform = '';
    config.target.classList.add('lightense-transitioning');

    // fadeout backdrop
    config.container.style.opacity = '';

    // hide backdrop and remove target element wrapper
    setTimeout(function () {
      config.container.style.visibility = '';
      config.container.style.backgroundColor = '';
      config.wrap.parentNode.replaceChild(config.target, config.wrap);
      config.target.classList.remove('lightense-transitioning');
    }, config.time);
  }

  function checkViewer() {
    var scrollOffset = Math.abs(config.scrollY - w.scrollY);
    if (scrollOffset >= 30) {
      removeViewer();
    }
  }

  function init(element) {
    config.target = element;

    // TODO: need refine
    // if element already openned, close it
    if (config.target.classList.contains('lightense-open')) {
      return removeViewer();
    }

    // save current window scroll position for later use
    config.scrollY = w.scrollY;

    // save target attributes
    config.src = element.getAttribute('data-image') || element.src;
    config.background = config.target.getAttribute('data-background') || false;

    var img = new Image();
    img.onload = function () {
      createTransform(this);
      createViewer();
      bindEvents();
    };
    img.src = config.src;
  }

  function bindEvents() {
    w.addEventListener('keyup', onKeyUp, false);
    w.addEventListener('scroll', checkViewer, false);
    config.container.addEventListener('click', removeViewer, false);
  }

  function unbindEvents() {
    w.removeEventListener('keyup', onKeyUp, false);
    w.removeEventListener('scroll', checkViewer, false);
    config.container.removeEventListener('click', removeViewer, false);
  }

  // exit on excape (esc) key pressed
  function onKeyUp(event) {
    event.preventDefault();
    if (event.keyCode === 27) {
      removeViewer();
    }
  }

  function main(elements, cfg) {
    // parse arguments
    if (!elements) {
      throw 'You need to pass an element!';
    }

    // get user configs
    var userConfig = cfg || {};
    if (userConfig.time) {
      config.time = userConfig.time;
    }

    // prepare stylesheets
    createStyle();

    // prepare backdrop element
    createBackdrop();

    // pass and prepare elements
    startTracking(elements);
  }

  return main;
}();
<commit_msg>feat(script): use latest Lightense Images<commit_after>/*! lightense-images v1.0.1 | © Tunghsiao Liu | MIT */
(function webpackUniversalModuleDefinition(root, factory) {
	if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory();
	else if(typeof define === 'function' && define.amd)
		define([], factory);
	else if(typeof exports === 'object')
		exports["Lightense"] = factory();
	else
		root["Lightense"] = factory();
})(this, function() {
return /******/ (function(modules) { // webpackBootstrap
/******/ 	// The module cache
/******/ 	var installedModules = {};

/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {

/******/ 		// Check if module is in cache
/******/ 		if(installedModules[moduleId])
/******/ 			return installedModules[moduleId].exports;

/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = installedModules[moduleId] = {
/******/ 			exports: {},
/******/ 			id: moduleId,
/******/ 			loaded: false
/******/ 		};

/******/ 		// Execute the module function
/******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

/******/ 		// Flag the module as loaded
/******/ 		module.loaded = true;

/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}

/******/ 	// expose the modules object (__webpack_modules__)
/******/ 	__webpack_require__.m = modules;

/******/ 	// expose the module cache
/******/ 	__webpack_require__.c = installedModules;

/******/ 	// __webpack_public_path__
/******/ 	__webpack_require__.p = "";

/******/ 	// Load entry module and return exports
/******/ 	return __webpack_require__(0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ function(module, exports) {

	'use strict';

	var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };

	var Lightense = function () {
	  'use strict';

	  // save some bytes

	  var w = window,
	      d = document;

	  // default options
	  var defaults = {
	    time: 300,
	    padding: 40,
	    offset: 40,
	    keyboard: true,
	    cubicBezier: 'cubic-bezier(.2, 0, .1, 1)',
	    zIndex: 2147483647
	  };

	  // init user options
	  var config = {};

	  function startTracking(passedElements) {
	    // if passed an array of elements, assign tracking to all
	    var len = passedElements.length;
	    if (len) {
	      // loop and assign
	      for (var i = 0; i < len; i++) {
	        track(passedElements[i]);
	      }
	    } else {
	      track(passedElements);
	    }
	  }

	  function track(element) {
	    var imageSource = element.getAttribute('data-image') || element.src;
	    if (imageSource) {
	      element.classList.add('lightense-target');
	      element.addEventListener('click', function (event) {
	        if (config.keyboard) {
	          // if Command (macOS) or Ctrl (Windows) key pressed, stop processing
	          // and open the image in a new tab
	          if (event.metaKey || event.ctrlKey) {
	            return w.open(imageSource, '_blank');
	          }
	        }

	        // init instance
	        init(this);
	      }, false);
	    }
	  }

	  function createStyle() {
	    var css = '\n      .lightense-backdrop {\n        box-sizing: border-box;\n        width: 100%;\n        height: 100%;\n        position: fixed;\n        top: 0;\n        left: 0;\n        overflow: hidden;\n        z-index: ' + (config.zIndex - 1) + ';\n        padding: 0;\n        margin: 0;\n        transition: opacity ' + config.time + 'ms ease;\n        cursor: zoom-out;\n        opacity: 0;\n        background-color: rgba(255, 255, 255, .98);\n        visibility: hidden;\n      }\n\n      @supports (-webkit-backdrop-filter: blur(30px)) {\n        .lightense-backdrop {\n          background-color: rgba(255, 255, 255, .6);\n          -webkit-backdrop-filter: blur(30px);\n          backdrop-filter: blur(30px);\n        }\n      }\n\n      .lightense-wrap {\n        position: relative;\n        transition: transform ' + config.time + 'ms ' + config.cubicBezier + ';\n        z-index: ' + config.zIndex + ';\n        -webkit-user-select: none;\n        user-select: none;\n      }\n\n      .lightense-target {\n        cursor: zoom-in;\n        transition: transform ' + config.time + 'ms ' + config.cubicBezier + ';\n      }\n\n      .lightense-open {\n        cursor: zoom-out;\n      }\n\n      .lightense-transitioning {\n        pointer-events: none;\n      }\n    ';

	    var head = d.head || d.getElementsByTagName('head')[0];
	    var style = d.createElement('style');
	    if (style.styleSheet) {
	      style.styleSheet.cssText = css;
	    } else {
	      style.appendChild(d.createTextNode(css));
	    }
	    head.appendChild(style);
	  }

	  function createBackdrop() {
	    config.container = d.createElement('div');
	    config.container.className = 'lightense-backdrop';
	    d.body.appendChild(config.container);
	  }

	  function createTransform(img) {
	    // get original image size
	    var naturalWidth = img.width;
	    var naturalHeight = img.height;

	    // calc zoom ratio
	    var scrollTop = w.pageYOffset || d.documentElement.scrollTop || 0;
	    var scrollLeft = w.pageXOffset || d.documentElement.scrollLeft || 0;
	    var targetImage = config.target.getBoundingClientRect();
	    var maxScaleFactor = naturalWidth / targetImage.width;
	    var viewportWidth = w.innerWidth || d.documentElement.clientWidth || 0;
	    var viewportHeight = w.innerHeight || d.documentElement.clientHeight || 0;
	    var viewportWidthOffset = viewportWidth - config.padding;
	    var viewportHeightOffset = viewportHeight - config.padding;
	    var imageRatio = naturalWidth / naturalHeight;
	    var viewportRatio = viewportWidthOffset / viewportHeightOffset;

	    if (naturalWidth < viewportWidthOffset && naturalHeight < viewportHeightOffset) {
	      config.scaleFactor = maxScaleFactor;
	    } else if (imageRatio < viewportRatio) {
	      config.scaleFactor = viewportHeightOffset / naturalHeight * maxScaleFactor;
	    } else {
	      config.scaleFactor = viewportWidthOffset / naturalWidth * maxScaleFactor;
	    }

	    // calc animation
	    var viewportX = viewportWidth / 2;
	    var viewportY = scrollTop + viewportHeight / 2;
	    var imageCenterX = targetImage.left + scrollLeft + targetImage.width / 2;
	    var imageCenterY = targetImage.top + scrollTop + targetImage.height / 2;

	    config.translateX = viewportX - imageCenterX;
	    config.translateY = viewportY - imageCenterY;
	  }

	  function createViewer() {
	    config.target.classList.add('lightense-open');

	    // create wrapper element
	    config.wrap = d.createElement('div');
	    config.wrap.className = 'lightense-wrap';

	    // apply zoom ratio to target image
	    setTimeout(function () {
	      config.target.style.transform = 'scale(' + config.scaleFactor + ')';
	    }, 20);

	    // apply animation to outer wrapper
	    config.target.parentNode.insertBefore(config.wrap, config.target);
	    config.wrap.appendChild(config.target);
	    setTimeout(function () {
	      config.wrap.style.transform = 'translate3d(' + config.translateX + 'px, ' + config.translateY + 'px, 0)';
	    }, 20);

	    // show backdrop
	    if (config.background) config.container.style.backgroundColor = config.background;
	    config.container.style.visibility = 'visible';
	    setTimeout(function () {
	      config.container.style.opacity = '1';
	    }, 20);
	  }

	  function removeViewer() {
	    unbindEvents();

	    config.target.classList.remove('lightense-open');

	    // remove transform styles
	    config.wrap.style.transform = '';
	    config.target.style.transform = '';
	    config.target.classList.add('lightense-transitioning');

	    // fadeout backdrop
	    config.container.style.opacity = '';

	    // hide backdrop and remove target element wrapper
	    setTimeout(function () {
	      config.container.style.visibility = '';
	      config.container.style.backgroundColor = '';
	      config.wrap.parentNode.replaceChild(config.target, config.wrap);
	      config.target.classList.remove('lightense-transitioning');
	    }, config.time);
	  }

	  function checkViewer() {
	    var scrollOffset = Math.abs(config.scrollY - w.scrollY);
	    if (scrollOffset >= config.offset) {
	      removeViewer();
	    }
	  }

	  function init(element) {
	    config.target = element;

	    // TODO: need refine
	    // if element already openned, close it
	    if (config.target.classList.contains('lightense-open')) {
	      return removeViewer();
	    }

	    // save current window scroll position for later use
	    config.scrollY = w.scrollY;

	    // save target attributes
	    config.src = element.getAttribute('data-image') || element.src;
	    config.background = config.target.getAttribute('data-background') || false;

	    var img = new Image();
	    img.onload = function () {
	      createTransform(this);
	      createViewer();
	      bindEvents();
	    };
	    img.src = config.src;
	  }

	  function bindEvents() {
	    w.addEventListener('keyup', onKeyUp, false);
	    w.addEventListener('scroll', checkViewer, false);
	    config.container.addEventListener('click', removeViewer, false);
	  }

	  function unbindEvents() {
	    w.removeEventListener('keyup', onKeyUp, false);
	    w.removeEventListener('scroll', checkViewer, false);
	    config.container.removeEventListener('click', removeViewer, false);
	  }

	  // exit on excape (esc) key pressed
	  function onKeyUp(event) {
	    event.preventDefault();
	    if (event.keyCode === 27) {
	      removeViewer();
	    }
	  }

	  function main(elements) {
	    var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};

	    // parse arguments
	    if (!elements) {
	      throw 'You need to pass an element!';
	    }

	    // get user options
	    config = _extends({}, defaults, options);

	    // prepare stylesheets
	    createStyle();

	    // prepare backdrop element
	    createBackdrop();

	    // pass and prepare elements
	    startTracking(elements);
	  }

	  return main;
	}();

	module.exports = Lightense;

/***/ }
/******/ ])
});
;
<|endoftext|>