JavaScript - Syntax Highlighting - Customizable TextArea Words

From NoskeWiki
Revision as of 18:15, 2 August 2019 by NoskeWiki (talk | contribs)
Jump to navigation Jump to search
Syntax highlighting of words


About

NOTE: This page is a daughter page of: JavaScript - Syntax Highlighting


Based on highlight-within-textarea v2 by Will Boyd - modified to let you specify custom words more easily. You can play with his script interactively here. I usually stay away from jQuery, but this seems worth it. :)


Customizable_textarea_words

word-highlighter.html:

<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="shortcut icon" type="image/x-icon" href="https://static.codepen.io/assets/favicon/favicon-aec34940fbc1a6e787974dcd360f2c6b63348d4b1f4e06c77743096d55480f33.ico">
  <link rel="mask-icon" type="" href="https://static.codepen.io/assets/favicon/logo-pin-8f3771b1072e3c38bd662872f6b673a722f4b3ca2421637d5596661b4e2132cc.svg" color="#111">
  <title>Word/Syntax Highlights Within a Textarea</title>

  <!---------------------- INCLUDES START ---------------------->  
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
  <script src="jquery.highlight-within-textarea.js"></script>
  <link rel="stylesheet" href="highlight-within-textarea.css">
  <!---------------------- INCLUDES END ---------------------->  
  
  <style>
    /* Page level CSS (not related to text area) */
    html {
      min-height: 100%;
    }
    body {
      margin: 0;
      font: 14px 'Asap', sans-serif;
      background-image: linear-gradient(120deg, #9890e3 0%, #b1f4cf 100%);
    }
    .wrapper {
      width: 650px;
      margin: 40px auto;
      text-align: center;
    }
    h1 {
      font-size: 28px;
      text-shadow: 2px 2px 0 rgba(255, 255, 255, 0.5);
    }
  </style>

  <script>
    window.console = window.console || function(t) {};
  </script>

</head>
<body translate="no">

  <div class="wrapper">
  <h1>Word/Syntax Highlights Within a Textarea</h1>
  <p>Highlights certain words ['SELECT', 'FROM', 'WHERE'] in blue or ['AND', 'OR', 'AS'] pink as you type.</p>
    
  <!---------------------- HWT START ---------------------->
  <div class="hwt-container">
  <div class="hwt-backdrop"><div class="hwt-highlights hwt-content"></div></div>
  <textarea spellcheck="false" class="hwt-input hwt-content">
SELECT *
FROM MyTable AS m
WHERE
  m.person = 'Will Boyd'
  AND m.url = 'https://github.com/lonekorean/highlight-within-textarea'
  </textarea>
  </div>
  <!---------------------- HWT END ---------------------->
    
  </div>

  <script id="rendered-js">

    $('textarea').highlightWithinTextarea({
      highlight: [
      { highlight: 'SELECT', className: 'blue' },
      { highlight: 'FROM',   className: 'blue' },
      { highlight: 'WHERE',  className: 'blue' },
      { highlight: 'AND',  className: 'pink' },
      { highlight: 'OR',  className: 'pink' },
      { highlight: 'AS',  className: 'pink' },
      ] });
  </script>

</body>
</html>

highlight-within-textarea.css:

/* == Hightlight TextArea CSS == */

.hwt-container {
  display: inline-block;
  position: relative;
  overflow: hidden !important;
  -webkit-text-size-adjust: none !important;
  background-color: rgba(255, 255, 255, 0.2);
  box-shadow: 0 0 50px rgba(0, 0, 0, 0.2);
}
.hwt-backdrop {
  position: absolute !important;
  top: 0 !important;
  right: -99px !important;
  bottom: 0 !important;
  left: 0 !important;
  padding-right: 99px !important;
  overflow-x: hidden !important;
  overflow-y: auto !important;
}
.hwt-highlights {
  width: auto !important;
  height: auto !important;
  border-color: transparent !important;
  white-space: pre-wrap !important;
  word-wrap: break-word !important;
  color: transparent !important;
  overflow: hidden !important;
}
.hwt-input {
  display: block !important;
  position: relative !important;
  margin: 0;
  padding: 0;
  border-radius: 0;
  font: inherit;
  overflow-x: hidden !important;
  overflow-y: auto !important;
}
.hwt-content {
  border: 1px solid;
  background: none transparent !important;
  width: 650px;
  height: 225px;
  padding: 10px;
  border: 1px solid #fff;
  text-align: left;
  font-size: 12px;
  line-height: 1.5;
}
.hwt-content mark {
  padding: 0 !important;
  color: inherit;
}
.hwt-content mark {
  border-radius: 2px;
  background-color: #fcc2d7;
  box-shadow: 0 0 0 1px #fff;
}
.hwt-content mark.blue {
  background-color: #a3daff;
}
.hwt-content mark.pink {
  background-color: #fcc2d7;
}

word-jquery.highlight-within-textarea.js:

/*
 * highlight-within-textarea
 *
 * @author  Will Boyd
 * @github  https://github.com/lonekorean/highlight-within-textarea
 */

(function ($) {
  let ID = 'hwt';
  let CASE_SENSITIVE = true;

  let HighlightWithinTextarea = function ($el, config) {
    this.init($el, config);
  };

  HighlightWithinTextarea.prototype = {
    init: function ($el, config) {
      this.$el = $el;

      // backwards compatibility with v1 (deprecated)
      if (this.getType(config) === 'function') {
        config = {
          highlight: config
        };
      }

      if (this.getType(config) === 'custom') {
        this.highlight = config;
        this.generate();
      } else {
        console.error('valid config object not provided');
      }
    },

    // Returns identifier strings that aren't necessarily "real" JavaScript types.
    getType: function (instance) {
      let type = typeof instance;
      if (!instance) {
        return 'falsey';
      } else if (Array.isArray(instance)) {
        if (instance.length === 2 && typeof instance[0] === 'number' && typeof instance[1] === 'number') {
          return 'range';
        } else {
          return 'array';
        }
      } else if (type === 'object') {
        if (instance instanceof RegExp) {
          return 'regexp';
        } else if (instance.hasOwnProperty('highlight')) {
          return 'custom';
        }
      } else if (type === 'function' || type === 'string') {
        return type;
      }

      return 'other';
    },

    generate: function () {
      this.$el
        .addClass(ID + '-input ' + ID + '-content')
        .on('input.' + ID, this.handleInput.bind(this))
        .on('scroll.' + ID, this.handleScroll.bind(this));

      this.$highlights = $('<div>', {
        class: ID + '-highlights ' + ID + '-content'
      });

      this.$backdrop = $('<div>', {
          class: ID + '-backdrop'
        })
        .append(this.$highlights);

      this.$container = $('<div>', {
          class: ID + '-container'
        })
        .insertAfter(this.$el)
        .append(this.$backdrop, this.$el) // Moves $el into $container.
        .on('scroll', this.blockContainerScroll.bind(this));

      this.browser = this.detectBrowser();
      switch (this.browser) {
        case 'firefox':
          this.fixFirefox();
          break;
        case 'ios':
          this.fixIOS();
          break;
      }

      // Plugin function checks this for success.
      this.isGenerated = true;

      // Trigger input event to highlight any existing input.
      this.handleInput();
    },

    // browser sniffing sucks, but there are browser-specific quirks to handle
    // that are not a matter of feature detection.
    detectBrowser: function () {
      let ua = window.navigator.userAgent.toLowerCase();
      if (ua.indexOf('firefox') !== -1) {
        return 'firefox';
      } else if (!!ua.match(/msie|trident\/7|edge/)) {
        return 'ie';
      } else if (!!ua.match(/ipad|iphone|ipod/) && ua.indexOf('windows phone') === -1) {
        // Windows Phone flags itself as "like iPhone", thus the extra check
        return 'ios';
      } else {
        return 'other';
      }
    },

    // Firefox doesn't show text that scrolls into the padding of a textarea, so
    // rearrange a couple box models to make highlights behave the same way.
    fixFirefox: function () {
      // take padding and border pixels from highlights div
      let padding = this.$highlights.css([
        'padding-top', 'padding-right', 'padding-bottom', 'padding-left'
      ]);
      let border = this.$highlights.css([
        'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'
      ]);
      this.$highlights.css({
        'padding': '0',
        'border-width': '0'
      });

      this.$backdrop
        .css({
          // Give padding pixels to backdrop div.
          'margin-top': '+=' + padding['padding-top'],
          'margin-right': '+=' + padding['padding-right'],
          'margin-bottom': '+=' + padding['padding-bottom'],
          'margin-left': '+=' + padding['padding-left'],
        })
        .css({
          // Give border pixels to backdrop div.
          'margin-top': '+=' + border['border-top-width'],
          'margin-right': '+=' + border['border-right-width'],
          'margin-bottom': '+=' + border['border-bottom-width'],
          'margin-left': '+=' + border['border-left-width'],
        });
    },

    // iOS adds 3px of (unremovable) padding to the left and right of a textarea,
    // so adjust highlights div to match.
    fixIOS: function () {
      this.$highlights.css({
        'padding-left': '+=3px',
        'padding-right': '+=3px'
      });
    },

    handleInput: function () {
      let input = this.$el.val();
      let ranges = this.getRanges(input, this.highlight);
      let unstaggeredRanges = this.removeStaggeredRanges(ranges);
      let boundaries = this.getBoundaries(unstaggeredRanges);
      this.renderMarks(boundaries);
    },

    getRanges: function (input, highlight) {
      let type = this.getType(highlight);
      switch (type) {
        case 'array':
          return this.getArrayRanges(input, highlight);
        case 'function':
          return this.getFunctionRanges(input, highlight);
        case 'regexp':
          return this.getRegExpRanges(input, highlight);
        case 'string':
          return this.getStringRanges(input, highlight);
        case 'range':
          return this.getRangeRanges(input, highlight);
        case 'custom':
          return this.getCustomRanges(input, highlight);
        default:
          if (!highlight) {
            // Do nothing for false values.
            return [];
          } else {
            console.error('unrecognized highlight type');
          }
      }
    },

    getArrayRanges: function (input, arr) {
      let ranges = arr.map(this.getRanges.bind(this, input));
      return Array.prototype.concat.apply([], ranges);
    },

    getFunctionRanges: function (input, func) {
      return this.getRanges(input, func(input));
    },

    getRegExpRanges: function (input, regex) {
      let ranges = [];
      let match;
      while (match = regex.exec(input), match !== null) {
        ranges.push([match.index, match.index + match[0].length]);
        if (!regex.global) {
          // Non-global regexes do not increase lastIndex, causing an infinite loop,
          // but we can just break manually after the first match.
          break;
        }
      }
      return ranges;
    },

    getStringRanges: function (input, str) {
      let ranges = [];
      let inputIn = '';
      let strLower = '';
      if (CASE_SENSITIVE) {
        inputIn = input;
        strIn = str;
      } else {
        inputIn = input.toLowerCase();
        strIn = str.toLowerCase();
      }
      let index = 0;
      while (index = inputIn.indexOf(strIn, index), index !== -1) {
        ranges.push([index, index + strIn.length]);
        index += strIn.length;
      }
      return ranges;
    },

    getRangeRanges: function (input, range) {
      return [range];
    },

    getCustomRanges: function (input, custom) {
      let ranges = this.getRanges(input, custom.highlight);
      if (custom.className) {
        ranges.forEach(function (range) {
          // Persist class name as a property of the array.
          if (range.className) {
            range.className = custom.className + ' ' + range.className;
          } else {
            range.className = custom.className;
          }
        });
      }
      return ranges;
    },

    // Prevent staggered overlaps (clean nesting is fine).
    removeStaggeredRanges: function (ranges) {
      let unstaggeredRanges = [];
      ranges.forEach(function (range) {
        let isStaggered = unstaggeredRanges.some(function (unstaggeredRange) {
          let isStartInside = range[0] > unstaggeredRange[0] && range[0] < unstaggeredRange[1];
          let isStopInside = range[1] > unstaggeredRange[0] && range[1] < unstaggeredRange[1];
          return isStartInside !== isStopInside; // xor
        });
        if (!isStaggered) {
          unstaggeredRanges.push(range);
        }
      });
      return unstaggeredRanges;
    },

    getBoundaries: function (ranges) {
      let boundaries = [];
      ranges.forEach(function (range) {
        boundaries.push({
          type: 'start',
          index: range[0],
          className: range.className
        });
        boundaries.push({
          type: 'stop',
          index: range[1]
        });
      });

      this.sortBoundaries(boundaries);
      return boundaries;
    },

    sortBoundaries: function (boundaries) {
      // Backwards sort (since marks are inserted right to left).
      boundaries.sort(function (a, b) {
        if (a.index !== b.index) {
          return b.index - a.index;
        } else if (a.type === 'stop' && b.type === 'start') {
          return 1;
        } else if (a.type === 'start' && b.type === 'stop') {
          return -1;
        } else {
          return 0;
        }
      });
    },

    renderMarks: function (boundaries) {
      let input = this.$el.val();
      boundaries.forEach(function (boundary) {
        let markup;
        if (boundary.type === 'stop') {
          markup = '</mark>';
        } else if (boundary.className) {
          markup = '<mark class="' + boundary.className + '">';
        } else {
          markup = '<mark>';
        }
        input = input.slice(0, boundary.index) + markup + input.slice(boundary.index);
      });

      // This keeps scrolling aligned when input ends with a newline.
      input = input.replace(/\n(<\/mark>)?$/, '\n\n$1');

      if (this.browser === 'ie') {
        // IE/Edge wraps whitespace differently in a div vs textarea, this fixes it.
        input = input
          .replace(/ /g, ' <wbr>')
          .replace(/<mark <wbr>/g, '<mark ');
      }

      this.$highlights.html(input);
    },

    handleScroll: function () {
      let scrollTop = this.$el.scrollTop();
      this.$backdrop.scrollTop(scrollTop);

      // Chrome and Safari won't break long strings of spaces, which can cause
      // horizontal scrolling, this compensates by shifting highlights by the
      // horizontally scrolled amount to keep things aligned.
      let scrollLeft = this.$el.scrollLeft();
      this.$backdrop.css('transform', (scrollLeft > 0) ? 'translateX(' + -scrollLeft + 'px)' : '');
    },

    // In Chrome, page up/down in the textarea will shift stuff within the
    // container (despite the CSS), this immediately reverts the shift.
    blockContainerScroll: function () {
      this.$container.scrollLeft(0);
    },

    destroy: function () {
      this.$backdrop.remove();
      this.$el
        .unwrap()
        .removeClass(ID + '-text ' + ID + '-input')
        .off(ID)
        .removeData(ID);
    },
  };

  // Register the jQuery plugin.
  $.fn.highlightWithinTextarea = function (options) {
    return this.each(function () {
      let $this = $(this);
      let plugin = $this.data(ID);

      if (typeof options === 'string') {
        if (plugin) {
          switch (options) {
            case 'update':
              plugin.handleInput();
              break;
            case 'destroy':
              plugin.destroy();
              break;
            default:
              console.error('unrecognized method string');
          }
        } else {
          console.error('plugin must be instantiated first');
        }
      } else {
        if (plugin) {
          plugin.destroy();
        }
        plugin = new HighlightWithinTextarea($this, options);
        if (plugin.isGenerated) {
          $this.data(ID, plugin);
        }
      }
    });
  };
})(jQuery);


Code license
For all of the code on my site... if there are specific instruction or licence comments please leave them in. If you copy my code with minimum modifications to another webpage, or into any code other people will see I would love an acknowledgment to my site.... otherwise, the license for this code is more-or-less WTFPL (do what you want)! If only copying <20 lines, then don't bother. That said - if you'd like to add a web-link to my site www.andrewnoske.com or (better yet) the specific page with code, that's a really sweet gestures! Links to the page may be useful to yourself or your users and helps increase traffic to my site. Hope my code is useful! :)