Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/recipe-fiddle.git
/*!
 * Browser-based inline editor for enumerated lists.
 *
 * DESCRIPTION
 * Enhances a browser's basic contenteditable functionality for editing
 * list items in a similar fashion to a Word Processor.
 *
 * LICENSE
 * http://opensource.org/licenses/MIT
 *
 * LINTING
 * The source code must lint without errors or warnings:
 *
 * jslint --white rz.edit.js
 */

/*jslint browser: true*/
/*jslint nomen: true*/
/*jslint regexp: true*/
/*jslint plusplus: true */
/*jslint bitwise: true */
/*global $, jQuery, alert*/
"use strict";

/**
 * Quickly generates a hashcode for any string. This is used to detect
 * when content has changed (and needs to be saved).
 *
 * @see http://stackoverflow.com/a/7616484/59087
 */
String.prototype.hashCode = function() {
  var hash = 0, i = 0, l = this.length, c;

  if( l > 0 ) {
    while( i < l ) {
      c = this.charCodeAt(i);
      hash = ((hash<<5)-hash)+c;
      hash |= 0;
      i++;
    }
  }

  return hash;
};

/**
 * Returns the value for a URL parameter. This will return the empty
 * string if the parameter is not defined.
 *
 * @see http://stackoverflow.com/a/8764051/59087
 */
function getURLParameter( name ) {
  return decodeURIComponent(
    (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)')
      .exec(location.search)||[undefined,''])[1].replace(/\+/g, '%20')
  )||'';
}

(function($, window, undefined) {
  $.Editor = function( element, options ) {
    var _this = this,
        $this = $(element),
        settings = $.extend( {}, $.Editor.defaults, options ),
        // Dirty hash code value.
        dHash = $('ol').text().hashCode();

    /**
     * Returns the node that currently contains the editing caret.
     * If the caret is in a text * element, then this will return its
     * containing node.
     *
     * @see http://stackoverflow.com/a/1563492/59087
     */
    this.getCaretNode = function() {
      var range, sel, container;
      if (document.selection && document.selection.createRange) {
        // IE...
        range = document.selection.createRange();
        return range.parentElement();
      }

      if( window.getSelection ) {
        sel = window.getSelection();
        if( sel.getRangeAt ) {
          if( sel.rangeCount > 0 ) {
            range = sel.getRangeAt(0);
          }
        }
        else {
          range = document.createRange();
          range.setStart(sel.anchorNode, sel.anchorOffset);
          range.setEnd(sel.focusNode, sel.focusOffset);

          if( range.collapsed !== sel.isCollapsed ) {
            range.setStart(sel.focusNode, sel.focusOffset);
            range.setEnd(sel.anchorNode, sel.anchorOffset);
          }
        }

        if( range ) {
          container = range.commonAncestorContainer;

          // Return the parent container for text nodes (3).
          return container.nodeType === 3 ? container.parentNode : container;
        }   
      }
    };

    /**
     * Returns the position of the caret relative to the given node. If no
     * node is given, this will use the caret's current node.
     */
    this.getCaretPosition = function( node ) {
      node = node || _this.getCaretNode();

      var caretOffset = 0,
          doc = node.ownerDocument || node.document,
          win = doc.defaultView || doc.parentWindow,
          sel = doc.selection,
          range,
          preCaretRange;

      if( typeof win.getSelection !== 'undefined' ) {
        range = win.getSelection().getRangeAt(0);
        preCaretRange = range.cloneRange();
        preCaretRange.selectNodeContents(node);
        preCaretRange.setEnd(range.endContainer, range.endOffset);
        caretOffset = preCaretRange.toString().length;
      }
      else if( sel && sel.type !== 'Control' ) {
        range = sel.createRange();
        preCaretRange = doc.body.createTextRange();
        preCaretRange.moveToElementText(node);
        preCaretRange.setEndPoint('EndToEnd', range);
        caretOffset = preCaretRange.text.length;
      }

      return caretOffset;
    };

    /**
     * Sets the position of the caret to the end of the given node. If no
     * node is given, this will use the caret's current node.
     *
     * @see http://stackoverflow.com/a/3866442/59087
     */
    this.setCaretPositionEnd = function( node ) {
      node = node || _this.getCaretNode();

      var range, selection;

      if( document.createRange ) {
        // Firefox, Chrome, Opera, Safari, IE 9+ ...

        // Create a range (a range is a like the selection but invisible)
        range = document.createRange();
        // Select the entire contents of the element with the range
        range.selectNodeContents(node);
        // Collapse the range to the end point; false means collapse to end
        range.collapse(false);
        // Get the selection object (allows you to change selection)
        selection = window.getSelection();
        // Remove any selections already made.
        selection.removeAllRanges();
        // Make the range you have just created the visible selection
        selection.addRange(range);
      }
      else if( document.selection ) { 
        // IE 8 and lower ...

        // Create a range (a range is a like the selection but invisible)
        range = document.body.createTextRange();
        // Select the entire contents of the element with the range
        range.moveToElementText(node);
        // Collapse the range to the end point; false means collapse to end
        range.collapse(false);
        // Select the range (make it the visible selection).
        range.select();
      }
    };

    /**
     * Ensures the document always has at least one line item and at most
     * one ordered list.
     *
     * @see http://stackoverflow.com/a/4063528/59087
     */
    this.normalize = function() {
      var list = '<ol><li></li></ol>',
          $node;

      // Convert non-ordered list tags to empty lists, preparing for merge.
      $('p,div').replaceWith( list );

      // Merge all ordered lists together to eliminate pesky P and DIV tags.
      $node = $('ol:gt(0)').remove().children('li').appendTo('ol:eq(0)');

      // Ensure the document always has at least one ordered list.
      if( $('ol').length === 0 ) {
        $(document.body).prepend( list );
      }

      // Remove br tags and other browser blemishes.
      $('ol').nextAll().remove();
      $('ol').prevAll().remove();

      // If there are still no list items, create some.
      if( $('ol').children().length === 0 ) {
        $node = $('<li></li>');
        $('ol').append( $node );
      }

      // Reposition the cursor into the list item.
      if( $node.is( 'li' ) ) {
        _this.setCaretPositionEnd( $node[0] );
      }

      // List items must have some content (zero-width character) to
      // prevent the cursor from skipping over while editing.
      $('li:empty').append( '\u200B' );
    };

    /**
     * Called when the user presses Enter, Backspace, or Delete.
     */
    this.doNormalization = function() {
      setTimeout( function() { _this.normalize(); }, 32 );
    };

    /**
     * Returns the style for a node.
     *
     * @param n The node to check.
     * @param p The property to retrieve (usually 'display').
     *
     * @see http://www.quirksmode.org/dom/getstyles.html
     */
    this.getStyle = function( n, p ) {
      // @see https://groups.google.com/d/msg/jquery-en/L1hxEFhYt_c/B-tQdWjx-7gJ
      return n.currentStyle ?
        n.currentStyle[p] : (
          document.defaultView.getComputedStyle(n, null) ?
          document.defaultView.getComputedStyle(n, null).getPropertyValue(p) :
          ""
        );
    };

    /**
     * Converts HTML to text, preserving semantic newlines for block-level
     * elements.
     *
     * @param node - The HTML node to perform text extraction.
     *
     * @see http://stackoverflow.com/a/20384452/59087
     */
    this.toText = function( node ) {
      var result = '',
          i = 0,
          j = node.childNodes.length,
          d;

      // document.TEXT_NODE = 3 (not supported by some browsers; i.e., IE).
      if( node.nodeType === 3 ) {
        // Replace repeated spaces, newlines, and tabs with a space.
        result = node.nodeValue.replace( /\s+/g, ' ' );
        // Remove invisible spaces.
        result = result.replace( /[\u200b]*/g, '' );
      }
      else {
        while( i < j ) {
          result += _this.toText( node.childNodes[i] );
          i++;
        }

        d = _this.getStyle( node, 'display' );

        // Add new lines for block elements, list items, and table rows.
        if( node.tagName === 'BR' || node.tagName === 'HR' ||
            d.match( /^block/ ) || d.match( /list/ ) || d.match( /row/ ) ) {
          result += '\n';
        }
      }

      return result;
    };

    /**
     * Called after text has been pasted into the content area.
     */
    this.paste = function() {
      var t = _this.toText( document.body );

      // Remove blank lines to prevent empty line items.
      t = t.replace( /^\s*[\r\n]/gm, '' );

      // Split sentences.
      t = t.replace( /(\.) +/g, '$1\n' );

      // Trim whitespace from the beginning and end so that the numbers can
      // be removed.
      t = t.replace( /^\s\s*/gm, '' ).replace( /\s\s*$/gm, '' );

      // Remove numbers [e.g., "1." and "2)"] from the start of lines.
      t = t.replace( /^\d+[\.\)]\s?([^\d])/gm, '$1' );

      // Wrap all non-empty lines with a list item.
      t = t.replace( /(.+)[\r\n]*/g, '<li>$1</li>\n' );

      // Ensure at least one list item.
      if( t.length === 0 ) {
        t = '<li></li>';
      }

      // Update the document with the new, unformatted, list items.
      $this.html( '<ol>' + t + '</ol>' );
    };

    /**
     * Listens for paste events.
     */
    this.initPasteHooks = function() {
      // Wait for pasted content to be available.
      $this.on( 'paste', function( e ) {
        // Wait for the document to be updated...
        setTimeout( function() { _this.paste(); }, 64 );
      });
    };

    /**
     * Listens for all keyboard events.
     */
    this.initKeyboardHooks = function() {
      $this.on( 'keydown', function( e ) {
        var code = e.keyCode || e.which,
            result = true,
            $node = $(_this.getCaretNode());

        // Avoid cursor movements when shift and control keys are pressed
        // to prevent deselection of text.
        if( code === 16 || code === 17 ) {
          return result;
        }

        // Reposition the cursor from an invalid node. When selecting text,
        // in Firefox, the node becomes 'ol', which makes it difficult to
        // detect inserting text into the ol node vs. selecting the list
        // item text. Note that Chrome will insert <span> elements, which
        // are perfectly legitimate.
        if( $node.is( 'html' ) || $node.is( 'body' ) ) {
          _this.setCaretPositionEnd( $('li').first()[0] );
        }

        switch( code ) {
          case  8:
          case 46:
            _this.doNormalization();
            break;
          case 116:
            // F5 refreshes the page, which implies saving first...
            _this.save();
            break;
          case 13:
            // Prevent <br/> insertions by sabotaging shift+enter.
            result = e.shiftKey ? false : _this.doNormalization();
            break;
        }

        _this.maxItemLength();

        return result;
      });
    };

    /**
     * Sets the cursor position to the end of the first list item.
     */
    this.initCursorPosition = function() {
      var $node = $('li:first');

      if( $node ) {
        _this.setCaretPositionEnd( $node[0] );
        $node.focus();
      }
    };

    /**
     * Sets various timers: saving, checking for length.
     */
    this.initTimerHooks = function() {
      setInterval(
        function() { _this.save(); },
        settings.saveInterval * 1000
      );

      setInterval(
        function() { _this.maxItemLength(); },
        settings.maxItemLengthInterval * 1000
      );

      // Save when the tab closes or browser refreshes.
      window.onbeforeunload = function() {
        _this.save();
      }

      // Closing the tab or the browser window...
      $this.on( 'focusout', function() {
        _this.save();
      });
    };

    this.hashText = function() {
      return $(document.body).text().hashCode();
    };

    this.isDirty = function() {
      return _this.hashText() !== _this.dHash;
    };

    /**
     * Clears the dirty flag by regenerating the hash text.
     */
    this.clean = function() {
      _this.dHash = _this.hashText();
    };

    /**
     * Called when the list should be saved, which happens on blur
     * events and a regular interval (see initTimerHooks). This compares
     * the hash of the text to see if it has changed, which is simpler
     * than tracking the dirtiness via pastes content-changing keypresses,
     * and DOM changes.
     */
    this.save = function() {
      if( _this.isDirty() ) {
        _this.clean();
        var s = $.trim( _this.toText( $('ol')[0] ) );

        // Calls the save routine, passing the ID from the URL.
        if( s.length > 1 ) {
          $.post( settings.postPage, {
            command: 'instruction-accept',
            instructions_group_id: getURLParameter( 'id' ),
            steps: s
          });
        }
      }
    };

    /**
     * Called when the maximum number of characters per item has been
     * reached.
     */
    this.maxItemLength = function() {
      $( 'li' ).each( function() {
        var l = $(this).text().length;

        if( l > settings.maxItemLength ) {
          $(this).addClass( 'maxLength' );
        }
        else {
          $(this).removeClass();
        }
      });
    };

    /**
     * Called when the maximum number of list items has been reached.
     */
    this.maxItems = function() {};

    /**
     * Called when an existing list has been split into two lists.
     */
    this.itemListSplit = function() {};

    /**
     * Called when the jQuery plugin is assigned to a DIV element on the
     * page. This method is responsible for ensuring that the list:
     *
     * - is the only element in the div;
     * - has at least one child element: a list item;
     * - is editable; and
     * - has keyboard hooks.
     */
    this.initialize = function( element, options ) {
      this.initKeyboardHooks();
      this.initPasteHooks();
      this.initTimerHooks();
      this.initCursorPosition();
    };

    // Set the options and prepare the DIV element for editing.
    this.initialize( element, options );
  };

  $.fn.Editor = function( options ) {
    // Creates a new instance of the list editor. The number of list editor
    // instances must be tracked. An entire list can be deleted so long as
    // at least one other list on the page is editable with the Editor.
    return this.each( function() {
      var editor = new $.Editor( this, options );
    });
  };

  $.Editor.defaults = {
    maxItemLength: 200,
    // Check for max length
    maxItemLengthInterval: 0.5,
    maxItems: 50,
    // Seconds between save events
    saveInterval: 10,
    postPage: 'index.php'
  };
}(jQuery, window));

$(document).ready(function() {
  $(document.body).Editor();
});