2  * Highlighter module for Rangy, a cross-browser JavaScript range and selection library
 
   3  * https://github.com/timdown/rangy
 
   5  * Depends on Rangy core, ClassApplier and optionally TextRange modules.
 
   7  * Copyright 2015, Tim Down
 
   8  * Licensed under the MIT license.
 
  10  * Build date: 10 May 2015
 
  12 (function(factory, root) {
 
  13     if (typeof define == "function" && define.amd) {
 
  14         // AMD. Register as an anonymous module with a dependency on Rangy.
 
  15         define(["./rangy-core"], factory);
 
  16     } else if (typeof module != "undefined" && typeof exports == "object") {
 
  17         // Node/CommonJS style
 
  18         module.exports = factory( require("rangy") );
 
  20         // No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
 
  24     rangy.createModule("Highlighter", ["ClassApplier"], function(api, module) {
 
  26         var contains = dom.arrayContains;
 
  27         var getBody = dom.getBody;
 
  28         var createOptions = api.util.createOptions;
 
  29         var forEach = api.util.forEach;
 
  30         var nextHighlightId = 1;
 
  32         // Puts highlights in order, last in document first.
 
  33         function compareHighlights(h1, h2) {
 
  34             return h1.characterRange.start - h2.characterRange.start;
 
  37         function getContainerElement(doc, id) {
 
  38             return id ? doc.getElementById(id) : getBody(doc);
 
  41         /*----------------------------------------------------------------------------------------------------------------*/
 
  43         var highlighterTypes = {};
 
  45         function HighlighterType(type, converterCreator) {
 
  47             this.converterCreator = converterCreator;
 
  50         HighlighterType.prototype.create = function() {
 
  51             var converter = this.converterCreator();
 
  52             converter.type = this.type;
 
  56         function registerHighlighterType(type, converterCreator) {
 
  57             highlighterTypes[type] = new HighlighterType(type, converterCreator);
 
  60         function getConverter(type) {
 
  61             var highlighterType = highlighterTypes[type];
 
  62             if (highlighterType instanceof HighlighterType) {
 
  63                 return highlighterType.create();
 
  65                 throw new Error("Highlighter type '" + type + "' is not valid");
 
  69         api.registerHighlighterType = registerHighlighterType;
 
  71         /*----------------------------------------------------------------------------------------------------------------*/
 
  73         function CharacterRange(start, end) {
 
  78         CharacterRange.prototype = {
 
  79             intersects: function(charRange) {
 
  80                 return this.start < charRange.end && this.end > charRange.start;
 
  83             isContiguousWith: function(charRange) {
 
  84                 return this.start == charRange.end || this.end == charRange.start;
 
  87             union: function(charRange) {
 
  88                 return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end));
 
  91             intersection: function(charRange) {
 
  92                 return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end));
 
  95             getComplements: function(charRange) {
 
  97                 if (this.start >= charRange.start) {
 
  98                     if (this.end <= charRange.end) {
 
 101                     ranges.push(new CharacterRange(charRange.end, this.end));
 
 103                     ranges.push(new CharacterRange(this.start, Math.min(this.end, charRange.start)));
 
 104                     if (this.end > charRange.end) {
 
 105                         ranges.push(new CharacterRange(charRange.end, this.end));
 
 111             toString: function() {
 
 112                 return "[CharacterRange(" + this.start + ", " + this.end + ")]";
 
 116         CharacterRange.fromCharacterRange = function(charRange) {
 
 117             return new CharacterRange(charRange.start, charRange.end);
 
 120         /*----------------------------------------------------------------------------------------------------------------*/
 
 122         var textContentConverter = {
 
 123             rangeToCharacterRange: function(range, containerNode) {
 
 124                 var bookmark = range.getBookmark(containerNode);
 
 125                 return new CharacterRange(bookmark.start, bookmark.end);
 
 128             characterRangeToRange: function(doc, characterRange, containerNode) {
 
 129                 var range = api.createRange(doc);
 
 130                 range.moveToBookmark({
 
 131                     start: characterRange.start,
 
 132                     end: characterRange.end,
 
 133                     containerNode: containerNode
 
 139             serializeSelection: function(selection, containerNode) {
 
 140                 var ranges = selection.getAllRanges(), rangeCount = ranges.length;
 
 143                 var backward = rangeCount == 1 && selection.isBackward();
 
 145                 for (var i = 0, len = ranges.length; i < len; ++i) {
 
 147                         characterRange: this.rangeToCharacterRange(ranges[i], containerNode),
 
 155             restoreSelection: function(selection, savedSelection, containerNode) {
 
 156                 selection.removeAllRanges();
 
 157                 var doc = selection.win.document;
 
 158                 for (var i = 0, len = savedSelection.length, range, rangeInfo, characterRange; i < len; ++i) {
 
 159                     rangeInfo = savedSelection[i];
 
 160                     characterRange = rangeInfo.characterRange;
 
 161                     range = this.characterRangeToRange(doc, rangeInfo.characterRange, containerNode);
 
 162                     selection.addRange(range, rangeInfo.backward);
 
 167         registerHighlighterType("textContent", function() {
 
 168             return textContentConverter;
 
 171         /*----------------------------------------------------------------------------------------------------------------*/
 
 173         // Lazily load the TextRange-based converter so that the dependency is only checked when required.
 
 174         registerHighlighterType("TextRange", (function() {
 
 179                     // Test that textRangeModule exists and is supported
 
 180                     var textRangeModule = api.modules.TextRange;
 
 181                     if (!textRangeModule) {
 
 182                         throw new Error("TextRange module is missing.");
 
 183                     } else if (!textRangeModule.supported) {
 
 184                         throw new Error("TextRange module is present but not supported.");
 
 188                         rangeToCharacterRange: function(range, containerNode) {
 
 189                             return CharacterRange.fromCharacterRange( range.toCharacterRange(containerNode) );
 
 192                         characterRangeToRange: function(doc, characterRange, containerNode) {
 
 193                             var range = api.createRange(doc);
 
 194                             range.selectCharacters(containerNode, characterRange.start, characterRange.end);
 
 198                         serializeSelection: function(selection, containerNode) {
 
 199                             return selection.saveCharacterRanges(containerNode);
 
 202                         restoreSelection: function(selection, savedSelection, containerNode) {
 
 203                             selection.restoreCharacterRanges(containerNode, savedSelection);
 
 212         /*----------------------------------------------------------------------------------------------------------------*/
 
 214         function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) {
 
 217                 nextHighlightId = Math.max(nextHighlightId, id + 1);
 
 219                 this.id = nextHighlightId++;
 
 221             this.characterRange = characterRange;
 
 223             this.classApplier = classApplier;
 
 224             this.converter = converter;
 
 225             this.containerElementId = containerElementId || null;
 
 226             this.applied = false;
 
 229         Highlight.prototype = {
 
 230             getContainerElement: function() {
 
 231                 return getContainerElement(this.doc, this.containerElementId);
 
 234             getRange: function() {
 
 235                 return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement());
 
 238             fromRange: function(range) {
 
 239                 this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement());
 
 242             getText: function() {
 
 243                 return this.getRange().toString();
 
 246             containsElement: function(el) {
 
 247                 return this.getRange().containsNodeContents(el.firstChild);
 
 250             unapply: function() {
 
 251                 this.classApplier.undoToRange(this.getRange());
 
 252                 this.applied = false;
 
 255             apply: function(serializedHighlight) {
 
 256                 this.classApplier.applyToRange(this.getRange() ,null, serializedHighlight);
 
 260             getHighlightElements: function() {
 
 261                 return this.classApplier.getElementsWithClassIntersectingRange(this.getRange());
 
 264             toString: function() {
 
 265                 return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.className + ", character range: " +
 
 266                     this.characterRange.start + " - " + this.characterRange.end + ")]";
 
 270         /*----------------------------------------------------------------------------------------------------------------*/
 
 272         function Highlighter(doc, type) {
 
 273             type = type || "textContent";
 
 274             this.doc = doc || document;
 
 275             this.classAppliers = {};
 
 276             this.highlights = [];
 
 277             this.converter = getConverter(type);
 
 280         Highlighter.prototype = {
 
 281             addClassApplier: function(classApplier) {
 
 282                 this.classAppliers[classApplier.className] = classApplier;
 
 285             getHighlightForElement: function(el) {
 
 286                 var highlights = this.highlights;
 
 287                 for (var i = 0, len = highlights.length; i < len; ++i) {
 
 288                     if (highlights[i].containsElement(el)) {
 
 289                         return highlights[i];
 
 295             removeHighlights: function(highlights) {
 
 296                 for (var i = 0, len = this.highlights.length, highlight; i < len; ++i) {
 
 297                     highlight = this.highlights[i];
 
 298                     if (contains(highlights, highlight)) {
 
 300                         this.highlights.splice(i--, 1);
 
 305             removeAllHighlights: function() {
 
 306                 this.removeHighlights(this.highlights);
 
 309             getIntersectingHighlights: function(ranges) {
 
 310                 // Test each range against each of the highlighted ranges to see whether they overlap
 
 311                 var intersectingHighlights = [], highlights = this.highlights;
 
 312                 forEach(ranges, function(range) {
 
 313                     //var selCharRange = converter.rangeToCharacterRange(range);
 
 314                     forEach(highlights, function(highlight) {
 
 315                         if (range.intersectsRange( highlight.getRange() ) && !contains(intersectingHighlights, highlight)) {
 
 316                             intersectingHighlights.push(highlight);
 
 321                 return intersectingHighlights;
 
 324             highlightCharacterRanges: function(className, charRanges, options) {
 
 326                 var highlights = this.highlights;
 
 327                 var converter = this.converter;
 
 329                 var highlightsToRemove = [];
 
 330                 var classApplier = className ? this.classAppliers[className] : null;
 
 332                 options = createOptions(options, {
 
 333                     containerElementId: null,
 
 337                 var containerElementId = options.containerElementId;
 
 338                 var exclusive = options.exclusive;
 
 340                 var containerElement, containerElementRange, containerElementCharRange;
 
 341                 if (containerElementId) {
 
 342                     containerElement = this.doc.getElementById(containerElementId);
 
 343                     if (containerElement) {
 
 344                         containerElementRange = api.createRange(this.doc);
 
 345                         containerElementRange.selectNodeContents(containerElement);
 
 346                         containerElementCharRange = new CharacterRange(0, containerElementRange.toString().length);
 
 350                 var charRange, highlightCharRange, removeHighlight, isSameClassApplier, highlightsToKeep, splitHighlight;
 
 352                 for (i = 0, len = charRanges.length; i < len; ++i) {
 
 353                     charRange = charRanges[i];
 
 354                     highlightsToKeep = [];
 
 356                     // Restrict character range to container element, if it exists
 
 357                     if (containerElementCharRange) {
 
 358                         charRange = charRange.intersection(containerElementCharRange);
 
 361                     // Ignore empty ranges
 
 362                     if (charRange.start == charRange.end) {
 
 366                     // Check for intersection with existing highlights. For each intersection, create a new highlight
 
 367                     // which is the union of the highlight range and the selected range
 
 368                     for (j = 0; j < highlights.length; ++j) {
 
 369                         removeHighlight = false;
 
 371                         if (containerElementId == highlights[j].containerElementId) {
 
 372                             highlightCharRange = highlights[j].characterRange;
 
 373                             isSameClassApplier = (classApplier == highlights[j].classApplier);
 
 374                             splitHighlight = !isSameClassApplier && exclusive;
 
 376                             // Replace the existing highlight if it needs to be:
 
 377                             //  1. merged (isSameClassApplier)
 
 378                             //  2. partially or entirely erased (className === null)
 
 379                             //  3. partially or entirely replaced (isSameClassApplier == false && exclusive == true)
 
 380                             if (    (highlightCharRange.intersects(charRange) || highlightCharRange.isContiguousWith(charRange)) &&
 
 381                                     (isSameClassApplier || splitHighlight) ) {
 
 383                                 // Remove existing highlights, keeping the unselected parts
 
 384                                 if (splitHighlight) {
 
 385                                     forEach(highlightCharRange.getComplements(charRange), function(rangeToAdd) {
 
 386                                         highlightsToKeep.push( new Highlight(doc, rangeToAdd, highlights[j].classApplier, converter, null, containerElementId) );
 
 390                                 removeHighlight = true;
 
 391                                 if (isSameClassApplier) {
 
 392                                     charRange = highlightCharRange.union(charRange);
 
 397                         if (removeHighlight) {
 
 398                             highlightsToRemove.push(highlights[j]);
 
 399                             highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
 
 401                             highlightsToKeep.push(highlights[j]);
 
 407                         highlightsToKeep.push(new Highlight(doc, charRange, classApplier, converter, null, containerElementId));
 
 409                     var  oldHighlights = this.serialize(null).split("|");
 
 411                     this.highlights = highlights = highlightsToKeep;
 
 414                 // Remove the old highlights
 
 415                 forEach(highlightsToRemove, function(highlightToRemove) {
 
 416                     highlightToRemove.unapply();
 
 420                 var serializedHighlights = this.serialize(null).split("|");
 
 421                 var highlightStr = array_diff(oldHighlights, serializedHighlights)[0];
 
 423                 // Apply new highlights
 
 424                 var newHighlights = [];
 
 425                 forEach(highlights, function(highlight) {
 
 426                     if (!highlight.applied) {
 
 427                         highlight.apply(highlightStr);
 
 428                         newHighlights.push(highlight);
 
 432                 return newHighlights;
 
 435             highlightRanges: function(className, ranges, options) {
 
 436                 var selCharRanges = [];
 
 437                 var converter = this.converter;
 
 439                 options = createOptions(options, {
 
 440                     containerElement: null,
 
 444                 var containerElement = options.containerElement;
 
 445                 var containerElementId = containerElement ? containerElement.id : null;
 
 446                 var containerElementRange;
 
 447                 if (containerElement) {
 
 448                     containerElementRange = api.createRange(containerElement);
 
 449                     containerElementRange.selectNodeContents(containerElement);
 
 452                 forEach(ranges, function(range) {
 
 453                     var scopedRange = containerElement ? containerElementRange.intersection(range) : range;
 
 454                     selCharRanges.push( converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())) );
 
 457                 return this.highlightCharacterRanges(className, selCharRanges, {
 
 458                     containerElementId: containerElementId,
 
 459                     exclusive: options.exclusive
 
 463             highlightSelection: function(className, options) {
 
 464                 var converter = this.converter;
 
 465                 var classApplier = className ? this.classAppliers[className] : false;
 
 467                 options = createOptions(options, {
 
 468                     containerElementId: null,
 
 469                     selection: api.getSelection(this.doc),
 
 473                 var containerElementId = options.containerElementId;
 
 474                 var exclusive = options.exclusive;
 
 475                 var selection = options.selection;
 
 476                 var doc = selection.win.document;
 
 477                 var containerElement = getContainerElement(doc, containerElementId);
 
 479                 if (!classApplier && className !== false) {
 
 480                     throw new Error("No class applier found for class '" + className + "'");
 
 483                 // Store the existing selection as character ranges
 
 484                 var serializedSelection = converter.serializeSelection(selection, containerElement);
 
 486                 // Create an array of selected character ranges
 
 487                 var selCharRanges = [];
 
 488                 forEach(serializedSelection, function(rangeInfo) {
 
 489                     selCharRanges.push( CharacterRange.fromCharacterRange(rangeInfo.characterRange) );
 
 492                 var newHighlights = this.highlightCharacterRanges(className, selCharRanges, {
 
 493                     containerElementId: containerElementId,
 
 498                 converter.restoreSelection(selection, serializedSelection, containerElement);
 
 500                 return newHighlights;
 
 503             unhighlightSelection: function(selection) {
 
 504                 selection = selection || api.getSelection(this.doc);
 
 505                 var intersectingHighlights = this.getIntersectingHighlights( selection.getAllRanges() );
 
 506                 this.removeHighlights(intersectingHighlights);
 
 507                 selection.removeAllRanges();
 
 508                 return intersectingHighlights;
 
 511             getHighlightsInSelection: function(selection) {
 
 512                 selection = selection || api.getSelection(this.doc);
 
 513                 return this.getIntersectingHighlights(selection.getAllRanges());
 
 516             selectionOverlapsHighlight: function(selection) {
 
 517                 return this.getHighlightsInSelection(selection).length > 0;
 
 520             serialize: function(options) {
 
 521                 var highlighter = this;
 
 522                 var highlights = highlighter.highlights;
 
 523                 var serializedType, serializedHighlights, convertType, serializationConverter;
 
 525                 highlights.sort(compareHighlights);
 
 526                 options = createOptions(options, {
 
 527                     serializeHighlightText: false,
 
 528                     type: highlighter.converter.type
 
 531                 serializedType = options.type;
 
 532                 convertType = (serializedType != highlighter.converter.type);
 
 535                     serializationConverter = getConverter(serializedType);
 
 538                 serializedHighlights = ["type:" + serializedType];
 
 540                 forEach(highlights, function(highlight) {
 
 541                     var characterRange = highlight.characterRange;
 
 542                     var containerElement;
 
 544                     // Convert to the current Highlighter's type, if different from the serialization type
 
 546                         containerElement = highlight.getContainerElement();
 
 547                         characterRange = serializationConverter.rangeToCharacterRange(
 
 548                             highlighter.converter.characterRangeToRange(highlighter.doc, characterRange, containerElement),
 
 554                         characterRange.start,
 
 557                         highlight.classApplier.className,
 
 558                         highlight.containerElementId
 
 561                     if (options.serializeHighlightText) {
 
 562                         parts.push(highlight.getText());
 
 564                     serializedHighlights.push( parts.join("$") );
 
 566                 return serializedHighlights.join("|");
 
 569             deserialize: function(serialized) {
 
 570                 var serializedHighlights = serialized.split("|");
 
 573                 var firstHighlight = serializedHighlights[0];
 
 575                 var serializationType, serializationConverter, convertType = false;
 
 576                 if ( firstHighlight && (regexResult = /^type:(\w+)$/.exec(firstHighlight)) ) {
 
 577                     serializationType = regexResult[1];
 
 578                     if (serializationType != this.converter.type) {
 
 579                         serializationConverter = getConverter(serializationType);
 
 582                     serializedHighlights.shift();
 
 584                     throw new Error("Serialized highlights are invalid.");
 
 587                 var classApplier, highlight, characterRange, containerElementId, containerElement;
 
 589                 for (var i = serializedHighlights.length, parts; i-- > 0; ) {
 
 590                     parts = serializedHighlights[i].split("$");
 
 591                     characterRange = new CharacterRange(+parts[0], +parts[1]);
 
 592                     containerElementId = parts[4] || null;
 
 594                     // Convert to the current Highlighter's type, if different from the serialization type
 
 596                         containerElement = getContainerElement(this.doc, containerElementId);
 
 597                         characterRange = this.converter.rangeToCharacterRange(
 
 598                             serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement),
 
 603                     classApplier = this.classAppliers[ parts[3] ];
 
 606                         throw new Error("No class applier found for class '" + parts[3] + "'");
 
 609                     highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId);
 
 612                     highlight.apply(serializedHighlights[i]);
 
 613                     highlights.push(highlight);
 
 615                 this.highlights = highlights;
 
 619         api.Highlighter = Highlighter;
 
 621         api.createHighlighter = function(doc, rangeCharacterOffsetConverterType) {
 
 622             return new Highlighter(doc, rangeCharacterOffsetConverterType);