/** * Highlighter module for Rangy, a cross-browser JavaScript range and selection library * https://github.com/timdown/rangy * * Depends on Rangy core, ClassApplier and optionally TextRange modules. * * Copyright 2015, Tim Down * Licensed under the MIT license. * Version: 1.3.0 * Build date: 10 May 2015 */ (function (factory, root) { if (typeof define == "function" && define.amd) { // AMD. Register as an anonymous module with a dependency on Rangy. define(["./rangy-core"], factory); } else if (typeof module != "undefined" && typeof exports == "object") { // Node/CommonJS style module.exports = factory(require("rangy")); } else { // No AMD or CommonJS support so we use the rangy property of root (probably the global variable) factory(root.rangy); } })(function (rangy) { rangy.createModule("Highlighter", ["ClassApplier"], function (api, module) { var dom = api.dom; var contains = dom.arrayContains; var getBody = dom.getBody; var createOptions = api.util.createOptions; var forEach = api.util.forEach; var nextHighlightId = 1; // Puts highlights in order, last in document first. function compareHighlights(h1, h2) { return h1.characterRange.start - h2.characterRange.start; } function getContainerElement(doc, id) { return id ? doc.getElementById(id) : getBody(doc); } /*----------------------------------------------------------------------------------------------------------------*/ 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; }, isContiguousWith: function (charRange) { return this.start == charRange.end || this.end == charRange.start; }, isEqual: function (charRange) { return this.start === charRange.start && this.end === charRange.end }, isContain: function (charRange, exclusive) { return exclusive ? this.start < charRange.start && this.end > charRange.end : this.start <= charRange.start && this.end >= charRange.end }, 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)); }, getComplements: function (charRange) { var ranges = []; if (this.start >= charRange.start) { if (this.end <= charRange.end) { return []; } ranges.push(new CharacterRange(charRange.end, this.end)); } else { ranges.push(new CharacterRange(this.start, Math.min(this.end, charRange.start))); if (this.end > charRange.end) { ranges.push(new CharacterRange(charRange.end, this.end)); } } return ranges; }, 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; this.innerText = this.getText().replace(/\s+/g, ''); } Highlight.prototype = { getContainerElement: function () { return getContainerElement(this.doc, this.containerElementId); }, 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.className + ", 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.className] = classApplier; }, getMergedCharacterRange: function (characterRange, classNames, isSure) { var highlights = this.highlights, i, len = highlights.length, hRange var useHighlights =[] for (i = 0; i < len; i++) { if (classNames.indexOf(highlights[i].classApplier.className) >= 0) { useHighlights.push(highlights[i]) } } len = useHighlights.length if(!len){ return null } if(!isSure){ for (i = 0; i < len; i++) { hRange = useHighlights[i].characterRange if(hRange.isContain(characterRange)){ characterRange = hRange isSure = true break } } if(!isSure){ return null } } for (i = 0; i < len; i++) { hRange = useHighlights[i].characterRange if(hRange.intersects(characterRange) || hRange.isContiguousWith(characterRange)){ characterRange = hRange.union(characterRange) } } return characterRange; }, createCharacterRange: function (start, end) { return new CharacterRange(start, end) }, getHighlightForElement: function (el) { var highlights = this.highlights; var possibleMatches = []; var possibleMatchesContainReview = false; for (var i = 0, len = highlights.length; i < len; ++i) { if (highlights[i].containsElement(el)) { possibleMatches.push(highlights[i]); if (highlights[i].classApplier.className === 'review') { possibleMatchesContainReview = true; } } } if (!possibleMatches.length) { return null; } if (!possibleMatchesContainReview) { return possibleMatches[0]; } for (var i = 0, len = possibleMatches.length; i < len; ++i) { if (possibleMatches[i].classApplier.className === 'review') { return possibleMatches[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; 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; }, highghtWhichContainSelection: function (classNames, selection) { selection = selection || api.getSelection(this.doc) var highlights = this.highlights, ranges = selection.getAllRanges() var highlight, range loop: for(var i =0; i < highlights.length; i++){ highlight = highlights[i] if(classNames.indexOf(highlight.classApplier.className) < 0){ continue } for(var j=0; j< ranges.length; j++){ range = ranges[j] if(!highlight.getRange().containsRange(range)){ continue loop } } return highlight } return null }, highlightCharacterRanges: function (className, charRanges, options) { var i, len, j; var highlights = this.highlights; var converter = this.converter; var doc = this.doc; var highlightsToRemove = []; var classApplier = className ? this.classAppliers[className] : null; options = createOptions(options, { containerElementId: null, exclusive: true }); var containerElementId = options.containerElementId; var exclusive = options.exclusive; 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); } } var charRange, highlightCharRange, removeHighlight, isSameClassApplier, highlightsToKeep, splitHighlight; for (i = 0, len = charRanges.length; i < len; ++i) { charRange = charRanges[i]; highlightsToKeep = []; // Restrict character range to container element, if it exists if (containerElementCharRange) { charRange = charRange.intersection(containerElementCharRange); } // Ignore empty ranges if (charRange.start === charRange.end) { continue; } // 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) { removeHighlight = false; if (containerElementId === highlights[j].containerElementId) { highlightCharRange = highlights[j].characterRange; isSameClassApplier = (classApplier === highlights[j].classApplier); splitHighlight = !isSameClassApplier && exclusive; // Replace the existing highlight if it needs to be: // 1. merged (isSameClassApplier) // 2. partially or entirely erased (className === null) // 3. partially or entirely replaced (isSameClassApplier == false && exclusive == true) if ((highlightCharRange.intersects(charRange) || highlightCharRange.isContiguousWith(charRange)) && (isSameClassApplier || splitHighlight)) { // Remove existing highlights, keeping the unselected parts if (splitHighlight) { forEach(highlightCharRange.getComplements(charRange), function (rangeToAdd) { highlightsToKeep.push(new Highlight(doc, rangeToAdd, highlights[j].classApplier, converter, null, containerElementId)); }); } removeHighlight = true; if (isSameClassApplier) { charRange = highlightCharRange.union(charRange); } } } if (removeHighlight) { highlightsToRemove.push(highlights[j]); highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId); }else{ highlightsToKeep.push(highlights[j]); } } // Add new range if (classApplier) { highlightsToKeep.push(new Highlight(doc, charRange, classApplier, converter, null, containerElementId)); } this.highlights = highlights = highlightsToKeep; } // Remove the old highlights forEach(highlightsToRemove, function (highlightToRemove) { highlightToRemove.unapply(); }); // Apply new highlights var newHighlights = []; forEach(highlights, function (highlight) { if (!highlight.applied) { highlight.apply(); // 只返回 class 相同的 highlight, 过滤掉因 split 而产生的新的 highlight if(highlight.classApplier === classApplier){ newHighlights.push(highlight); } } }); return newHighlights; }, highlightRanges: function (className, ranges, options) { var selCharRanges = []; var converter = this.converter; options = createOptions(options, { containerElement: null, exclusive: true }); var containerElement = options.containerElement; 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(className, selCharRanges, { containerElementId: containerElementId, exclusive: options.exclusive }); }, highlightSelection: function (className, options) { var converter = this.converter; var classApplier = className ? this.classAppliers[className] : false; options = createOptions(options, { containerElementId: null, selection: api.getSelection(this.doc), exclusive: true }); var containerElementId = options.containerElementId; var exclusive = options.exclusive; var selection = options.selection; var doc = selection.win.document; var containerElement = getContainerElement(doc, containerElementId); if (!classApplier && className !== false) { 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: containerElementId, exclusive: exclusive }); // Restore selection converter.restoreSelection(selection, serializedSelection, containerElement); return newHighlights; }, unhighlightSelection: function (selection) { selection = selection || api.getSelection(this.doc); var intersectingHighlights = this.getIntersectingHighlights(selection.getAllRanges()); this.removeHighlights(intersectingHighlights); selection.removeAllRanges(); return intersectingHighlights; }, getHighlightsInSelection: function (selection) { selection = selection || api.getSelection(this.doc); return this.getIntersectingHighlights(selection.getAllRanges()); }, selectionOverlapsHighlight: function (selection) { return this.getHighlightsInSelection(selection).length > 0; }, serialize: function (options) { var highlighter = this; var highlights = highlighter.highlights; var serializedType, serializedHighlights, convertType, serializationConverter; highlights.sort(compareHighlights); options = createOptions(options, { serializeHighlightText: false, type: highlighter.converter.type }); serializedType = options.type; convertType = (serializedType != highlighter.converter.type); if (convertType) { serializationConverter = getConverter(serializedType); } serializedHighlights = ["type:" + serializedType]; forEach(highlights, function (highlight) { var characterRange = highlight.characterRange; var containerElement; // Convert to the current Highlighter's type, if different from the serialization type if (convertType) { containerElement = highlight.getContainerElement(); characterRange = serializationConverter.rangeToCharacterRange( highlighter.converter.characterRangeToRange(highlighter.doc, characterRange, containerElement), containerElement ); } var parts = [ characterRange.start, characterRange.end, highlight.id, highlight.classApplier.className, highlight.containerElementId ]; if (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; // Convert to the current Highlighter's type, if different from the serialization type if (convertType) { containerElement = getContainerElement(this.doc, containerElementId); characterRange = this.converter.rangeToCharacterRange( serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement), containerElement ); } classApplier = this.classAppliers[parts[3]]; if (!classApplier) { throw new Error("No class applier found for class '" + 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); }; }); return rangy; }, this);