Friday, November 29, 2013

UI-less Polymer Element for Sane Change Events


Yesterday, I managed to scrape together a Polymer (JS) element that listened for events on the containing document. This was a non-UI element that was just in place to interact with other elements on the page. I think there is a lot of potential for this kind of thing, which is only one of the reasons that Polymer excites me enough to be writing a book on it.

Today, I hope to try the opposite. Instead of listening for events on the web page in which my custom Polymer element is contained, I would like to listen for events on an element inside my custom Polymer element. The idea in this case is to take something that is very hard to listen for events on, say a contenteditable <div>, and normalize them for easier consumption.

Something like this:
    <change-sink>
      <div contenteditable>
        <!-- initial content here... -->
      </div>
    </change-sink>
    <script>
      document.querySelector('change-sink').addEventListener('change', function(e){
        console.log('Was:');
        console.log(e.detail.was);
        console.log('But now it\'s different!');
      });
    </script>
Content editable stuff is a pain to identify when changes occur. It's possible, but far from easy. That's where this new <change-sink> tag comes in.

I start by importing the Polymer definition as usual:
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- 1. Load Polymer before any code that touches the DOM. -->
    <script src="scripts/polymer.min.js"></script>
    <!-- 2. Load component(s) -->
    <link rel="import" href="scripts/change-sink.html">
  </head>
  <body>
    <change-sink>
      <div contenteditable>
        <!-- initial content here... -->
      </div>
    </change-sink>
  </body>
<script>
  <!-- change-sink listeners here... -->
</script>
</html>
I begin the definition of change-sink.html with a simple entered-view callback that grabs a reference to the element contained within <change-sink> and placing a keyup-console-logger on it:
<polymer-element name="change-sink">
  <script>
    Polymer('change-sink', {
      enteredView: function() {
        var that = this,
            el = this.children[0];
        el.addEventListener('keyup', function(e){
          console.log(el.innerHTML);
        });
      }
    });
  </script>
</polymer-element>
That does the trick as I now see the contenteditable contents change with each keyup:
<h1>Change e!</h1>
<h1>Change Me!</h1>
Instead of simply generating console messages, I want to support the API described earlier. When a change occurs, I should fire a "change" event whose details include the previous values for comparison. That is easy enough:
    Polymer('change-sink', {
      was: undefined,
      enteredView: function() {
        var that = this,
            el = this.children[0];
        el.addEventListener('keyup', function(e){
          that.fire('change', {was: that.was});
          that.was = el.innerHTML;
        });
      }
    });
I support a was property on the Polymer element itself. The previous value is then assigned there whenever a change occurs. Just before that assignment, I fire the change event with the previous value of the contained element.

And that works just fine! When I make my first change, the was value is undefined and, after the second change, the was value is defined properly:
Was:
undefined
But now it's different! 

Was:
<h1>Change Me!</h1>
But now it's different!
OK. That is pretty cool. Cool, but it is not actually firing on changes—just keyup. I'll get change events when I arrow key around my content editable <div>. I'll also get events with every character typed, not with every change. I am not going to see change events when content is pasted. Also, it would be cool if I could "change sink" <input> changes as well as content editable <div> tag changes.

All of that turns out to be pretty easy. I need to listen for a few more event types, use a debounce, and check for content in "value" if it is not in innerHTML:
    Polymer('change-sink', {
      was: undefined,
      child: undefined,
      enteredView: function() {
        this.child = this.children[0];

        var that = this;
        this.child.addEventListener('keyup', function(_){lazyChange(that)});
        this.child.addEventListener('blur',  function(_){lazyChange(that)});
        this.child.addEventListener('paste', function(_){lazyChange(that)});
        this.child.addEventListener('input', function(_){lazyChange(that)});
      }
    });

  var lazyChange = debounce(change, 750);

  function change(_el) {
    var el = _el.child;
    if (el.innerHTML == _el.was) return;

    _el.fire('change', {was: _el.was});
    _el.was = el.innerHTML || el.value;
  }
That does the trick. It now works with <textarea> just as easily as it does with content editable <div> tags. I can paste and see a change event. I can type new content, but only see one event. It is all pretty slick.



The actual heavy lifting comes from Polymer establishing the custom element and its relationship with the child element. Once that is in place, normalizing an otherwise ugly event proves very straight forward. Best of all, this is built for re-use. Anyone with this <change-sink> code now has normalized change events. I am eager to see what else I can do with this stuff!


Day #950

1 comment:

  1. Hi Chris.
    I'm also working with contenteditable inside polymer.
    Have you solved a document.execCommand problem?

    ReplyDelete