2

I am making a minimal JavaScript WYSIWYG control.

I don't want to use document.execCommand because it doesn't allow arbitrary HTML, it's inconsistent across browsers etc.

Here is what I have so far stripped down to minimum working code:

http://jsfiddle.net/2WxQn/1/

<button data-action="strong"><strong>b</strong></button>
<button data-action="em"><em>i</em></button>
<button data-action="u"><u>u</u></button>
<p contenteditable>The quick brown fox jumps over the lazy dog.</p>
<script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>
<script>
  $( function(){
    $( 'button' ).on( 'click', function(){
      var selection = window.getSelection();                   
      var range = selection.getRangeAt( 0 );          
      var action = $( this ).attr( 'data-action' );            
      var node = document.createElement( action );
      var frag = range.extractContents();
      node.appendChild( frag );
      range.insertNode( node );

      return false;           
    });
  });
</script>

If some of the selection already contains strong tags (or whatever), how do I make it so that clicking the button a second time removes these tags instead of wrapping the selection with a new strong tag?

Writing this question has given me an idea. I will try it now and answer my own question if it works - that way this question is here in case anybody else comes up against this. Otherwise I shall await your assistance with baited breath :)

EDIT: obviously if somebody else posts a working solution, I will accept their answer rather than mine if it is better.

EDIT(2): so my idea didn't pan out. It turns out that something (probably range.insertNode) will magically balance the tags for you. I don't seem to have enough information from selection, range or frag to always know if the selection is inside a given tag. Any ideas?

nrkn
  • 1,752
  • 3
  • 15
  • 24
  • I am amazed that you can make a simple WYSIWYG editor with so few code! – Uooo Apr 12 '13 at 07:52
  • It's `extractContents()` that does the magic. To make a well-formed document fragment from an arbitrary range and leave the document in a well-formed state when the range's ends maybe at completely different levels in the DOM tree may require a certain amount of duplication of nodes. – Tim Down Apr 12 '13 at 08:29
  • Hi @TimDown - you wrote Rangy didn't you? - I don't seem to see anything helpful on the object returned by extractContents - do you have any ideas? What is vaguely taking shape in my mind is that I may have to take the anchorNode and focusNode from the selection, walk up the dom to the nearest common parent of the two, and then try and do some kind of reference counting walking back down so I can balance the tags myself? Do you think I may be on the right track or do you have any other ideas? – nrkn Apr 12 '13 at 08:44
  • 2
    I wouldn't use `extractContents()` because it may mangle your DOM. I would do the walking from start to end of the selection range (note `focusNode` can be earlier in the document than `anchorNode` if the selection is backwards, so I'd use a range obtained from the selection instead), collecting nodes you want to change, and then change them. You could take a look at Rangy's class applier module, which does a similar kind of thing: https://code.google.com/p/rangy/source/browse/trunk/src/js/modules/rangy-cssclassapplier.js – Tim Down Apr 12 '13 at 11:19
  • Cheers - I'll look into it tomorrow and report back. Thanks again. – nrkn Apr 12 '13 at 13:51
  • I had a look at that - it's really cool and I learnt some interesting things but I'm starting to think this could actually be much simpler problem if I think of it as a graph problem instead - made a visualizer to help me: http://i.imgur.com/w0JdA0l.png – nrkn Apr 13 '13 at 04:52

1 Answers1

1

EDIT : This is not a good solution. It completely breaks down with anything more complex than a single line of simple text. I have worked out and will be posting a better solution soon.

Figured it out.

I build an array containing each text node in the wysiwyg area, and a list of its parent tags.

Then I wrapped the selection in a custom element so it would be easy to remove later, and so as not to conflict with any existing HTML elements, using the x- prefix as recommended.

I then rebuilt the contents of the wysiwyg element from that list, removing the tag for the clicked button from all nodes in the selection if they all already had it, which is how most wywiwyg editors handle it.

http://jsfiddle.net/x7WRZ/

<button data-action="B"><b>b</b></button>
<button data-action="I"><i>i</i></button>
<button data-action="U"><u>u</u></button>
<p contenteditable>The quick brown fox jumps over the lazy dog.</p>
<script src="http://code.jquery.com/jquery-1.9.1.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.4.4/underscore-min.js"></script>
<script>
  $( function(){    
    var selectionWrapper = 'X-SELECTION';

    function getTextData( element ) {
      function getTextNodesIn( root ) {
        var textNodes = [];
        var parents = [];

        function getTextNodes( node ) {
          if( node.nodeType === 3 ){     
            var text = node.textContent;
            textNodes.push({
              text: text,
              parents: parents.slice( 0 )
            });
          } else {
            if( node !== root ){
              parents.push( node.tagName );
            }
            for( var i = 0, len = node.childNodes.length; i < len; ++i ){
              getTextNodes( node.childNodes[ i ] );
            }
            parents.pop();
          }
        }

        getTextNodes( element );

        return textNodes;
      }    

      return getTextNodesIn( element );   
    }

    function handleSelection( container, action ) {
      var textData = getTextData( container );
      container.innerHTML = '';

      //if every textNode in the selection has action as a parent, we want
      //to remove it from all of them.
      var selection = _( textData ).filter( function( data ){
        return _( data.parents ).contains( selectionWrapper );
      });
      var remove = _( selection ).every( function( data ) {
        return _( data.parents ).contains( action ) || data.text.trim() === ''; 
      });
      _( selection ).each( function( data ){
        if( remove ) {
          data.parents = _( data.parents ).without( action );
        } else {
          data.parents.push( action );
        }
      });

      //rebuild each text node
      _( textData ).each( function( data ){
        //no need to add empty text nodes
        if( data.text === '' ) {
          return;
        }
        //remove duplicates of the same parent tag and remove the selection wrapper
        var parents = _( data.parents ).chain().uniq().without( selectionWrapper ).value();            
        var target = container;
        _( parents ).each( function( parent ){
          var node = document.createElement( parent );
          target.appendChild( node );
          target = node;
        });
        var text = document.createTextNode( data.text );
        target.appendChild( text );
      });
    }

    $( 'button' ).on( 'click', function(){
      var action = $( this ).attr( 'data-action' );            
      var selection = window.getSelection();  

      for( var i = 0; i < selection.rangeCount; i++ ){
        var range = selection.getRangeAt( i );      
        var node = document.createElement( selectionWrapper );                    
        node.appendChild( range.extractContents() );          
        range.insertNode( node );
        handleSelection( $( 'p' )[ 0 ], action );
      }

      return false;           
    });
  });    
</script>
nrkn
  • 1,752
  • 3
  • 15
  • 24
  • Note this is still a minimal example - there are a bunch of additional things you'd probably want to do for real world use - if you're curious ask and I'll enumerate some of them. – nrkn Apr 18 '13 at 09:33