1. “Hey, Google+ Feedback Tool, I’m stealing you” – Part 1

    Have you noticed the feedback tool that Google placed on every page in Google+?. (It is actually available in other Google products as well.)

    I would say it’s the best feedback tool out there in terms of simplicity, user-friendliness and the overall idea. When I saw it, I was like “Dude… I’m stealing that!” …and I am. I mean, I’m not actually stealing their code or something—that would just be impolite at least—I’m trying to recreate the tool on my own.

    DOM Tree Snapshot

    From developer’s point of view, the tool allows people to write a little message to Google, highlight areas and DOM elements on the page that are relevant to the problem and send a screenshot of the page with those areas highlighted.

    Even though that is a very good solution, it requires a Flash object or an ActiveX component to do the actual screenshot. (Update: As Elliott Sprehn mentions in the comments, Google generates the screenshot client-side using their own rendering engine that scans the DOM and redraws the page on a canvas.) However, we can just take a snapshot of the DOM tree with stylesheets and recreate the state afterwards. Great thing about this approach is that you can browse the actual DOM tree the user encountered rather than guessing.

    var snapshot = document.documentElement.innerHTML;
    

    Note: You only get the HTML from <head> to </body>. You could use the outerHTML property to get the whole <html> to </html>. However, for one reason mentioned below in the post, we will just use innerHTML.

    This could, however, end up being pretty messy if you have some JavaScript that manipulates the DOM and expects the elements to have a different state. Due to those dangerous situations (we could not debug wery well in such case), we have to strip the JavaScript included in the page.

    snapshot = snapshot.replace(/<script[^>]*?>[\s\S]*<\/script>/gi, '');
    

    Note: I put [\s\S] instead of a period (.) because in JavaScript, there is no modifier that would make periods include new line characters.

    Another thing you might want to do is to alter resource paths in the snapshot so that you can load the snapshot on a different domain or generate an image using wkhtmltoimage or some similar solution.

    var origin = location.protocol + '//' + location.host;
    var dirs = location.pathname.split(/\//g).slice(1, -1);
    console.log(dirs);
    
    var global_rx = /\s(type|href|src)="?(\.\.?|\/)[^\s">]*/gi;
    var single_rx = /(\s)(type|href|src)(="?)((?:\.\.?|\/)[^\s">]*)/i;
    
    snapshot = snapshot.replace(global_rx, function (match) {
      if (match.search('://') !== -1) { // absolute path
        return match;
      }
    
      match = match.match(single_rx);
      var prefix = match[1] + match[2] + match[3];
      var link = match[4];
    
      if (link[0] === '/') { // absolute path, same domain
        return prefix + origin + link;
      }
    
      link = link.split(/\//g);
      var i = 0
      for (var i = 0; link[i] === '..' && i < link.length; ++i) {}
      console.log(link, i);
      return prefix + origin + (i !== dirs.length ? '/' : '') +
        dirs.slice(0, dirs.length - i).join('/') + '/' +
        link.slice(i, link.length).join('/');
    });
    

    User Interface

    Now that we have the snapshot, we can start building the UI. We are going to have the original page used as background. On top of that, there are going to be several additinal layers.

    The layer right on top of the background (the original content) is going to be a canvas element which will eventually create the mask (highlights). I’ll get to this layer later.

    The most important layer in terms of usability is an iframe containing a copy of the original page. We already have the snapshot which we are going to place inside the iframe. The tricky thing about this is that this layer is going to remain invisible to the user (via opacity) but it’s going to handle DOM events and pass them to our script in the original window.

    var width = document.documentElement.scrollWidth;
    var height = Math.max(
      window.innerHeight,
      document.documentElement.scrollHeight
    );
    
    var iframe = document.createElement('iframe');
    iframe.src = './dom-feedback-copy.html';
    s.style.width = width + 'px';
    s.style.height = height + 'px';
    document.body.appendChild(iframe);
    
    iframe {
      display: none;
      position: absolute;
      top: 0;
      left: 0;
      opacity: 0;
      border: none;
    }
    

    We are loading the page dom-feedback-copy.html that will be filled with the snapshot and will pass DOM events to our script. Unfortunately, we cannot generate this page dynamically (either using a data URI or altering the contentWindow of the iframe) due to the cross-origin policy. You can put this file wherever you want as long as you keep it on the same domain (second level).

    Let’s see what can the file look like if we only want to pass click events. We can even use the widely implemented cross-document messaging.

    <!DOCTYPE html>
    <html>
    <script>
    (function () {
    
      // Loosen up the cross-origin policy
      document.domain = location.hostname;
    
      var origin = location.protocol + '//' + location.host;
    
      var getOffset = function (el) {
        var x = 0;
        var y = 0;
        do {
          x += el.offsetLeft;
          y += el.offsetTop;
        } while (el = el.offsetParent);
        return [x, y];
      };
      var broadcastEvent = function (e) {
        var target = e.target;
        var offset = getOffset(target);
        var data = {
          type: e.type,
          tagName: target.tagName,
          offsetX: offset[0],
          offsetY: offset[1],
          x: e.pageX,
          y: e.pageY
        };
        window.parent.postMessage(data, origin);
      };
      window.addEventListener('click', broadcastEvent, false);
    
    }());
    </script>
    </html>
    

    Notes: 1.) Make sure that doctypes match to prevent minor rendering differences. 2.) The script element will be overwritten by the snapshot, but the functionallity will remain because the snapshot will be injected during the load event which gets fired after the whole HTML code is parsed.

    So let’s check out what are we getting from the iframe:

    • event type (only click at this point)
    • tag name of the clicked element
    • offset of the element within the page
    • size of the element

    This data is enough for us to highlight the element within the canvas layer.

    Canvas, DOM element highlighting

    We are going to create a canvas element at the initialization and fill it with semi-transparent black rectangle.

    var canvas = document.createElement('canvas');
    // size from the code above
    canvas.width = width;
    canvas.height = height;
    
    var ctx = canvas.getContext('2d');
    ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
    ctx.fillRect(0, 0, width, height);
    

    Next this we need is a message event listener to process the click events received from the iframe and highlight (cut) regions in the canvas based on the data in the messages.

    var highlight = function (x, y, width, height) {
      ctx.globalCompositeOperation = 'destination-out';
      ctx.fillStyle = '#FFFFFF';
      ctx.fillRect(x, y, width, height);
    };
    var receiveMessage = function (e) {
      var data = e.data;
      highlight(data.offsetX, data.offsetY, data.width, data.height);
    };
    window.addEventListener('message', receiveMessage, false);
    

    More is coming soon; stay tuned. In the meantime, you can check out my github repository where is the source code described in this post as well as a demo of the product.

    by Jan Kuča

Notes

  1. jankuca posted this