Skip to content
PanZoom.ts 11.7 KiB
Newer Older
import EventEmitter from "eventemitter3";
import {
  calculatePinchZoom,
  calculateTouchMidPoint,
  getTouchDistance,
} from "./lib/pinch.utils";
import {
  checkZoomBounds,
  handleCalculateZoomPositions,
} from "./lib/zoom.utils";
import { Panning } from "./lib/panning.utils";

interface TransformState {
Grant's avatar
Grant committed
  /**
   * Zoom scale
   *
   * < 0 : zoomed out
   * > 0 : zoomed in
   */
  scale: number;
Grant's avatar
Grant committed

  /**
   * X position of canvas
   */
  x: number;
Grant's avatar
Grant committed

  /**
   * Y position of canvas
   */
  y: number;
}

interface Flags {
Grant's avatar
Grant committed
  /**
   * If CSS Zoom is used
   *
   * CSS Zoom is not supported on Firefox, as it's not a standard
   * But on iOS, <canvas> is fuzzy (ignoring other css rules) when transform: scale()'d up
   *
   * @see https://caniuse.com/css-zoom
   */
  useZoom: boolean;
}

interface TouchState {
Grant's avatar
Grant committed
  /**
   * Timestamp of last touch
   */
  lastTouch: number | null;
Grant's avatar
Grant committed

  /**
   * Distance between each finger when pinch starts
   */
  pinchStartDistance: number | null;
Grant's avatar
Grant committed

  /**
   * previous distance between each finger
   */
  lastDistance: number | null;
Grant's avatar
Grant committed

  /**
   * scale when pinch starts
   */
  pinchStartScale: number | null;
Grant's avatar
Grant committed

  /**
   * middle coord of pinch
   */
  pinchMidpoint: { x: number; y: number } | null;
}

Grant's avatar
Grant committed
interface MouseState {
  /**
   * timestamp of mouse down
   */
  mouseDown: number | null;
}

interface ISetup {
Grant's avatar
Grant committed
  /**
   * Scale limits
   * [minimum scale, maximum scale]
   */
  scale: [number, number];
}

Grant's avatar
Grant committed
// TODO: move these event interfaces out
export interface ClickEvent {
  clientX: number;
  clientY: number;
}

export interface HoverEvent {
  clientX: number;
  clientY: number;
}

Grant's avatar
Grant committed
export interface ViewportMoveEvent {
  scale: number;
  x: number;
  y: number;
}

interface PanZoomEvents {
  doubleTap: (e: TouchEvent) => void;
Grant's avatar
Grant committed
  click: (e: ClickEvent) => void;
  hover: (e: HoverEvent) => void;
Grant's avatar
Grant committed
  viewportMove: (e: ViewportMoveEvent) => void;
}

export class PanZoom extends EventEmitter<PanZoomEvents> {
  public $wrapper: HTMLDivElement = null as any;
  public $zoom: HTMLDivElement = null as any;
  public $move: HTMLDivElement = null as any;

  public transform: TransformState;
  public touch: TouchState;
  public mouse: MouseState;
  public setup: ISetup;
  public flags: Flags;

  public panning: Panning;

  constructor() {
    super();

    this.transform = {
      scale: 1,
      x: 0,
      y: 0,
    };

    this.touch = {
      lastTouch: null,
      pinchStartDistance: null,
      lastDistance: null,
      pinchStartScale: null,
      pinchMidpoint: null,
    };

Grant's avatar
Grant committed
    this.mouse = {
      mouseDown: null,
    };

    this.panning = new Panning(this);

    this.setup = {
      scale: [1, 50],
    };

    this.flags = {
      useZoom: false,
    };
  }

  initialize(
    $wrapper: HTMLDivElement,
    $zoom: HTMLDivElement,
    $move: HTMLDivElement
  ) {
    this.$wrapper = $wrapper;
    this.$zoom = $zoom;
    this.$move = $move;

    this.detectFlags();
    this.registerMouseEvents();
    this.registerTouchEvents();
  }

  detectFlags() {
    // Pxls/resources/public/include/helpers.js
    let haveZoomRendering = false;
    let haveImageRendering = false;
    const webkitBased = navigator.userAgent.match(/AppleWebKit/i);
    const iOSSafari =
      navigator.userAgent.match(/(iPod|iPhone|iPad)/i) && webkitBased;
    const desktopSafari =
      navigator.userAgent.match(/safari/i) &&
      !navigator.userAgent.match(/chrome/i);
    const msEdge = navigator.userAgent.indexOf("Edge") > -1;
    const possiblyMobile =
      window.innerWidth < 768 && navigator.userAgent.includes("Mobile");
    if (iOSSafari) {
      const iOS =
        parseFloat(
          (
            "" +
            (/CPU.*OS ([0-9_]{1,5})|(CPU like).*AppleWebKit.*Mobile/i.exec(
              navigator.userAgent
            ) || [0, ""])[1]
          )
            .replace("undefined", "3_2")
            .replace("_", ".")
            .replace("_", "")
        ) || false;
      haveImageRendering = false;
      if (iOS && iOS >= 11) {
        haveZoomRendering = true;
      }
    } else if (desktopSafari) {
      haveImageRendering = false;
      haveZoomRendering = true;
    }
    if (msEdge) {
      haveImageRendering = false;
    }

    this.flags.useZoom = haveZoomRendering;
  }

  registerTouchEvents() {
    this.$wrapper.addEventListener(
      "touchstart",
      (event) => {
        const isDoubleTap =
          this.touch.lastTouch && +new Date() - this.touch.lastTouch < 200;

        if (isDoubleTap && event.touches.length === 1) {
          this.emit("doubleTap", event);
        } else {
          this.touch.lastTouch = +new Date();

          const { touches } = event;

          const isPanningAction = touches.length === 1;
          const isPinchAction = touches.length === 2;

          if (isPanningAction) {
            this.panning.start(touches[0].clientX, touches[0].clientY);
          }
          if (isPinchAction) {
            this.onPinchStart(event);
          }
        }
      },
      { passive: false }
    );

    this.$wrapper.addEventListener("touchmove", (event) => {
      if (this.panning.enabled && event.touches.length === 1) {
        event.preventDefault();
        event.stopPropagation();

        const touch = event.touches[0];

        this.panning.move(touch.clientX, touch.clientY);
      } else if (event.touches.length > 1) {
        this.onPinch(event);
      }
    });

    this.$wrapper.addEventListener("touchend", (event) => {
      if (this.panning.enabled) {
        this.panning.enabled = false;

        const touch = event.changedTouches[0];

        this.panning.end(touch.clientX, touch.clientY);
      }
    });
  }

  /// /////
  // pinch
  /// /////

  onPinchStart(event: TouchEvent) {
    const distance = getTouchDistance(event);

    this.touch.pinchStartDistance = distance;
    this.touch.lastDistance = distance;
    this.touch.pinchStartScale = this.transform.scale;
    this.panning.enabled = false;
  }

  onPinch(event: TouchEvent) {
    event.preventDefault();
    event.stopPropagation();

    const { scale } = this.transform;

    // one finger started from outside the wrapper
    if (this.touch.pinchStartDistance === null) return;

    let el: HTMLElement = document.body;
    // switch (
    //   (document.getElementById("test-flag")! as HTMLSelectElement).value
    // ) {
    //   case "body":
    //     el = document.body;
    //     break;
    //   case "wrapper":
    //     el = this.$wrapper;
    //     break;
    //   case "move":
    //     el = this.$move;
    //     break;
    //   default:
    //   case "zoom":
    //     el = this.$zoom;
    //     break;
    // }

    const midPoint = calculateTouchMidPoint(this, event, scale, el);

    if (!Number.isFinite(midPoint.x) || !Number.isFinite(midPoint.y)) return;

    const currentDistance = getTouchDistance(event);
    const newScale = calculatePinchZoom(this, currentDistance);

    if (newScale === scale) return;

    // const { x, y } = handleCalculateZoomPositions(
    //   this,
    //   midPoint.x,
    //   midPoint.y,
    //   newScale
    // );

    this.touch.pinchMidpoint = midPoint;
    this.touch.lastDistance = currentDistance;

    this.debug(midPoint.x, midPoint.y, "midpoint");

    // TODO: this might be css zoom specific, I have no way to test this
    this.transform.x = midPoint.x / newScale - midPoint.x / scale;
    this.transform.y = midPoint.y / newScale - midPoint.x / scale;
    this.transform.scale = newScale;
    this.update();
  }

  debug(x: number, y: number, id?: string) {
    // if (document.getElementById("debug-" + id)) {
    //   document.getElementById("debug-" + id)!.style.top = y + "px";
    //   document.getElementById("debug-" + id)!.style.left = x + "px";
    //   return;
    // }
    // let el = document.createElement("div");
    // if (id) el.id = "debug-" + id;
    // el.classList.add("debug-point");
    // el.style.setProperty("top", y + "px");
    // el.style.setProperty("left", x + "px");
    // document.body.appendChild(el);
  }

  registerMouseEvents() {
    // zoom
    this.$wrapper.addEventListener(
      "wheel",
      (e) => {
        // if (!self.allowDrag) return;
        const oldScale = this.transform.scale;

        let delta = -e.deltaY;

        switch (e.deltaMode) {
          case WheelEvent.DOM_DELTA_PIXEL:
            // 53 pixels is the default chrome gives for a wheel scroll.
            delta /= 53;
            break;
          case WheelEvent.DOM_DELTA_LINE:
            // default case on Firefox, three lines is default number.
            delta /= 3;
            break;
          case WheelEvent.DOM_DELTA_PAGE:
            delta = Math.sign(delta);
            break;
        }

        // TODO: move this to settings
        this.nudgeScale(delta / 2);

        const scale = this.transform.scale;
        if (oldScale !== scale) {
          const dx = e.clientX - this.$wrapper.clientWidth / 2;
          const dy = e.clientY - this.$wrapper.clientHeight / 2;
          this.transform.x -= dx / oldScale;
          this.transform.x += dx / scale;
          this.transform.y -= dy / oldScale;
          this.transform.y += dy / scale;
          this.update();
          // place.update();
        }
      },
      { passive: true }
    );

    this.$wrapper.addEventListener(
      "mousedown",
      (e) => {
        e.preventDefault();
        e.stopPropagation();

Grant's avatar
Grant committed
        this.mouse.mouseDown = Date.now();

        this.panning.start(e.clientX, e.clientY);
      },
      { passive: false }
    );

    // mouse move should not be tied to the element, in case the mouse exits the window
    document.addEventListener(
      "mousemove",
      (e) => {
Grant's avatar
Grant committed
        if (this.panning.enabled) {
          e.preventDefault();
          e.stopPropagation();
Grant's avatar
Grant committed
          this.panning.move(e.clientX, e.clientY);
        } else {
          // not panning
          this.emit("hover", {
            clientX: e.clientX,
            clientY: e.clientY,
          });
        }
      },
      { passive: false }
    );

    // mouse up should not be tied to the element, in case the mouse releases outside of the window
    document.addEventListener(
      "mouseup",
      (e) => {
Grant's avatar
Grant committed
        if (this.mouse.mouseDown && Date.now() - this.mouse.mouseDown <= 500) {
          // if the mouse was down for less than a half a second, it's a click
          // this can't depend on this.panning.enabled because that'll always be true when mouse is down

          const delta = [
            Math.abs(this.panning.x - e.clientX),
            Math.abs(this.panning.y - e.clientY),
          ];

          if (delta[0] < 5 && delta[1] < 5) {
            // difference from the start position to the up position is very very slow,
            // so it's most likely intended to be a click
            this.emit("click", {
              clientX: e.clientX,
              clientY: e.clientY,
            });
          }
        }
Grant's avatar
Grant committed
        if (this.panning.enabled) {
          // currently panning
          e.preventDefault();
          e.stopPropagation();
Grant's avatar
Grant committed
          this.panning.end(e.clientX, e.clientY);
        }
      },
      { passive: false }
    );
  }

  update() {
Grant's avatar
Grant committed
    this.emit("viewportMove", {
      scale: this.transform.scale,
      x: this.transform.x,
      y: this.transform.y,
    });

    if (this.flags.useZoom) {
      this.$zoom.style.setProperty("zoom", this.transform.scale * 100 + "%");
    } else {
      this.$zoom.style.setProperty(
        "transform",
        `scale(${this.transform.scale})`
      );
    }

    this.$move.style.setProperty(
      "transform",
      `translate(${this.transform.x}px, ${this.transform.y}px)`
    );
  }

  cleanup() {
    // remove event handlers
  }

  // utilities

  nudgeScale(adj: number) {
    this.transform.scale = checkZoomBounds(
      this.transform.scale * 1.5 ** adj,
      this.setup.scale[0],
      this.setup.scale[1]
    );
  }
}