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);