Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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 2022, Tim Down
8
 * Licensed under the MIT license.
9
 * Version: 1.3.1
10
 * Build date: 17 August 2022
11
 */
12
(function(factory, root) {
13
    // No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
14
    factory(root.rangy);
15
})(function(rangy) {
16
    rangy.createModule("Highlighter", ["ClassApplier"], function(api, module) {
17
        var dom = api.dom;
18
        var contains = dom.arrayContains;
19
        var getBody = dom.getBody;
20
        var createOptions = api.util.createOptions;
21
        var forEach = api.util.forEach;
22
        var nextHighlightId = 1;
23
 
24
        // Puts highlights in order, last in document first.
25
        function compareHighlights(h1, h2) {
26
            return h1.characterRange.start - h2.characterRange.start;
27
        }
28
 
29
        function getContainerElement(doc, id) {
30
            return id ? doc.getElementById(id) : getBody(doc);
31
        }
32
 
33
        /*----------------------------------------------------------------------------------------------------------------*/
34
 
35
        var highlighterTypes = {};
36
 
37
        function HighlighterType(type, converterCreator) {
38
            this.type = type;
39
            this.converterCreator = converterCreator;
40
        }
41
 
42
        HighlighterType.prototype.create = function() {
43
            var converter = this.converterCreator();
44
            converter.type = this.type;
45
            return converter;
46
        };
47
 
48
        function registerHighlighterType(type, converterCreator) {
49
            highlighterTypes[type] = new HighlighterType(type, converterCreator);
50
        }
51
 
52
        function getConverter(type) {
53
            var highlighterType = highlighterTypes[type];
54
            if (highlighterType instanceof HighlighterType) {
55
                return highlighterType.create();
56
            } else {
57
                throw new Error("Highlighter type '" + type + "' is not valid");
58
            }
59
        }
60
 
61
        api.registerHighlighterType = registerHighlighterType;
62
 
63
        /*----------------------------------------------------------------------------------------------------------------*/
64
 
65
        function CharacterRange(start, end) {
66
            this.start = start;
67
            this.end = end;
68
        }
69
 
70
        CharacterRange.prototype = {
71
            intersects: function(charRange) {
72
                return this.start < charRange.end && this.end > charRange.start;
73
            },
74
 
75
            isContiguousWith: function(charRange) {
76
                return this.start == charRange.end || this.end == charRange.start;
77
            },
78
 
79
            union: function(charRange) {
80
                return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end));
81
            },
82
 
83
            intersection: function(charRange) {
84
                return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end));
85
            },
86
 
87
            getComplements: function(charRange) {
88
                var ranges = [];
89
                if (this.start >= charRange.start) {
90
                    if (this.end <= charRange.end) {
91
                        return [];
92
                    }
93
                    ranges.push(new CharacterRange(charRange.end, this.end));
94
                } else {
95
                    ranges.push(new CharacterRange(this.start, Math.min(this.end, charRange.start)));
96
                    if (this.end > charRange.end) {
97
                        ranges.push(new CharacterRange(charRange.end, this.end));
98
                    }
99
                }
100
                return ranges;
101
            },
102
 
103
            toString: function() {
104
                return "[CharacterRange(" + this.start + ", " + this.end + ")]";
105
            }
106
        };
107
 
108
        CharacterRange.fromCharacterRange = function(charRange) {
109
            return new CharacterRange(charRange.start, charRange.end);
110
        };
111
 
112
        /*----------------------------------------------------------------------------------------------------------------*/
113
 
114
        var textContentConverter = {
115
            rangeToCharacterRange: function(range, containerNode) {
116
                var bookmark = range.getBookmark(containerNode);
117
                return new CharacterRange(bookmark.start, bookmark.end);
118
            },
119
 
120
            characterRangeToRange: function(doc, characterRange, containerNode) {
121
                var range = api.createRange(doc);
122
                range.moveToBookmark({
123
                    start: characterRange.start,
124
                    end: characterRange.end,
125
                    containerNode: containerNode
126
                });
127
 
128
                return range;
129
            },
130
 
131
            serializeSelection: function(selection, containerNode) {
132
                var ranges = selection.getAllRanges(), rangeCount = ranges.length;
133
                var rangeInfos = [];
134
 
135
                var backward = rangeCount == 1 && selection.isBackward();
136
 
137
                for (var i = 0, len = ranges.length; i < len; ++i) {
138
                    rangeInfos[i] = {
139
                        characterRange: this.rangeToCharacterRange(ranges[i], containerNode),
140
                        backward: backward
141
                    };
142
                }
143
 
144
                return rangeInfos;
145
            },
146
 
147
            restoreSelection: function(selection, savedSelection, containerNode) {
148
                selection.removeAllRanges();
149
                var doc = selection.win.document;
150
                for (var i = 0, len = savedSelection.length, range, rangeInfo, characterRange; i < len; ++i) {
151
                    rangeInfo = savedSelection[i];
152
                    characterRange = rangeInfo.characterRange;
153
                    range = this.characterRangeToRange(doc, rangeInfo.characterRange, containerNode);
154
                    selection.addRange(range, rangeInfo.backward);
155
                }
156
            }
157
        };
158
 
159
        registerHighlighterType("textContent", function() {
160
            return textContentConverter;
161
        });
162
 
163
        /*----------------------------------------------------------------------------------------------------------------*/
164
 
165
        // Lazily load the TextRange-based converter so that the dependency is only checked when required.
166
        registerHighlighterType("TextRange", (function() {
167
            var converter;
168
 
169
            return function() {
170
                if (!converter) {
171
                    // Test that textRangeModule exists and is supported
172
                    var textRangeModule = api.modules.TextRange;
173
                    if (!textRangeModule) {
174
                        throw new Error("TextRange module is missing.");
175
                    } else if (!textRangeModule.supported) {
176
                        throw new Error("TextRange module is present but not supported.");
177
                    }
178
 
179
                    converter = {
180
                        rangeToCharacterRange: function(range, containerNode) {
181
                            return CharacterRange.fromCharacterRange( range.toCharacterRange(containerNode) );
182
                        },
183
 
184
                        characterRangeToRange: function(doc, characterRange, containerNode) {
185
                            var range = api.createRange(doc);
186
                            range.selectCharacters(containerNode, characterRange.start, characterRange.end);
187
                            return range;
188
                        },
189
 
190
                        serializeSelection: function(selection, containerNode) {
191
                            return selection.saveCharacterRanges(containerNode);
192
                        },
193
 
194
                        restoreSelection: function(selection, savedSelection, containerNode) {
195
                            selection.restoreCharacterRanges(containerNode, savedSelection);
196
                        }
197
                    };
198
                }
199
 
200
                return converter;
201
            };
202
        })());
203
 
204
        /*----------------------------------------------------------------------------------------------------------------*/
205
 
206
        function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) {
207
            if (id) {
208
                this.id = id;
209
                nextHighlightId = Math.max(nextHighlightId, id + 1);
210
            } else {
211
                this.id = nextHighlightId++;
212
            }
213
            this.characterRange = characterRange;
214
            this.doc = doc;
215
            this.classApplier = classApplier;
216
            this.converter = converter;
217
            this.containerElementId = containerElementId || null;
218
            this.applied = false;
219
        }
220
 
221
        Highlight.prototype = {
222
            getContainerElement: function() {
223
                return getContainerElement(this.doc, this.containerElementId);
224
            },
225
 
226
            getRange: function() {
227
                return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement());
228
            },
229
 
230
            fromRange: function(range) {
231
                this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement());
232
            },
233
 
234
            getText: function() {
235
                return this.getRange().toString();
236
            },
237
 
238
            containsElement: function(el) {
239
                return this.getRange().containsNodeContents(el.firstChild);
240
            },
241
 
242
            unapply: function() {
243
                this.classApplier.undoToRange(this.getRange());
244
                this.applied = false;
245
            },
246
 
247
            apply: function() {
248
                this.classApplier.applyToRange(this.getRange());
249
                this.applied = true;
250
            },
251
 
252
            getHighlightElements: function() {
253
                return this.classApplier.getElementsWithClassIntersectingRange(this.getRange());
254
            },
255
 
256
            toString: function() {
257
                return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.className + ", character range: " +
258
                    this.characterRange.start + " - " + this.characterRange.end + ")]";
259
            }
260
        };
261
 
262
        /*----------------------------------------------------------------------------------------------------------------*/
263
 
264
        function Highlighter(doc, type) {
265
            type = type || "textContent";
266
            this.doc = doc || document;
267
            this.classAppliers = {};
268
            this.highlights = [];
269
            this.converter = getConverter(type);
270
        }
271
 
272
        Highlighter.prototype = {
273
            addClassApplier: function(classApplier) {
274
                this.classAppliers[classApplier.className] = classApplier;
275
            },
276
 
277
            getHighlightForElement: function(el) {
278
                var highlights = this.highlights;
279
                for (var i = 0, len = highlights.length; i < len; ++i) {
280
                    if (highlights[i].containsElement(el)) {
281
                        return highlights[i];
282
                    }
283
                }
284
                return null;
285
            },
286
 
287
            removeHighlights: function(highlights) {
288
                for (var i = 0, len = this.highlights.length, highlight; i < len; ++i) {
289
                    highlight = this.highlights[i];
290
                    if (contains(highlights, highlight)) {
291
                        highlight.unapply();
292
                        this.highlights.splice(i--, 1);
293
                    }
294
                }
295
            },
296
 
297
            removeAllHighlights: function() {
298
                this.removeHighlights(this.highlights);
299
            },
300
 
301
            getIntersectingHighlights: function(ranges) {
302
                // Test each range against each of the highlighted ranges to see whether they overlap
303
                var intersectingHighlights = [], highlights = this.highlights;
304
                forEach(ranges, function(range) {
305
                    //var selCharRange = converter.rangeToCharacterRange(range);
306
                    forEach(highlights, function(highlight) {
307
                        if (range.intersectsRange( highlight.getRange() ) && !contains(intersectingHighlights, highlight)) {
308
                            intersectingHighlights.push(highlight);
309
                        }
310
                    });
311
                });
312
 
313
                return intersectingHighlights;
314
            },
315
 
316
            highlightCharacterRanges: function(className, charRanges, options) {
317
                var i, len, j;
318
                var highlights = this.highlights;
319
                var converter = this.converter;
320
                var doc = this.doc;
321
                var highlightsToRemove = [];
322
                var classApplier = className ? this.classAppliers[className] : null;
323
 
324
                options = createOptions(options, {
325
                    containerElementId: null,
326
                    exclusive: true
327
                });
328
 
329
                var containerElementId = options.containerElementId;
330
                var exclusive = options.exclusive;
331
 
332
                var containerElement, containerElementRange, containerElementCharRange;
333
                if (containerElementId) {
334
                    containerElement = this.doc.getElementById(containerElementId);
335
                    if (containerElement) {
336
                        containerElementRange = api.createRange(this.doc);
337
                        containerElementRange.selectNodeContents(containerElement);
338
                        containerElementCharRange = new CharacterRange(0, containerElementRange.toString().length);
339
                    }
340
                }
341
 
342
                var charRange, highlightCharRange, removeHighlight, isSameClassApplier, highlightsToKeep, splitHighlight;
343
 
344
                for (i = 0, len = charRanges.length; i < len; ++i) {
345
                    charRange = charRanges[i];
346
                    highlightsToKeep = [];
347
 
348
                    // Restrict character range to container element, if it exists
349
                    if (containerElementCharRange) {
350
                        charRange = charRange.intersection(containerElementCharRange);
351
                    }
352
 
353
                    // Ignore empty ranges
354
                    if (charRange.start == charRange.end) {
355
                        continue;
356
                    }
357
 
358
                    // Check for intersection with existing highlights. For each intersection, create a new highlight
359
                    // which is the union of the highlight range and the selected range
360
                    for (j = 0; j < highlights.length; ++j) {
361
                        removeHighlight = false;
362
 
363
                        if (containerElementId == highlights[j].containerElementId) {
364
                            highlightCharRange = highlights[j].characterRange;
365
                            isSameClassApplier = (classApplier == highlights[j].classApplier);
366
                            splitHighlight = !isSameClassApplier && exclusive;
367
 
368
                            // Replace the existing highlight if it needs to be:
369
                            //  1. merged (isSameClassApplier)
370
                            //  2. partially or entirely erased (className === null)
371
                            //  3. partially or entirely replaced (isSameClassApplier == false && exclusive == true)
372
                            if (    (highlightCharRange.intersects(charRange) || highlightCharRange.isContiguousWith(charRange)) &&
373
                                    (isSameClassApplier || splitHighlight) ) {
374
 
375
                                // Remove existing highlights, keeping the unselected parts
376
                                if (splitHighlight) {
377
                                    forEach(highlightCharRange.getComplements(charRange), function(rangeToAdd) {
378
                                        highlightsToKeep.push( new Highlight(doc, rangeToAdd, highlights[j].classApplier, converter, null, containerElementId) );
379
                                    });
380
                                }
381
 
382
                                removeHighlight = true;
383
                                if (isSameClassApplier) {
384
                                    charRange = highlightCharRange.union(charRange);
385
                                }
386
                            }
387
                        }
388
 
389
                        if (removeHighlight) {
390
                            highlightsToRemove.push(highlights[j]);
391
                            highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
392
                        } else {
393
                            highlightsToKeep.push(highlights[j]);
394
                        }
395
                    }
396
 
397
                    // Add new range
398
                    if (classApplier) {
399
                        highlightsToKeep.push(new Highlight(doc, charRange, classApplier, converter, null, containerElementId));
400
                    }
401
                    this.highlights = highlights = highlightsToKeep;
402
                }
403
 
404
                // Remove the old highlights
405
                forEach(highlightsToRemove, function(highlightToRemove) {
406
                    highlightToRemove.unapply();
407
                });
408
 
409
                // Apply new highlights
410
                var newHighlights = [];
411
                forEach(highlights, function(highlight) {
412
                    if (!highlight.applied) {
413
                        highlight.apply();
414
                        newHighlights.push(highlight);
415
                    }
416
                });
417
 
418
                return newHighlights;
419
            },
420
 
421
            highlightRanges: function(className, ranges, options) {
422
                var selCharRanges = [];
423
                var converter = this.converter;
424
 
425
                options = createOptions(options, {
426
                    containerElement: null,
427
                    exclusive: true
428
                });
429
 
430
                var containerElement = options.containerElement;
431
                var containerElementId = containerElement ? containerElement.id : null;
432
                var containerElementRange;
433
                if (containerElement) {
434
                    containerElementRange = api.createRange(containerElement);
435
                    containerElementRange.selectNodeContents(containerElement);
436
                }
437
 
438
                forEach(ranges, function(range) {
439
                    var scopedRange = containerElement ? containerElementRange.intersection(range) : range;
440
                    selCharRanges.push( converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())) );
441
                });
442
 
443
                return this.highlightCharacterRanges(className, selCharRanges, {
444
                    containerElementId: containerElementId,
445
                    exclusive: options.exclusive
446
                });
447
            },
448
 
449
            highlightSelection: function(className, options) {
450
                var converter = this.converter;
451
                var classApplier = className ? this.classAppliers[className] : false;
452
 
453
                options = createOptions(options, {
454
                    containerElementId: null,
455
                    exclusive: true
456
                });
457
 
458
                var containerElementId = options.containerElementId;
459
                var exclusive = options.exclusive;
460
                var selection = options.selection || api.getSelection(this.doc);
461
                var doc = selection.win.document;
462
                var containerElement = getContainerElement(doc, containerElementId);
463
 
464
                if (!classApplier && className !== false) {
465
                    throw new Error("No class applier found for class '" + className + "'");
466
                }
467
 
468
                // Store the existing selection as character ranges
469
                var serializedSelection = converter.serializeSelection(selection, containerElement);
470
 
471
                // Create an array of selected character ranges
472
                var selCharRanges = [];
473
                forEach(serializedSelection, function(rangeInfo) {
474
                    selCharRanges.push( CharacterRange.fromCharacterRange(rangeInfo.characterRange) );
475
                });
476
 
477
                var newHighlights = this.highlightCharacterRanges(className, selCharRanges, {
478
                    containerElementId: containerElementId,
479
                    exclusive: exclusive
480
                });
481
 
482
                // Restore selection
483
                converter.restoreSelection(selection, serializedSelection, containerElement);
484
 
485
                return newHighlights;
486
            },
487
 
488
            unhighlightSelection: function(selection) {
489
                selection = selection || api.getSelection(this.doc);
490
                var intersectingHighlights = this.getIntersectingHighlights( selection.getAllRanges() );
491
                this.removeHighlights(intersectingHighlights);
492
                selection.removeAllRanges();
493
                return intersectingHighlights;
494
            },
495
 
496
            getHighlightsInSelection: function(selection) {
497
                selection = selection || api.getSelection(this.doc);
498
                return this.getIntersectingHighlights(selection.getAllRanges());
499
            },
500
 
501
            selectionOverlapsHighlight: function(selection) {
502
                return this.getHighlightsInSelection(selection).length > 0;
503
            },
504
 
505
            serialize: function(options) {
506
                var highlighter = this;
507
                var highlights = highlighter.highlights;
508
                var serializedType, serializedHighlights, convertType, serializationConverter;
509
 
510
                highlights.sort(compareHighlights);
511
                options = createOptions(options, {
512
                    serializeHighlightText: false,
513
                    type: highlighter.converter.type
514
                });
515
 
516
                serializedType = options.type;
517
                convertType = (serializedType != highlighter.converter.type);
518
 
519
                if (convertType) {
520
                    serializationConverter = getConverter(serializedType);
521
                }
522
 
523
                serializedHighlights = ["type:" + serializedType];
524
 
525
                forEach(highlights, function(highlight) {
526
                    var characterRange = highlight.characterRange;
527
                    var containerElement;
528
 
529
                    // Convert to the current Highlighter's type, if different from the serialization type
530
                    if (convertType) {
531
                        containerElement = highlight.getContainerElement();
532
                        characterRange = serializationConverter.rangeToCharacterRange(
533
                            highlighter.converter.characterRangeToRange(highlighter.doc, characterRange, containerElement),
534
                            containerElement
535
                        );
536
                    }
537
 
538
                    var parts = [
539
                        characterRange.start,
540
                        characterRange.end,
541
                        highlight.id,
542
                        highlight.classApplier.className,
543
                        highlight.containerElementId
544
                    ];
545
 
546
                    if (options.serializeHighlightText) {
547
                        parts.push(highlight.getText());
548
                    }
549
                    serializedHighlights.push( parts.join("$") );
550
                });
551
 
552
                return serializedHighlights.join("|");
553
            },
554
 
555
            deserialize: function(serialized) {
556
                var serializedHighlights = serialized.split("|");
557
                var highlights = [];
558
 
559
                var firstHighlight = serializedHighlights[0];
560
                var regexResult;
561
                var serializationType, serializationConverter, convertType = false;
562
                if ( firstHighlight && (regexResult = /^type:(\w+)$/.exec(firstHighlight)) ) {
563
                    serializationType = regexResult[1];
564
                    if (serializationType != this.converter.type) {
565
                        serializationConverter = getConverter(serializationType);
566
                        convertType = true;
567
                    }
568
                    serializedHighlights.shift();
569
                } else {
570
                    throw new Error("Serialized highlights are invalid.");
571
                }
572
 
573
                var classApplier, highlight, characterRange, containerElementId, containerElement;
574
 
575
                for (var i = serializedHighlights.length, parts; i-- > 0; ) {
576
                    parts = serializedHighlights[i].split("$");
577
                    characterRange = new CharacterRange(+parts[0], +parts[1]);
578
                    containerElementId = parts[4] || null;
579
 
580
                    // Convert to the current Highlighter's type, if different from the serialization type
581
                    if (convertType) {
582
                        containerElement = getContainerElement(this.doc, containerElementId);
583
                        characterRange = this.converter.rangeToCharacterRange(
584
                            serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement),
585
                            containerElement
586
                        );
587
                    }
588
 
589
                    classApplier = this.classAppliers[ parts[3] ];
590
 
591
                    if (!classApplier) {
592
                        throw new Error("No class applier found for class '" + parts[3] + "'");
593
                    }
594
 
595
                    highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId);
596
                    highlight.apply();
597
                    highlights.push(highlight);
598
                }
599
                this.highlights = highlights;
600
            }
601
        };
602
 
603
        api.Highlighter = Highlighter;
604
 
605
        api.createHighlighter = function(doc, rangeCharacterOffsetConverterType) {
606
            return new Highlighter(doc, rangeCharacterOffsetConverterType);
607
        };
608
    });
609
 
610
    return rangy;
611
}, this);