/** * Highlighter module for Rangy, a cross-browser JavaScript range and selection library * http://code.google.com/p/rangy/ * * Depends on Rangy core, TextRange and CssClassApplier modules. * * Copyright 2013, Tim Down * Licensed under the MIT license. * Version: 1.3alpha.772 * Build date: 26 February 2013 */ rangy.createModule("Highlighter", function(api, module) { api.requireModules( ["CssClassApplier"] ); var dom = api.dom; var contains = dom.arrayContains; var getBody = dom.getBody; // Puts highlights in order, last in document first. function compareHighlights(h1, h2) { return h1.characterRange.start - h2.characterRange.start; } var forEach = [].forEach ? function(arr, func) { arr.forEach(func); } : function(arr, func) { for (var i = 0, len = arr.length; i < len; ++i) { func( arr[i] ); } }; var nextHighlightId = 1; /*----------------------------------------------------------------------------------------------------------------*/ var highlighterTypes = {}; function HighlighterType(type, converterCreator) { this.type = type; this.converterCreator = converterCreator; } HighlighterType.prototype.create = function() { var converter = this.converterCreator(); converter.type = this.type; return converter; }; function registerHighlighterType(type, converterCreator) { highlighterTypes[type] = new HighlighterType(type, converterCreator); } function getConverter(type) { var highlighterType = highlighterTypes[type]; if (highlighterType instanceof HighlighterType) { return highlighterType.create(); } else { throw new Error("Highlighter type '" + type + "' is not valid"); } } api.registerHighlighterType = registerHighlighterType; /*----------------------------------------------------------------------------------------------------------------*/ function CharacterRange(start, end) { this.start = start; this.end = end; } CharacterRange.prototype = { intersects: function(charRange) { return this.start < charRange.end && this.end > charRange.start; }, union: function(charRange) { return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end)); }, intersection: function(charRange) { return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end)); }, toString: function() { return "[CharacterRange(" + this.start + ", " + this.end + ")]"; } }; CharacterRange.fromCharacterRange = function(charRange) { return new CharacterRange(charRange.start, charRange.end); }; /*----------------------------------------------------------------------------------------------------------------*/ var textContentConverter = { rangeToCharacterRange: function(range, containerNode) { var bookmark = range.getBookmark(containerNode); return new CharacterRange(bookmark.start, bookmark.end); }, characterRangeToRange: function(doc, characterRange, containerNode) { var range = api.createRange(doc); range.moveToBookmark({ start: characterRange.start, end: characterRange.end, containerNode: containerNode }); return range; }, serializeSelection: function(selection, containerNode) { var ranges = selection.getAllRanges(), rangeCount = ranges.length; var rangeInfos = []; var backward = rangeCount == 1 && selection.isBackward(); for (var i = 0, len = ranges.length; i < len; ++i) { rangeInfos[i] = { characterRange: this.rangeToCharacterRange(ranges[i], containerNode), backward: backward }; } return rangeInfos; }, restoreSelection: function(selection, savedSelection, containerNode) { selection.removeAllRanges(); var doc = selection.win.document; for (var i = 0, len = savedSelection.length, range, rangeInfo, characterRange; i < len; ++i) { rangeInfo = savedSelection[i]; characterRange = rangeInfo.characterRange; range = this.characterRangeToRange(doc, rangeInfo.characterRange, containerNode); selection.addRange(range, rangeInfo.backward); } } }; registerHighlighterType("textContent", function() { return textContentConverter; }); /*----------------------------------------------------------------------------------------------------------------*/ // Lazily load the TextRange-based converter so that the dependency is only checked when required. registerHighlighterType("TextRange", (function() { var converter; return function() { if (!converter) { // Test that textRangeModule exists and is supported var textRangeModule = api.modules.TextRange; if (!textRangeModule) { throw new Error("TextRange module is missing."); } else if (!textRangeModule.supported) { throw new Error("TextRange module is present but not supported."); } converter = { rangeToCharacterRange: function(range, containerNode) { return CharacterRange.fromCharacterRange( range.toCharacterRange(containerNode) ); }, characterRangeToRange: function(doc, characterRange, containerNode) { var range = api.createRange(doc); range.selectCharacters(containerNode, characterRange.start, characterRange.end); return range; }, serializeSelection: function(selection, containerNode) { return selection.saveCharacterRanges(containerNode); }, restoreSelection: function(selection, savedSelection, containerNode) { selection.restoreCharacterRanges(containerNode, savedSelection); } }; } return converter; }; })()); /*----------------------------------------------------------------------------------------------------------------*/ function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) { if (id) { this.id = id; nextHighlightId = Math.max(nextHighlightId, id + 1); } else { this.id = nextHighlightId++; } this.characterRange = characterRange; this.doc = doc; this.classApplier = classApplier; this.converter = converter; this.containerElementId = containerElementId || null; this.applied = false; } Highlight.prototype = { getContainerElement: function() { return this.containerElementId ? this.doc.getElementById(this.containerElementId) : getBody(this.doc); }, getRange: function() { return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement()); }, fromRange: function(range) { this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement()); }, getText: function() { return this.getRange().toString(); }, containsElement: function(el) { return this.getRange().containsNodeContents(el.firstChild); }, unapply: function() { this.classApplier.undoToRange(this.getRange()); this.applied = false; }, apply: function() { this.classApplier.applyToRange(this.getRange()); this.applied = true; }, getHighlightElements: function() { return this.classApplier.getElementsWithClassIntersectingRange(this.getRange()); }, toString: function() { return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.cssClass + ", character range: " + this.characterRange.start + " - " + this.characterRange.end + ")]"; } }; /*----------------------------------------------------------------------------------------------------------------*/ function Highlighter(doc, type) { type = type || "textContent"; this.doc = doc || document; this.classAppliers = {}; this.highlights = []; this.converter = getConverter(type); } Highlighter.prototype = { addClassApplier: function(classApplier) { this.classAppliers[classApplier.cssClass] = classApplier; }, getHighlightForElement: function(el) { var highlights = this.highlights; for (var i = 0, len = highlights.length; i < len; ++i) { if (highlights[i].containsElement(el)) { return highlights[i]; } } return null; }, removeHighlights: function(highlights) { for (var i = 0, len = this.highlights.length, highlight; i < len; ++i) { highlight = this.highlights[i]; if (contains(highlights, highlight)) { highlight.unapply(); this.highlights.splice(i--, 1); } } }, removeAllHighlights: function() { this.removeHighlights(this.highlights); }, getIntersectingHighlights: function(ranges) { // Test each range against each of the highlighted ranges to see whether they overlap var intersectingHighlights = [], highlights = this.highlights, converter = this.converter; forEach(ranges, function(range) { //var selCharRange = converter.rangeToCharacterRange(range); forEach(highlights, function(highlight) { if (range.intersectsRange( highlight.getRange() ) && !contains(intersectingHighlights, highlight)) { intersectingHighlights.push(highlight); } }); }); return intersectingHighlights; }, highlightCharacterRanges: function(className, charRanges, containerElementId) { var i, len, j; var highlights = this.highlights; var converter = this.converter; var doc = this.doc; var highlightsToRemove = []; var classApplier = this.classAppliers[className]; containerElementId = containerElementId || null; var containerElement, containerElementRange, containerElementCharRange; if (containerElementId) { containerElement = this.doc.getElementById(containerElementId); if (containerElement) { containerElementRange = api.createRange(this.doc); containerElementRange.selectNodeContents(containerElement); containerElementCharRange = new CharacterRange(0, containerElementRange.toString().length); containerElementRange.detach(); } } var charRange, highlightCharRange, highlightRange, merged; for (i = 0, len = charRanges.length; i < len; ++i) { charRange = charRanges[i]; merged = false; // Restrict character range to container element, if it exists if (containerElementCharRange) { charRange = charRange.intersection(containerElementCharRange); } // Check for intersection with existing highlights. For each intersection, create a new highlight // which is the union of the highlight range and the selected range for (j = 0; j < highlights.length; ++j) { if (containerElementId == highlights[j].containerElementId) { highlightCharRange = highlights[j].characterRange; if (highlightCharRange.intersects(charRange)) { // Replace the existing highlight in the list of current highlights and add it to the list for // removal highlightsToRemove.push(highlights[j]); highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId); } } } if (!merged) { highlights.push( new Highlight(doc, charRange, classApplier, converter, null, containerElementId) ); } } // Remove the old highlights forEach(highlightsToRemove, function(highlightToRemove) { highlightToRemove.unapply(); }); // Apply new highlights var newHighlights = []; forEach(highlights, function(highlight) { if (!highlight.applied) { highlight.apply(); newHighlights.push(highlight); } }); return newHighlights; }, highlightRanges: function(className, ranges, containerElement) { var selCharRanges = []; var converter = this.converter; var containerElementId = containerElement ? containerElement.id : null; var containerElementRange; if (containerElement) { containerElementRange = api.createRange(containerElement); containerElementRange.selectNodeContents(containerElement); } forEach(ranges, function(range) { var scopedRange = containerElement ? containerElementRange.intersection(range) : range; selCharRanges.push( converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())) ); }); return this.highlightCharacterRanges(selCharRanges, ranges, containerElementId); }, highlightSelection: function(className, selection, containerElementId) { var converter = this.converter; selection = selection || api.getSelection(); var classApplier = this.classAppliers[className]; var highlights = this.highlights; var doc = selection.win.document; var containerElement = containerElementId ? doc.getElementById(containerElementId) : getBody(doc); if (!classApplier) { throw new Error("No class applier found for class '" + className + "'"); } // Store the existing selection as character ranges var serializedSelection = converter.serializeSelection(selection, containerElement); // Create an array of selected character ranges var selCharRanges = []; forEach(serializedSelection, function(rangeInfo) { selCharRanges.push( CharacterRange.fromCharacterRange(rangeInfo.characterRange) ); }); var newHighlights = this.highlightCharacterRanges(className, selCharRanges, containerElementId); // Restore selection converter.restoreSelection(selection, serializedSelection, containerElement); return newHighlights; }, unhighlightSelection: function(selection) { selection = selection || api.getSelection(); var intersectingHighlights = this.getIntersectingHighlights( selection.getAllRanges() ); this.removeHighlights(intersectingHighlights); selection.removeAllRanges(); }, selectionOverlapsHighlight: function(selection) { selection = selection || api.getSelection(); return this.getIntersectingHighlights(selection.getAllRanges()).length > 0; }, serialize: function(options) { var highlights = this.highlights; highlights.sort(compareHighlights); var serializedHighlights = ["type:" + this.converter.type]; forEach(highlights, function(highlight) { var characterRange = highlight.characterRange; var parts = [ characterRange.start, characterRange.end, highlight.id, highlight.classApplier.cssClass, highlight.containerElementId ]; if (options && options.serializeHighlightText) { parts.push(highlight.getText()); } serializedHighlights.push( parts.join("$") ); }); return serializedHighlights.join("|"); }, deserialize: function(serialized) { var serializedHighlights = serialized.split("|"); var highlights = []; var firstHighlight = serializedHighlights[0]; var regexResult; var serializationType, serializationConverter, convertType = false; if ( firstHighlight && (regexResult = /^type:(\w+)$/.exec(firstHighlight)) ) { serializationType = regexResult[1]; if (serializationType != this.converter.type) { serializationConverter = getConverter(serializationType); convertType = true; } serializedHighlights.shift(); } else { throw new Error("Serialized highlights are invalid."); } var classApplier, highlight, characterRange, containerElementId, containerElement; for (var i = serializedHighlights.length, parts; i-- > 0; ) { parts = serializedHighlights[i].split("$"); characterRange = new CharacterRange(+parts[0], +parts[1]); containerElementId = parts[4] || null; containerElement = containerElementId ? this.doc.getElementById(containerElementId) : getBody(this.doc); // Convert to the current Highlighter's type, if different from the serialization type if (convertType) { characterRange = this.converter.rangeToCharacterRange( serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement), containerElement ); } classApplier = this.classAppliers[parts[3]]; highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId); highlight.apply(); highlights.push(highlight); } this.highlights = highlights; } }; api.Highlighter = Highlighter; api.createHighlighter = function(doc, rangeCharacterOffsetConverterType) { return new Highlighter(doc, rangeCharacterOffsetConverterType); }; });