Added Android code
[wl-app.git] / Android / folioreader / src / main / assets / js / rangy-highlighter.js
1 /**
2  * Highlighter module for Rangy, a cross-browser JavaScript range and selection library
3  * https://github.com/timdown/rangy
4  *
5  * Depends on Rangy core, ClassApplier and optionally TextRange modules.
6  *
7  * Copyright 2015, Tim Down
8  * Licensed under the MIT license.
9  * Version: 1.3.0
10  * Build date: 10 May 2015
11  */
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") );
19     } else {
20         // No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
21         factory(root.rangy);
22     }
23 })(function(rangy) {
24     rangy.createModule("Highlighter", ["ClassApplier"], function(api, module) {
25         var dom = api.dom;
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;
31
32         // Puts highlights in order, last in document first.
33         function compareHighlights(h1, h2) {
34             return h1.characterRange.start - h2.characterRange.start;
35         }
36
37         function getContainerElement(doc, id) {
38             return id ? doc.getElementById(id) : getBody(doc);
39         }
40
41         /*----------------------------------------------------------------------------------------------------------------*/
42
43         var highlighterTypes = {};
44
45         function HighlighterType(type, converterCreator) {
46             this.type = type;
47             this.converterCreator = converterCreator;
48         }
49
50         HighlighterType.prototype.create = function() {
51             var converter = this.converterCreator();
52             converter.type = this.type;
53             return converter;
54         };
55
56         function registerHighlighterType(type, converterCreator) {
57             highlighterTypes[type] = new HighlighterType(type, converterCreator);
58         }
59
60         function getConverter(type) {
61             var highlighterType = highlighterTypes[type];
62             if (highlighterType instanceof HighlighterType) {
63                 return highlighterType.create();
64             } else {
65                 throw new Error("Highlighter type '" + type + "' is not valid");
66             }
67         }
68
69         api.registerHighlighterType = registerHighlighterType;
70
71         /*----------------------------------------------------------------------------------------------------------------*/
72
73         function CharacterRange(start, end) {
74             this.start = start;
75             this.end = end;
76         }
77
78         CharacterRange.prototype = {
79             intersects: function(charRange) {
80                 return this.start < charRange.end && this.end > charRange.start;
81             },
82
83             isContiguousWith: function(charRange) {
84                 return this.start == charRange.end || this.end == charRange.start;
85             },
86
87             union: function(charRange) {
88                 return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end));
89             },
90
91             intersection: function(charRange) {
92                 return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end));
93             },
94
95             getComplements: function(charRange) {
96                 var ranges = [];
97                 if (this.start >= charRange.start) {
98                     if (this.end <= charRange.end) {
99                         return [];
100                     }
101                     ranges.push(new CharacterRange(charRange.end, this.end));
102                 } else {
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));
106                     }
107                 }
108                 return ranges;
109             },
110
111             toString: function() {
112                 return "[CharacterRange(" + this.start + ", " + this.end + ")]";
113             }
114         };
115
116         CharacterRange.fromCharacterRange = function(charRange) {
117             return new CharacterRange(charRange.start, charRange.end);
118         };
119
120         /*----------------------------------------------------------------------------------------------------------------*/
121
122         var textContentConverter = {
123             rangeToCharacterRange: function(range, containerNode) {
124                 var bookmark = range.getBookmark(containerNode);
125                 return new CharacterRange(bookmark.start, bookmark.end);
126             },
127
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
134                 });
135
136                 return range;
137             },
138
139             serializeSelection: function(selection, containerNode) {
140                 var ranges = selection.getAllRanges(), rangeCount = ranges.length;
141                 var rangeInfos = [];
142
143                 var backward = rangeCount == 1 && selection.isBackward();
144
145                 for (var i = 0, len = ranges.length; i < len; ++i) {
146                     rangeInfos[i] = {
147                         characterRange: this.rangeToCharacterRange(ranges[i], containerNode),
148                         backward: backward
149                     };
150                 }
151
152                 return rangeInfos;
153             },
154
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);
163                 }
164             }
165         };
166
167         registerHighlighterType("textContent", function() {
168             return textContentConverter;
169         });
170
171         /*----------------------------------------------------------------------------------------------------------------*/
172
173         // Lazily load the TextRange-based converter so that the dependency is only checked when required.
174         registerHighlighterType("TextRange", (function() {
175             var converter;
176
177             return function() {
178                 if (!converter) {
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.");
185                     }
186
187                     converter = {
188                         rangeToCharacterRange: function(range, containerNode) {
189                             return CharacterRange.fromCharacterRange( range.toCharacterRange(containerNode) );
190                         },
191
192                         characterRangeToRange: function(doc, characterRange, containerNode) {
193                             var range = api.createRange(doc);
194                             range.selectCharacters(containerNode, characterRange.start, characterRange.end);
195                             return range;
196                         },
197
198                         serializeSelection: function(selection, containerNode) {
199                             return selection.saveCharacterRanges(containerNode);
200                         },
201
202                         restoreSelection: function(selection, savedSelection, containerNode) {
203                             selection.restoreCharacterRanges(containerNode, savedSelection);
204                         }
205                     };
206                 }
207
208                 return converter;
209             };
210         })());
211
212         /*----------------------------------------------------------------------------------------------------------------*/
213
214         function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) {
215             if (id) {
216                 this.id = id;
217                 nextHighlightId = Math.max(nextHighlightId, id + 1);
218             } else {
219                 this.id = nextHighlightId++;
220             }
221             this.characterRange = characterRange;
222             this.doc = doc;
223             this.classApplier = classApplier;
224             this.converter = converter;
225             this.containerElementId = containerElementId || null;
226             this.applied = false;
227         }
228
229         Highlight.prototype = {
230             getContainerElement: function() {
231                 return getContainerElement(this.doc, this.containerElementId);
232             },
233
234             getRange: function() {
235                 return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement());
236             },
237
238             fromRange: function(range) {
239                 this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement());
240             },
241
242             getText: function() {
243                 return this.getRange().toString();
244             },
245
246             containsElement: function(el) {
247                 return this.getRange().containsNodeContents(el.firstChild);
248             },
249
250             unapply: function() {
251                 this.classApplier.undoToRange(this.getRange());
252                 this.applied = false;
253             },
254
255             apply: function(serializedHighlight) {
256                 this.classApplier.applyToRange(this.getRange() ,null, serializedHighlight);
257                 this.applied = true;
258             },
259
260             getHighlightElements: function() {
261                 return this.classApplier.getElementsWithClassIntersectingRange(this.getRange());
262             },
263
264             toString: function() {
265                 return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.className + ", character range: " +
266                     this.characterRange.start + " - " + this.characterRange.end + ")]";
267             }
268         };
269
270         /*----------------------------------------------------------------------------------------------------------------*/
271
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);
278         }
279
280         Highlighter.prototype = {
281             addClassApplier: function(classApplier) {
282                 this.classAppliers[classApplier.className] = classApplier;
283             },
284
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];
290                     }
291                 }
292                 return null;
293             },
294
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)) {
299                         highlight.unapply();
300                         this.highlights.splice(i--, 1);
301                     }
302                 }
303             },
304
305             removeAllHighlights: function() {
306                 this.removeHighlights(this.highlights);
307             },
308
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);
317                         }
318                     });
319                 });
320
321                 return intersectingHighlights;
322             },
323
324             highlightCharacterRanges: function(className, charRanges, options) {
325                 var i, len, j;
326                 var highlights = this.highlights;
327                 var converter = this.converter;
328                 var doc = this.doc;
329                 var highlightsToRemove = [];
330                 var classApplier = className ? this.classAppliers[className] : null;
331
332                 options = createOptions(options, {
333                     containerElementId: null,
334                     exclusive: true
335                 });
336
337                 var containerElementId = options.containerElementId;
338                 var exclusive = options.exclusive;
339
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);
347                     }
348                 }
349
350                 var charRange, highlightCharRange, removeHighlight, isSameClassApplier, highlightsToKeep, splitHighlight;
351
352                 for (i = 0, len = charRanges.length; i < len; ++i) {
353                     charRange = charRanges[i];
354                     highlightsToKeep = [];
355
356                     // Restrict character range to container element, if it exists
357                     if (containerElementCharRange) {
358                         charRange = charRange.intersection(containerElementCharRange);
359                     }
360
361                     // Ignore empty ranges
362                     if (charRange.start == charRange.end) {
363                         continue;
364                     }
365
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;
370
371                         if (containerElementId == highlights[j].containerElementId) {
372                             highlightCharRange = highlights[j].characterRange;
373                             isSameClassApplier = (classApplier == highlights[j].classApplier);
374                             splitHighlight = !isSameClassApplier && exclusive;
375
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) ) {
382
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) );
387                                     });
388                                 }
389
390                                 removeHighlight = true;
391                                 if (isSameClassApplier) {
392                                     charRange = highlightCharRange.union(charRange);
393                                 }
394                             }
395                         }
396
397                         if (removeHighlight) {
398                             highlightsToRemove.push(highlights[j]);
399                             highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
400                         } else {
401                             highlightsToKeep.push(highlights[j]);
402                         }
403                     }
404
405                     // Add new range
406                     if (classApplier) {
407                         highlightsToKeep.push(new Highlight(doc, charRange, classApplier, converter, null, containerElementId));
408                     }
409                     var  oldHighlights = this.serialize(null).split("|");
410
411                     this.highlights = highlights = highlightsToKeep;
412                 }
413
414                 // Remove the old highlights
415                 forEach(highlightsToRemove, function(highlightToRemove) {
416                     highlightToRemove.unapply();
417                 });
418
419
420                 var serializedHighlights = this.serialize(null).split("|");
421                 var highlightStr = array_diff(oldHighlights, serializedHighlights)[0];
422
423                 // Apply new highlights
424                 var newHighlights = [];
425                 forEach(highlights, function(highlight) {
426                     if (!highlight.applied) {
427                         highlight.apply(highlightStr);
428                         newHighlights.push(highlight);
429                     }
430                 });
431
432                 return newHighlights;
433             },
434
435             highlightRanges: function(className, ranges, options) {
436                 var selCharRanges = [];
437                 var converter = this.converter;
438
439                 options = createOptions(options, {
440                     containerElement: null,
441                     exclusive: true
442                 });
443
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);
450                 }
451
452                 forEach(ranges, function(range) {
453                     var scopedRange = containerElement ? containerElementRange.intersection(range) : range;
454                     selCharRanges.push( converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())) );
455                 });
456
457                 return this.highlightCharacterRanges(className, selCharRanges, {
458                     containerElementId: containerElementId,
459                     exclusive: options.exclusive
460                 });
461             },
462
463             highlightSelection: function(className, options) {
464                 var converter = this.converter;
465                 var classApplier = className ? this.classAppliers[className] : false;
466
467                 options = createOptions(options, {
468                     containerElementId: null,
469                     selection: api.getSelection(this.doc),
470                     exclusive: true
471                 });
472
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);
478
479                 if (!classApplier && className !== false) {
480                     throw new Error("No class applier found for class '" + className + "'");
481                 }
482
483                 // Store the existing selection as character ranges
484                 var serializedSelection = converter.serializeSelection(selection, containerElement);
485
486                 // Create an array of selected character ranges
487                 var selCharRanges = [];
488                 forEach(serializedSelection, function(rangeInfo) {
489                     selCharRanges.push( CharacterRange.fromCharacterRange(rangeInfo.characterRange) );
490                 });
491
492                 var newHighlights = this.highlightCharacterRanges(className, selCharRanges, {
493                     containerElementId: containerElementId,
494                     exclusive: exclusive
495                 });
496
497                 // Restore selection
498                 converter.restoreSelection(selection, serializedSelection, containerElement);
499
500                 return newHighlights;
501             },
502
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;
509             },
510
511             getHighlightsInSelection: function(selection) {
512                 selection = selection || api.getSelection(this.doc);
513                 return this.getIntersectingHighlights(selection.getAllRanges());
514             },
515
516             selectionOverlapsHighlight: function(selection) {
517                 return this.getHighlightsInSelection(selection).length > 0;
518             },
519
520             serialize: function(options) {
521                 var highlighter = this;
522                 var highlights = highlighter.highlights;
523                 var serializedType, serializedHighlights, convertType, serializationConverter;
524
525                 highlights.sort(compareHighlights);
526                 options = createOptions(options, {
527                     serializeHighlightText: false,
528                     type: highlighter.converter.type
529                 });
530
531                 serializedType = options.type;
532                 convertType = (serializedType != highlighter.converter.type);
533
534                 if (convertType) {
535                     serializationConverter = getConverter(serializedType);
536                 }
537
538                 serializedHighlights = ["type:" + serializedType];
539
540                 forEach(highlights, function(highlight) {
541                     var characterRange = highlight.characterRange;
542                     var containerElement;
543
544                     // Convert to the current Highlighter's type, if different from the serialization type
545                     if (convertType) {
546                         containerElement = highlight.getContainerElement();
547                         characterRange = serializationConverter.rangeToCharacterRange(
548                             highlighter.converter.characterRangeToRange(highlighter.doc, characterRange, containerElement),
549                             containerElement
550                         );
551                     }
552
553                     var parts = [
554                         characterRange.start,
555                         characterRange.end,
556                         highlight.id,
557                         highlight.classApplier.className,
558                         highlight.containerElementId
559                     ];
560
561                     if (options.serializeHighlightText) {
562                         parts.push(highlight.getText());
563                     }
564                     serializedHighlights.push( parts.join("$") );
565                 });
566                 return serializedHighlights.join("|");
567             },
568
569             deserialize: function(serialized) {
570                 var serializedHighlights = serialized.split("|");
571                 var highlights = [];
572
573                 var firstHighlight = serializedHighlights[0];
574                 var regexResult;
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);
580                         convertType = true;
581                     }
582                     serializedHighlights.shift();
583                 } else {
584                     throw new Error("Serialized highlights are invalid.");
585                 }
586
587                 var classApplier, highlight, characterRange, containerElementId, containerElement;
588
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;
593
594                     // Convert to the current Highlighter's type, if different from the serialization type
595                     if (convertType) {
596                         containerElement = getContainerElement(this.doc, containerElementId);
597                         characterRange = this.converter.rangeToCharacterRange(
598                             serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement),
599                             containerElement
600                         );
601                     }
602
603                     classApplier = this.classAppliers[ parts[3] ];
604
605                     if (!classApplier) {
606                         throw new Error("No class applier found for class '" + parts[3] + "'");
607                     }
608
609                     highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId);
610
611
612                     highlight.apply(serializedHighlights[i]);
613                     highlights.push(highlight);
614                 }
615                 this.highlights = highlights;
616             }
617         };
618
619         api.Highlighter = Highlighter;
620
621         api.createHighlighter = function(doc, rangeCharacterOffsetConverterType) {
622             return new Highlighter(doc, rangeCharacterOffsetConverterType);
623         };
624     });
625     
626     return rangy;
627 }, this);