nrkn
nrkn

Reputation: 1852

Minimal JavaScript wysiwyg - remove tag from selected text if already exists

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?

Upvotes: 2

Views: 2204

Answers (1)

nrkn
nrkn

Reputation: 1852

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>

Upvotes: 1

Related Questions