cursor.js

Handles the shared cursors, display and capturing the events. Also handles clicks and scrolls. Includes UI.

  • /* This Source Code Form is subject to the terms of the Mozilla Public
     * License, v. 2.0. If a copy of the MPL was not distributed with this file,
     * You can obtain one at http://mozilla.org/MPL/2.0/. */
  • Cursor viewing support

    define(["jquery", "ui", "util", "session", "elementFinder", "tinycolor", "eventMaker", "peers", "templating"], function ($, ui, util, session, elementFinder, tinycolor, eventMaker, peers, templating) {
      var assert = util.assert;
      var cursor = util.Module("cursor");
    
      var FOREGROUND_COLORS = ["#111", "#eee"];
      var CURSOR_HEIGHT = 50;
      var CURSOR_ANGLE = (35 / 180) * Math.PI;
      var CURSOR_WIDTH = Math.ceil(Math.sin(CURSOR_ANGLE) * CURSOR_HEIGHT);
  • Number of milliseconds after page load in which a scroll-update related hello-back message will be processed:

      var SCROLL_UPDATE_CUTOFF = 2000;
    
      session.hub.on("cursor-update", function (msg) {
        if (msg.sameUrl) {
          Cursor.getClient(msg.clientId).updatePosition(msg);
        } else {
  • FIXME: This should be caught even before the cursor-update message, when the peer goes to another URL

          Cursor.getClient(msg.clientId).hideOtherUrl(msg);
        }
      });
  • FIXME: should check for a peer leaving and remove the cursor object

      var Cursor = util.Class({
    
        constructor: function (clientId) {
          this.clientId = clientId;
          this.element = templating.clone("cursor");
          this.elementClass = "togetherjs-scrolled-normal";
          this.element.addClass(this.elementClass);
          this.updatePeer(peers.getPeer(clientId));
          this.lastTop = this.lastLeft = null;
          $(document.body).append(this.element);
          this.element.animateCursorEntry();
          this.keydownTimeout = null;
          this.clearKeydown = this.clearKeydown.bind(this);
          this.atOtherUrl = false;
        },
  • How long after receiving a setKeydown call that we should show the user typing. This should be more than MIN_KEYDOWN_TIME:

        KEYDOWN_WAIT_TIME: 2000,
    
        updatePeer: function (peer) {
  • FIXME: can I use peer.setElement()?

          this.element.css({color: peer.color});
          var img = this.element.find("img.togetherjs-cursor-img");
          img.attr("src", makeCursor(peer.color));
          var name = this.element.find(".togetherjs-cursor-name");
          var nameContainer = this.element.find(".togetherjs-cursor-container");
          assert(name.length);
          name.text(peer.name);
          nameContainer.css({
            backgroundColor: peer.color,
            color: tinycolor.mostReadable(peer.color, FOREGROUND_COLORS)
          });
          var path = this.element.find("svg path");
          path.attr("fill", peer.color);
  • FIXME: should I just remove the element?

          if (peer.status != "live") {
  • this.element.hide();

            this.element.find("svg").animate({
              opacity: 0
            }, 350);
            this.element.find(".togetherjs-cursor-container").animate({
                    width: 34,
                    height: 20,
                    padding: 12,
                    margin: 0
                }, 200).animate({
                    width: 0,
                    height: 0,
                    padding: 0,
                    opacity: 0
                    }, 200);
          } else {
  • this.element.show();

            this.element.animate({
              opacity:0.3
            }).animate({
              opacity:1
            });
          }
        },
    
        setClass: function (name) {
          if (name != this.elementClass) {
            this.element.removeClass(this.elementClass).addClass(name);
            this.elementClass = name;
          }
        },
    
        updatePosition: function (pos) {
          var top, left;
          if (this.atOtherUrl) {
            this.element.show();
            this.atOtherUrl = false;
          }
          if (pos.element) {
            var target = $(elementFinder.findElement(pos.element));
            var offset = target.offset();
            top = offset.top + pos.offsetY;
            left = offset.left + pos.offsetX;
          } else {
  • No anchor, just an absolute position

            top = pos.top;
            left = pos.left;
          }
  • These are saved for use by .refresh():

          this.lastTop = top;
          this.lastLeft = left;
          this.setPosition(top, left);
        },
    
        hideOtherUrl: function (msg) {
          if (this.atOtherUrl) {
            return;
          }
          this.atOtherUrl = true;
  • FIXME: should show away status better:

          this.element.hide();
        },
  • place Cursor rotate function down here FIXME: this doesnt do anything anymore. This is in the CSS as an animation

        rotateCursorDown: function(){
          var e = $(this.element).find('svg');
            e.animate({borderSpacing: -150, opacity: 1}, {
            step: function(now, fx) {
              if (fx.prop == "borderSpacing") {
                e.css('-webkit-transform', 'rotate('+now+'deg)')
                  .css('-moz-transform', 'rotate('+now+'deg)')
                  .css('-ms-transform', 'rotate('+now+'deg)')
                  .css('-o-transform', 'rotate('+now+'deg)')
                  .css('transform', 'rotate('+now+'deg)');
              } else {
                e.css(fx.prop, now);
              }
            },
            duration: 500
          }, 'linear').promise().then(function () {
            e.css('-webkit-transform', '')
              .css('-moz-transform', '')
              .css('-ms-transform', '')
              .css('-o-transform', '')
              .css('transform', '')
              .css("opacity", "");
          });
        },
    
        setPosition: function (top, left) {
          var wTop = $(window).scrollTop();
          var height = $(window).height();
    
          if (top < wTop) {
  • FIXME: this is a totally arbitrary number, but is meant to be big enough to keep the cursor name from being off the top of the screen.

            top = 25;
            this.setClass("togetherjs-scrolled-above");
          } else if (top > wTop + height - CURSOR_HEIGHT) {
            top = height - CURSOR_HEIGHT - 5;
            this.setClass("togetherjs-scrolled-below");
          } else {
            this.setClass("togetherjs-scrolled-normal");
          }
          this.element.css({
            top: top,
            left: left
          });
        },
    
        refresh: function () {
          if (this.lastTop !== null) {
            this.setPosition(this.lastTop, this.lastLeft);
          }
        },
    
        setKeydown: function () {
          if (this.keydownTimeout) {
            clearTimeout(this.keydownTimeout);
          } else {
            this.element.find(".togetherjs-cursor-typing").show().animateKeyboard();
          }
          this.keydownTimeout = setTimeout(this.clearKeydown, this.KEYDOWN_WAIT_TIME);
        },
    
        clearKeydown: function () {
          this.keydownTimeout = null;
          this.element.find(".togetherjs-cursor-typing").hide().stopKeyboardAnimation();
        },
    
        _destroy: function () {
          this.element.remove();
          this.element = null;
        }
      });
    
      Cursor._cursors = {};
    
      cursor.getClient = Cursor.getClient = function (clientId) {
        var c = Cursor._cursors[clientId];
        if (! c) {
          c = Cursor._cursors[clientId] = Cursor(clientId);
        }
        return c;
      };
    
      Cursor.forEach = function (callback, context) {
        context = context || null;
        for (var a in Cursor._cursors) {
          if (Cursor._cursors.hasOwnProperty(a)) {
            callback.call(context, Cursor._cursors[a], a);
          }
        }
      };
    
      Cursor.destroy = function (clientId) {
        Cursor._cursors[clientId]._destroy();
        delete Cursor._cursors[clientId];
      };
    
      peers.on("new-peer identity-updated status-updated", function (peer) {
        var c = Cursor.getClient(peer.id);
        c.updatePeer(peer);
      });
    
      var lastTime = 0;
      var MIN_TIME = 100;
      var lastPosX = -1;
      var lastPosY = -1;
      var lastMessage = null;
      function mousemove(event) {
        var now = Date.now();
        if (now - lastTime < MIN_TIME) {
          return;
        }
        lastTime = now;
        var pageX = event.pageX;
        var pageY = event.pageY;
        if (Math.abs(lastPosX - pageX) < 3 && Math.abs(lastPosY - pageY) < 3) {
  • Not a substantial enough change

          return;
        }
        lastPosX = pageX;
        lastPosY = pageY;
        var target = event.target;
        var parent = $(target).closest(".togetherjs-window, .togetherjs-popup, #togetherjs-dock");
        if (parent.length) {
          target = parent[0];
        } else if (elementFinder.ignoreElement(target)) {
          target = null;
        }
        if ((! target) || target == document.documentElement || target == document.body) {
          lastMessage = {
            type: "cursor-update",
            top: pageY,
            left: pageX
          };
          session.send(lastMessage);
          return;
        }
        target = $(target);
        var offset = target.offset();
        if (! offset) {
  • FIXME: this really is walkabout.js's problem to fire events on the document instead of a specific element

          console.warn("Could not get offset of element:", target[0]);
          return;
        }
        var offsetX = pageX - offset.left;
        var offsetY = pageY - offset.top;
        lastMessage = {
          type: "cursor-update",
          element: elementFinder.elementLocation(target),
          offsetX: Math.floor(offsetX),
          offsetY: Math.floor(offsetY)
        };
        session.send(lastMessage);
      }
    
      function makeCursor(color) {
        var canvas = $("<canvas></canvas>");
        canvas.attr("height", CURSOR_HEIGHT);
        canvas.attr("width", CURSOR_WIDTH);
        var context = canvas[0].getContext('2d');
        context.fillStyle = color;
        context.moveTo(0, 0);
        context.beginPath();
        context.lineTo(0, CURSOR_HEIGHT/1.2);
        context.lineTo(Math.sin(CURSOR_ANGLE/2) * CURSOR_HEIGHT / 1.5,
                       Math.cos(CURSOR_ANGLE/2) * CURSOR_HEIGHT / 1.5);
        context.lineTo(Math.sin(CURSOR_ANGLE) * CURSOR_HEIGHT / 1.2,
                       Math.cos(CURSOR_ANGLE) * CURSOR_HEIGHT / 1.2);
        context.lineTo(0, 0);
        context.shadowColor = 'rgba(0,0,0,0.3)';
        context.shadowBlur = 2;
        context.shadowOffsetX = 1;
        context.shadowOffsetY = 2;
    	context.strokeStyle = "#ffffff";
    	context.stroke();
        context.fill();
        return canvas[0].toDataURL("image/png");
      }
    
      var scrollTimeout = null;
      var scrollTimeoutSet = 0;
      var SCROLL_DELAY_TIMEOUT = 75;
      var SCROLL_DELAY_LIMIT = 300;
    
      function scroll() {
        var now = Date.now();
        if (scrollTimeout) {
          if (now - scrollTimeoutSet < SCROLL_DELAY_LIMIT) {
            clearTimeout(scrollTimeout);
          } else {
  • Just let it progress anyway

            return;
          }
        }
        scrollTimeout = setTimeout(_scrollRefresh, SCROLL_DELAY_TIMEOUT);
        if (! scrollTimeoutSet) {
          scrollTimeoutSet = now;
        }
      }
    
      var lastScrollMessage = null;
      function _scrollRefresh() {
        scrollTimeout = null;
        scrollTimeoutSet = 0;
        Cursor.forEach(function (c) {
          c.refresh();
        });
        lastScrollMessage = {
          type: "scroll-update",
          position: elementFinder.elementByPixel($(window).scrollTop())
        };
        session.send(lastScrollMessage);
      }
  • FIXME: do the same thing for cursor position? And give up on the ad hoc update-on-hello?

      session.on("prepare-hello", function (helloMessage) {
        if (lastScrollMessage) {
          helloMessage.scrollPosition = lastScrollMessage.position;
        }
      });
    
      session.hub.on("scroll-update", function (msg) {
        msg.peer.scrollPosition = msg.position;
        if (msg.peer.following) {
          msg.peer.view.scrollTo();
        }
      });
  • In case there are multiple peers, we track that we've accepted one of their hello-based scroll updates, just so we don't bounce around (we don't intelligently choose which one to use, just the first that comes in)

      var acceptedScrollUpdate = false;
      session.hub.on("hello-back hello", function (msg) {
        if (msg.type == "hello") {
  • Once a hello comes in, a bunch of hello-backs not intended for us will also come in, and we should ignore them

          acceptedScrollUpdate = true;
        }
        if (! msg.scrollPosition) {
          return;
        }
        msg.peer.scrollPosition = msg.scrollPosition;
        if ((! acceptedScrollUpdate) &&
            msg.sameUrl &&
            Date.now() - session.timeHelloSent < SCROLL_UPDATE_CUTOFF) {
          acceptedScrollUpdate = true;
          msg.peer.view.scrollTo();
        }
      });
    
      session.on("ui-ready", function () {
        $(document).mousemove(mousemove);
        document.addEventListener("click", documentClick, true);
        document.addEventListener("keydown", documentKeydown, true);
        $(window).scroll(scroll);
        scroll();
      });
    
      session.on("close", function () {
        Cursor.forEach(function (c, clientId) {
          Cursor.destroy(clientId);
        });
        $(document).unbind("mousemove", mousemove);
        document.removeEventListener("click", documentClick, true);
        document.removeEventListener("keydown", documentKeydown, true);
        $(window).unbind("scroll", scroll);
      });
    
      session.hub.on("hello", function (msg) {
  • Immediately get our cursor onto this new person's screen:

        if (lastMessage) {
          session.send(lastMessage);
        }
        if (lastScrollMessage) {
          session.send(lastScrollMessage);
        }
      });
    
      function documentClick(event) {
        if (event.togetherjsInternal) {
  • This is an artificial internal event

          return;
        }
  • FIXME: this might just be my imagination, but somehow I just really don't want to do anything at this stage of the event handling (since I'm catching every click), and I'll just do something real soon:

        setTimeout(function () {
          if (! TogetherJS.running) {
  • This can end up running right after TogetherJS has been closed, often because TogetherJS was closed with a click...

          }
          var element = event.target;
          if (! TogetherJS.running) {
  • This can end up running right after TogetherJS has been closed, often because TogetherJS was closed with a click...

            return;
          }
          if (elementFinder.ignoreElement(element)) {
            return;
          }
  • Prevent click events on video objects to avoid conflicts with togetherjs's own video events

          if (element.nodeName.toLowerCase() === 'video'){
            return;
          }
    
          var location = elementFinder.elementLocation(element);
          var offset = $(element).offset();
          var offsetX = event.pageX - offset.left;
          var offsetY = event.pageY - offset.top;
          session.send({
            type: "cursor-click",
            element: location,
            offsetX: offsetX,
            offsetY: offsetY
          });
          displayClick({top: event.pageY, left: event.pageX}, peers.Self.color);
        });
      }
    
      var CLICK_TRANSITION_TIME = 3000;
    
      session.hub.on("cursor-click", function (pos) {
  • When the click is calculated isn't always the same as how the last cursor update was calculated, so we force the cursor to the last location during a click:

        if (! pos.sameUrl) {
  • FIXME: if we could have done a local click, but we follow along later, we'll be in different states if that click was important. Mostly click cloning just won't work.

          return;
        }
        Cursor.getClient(pos.clientId).updatePosition(pos);
        var element = templating.clone("click");
        var target = $(elementFinder.findElement(pos.element));
        var offset = target.offset();
        var top = offset.top + pos.offsetY;
        var left = offset.left + pos.offsetX;
        displayClick({top: top, left: left}, pos.peer.color);
        var cloneClicks = TogetherJS.getConfig("cloneClicks");
        if (cloneClicks && target.is(cloneClicks)) {
          eventMaker.performClick(target);
        }
      });
    
      function displayClick(pos, color) {
  • FIXME: should we hide the local click if no one else is going to see it? That means tracking who might be able to see our screen.

        var element = templating.clone("click");
        $(document.body).append(element);
        element.css({
          top: pos.top,
          left: pos.left,
          borderColor: color
        });
        setTimeout(function () {
          element.addClass("togetherjs-clicking");
        }, 100);
        setTimeout(function () {
          element.remove();
        }, CLICK_TRANSITION_TIME);
      }
    
      var lastKeydown = 0;
      var MIN_KEYDOWN_TIME = 500;
    
      function documentKeydown(event) {
        setTimeout(function () {
          var now = Date.now();
          if (now - lastKeydown < MIN_KEYDOWN_TIME) {
            return;
          }
          lastKeydown = now;
  • FIXME: is event.target interesting here? That is, what the user is typing into, not just that the user is typing? Also I'm assuming we don't care if the user it typing into a togetherjs-related field, since chat activity is as interesting as any other activity.

          session.send({type: "keydown"});
        });
      }
    
      session.hub.on("keydown", function (msg) {
  • FIXME: when the cursor is hidden there's nothing to show with setKeydown().

        var cursor = Cursor.getClient(msg.clientId);
        cursor.setKeydown();
      });
    
      util.testExpose({Cursor: Cursor});
    
      return cursor;
    
    });