nick-baliesnyi/wams

View on GitHub
src/client/ClientController.js

Summary

Maintainability
A
1 hr
Test Coverage
/*
 * WAMS code to be executed in the client browser.
 *
 * Author: Michael van der Kamp
 * Date: July 2018 - January 2019
 *
 * Original author: Jesse Rolheiser
 * Other revisions and supervision: Scott Bateman
 */

'use strict';

const io = require('socket.io-client');

const {
  constants,
  DataReporter,
  TouchReporter,
  IdStamper,
  Message,
  NOP,
} = require('../shared.js');
const Interactor = require('./Interactor.js');

// Symbols to identify these methods as intended only for internal use
const symbols = Object.freeze({
  attachListeners: Symbol('attachListeners'),
  render:          Symbol('render'),
});

/**
 * The ClientController coordinates communication with the wams server. It sends
 * messages based on user interaction with the canvas and receives messages from
 * the server detailing changes to post to the view.
 *
 * @memberof module:client
 *
 * @param {HTMLCanvasElement} canvas - The underlying CanvasElement object, (not
 * the context), which will fill the page.
 * @param {module:client.ClientView} view - The view that will handle rendering
 * duties.
 * @param {module:client.ClientModel} model - The client-side copy of the
 * server's model.
 */
class ClientController {
  constructor(root, canvas, view, model) {
    /**
     * Root element where WAMS canvas and HTML elements are located.
     *
     * @type {Element}
     */
    this.rootElement = root;

    /**
     * The HTMLCanvasElement object is stored by the ClientController so that it
     * is able to respond to user events triggered on the canvas. The view only
     * needs to know about the canvas drawing context.
     *
     * @type {HTMLCanvasElement}
     */
    this.canvas = canvas;

    /**
     * From socket.io, the socket provides a channel of communication with the
     * server.
     *
     * @type {Socket}
     * @see {@link https://socket.io/docs/client-api/}
     */
    this.socket = null;

    /**
     * The ClientModel is a client-side copy of the workspace model, kept up to
     * date by the controller.
     *
     * @type {module:client.ClientModel}
     */
    this.model = model;

    /**
     * The ClientView handles the final rendering of the model, as informed by
     * the controller.
     *
     * @type {module:client.ClientView}
     */
    this.view = view;

    /**
     * List of custom event names that will be listened to.
     *
     * @type {array}
     */
    this.eventListeners = [];

    /**
     * Queue of events to be called once Client-side listeners are set up.
     *
     * @type {array}
     */
    this.eventQueue = [];

    /**
     * Tracks whether a render has been scheduled for the next render frame.
     *
     * @type {boolean}
     */
    this.renderScheduled = false;

    /**
     * Bound reference to the render method, for use as a callback.
     *
     * @type {function}
     */
    this.render_fn = this[symbols.render].bind(this);

    /*
     * For proper function, we need to make sure that the canvas is as large as
     * it can be at all times, and that at all times we know how big the canvas
     * is.
     */
    this.resizeCanvasToFillWindow();

    window.WAMS = {
      on: (event, func) => {
        // listen for this DOM event
        document.addEventListener(event, func);
        this.eventListeners.push(event);

        // if this event was called before this code executed,
        // dispatch it again
        this.eventQueue.forEach(ev => {
          if (ev.action === event) {
            document.dispatchEvent(new CustomEvent(
              event,
              { detail: ev.payload },
            ));
          }
        });
      },
      dispatch: (event, func) => this.dispatch(event, func),
    };
  }

  /**
   * Attaches listeners to messages received over the socket connection. All
   * received messages at this layer should be those conforming to the Message /
   * Reporter protocol.
   *
   * This internal routine should be called as part of socket establishment.
   *
   * @alias [@@attachListeners]
   * @memberof module:client.ClientController
   */
  [symbols.attachListeners]() {
    const listeners = {
      // For the server to inform about changes to the model
      [Message.ADD_ELEMENT]: (data) => this.handle('addElement', data),
      [Message.ADD_IMAGE]:   (data) => this.handle('addImage', data),
      [Message.ADD_ITEM]:    (data) => this.handle('addItem', data),
      [Message.ADD_SHADOW]:  (data) => this.handle('addShadow', data),
      [Message.RM_ITEM]:     (data) => this.handle('removeItem', data),
      [Message.RM_SHADOW]:   (data) => this.handle('removeShadow', data),
      [Message.UD_ITEM]:     (data) => this.handle('updateItem', data),
      [Message.UD_SHADOW]:   (data) => this.handle('updateShadow', data),
      [Message.UD_VIEW]:     (data) => this.handle('updateView', data),

      // For hopefully occasional extra adjustments to objects in the model.
      [Message.RM_ATTRS]:   ({ data }) => this.handle('removeAttributes', data),
      [Message.SET_ATTRS]:  ({ data }) => this.handle('setAttributes', data),
      [Message.SET_IMAGE]:  ({ data }) => this.handle('setImage', data),
      [Message.SET_RENDER]: ({ data }) => this.handle('setRender', data),
      [Message.SET_PARENT]: ({ data }) => this.handle('setParent', data),

      // Connection establishment related (disconnect, initial setup)
      [Message.INITIALIZE]: (data) => this.setup(data),
      [Message.LAYOUT]:     NOP,

      // User event related
      [Message.CLICK]:     NOP,
      [Message.RESIZE]:    NOP,
      [Message.SWIPE]:     NOP,
      [Message.TRACK]:     NOP,
      [Message.TRANSFORM]: NOP,

      // Multi-device gesture related
      [Message.POINTER]: NOP,
      [Message.BLUR]:    NOP,

      // TODO: This could be more... elegant...
      [Message.FULL]: () => {
        document.body.innerHTML = 'WAMS is full! :(';
      },

      // For user-defined behavior
      [Message.DISPATCH]: ({ data }) => this.handleCustomEvent(data),
    };

    Object.entries(listeners).forEach(([p, v]) => this.socket.on(p, v));

    // Keep the view size up to date.
    window.addEventListener('resize', this.resize.bind(this), false);

    /*
     * As no automatic draw loop is used, (there are no animations), need to
     * know when to re-render in response to an image loading.
     */
    const schedule_fn = this.scheduleRender.bind(this);
    document.addEventListener(Message.IMG_LOAD, schedule_fn);
  }

  /**
   * Establishes a socket.io connection with the server, using the global WAMS
   * namespace. Connections should be non-persistent over disconnects, (i.e., no
   * reconnections), as this was the cause of many bugs.
   *
   * This internal routine should be called automatically upon ClientController
   * instantiation.
   */
  connect() {
    this.socket = io.connect(constants.NS_WAMS, {
      autoConnect:  false,
      reconnection: false,
      transports: ['websocket', 'polling'],
    });
    this[symbols.attachListeners]();
    window.requestAnimationFrame(this.render_fn);
    this.socket.connect();
  }

  /**
   * Renders a frame.
   *
   * @alias [@@render]
   * @memberof module:client.ClientController
   */
  [symbols.render]() {
    if (this.renderScheduled) {
      this.view.draw();
      this.renderScheduled = false;
    }
    window.requestAnimationFrame(this.render_fn);
  }

  /**
   * Generates a function for forwarding the given message to the server.
   *
   * @see {@link module:shared.Message}
   *
   * @param {string} message - The type of message to forward. One of the static
   * members of the Message class.
   *
   * @return {Function} A function bound to this instance for forwarding data to
   * the server with the given message type label.
   */
  forward(message) {
    function do_forward(data) {
      const dreport = new DataReporter({ data });
      new Message(message, dreport).emitWith(this.socket);
    }
    return do_forward.bind(this);
  }

  /**
   * Passes messages to the View, and schedules a render.
   *
   * @see {@link module:shared.Message}
   *
   * @param {string} message - The name of a ClientView method to run.
   * @param {...*} data - The argument to pass to the ClientView method.
   */
  handle(message, data) {
    this.model[message](data, this);
    this.scheduleRender();
  }

  /**
   * Helper function to ensure that only those events that
   * have attached listeners will be dispatched by the ClientModel.
   *
   * @param {string} message - the name of the ClientModel method to run.
   * @param {object} data - the argument to pass to `this.handle`.
   */
  handleCustomEvent(data) {
    if (this.eventListeners.indexOf(data.action) < 0) {
      this.eventQueue.push(data);
    }
    this.handle('dispatch', data);
  }

  /**
   * For responding to window resizing by the user. Resizes the canvas to fit
   * the new window size, and reports the change to the server so it can be
   * reflected in the model.
   */
  resize() {
    this.resizeCanvasToFillWindow();
    new Message(Message.RESIZE, this.view).emitWith(this.socket);
    this.view.draw();
  }

  /**
   * Stretches the canvas to fit the available window space, and updates the
   * view accordingly. 
   * 
   * Note: Scaling to account for device pixel ratio is disabled for iOS
   * as a workaround for a bug with Safari and Chrome, where `context.setTransform`
   * would make the page unresponsive.
   */
  resizeCanvasToFillWindow() {
    const iOS = /iPad|iPhone|iPod|Apple/.test(window.navigator.platform);
    const dpr = iOS ? 1 : (window.devicePixelRatio || 1);
    const w = window.innerWidth;
    const h = window.innerHeight;
    this.canvas.width = w * dpr;
    this.canvas.height = h * dpr;
    this.canvas.style.width = `${w}px`;
    this.canvas.style.height = `${h}px`;
    this.view.resizeToFillWindow(dpr, iOS);
  }

  /**
   * Schedules a render for the next frame interval.
   */
  scheduleRender() {
    this.renderScheduled = true;
  }

  /**
   * As this object will be instantiated on page load, and will generate a view
   * before communication lines with the server have been opened, the view will
   * not reflect the model automatically. This function responds to a message
   * from the server which contains the current state of the model, and forwards
   * this data to the view so that it can correctly render the model.
   *
   * @param {module:shared.FullStateReporter} data - All the information
   * necessary to initially synchronize this client's model with the server's
   * model.
   */
  setup(data) {
    if (data.clientScripts) this.loadClientScripts(data.clientScripts);
    if (data.stylesheets) this.loadStylesheets(data.stylesheets);
    document.title = data.title;

    IdStamper.cloneId(this.view, data.id);

    if (data.backgroundImage) {
      this.canvas.style.backgroundColor = 'transparent';
      document.body.style.backgroundImage = `url("${data.backgroundImage}")`;
    } else {
      this.canvas.style.backgroundColor = data.color;
    }
    this.model.setup(data);
    this.setupInteractor(data.useMultiScreenGestures);

    // Need to tell the model what the view looks like once setup is complete.
    new Message(Message.LAYOUT, this.view).emitWith(this.socket);
  }

  loadClientScripts(scripts) {
    scripts.forEach(src => {
      const script = document.createElement('script');
      script.src = src;
      document.body.appendChild(script);
    });
  }

  loadStylesheets(stylesheets) {
    stylesheets.forEach(src => {
      const link = document.createElement('link');
      link.href = src;
      link.type = 'text/css';
      link.rel = 'stylesheet';
      document.head.appendChild(link);
    });
  }

  /**
   * The Interactor is a level of abstraction between the ClientController and
   * the gesture recognition library such that libraries can be swapped out
   * more easily, if need be. At least in theory. All the ClientController
   * needs to provide is handler functions for responding to the recognized
   * gestures.
   *
   * @param {boolean} [useMultiScreenGestures=false] Whether to use server-side
   * gestures. Default is to use client-side gestures.
   */
  setupInteractor(useMultiScreenGestures = false) {
    if (useMultiScreenGestures) {
      this.setupInputForwarding();
    } else {
      new Interactor(this.rootElement, {
        swipe:     this.forward(Message.SWIPE),
        tap:       this.forward(Message.CLICK),
        track:     this.forward(Message.TRACK),
        transform: this.forward(Message.TRANSFORM),
      });
    }
  }

  /**
   * Set up input event forwarding.
   */
  setupInputForwarding() {
    if (window.MouseEvent || window.TouchEvent) {
      this.forwardTouchEvents();
      this.forwardMouseEvents();
    } else {
      this.forwardPointerEvents();
    }
    this.forwardBlurEvents();
  }

  /**
   * Forward the given events, by using the given callback.
   *
   * @param {string[]} eventnames
   * @param {function} callback
   */
  forwardEvents(eventnames, callback) {
    eventnames.forEach(eventname => {
      window.addEventListener(eventname, callback, {
        capture: true,
        once:    false,
        passive: false,
      });
    });
  }

  /**
   * Forward blur and cancel events.
   */
  forwardBlurEvents() {
    this.forwardEvents(['touchcancel', 'pointercancel', 'blur'], (event) => {
      event.preventDefault();
      const breport = new DataReporter();
      new Message(Message.BLUR, breport).emitWith(this.socket);
    });
  }

  /**
   * Forward pointer events.
   */
  forwardPointerEvents() {
    this.forwardEvents(['pointerdown', 'pointermove', 'pointerup'], (event) => {
      event.preventDefault();
      const treport = new TouchReporter(event);
      treport.changedTouches = [{
        identifier: event.pointerId,
        clientX:    event.clientX,
        clientY:    event.clientY,
      }];
      new Message(Message.POINTER, treport).emitWith(this.socket);
    });
  }

  /**
   * Forward mouse events.
   */
  forwardMouseEvents() {
    this.forwardEvents(['mousedown', 'mousemove', 'mouseup'], (event) => {
      event.preventDefault();
      if (event.button === 0) {
        const treport = new TouchReporter(event);
        treport.changedTouches = [{
          identifier: 0,
          clientX:    event.clientX,
          clientY:    event.clientY,
        }];
        new Message(Message.POINTER, treport).emitWith(this.socket);
      }
    });
  }

  /**
   * Forward touch events.
   */
  forwardTouchEvents() {
    this.forwardEvents(['touchstart', 'touchmove', 'touchend'], (event) => {
      event.preventDefault();
      const treport = new TouchReporter(event);
      treport.changedTouches = Array.from(event.changedTouches)
        .map(touch => {
          return {
            identifier: touch.identifier,
            clientX:    touch.clientX,
            clientY:    touch.clientY,
          };
        });
      new Message(Message.POINTER, treport).emitWith(this.socket);
    });
  }

  /**
   * Dispatch a custom ServerEvent.
   *
   * @param {string} action
   * @param {object} payload
   */
  dispatch(action, payload) {
    const dreport = new DataReporter({
      data: { action, payload },
    });
    new Message(Message.DISPATCH, dreport).emitWith(this.socket);
  }
}

module.exports = ClientController;