Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
YUI.add('moodle-editor_atto-editor', function (Y, NAME) {
2
 
3
// This file is part of Moodle - http://moodle.org/
4
//
5
// Moodle is free software: you can redistribute it and/or modify
6
// it under the terms of the GNU General Public License as published by
7
// the Free Software Foundation, either version 3 of the License, or
8
// (at your option) any later version.
9
//
10
// Moodle is distributed in the hope that it will be useful,
11
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
// GNU General Public License for more details.
14
//
15
// You should have received a copy of the GNU General Public License
16
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17
/* eslint-disable no-unused-vars */
18
 
19
/**
20
 * The Atto WYSIWG pluggable editor, written for Moodle.
21
 *
22
 * @module     moodle-editor_atto-editor
23
 * @package    editor_atto
24
 * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
25
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26
 * @main       moodle-editor_atto-editor
27
 */
28
 
29
/**
30
 * @module moodle-editor_atto-editor
31
 * @submodule editor-base
32
 */
33
 
34
var LOGNAME = 'moodle-editor_atto-editor';
35
var CSS = {
36
        CONTENT: 'editor_atto_content',
37
        CONTENTWRAPPER: 'editor_atto_content_wrap',
38
        TOOLBAR: 'editor_atto_toolbar',
39
        WRAPPER: 'editor_atto',
40
        HIGHLIGHT: 'highlight'
41
    },
42
    rangy = window.rangy;
43
 
44
/**
45
 * The Atto editor for Moodle.
46
 *
47
 * @namespace M.editor_atto
48
 * @class Editor
49
 * @constructor
50
 * @uses M.editor_atto.EditorClean
51
 * @uses M.editor_atto.EditorFilepicker
52
 * @uses M.editor_atto.EditorSelection
53
 * @uses M.editor_atto.EditorStyling
54
 * @uses M.editor_atto.EditorTextArea
55
 * @uses M.editor_atto.EditorToolbar
56
 * @uses M.editor_atto.EditorToolbarNav
57
 */
58
 
59
function Editor() {
60
    Editor.superclass.constructor.apply(this, arguments);
61
}
62
 
63
Y.extend(Editor, Y.Base, {
64
 
65
    /**
66
     * List of known block level tags.
67
     * Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
68
     *
69
     * @property BLOCK_TAGS
70
     * @type {Array}
71
     */
72
    BLOCK_TAGS: [
73
        'address',
74
        'article',
75
        'aside',
76
        'audio',
77
        'blockquote',
78
        'canvas',
79
        'dd',
80
        'div',
81
        'dl',
82
        'fieldset',
83
        'figcaption',
84
        'figure',
85
        'footer',
86
        'form',
87
        'h1',
88
        'h2',
89
        'h3',
90
        'h4',
91
        'h5',
92
        'h6',
93
        'header',
94
        'hgroup',
95
        'hr',
96
        'noscript',
97
        'ol',
98
        'output',
99
        'p',
100
        'pre',
101
        'section',
102
        'table',
103
        'tfoot',
104
        'ul',
105
        'video'
106
    ],
107
 
108
    PLACEHOLDER_CLASS: 'atto-tmp-class',
109
    ALL_NODES_SELECTOR: '[style],font[face]',
110
    FONT_FAMILY: 'fontFamily',
111
 
112
    /**
113
     * The wrapper containing the editor.
114
     *
115
     * @property _wrapper
116
     * @type Node
117
     * @private
118
     */
119
    _wrapper: null,
120
 
121
    /**
122
     * A reference to the content editable Node.
123
     *
124
     * @property editor
125
     * @type Node
126
     */
127
    editor: null,
128
 
129
    /**
130
     * A reference to the original text area.
131
     *
132
     * @property textarea
133
     * @type Node
134
     */
135
    textarea: null,
136
 
137
    /**
138
     * A reference to the label associated with the original text area.
139
     *
140
     * @property textareaLabel
141
     * @type Node
142
     */
143
    textareaLabel: null,
144
 
145
    /**
146
     * A reference to the list of plugins.
147
     *
148
     * @property plugins
149
     * @type object
150
     */
151
    plugins: null,
152
 
153
    /**
154
     * An indicator of the current input direction.
155
     *
156
     * @property coreDirection
157
     * @type string
158
     */
159
    coreDirection: null,
160
 
161
    /**
162
     * Enable/disable the empty placeholder content.
163
     *
164
     * @property enableAppropriateEmptyContent
165
     * @type Boolean
166
     */
167
    enableAppropriateEmptyContent: null,
168
 
169
    /**
170
     * Event Handles to clear on editor destruction.
171
     *
172
     * @property _eventHandles
173
     * @private
174
     */
175
    _eventHandles: null,
176
 
177
    initializer: function() {
178
        var template;
179
 
180
        // Note - it is not safe to use a CSS selector like '#' + elementid because the id
181
        // may have colons in it - e.g.  quiz.
182
        this.textarea = Y.one(document.getElementById(this.get('elementid')));
183
 
184
        if (!this.textarea) {
185
            // No text area found.
186
            return;
187
        }
188
 
189
        var extraclasses = this.textarea.getAttribute('class');
190
 
191
        this._eventHandles = [];
192
 
193
        var description = Y.Node.create('<div class="sr-only">' + M.util.get_string('richtexteditor', 'editor_atto') + '</div>');
194
        this._wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" role="application" />');
195
        this._wrapper.appendChild(description);
196
        this._wrapper.setAttribute('aria-describedby', description.generateID());
197
        template = Y.Handlebars.compile('<div id="{{elementid}}editable" ' +
198
                'contenteditable="true" ' +
199
                'role="textbox" ' +
200
                'spellcheck="true" ' +
201
                'aria-live="off" ' +
202
                'class="{{CSS.CONTENT}} ' + extraclasses + '" ' +
203
                '/>');
204
        this.editor = Y.Node.create(template({
205
            elementid: this.get('elementid'),
206
            CSS: CSS
207
        }));
208
 
209
        // Add a labelled-by attribute to the contenteditable.
210
        this.textareaLabel = Y.one('[for="' + this.get('elementid') + '"]');
211
        if (this.textareaLabel) {
212
            this.textareaLabel.generateID();
213
            this.editor.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
214
        }
215
 
216
        // Set diretcion according to current page language.
217
        this.coreDirection = Y.one('body').hasClass('dir-rtl') ? 'rtl' : 'ltr';
218
 
219
        // Enable the placeholder for empty content.
220
        this.enablePlaceholderForEmptyContent();
221
 
222
        // Add everything to the wrapper.
223
        this.setupToolbar();
224
 
225
        // Editable content wrapper.
226
        var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
227
        content.appendChild(this.editor);
228
        this._wrapper.appendChild(content);
229
 
230
        // Style the editor. According to the styles.css: 20 is the line-height, 8 is padding-top + padding-bottom.
231
        this.editor.setStyle('minHeight', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
232
 
233
        if (Y.UA.ie === 0) {
234
            // We set a height here to force the overflow because decent browsers allow the CSS property resize.
235
            this.editor.setStyle('height', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
236
        }
237
 
238
        // Disable odd inline CSS styles.
239
        this.disableCssStyling();
240
 
241
        // Use paragraphs not divs.
242
        if (document.queryCommandSupported('DefaultParagraphSeparator')) {
243
            document.execCommand('DefaultParagraphSeparator', false, 'p');
244
        }
245
 
246
        // Add the toolbar and editable zone to the page.
247
        this.textarea.get('parentNode').insert(this._wrapper, this.textarea).
248
                setAttribute('class', 'editor_atto_wrap');
249
 
250
        // Hide the old textarea.
251
        this.textarea.hide();
252
 
253
        // Set up custom event for editor updated.
254
        Y.mix(Y.Node.DOM_EVENTS, {'form:editorUpdated': true});
255
        this.textarea.on('form:editorUpdated', function() {
256
            this.updateEditorState();
257
        }, this);
258
 
259
        // Copy the text to the contenteditable div.
260
        this.updateFromTextArea();
261
 
262
        // Publish the events that are defined by this editor.
263
        this.publishEvents();
264
 
265
        // Add handling for saving and restoring selections on cursor/focus changes.
266
        this.setupSelectionWatchers();
267
 
268
        // Add polling to update the textarea periodically when typing long content.
269
        this.setupAutomaticPolling();
270
 
271
        // Setup plugins.
272
        this.setupPlugins();
273
 
274
        // Initialize the auto-save timer.
275
        this.setupAutosave();
276
        // Preload the icons for the notifications.
277
        this.setupNotifications();
278
    },
279
 
280
    /**
281
     * Focus on the editable area for this editor.
282
     *
283
     * @method focus
284
     * @chainable
285
     */
286
    focus: function() {
287
        this.editor.focus();
288
 
289
        return this;
290
    },
291
 
292
    /**
293
     * Publish events for this editor instance.
294
     *
295
     * @method publishEvents
296
     * @private
297
     * @chainable
298
     */
299
    publishEvents: function() {
300
        /**
301
         * Fired when changes are made within the editor.
302
         *
303
         * @event change
304
         */
305
        this.publish('change', {
306
            broadcast: true,
307
            preventable: true
308
        });
309
 
310
        /**
311
         * Fired when all plugins have completed loading.
312
         *
313
         * @event pluginsloaded
314
         */
315
        this.publish('pluginsloaded', {
316
            fireOnce: true
317
        });
318
 
319
        this.publish('atto:selectionchanged', {
320
            prefix: 'atto'
321
        });
322
 
323
        return this;
324
    },
325
 
326
    /**
327
     * Set up automated polling of the text area to update the textarea.
328
     *
329
     * @method setupAutomaticPolling
330
     * @chainable
331
     */
332
    setupAutomaticPolling: function() {
333
        this._registerEventHandle(this.editor.on(['keyup', 'cut'], this.updateOriginal, this));
334
        this._registerEventHandle(this.editor.on('paste', this.pasteCleanup, this));
335
 
336
        // Call this.updateOriginal after dropped content has been processed.
337
        this._registerEventHandle(this.editor.on('drop', this.updateOriginalDelayed, this));
338
 
339
        return this;
340
    },
341
 
342
    /**
343
     * Calls updateOriginal on a short timer to allow native event handlers to run first.
344
     *
345
     * @method updateOriginalDelayed
346
     * @chainable
347
     */
348
    updateOriginalDelayed: function() {
349
        Y.soon(Y.bind(this.updateOriginal, this));
350
 
351
        return this;
352
    },
353
 
354
    setupPlugins: function() {
355
        // Clear the list of plugins.
356
        this.plugins = {};
357
 
358
        var plugins = this.get('plugins');
359
 
360
        var groupIndex,
361
            group,
362
            pluginIndex,
363
            plugin,
364
            pluginConfig;
365
 
366
        for (groupIndex in plugins) {
367
            group = plugins[groupIndex];
368
            if (!group.plugins) {
369
                // No plugins in this group - skip it.
370
                continue;
371
            }
372
            for (pluginIndex in group.plugins) {
373
                plugin = group.plugins[pluginIndex];
374
 
375
                pluginConfig = Y.mix({
376
                    name: plugin.name,
377
                    group: group.group,
378
                    editor: this.editor,
379
                    toolbar: this.toolbar,
380
                    host: this
381
                }, plugin);
382
 
383
                // Add a reference to the current editor.
384
                if (typeof Y.M['atto_' + plugin.name] === "undefined") {
385
                    continue;
386
                }
387
                this.plugins[plugin.name] = new Y.M['atto_' + plugin.name].Button(pluginConfig);
388
            }
389
        }
390
 
391
        // Some plugins need to perform actions once all plugins have loaded.
392
        this.fire('pluginsloaded');
393
 
394
        return this;
395
    },
396
 
397
    enablePlugins: function(plugin) {
398
        this._setPluginState(true, plugin);
399
    },
400
 
401
    disablePlugins: function(plugin) {
402
        this._setPluginState(false, plugin);
403
    },
404
 
405
    _setPluginState: function(enable, plugin) {
406
        var target = 'disableButtons';
407
        if (enable) {
408
            target = 'enableButtons';
409
        }
410
 
411
        if (plugin) {
412
            this.plugins[plugin][target]();
413
        } else {
414
            Y.Object.each(this.plugins, function(currentPlugin) {
415
                currentPlugin[target]();
416
            }, this);
417
        }
418
    },
419
 
420
    /**
421
     * Update the state of the editor.
422
     */
423
    updateEditorState: function() {
424
        var disabled = this.textarea.hasAttribute('readonly'),
425
            editorfield = Y.one('#' + this.get('elementid') + 'editable');
426
        // Enable/Disable all plugins.
427
        this._setPluginState(!disabled);
428
        // Enable/Disable content of editor.
429
        if (editorfield) {
430
            editorfield.setAttribute('contenteditable', !disabled);
431
        }
432
    },
433
 
434
    /**
435
     * Enable the empty placeholder for empty content.
436
     */
437
    enablePlaceholderForEmptyContent: function() {
438
        this.enableAppropriateEmptyContent = true;
439
    },
440
 
441
    /**
442
     * Disable the empty placeholder for empty content.
443
     */
444
    disablePlaceholderForEmptyContent: function() {
445
        this.enableAppropriateEmptyContent = false;
446
    },
447
 
448
    /**
449
     * Register an event handle for disposal in the destructor.
450
     *
451
     * @method _registerEventHandle
452
     * @param {EventHandle} The Event Handle as returned by Y.on, and Y.delegate.
453
     * @private
454
     */
455
    _registerEventHandle: function(handle) {
456
        this._eventHandles.push(handle);
457
    }
458
 
459
}, {
460
    NS: 'editor_atto',
461
    ATTRS: {
462
        /**
463
         * The unique identifier for the form element representing the editor.
464
         *
465
         * @attribute elementid
466
         * @type String
467
         * @writeOnce
468
         */
469
        elementid: {
470
            value: null,
471
            writeOnce: true
472
        },
473
 
474
        /**
475
         * The contextid of the form.
476
         *
477
         * @attribute contextid
478
         * @type Integer
479
         * @writeOnce
480
         */
481
        contextid: {
482
            value: null,
483
            writeOnce: true
484
        },
485
 
486
        /**
487
         * Plugins with their configuration.
488
         *
489
         * The plugins structure is:
490
         *
491
         *     [
492
         *         {
493
         *             "group": "groupName",
494
         *             "plugins": [
495
         *                 "pluginName": {
496
         *                     "configKey": "configValue"
497
         *                 },
498
         *                 "pluginName": {
499
         *                     "configKey": "configValue"
500
         *                 }
501
         *             ]
502
         *         },
503
         *         {
504
         *             "group": "groupName",
505
         *             "plugins": [
506
         *                 "pluginName": {
507
         *                     "configKey": "configValue"
508
         *                 }
509
         *             ]
510
         *         }
511
         *     ]
512
         *
513
         * @attribute plugins
514
         * @type Object
515
         * @writeOnce
516
         */
517
        plugins: {
518
            value: {},
519
            writeOnce: true
520
        }
521
    }
522
});
523
 
524
// The Editor publishes custom events that can be subscribed to.
525
Y.augment(Editor, Y.EventTarget);
526
 
527
Y.namespace('M.editor_atto').Editor = Editor;
528
 
529
// Function for Moodle's initialisation.
530
Y.namespace('M.editor_atto.Editor').init = function(config) {
531
    return new Y.M.editor_atto.Editor(config);
532
};
533
// This file is part of Moodle - http://moodle.org/
534
//
535
// Moodle is free software: you can redistribute it and/or modify
536
// it under the terms of the GNU General Public License as published by
537
// the Free Software Foundation, either version 3 of the License, or
538
// (at your option) any later version.
539
//
540
// Moodle is distributed in the hope that it will be useful,
541
// but WITHOUT ANY WARRANTY; without even the implied warranty of
542
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
543
// GNU General Public License for more details.
544
//
545
// You should have received a copy of the GNU General Public License
546
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
547
 
548
/**
549
 * A notify function for the Atto editor.
550
 *
551
 * @module     moodle-editor_atto-notify
552
 * @submodule  notify
553
 * @package    editor_atto
554
 * @copyright  2014 Damyon Wiese
555
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
556
 */
557
 
558
var LOGNAME_NOTIFY = 'moodle-editor_atto-editor-notify',
559
    NOTIFY_INFO = 'info',
560
    NOTIFY_WARNING = 'warning';
561
 
562
function EditorNotify() {}
563
 
564
EditorNotify.ATTRS = {
565
};
566
 
567
EditorNotify.prototype = {
568
 
569
    /**
570
     * A single Y.Node for this editor. There is only ever one - it is replaced if a new message comes in.
571
     *
572
     * @property messageOverlay
573
     * @type {Node}
574
     */
575
    messageOverlay: null,
576
 
577
    /**
578
     * A single timer object that can be used to cancel the hiding behaviour.
579
     *
580
     * @property hideTimer
581
     * @type {timer}
582
     */
583
    hideTimer: null,
584
 
585
    /**
586
     * Initialize the notifications.
587
     *
588
     * @method setupNotifications
589
     * @chainable
590
     */
591
    setupNotifications: function() {
592
        var preload1 = new Image(),
593
            preload2 = new Image();
594
 
595
        preload1.src = M.util.image_url('i/warning', 'moodle');
596
        preload2.src = M.util.image_url('i/info', 'moodle');
597
 
598
        return this;
599
    },
600
 
601
    /**
602
     * Show a notification in a floaty overlay somewhere in the atto editor text area.
603
     *
604
     * @method showMessage
605
     * @param {String} message The translated message (use get_string)
606
     * @param {String} type Must be either "info" or "warning"
607
     * @param {Number} timeout Time in milliseconds to show this message for.
608
     * @chainable
609
     */
610
    showMessage: function(message, type, timeout) {
611
        var messageTypeIcon = '',
612
            intTimeout,
613
            bodyContent;
614
 
615
        if (this.messageOverlay === null) {
616
            this.messageOverlay = Y.Node.create('<div class="editor_atto_notification"></div>');
617
 
618
            this.messageOverlay.hide(true);
619
            this.textarea.get('parentNode').append(this.messageOverlay);
620
 
621
            this.messageOverlay.on('click', function() {
622
                this.messageOverlay.hide(true);
623
            }, this);
624
        }
625
 
626
        if (this.hideTimer !== null) {
627
            this.hideTimer.cancel();
628
        }
629
 
630
        if (type === NOTIFY_WARNING) {
631
            messageTypeIcon = '<img src="' +
632
                              M.util.image_url('i/warning', 'moodle') +
633
                              '" alt="' + M.util.get_string('warning', 'moodle') + '"/>';
634
        } else if (type === NOTIFY_INFO) {
635
            messageTypeIcon = '<img src="' +
636
                              M.util.image_url('i/info', 'moodle') +
637
                              '" alt="' + M.util.get_string('info', 'moodle') + '"/>';
638
        } else {
639
        }
640
 
641
        // Parse the timeout value.
642
        intTimeout = parseInt(timeout, 10);
643
        if (intTimeout <= 0) {
644
            intTimeout = 60000;
645
        }
646
 
647
        // Convert class to atto_info (for example).
648
        type = 'atto_' + type;
649
 
650
        bodyContent = Y.Node.create('<div class="' + type + '" role="alert" aria-live="assertive">' +
651
                                        messageTypeIcon + ' ' +
652
                                        Y.Escape.html(message) +
653
                                        '</div>');
654
        this.messageOverlay.empty();
655
        this.messageOverlay.append(bodyContent);
656
        this.messageOverlay.show(true);
657
 
658
        this.hideTimer = Y.later(intTimeout, this, function() {
659
            this.hideTimer = null;
660
            if (this.messageOverlay.inDoc()) {
661
                this.messageOverlay.hide(true);
662
            }
663
        });
664
 
665
        return this;
666
    }
667
 
668
};
669
 
670
Y.Base.mix(Y.M.editor_atto.Editor, [EditorNotify]);
671
// This file is part of Moodle - http://moodle.org/
672
//
673
// Moodle is free software: you can redistribute it and/or modify
674
// it under the terms of the GNU General Public License as published by
675
// the Free Software Foundation, either version 3 of the License, or
676
// (at your option) any later version.
677
//
678
// Moodle is distributed in the hope that it will be useful,
679
// but WITHOUT ANY WARRANTY; without even the implied warranty of
680
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
681
// GNU General Public License for more details.
682
//
683
// You should have received a copy of the GNU General Public License
684
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
685
 
686
/**
687
 * @module moodle-editor_atto-editor
688
 * @submodule textarea
689
 */
690
 
691
/**
692
 * Textarea functions for the Atto editor.
693
 *
694
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
695
 *
696
 * @namespace M.editor_atto
697
 * @class EditorTextArea
698
 */
699
 
700
function EditorTextArea() {}
701
 
702
EditorTextArea.ATTRS = {
703
};
704
 
705
EditorTextArea.prototype = {
706
 
707
    /**
708
     * Return the appropriate empty content value for the current browser.
709
     *
710
     * Different browsers use a different content when they are empty and
711
     * we must set this reliable across the board.
712
     *
713
     * @method _getEmptyContent
714
     * @return String The content to use representing no user-provided content
715
     * @private
716
     */
717
    _getEmptyContent: function() {
718
        if (!this.enableAppropriateEmptyContent) {
719
            // Return the empty string if we do not enable the empty placeholder. Ex: HTML mode.
720
            return '';
721
        }
722
        var alignment;
723
        if (this.coreDirection === 'rtl') {
724
            alignment = 'style="text-align: right;"';
725
        } else {
726
            alignment = 'style="text-align: left;"';
727
        }
728
        if (Y.UA.ie && Y.UA.ie < 10) {
729
            return '<p dir="' + this.coreDirection + '" ' + alignment + '></p>';
730
        } else {
731
            return '<p dir="' + this.coreDirection + '" ' + alignment + '><br></p>';
732
        }
733
    },
734
 
735
    /**
736
     * Copy and clean the text from the textarea into the contenteditable div.
737
     *
738
     * If the text is empty, provide a default paragraph tag to hold the content.
739
     *
740
     * @method updateFromTextArea
741
     * @chainable
742
     */
743
    updateFromTextArea: function() {
744
        // Clear it first.
745
        this.editor.setHTML('');
746
 
747
        // Copy cleaned HTML to editable div.
748
        this.editor.append(this._cleanHTML(this.textarea.get('value'), true));
749
 
750
        // Insert a paragraph in the empty contenteditable div.
751
        if (this.editor.getHTML() === '') {
752
            this.editor.setHTML(this._getEmptyContent());
753
        }
754
 
755
        return this;
756
    },
757
 
758
    /**
759
     * Copy the text from the contenteditable to the textarea which it replaced.
760
     *
761
     * @method updateOriginal
762
     * @chainable
763
     */
764
    updateOriginal: function() {
765
        // Get the previous and current value to compare them.
766
        var oldValue = this.textarea.get('value'),
767
            newValue = this.getCleanHTML();
768
 
769
        if (newValue === "" && this.isActive()) {
770
            // The content was entirely empty so get the empty content placeholder.
771
            newValue = this._getEmptyContent();
772
        }
773
 
774
        // Only call this when there has been an actual change to reduce processing.
775
        if (oldValue !== newValue) {
776
            // Insert the cleaned content.
777
            this.textarea.set('value', newValue);
778
 
779
            // Trigger the onchange callback on the textarea, essentially to notify the formchangechecker module.
780
            this.textarea.simulate('change');
781
 
782
            // Trigger handlers for this action.
783
            this.fire('change');
784
        }
785
 
786
        return this;
787
    }
788
};
789
 
790
Y.Base.mix(Y.M.editor_atto.Editor, [EditorTextArea]);
791
// This file is part of Moodle - http://moodle.org/
792
//
793
// Moodle is free software: you can redistribute it and/or modify
794
// it under the terms of the GNU General Public License as published by
795
// the Free Software Foundation, either version 3 of the License, or
796
// (at your option) any later version.
797
//
798
// Moodle is distributed in the hope that it will be useful,
799
// but WITHOUT ANY WARRANTY; without even the implied warranty of
800
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
801
// GNU General Public License for more details.
802
//
803
// You should have received a copy of the GNU General Public License
804
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
805
/* eslint-disable no-unused-vars */
806
 
807
/**
808
 * A autosave function for the Atto editor.
809
 *
810
 * @module     moodle-editor_atto-autosave
811
 * @submodule  autosave-base
812
 * @package    editor_atto
813
 * @copyright  2014 Damyon Wiese
814
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
815
 */
816
 
817
var SUCCESS_MESSAGE_TIMEOUT = 5000,
818
    RECOVER_MESSAGE_TIMEOUT = 60000,
819
    LOGNAME_AUTOSAVE = 'moodle-editor_atto-editor-autosave';
820
 
821
function EditorAutosave() {}
822
 
823
EditorAutosave.ATTRS = {
824
    /**
825
     * Enable/Disable auto save for this instance.
826
     *
827
     * @attribute autosaveEnabled
828
     * @type Boolean
829
     * @writeOnce
830
     */
831
    autosaveEnabled: {
832
        value: true,
833
        writeOnce: true
834
    },
835
 
836
    /**
837
     * The time between autosaves (in seconds).
838
     *
839
     * @attribute autosaveFrequency
840
     * @type Number
841
     * @default 60
842
     * @writeOnce
843
     */
844
    autosaveFrequency: {
845
        value: 60,
846
        writeOnce: true
847
    },
848
 
849
    /**
850
     * Unique hash for this page instance. Calculated from $PAGE->url in php.
851
     *
852
     * @attribute pageHash
853
     * @type String
854
     * @writeOnce
855
     */
856
    pageHash: {
857
        value: '',
858
        writeOnce: true
859
    }
860
};
861
 
862
EditorAutosave.prototype = {
863
 
864
    /**
865
     * The text that was auto saved in the last request.
866
     *
867
     * @property lastText
868
     * @type string
869
     */
870
    lastText: "",
871
 
872
    /**
873
     * Autosave instance.
874
     *
875
     * @property autosaveInstance
876
     * @type string
877
     */
878
    autosaveInstance: null,
879
 
880
    /**
881
     * Autosave Timer.
882
     *
883
     * @property autosaveTimer
884
     * @type object
885
     */
886
    autosaveTimer: null,
887
 
888
    /**
889
     * Initialize the autosave process
890
     *
891
     * @method setupAutosave
892
     * @chainable
893
     */
894
    setupAutosave: function() {
895
        var draftid = -1,
896
            form,
897
            optiontype = null,
898
            options = this.get('filepickeroptions'),
899
            params;
900
 
901
        if (!this.get('autosaveEnabled')) {
902
            // Autosave disabled for this instance.
903
            return;
904
        }
905
 
906
        this.autosaveInstance = Y.stamp(this);
907
        for (optiontype in options) {
908
            if (typeof options[optiontype].itemid !== "undefined") {
909
                draftid = options[optiontype].itemid;
910
            }
911
        }
912
 
913
        // First see if there are any saved drafts.
914
        // Make an ajax request.
915
        params = {
916
            contextid: this.get('contextid'),
917
            action: 'resume',
918
            draftid: draftid,
919
            elementid: this.get('elementid'),
920
            pageinstance: this.autosaveInstance,
921
            pagehash: this.get('pageHash')
922
        };
923
 
924
        this.autosaveIo(params, this, {
925
            success: function(response) {
926
                if (response === null) {
927
                    // This can happen when there is nothing to resume from.
928
                    return;
929
                } else if (!response) {
930
                    return;
931
                }
932
 
933
                // Revert untouched editor contents to an empty string.
934
                var emptyContents = [
935
                    // For FF and Chrome.
936
                    '<p></p>',
937
                    '<p><br></p>',
938
                    '<br>',
939
                    '<p dir="rtl" style="text-align: right;"></p>',
940
                    '<p dir="rtl" style="text-align: right;"><br></p>',
941
                    '<p dir="ltr" style="text-align: left;"></p>',
942
                    '<p dir="ltr" style="text-align: left;"><br></p>',
943
                    // For IE 9 and 10.
944
                    '<p>&nbsp;</p>',
945
                    '<p><br>&nbsp;</p>',
946
                    '<p dir="rtl" style="text-align: right;">&nbsp;</p>',
947
                    '<p dir="rtl" style="text-align: right;"><br>&nbsp;</p>',
948
                    '<p dir="ltr" style="text-align: left;">&nbsp;</p>',
949
                    '<p dir="ltr" style="text-align: left;"><br>&nbsp;</p>'
950
                ];
951
                if (emptyContents.includes(response.result)) {
952
                    response.result = '';
953
                }
954
 
955
                if (response.error || typeof response.result === 'undefined') {
956
                    this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
957
                            NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
958
                } else if (response.result !== this.textarea.get('value') &&
959
                        response.result !== '') {
960
                    this.recoverText(response.result);
961
                }
962
                this._fireSelectionChanged();
963
 
964
            },
965
            failure: function() {
966
                this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
967
                        NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
968
            }
969
        });
970
 
971
        // Now setup the timer for periodic saves.
972
        var delay = parseInt(this.get('autosaveFrequency'), 10) * 1000;
973
        this.autosaveTimer = Y.later(delay, this, this.saveDraft, false, true);
974
 
975
        // Now setup the listener for form submission.
976
        form = this.textarea.ancestor('form');
977
        if (form) {
978
            this.autosaveIoOnSubmit(form, {
979
                action: 'reset',
980
                contextid: this.get('contextid'),
981
                elementid: this.get('elementid'),
982
                pageinstance: this.autosaveInstance,
983
                pagehash: this.get('pageHash')
984
            });
985
        }
986
        return this;
987
    },
988
 
989
    /**
990
     * Recover a previous version of this text and show a message.
991
     *
992
     * @method recoverText
993
     * @param {String} text
994
     * @chainable
995
     */
996
    recoverText: function(text) {
997
        this.editor.setHTML(text);
998
        this.saveSelection();
999
        this.updateOriginal();
1000
        this.lastText = text;
1001
 
1002
        this.showMessage(M.util.get_string('textrecovered', 'editor_atto'),
1003
                NOTIFY_INFO, RECOVER_MESSAGE_TIMEOUT);
1004
 
1005
        // Fire an event that the editor content has changed.
1006
        require(['core_editor/events'], function(editorEvents) {
1007
            editorEvents.notifyEditorContentRestored(this.editor.getDOMNode());
1008
        }.bind(this));
1009
 
1010
        return this;
1011
    },
1012
 
1013
    /**
1014
     * Save a single draft via ajax.
1015
     *
1016
     * @method saveDraft
1017
     * @chainable
1018
     */
1019
    saveDraft: function() {
1020
        var url, params;
1021
 
1022
        if (!this.editor.getDOMNode()) {
1023
            // Stop autosaving if the editor was removed from the page.
1024
            this.autosaveTimer.cancel();
1025
            return;
1026
        }
1027
        // Only copy the text from the div to the textarea if the textarea is not currently visible.
1028
        if (!this.editor.get('hidden')) {
1029
            this.updateOriginal();
1030
        }
1031
        var newText = this.textarea.get('value');
1032
 
1033
        if (newText !== this.lastText) {
1034
 
1035
            // Make an ajax request.
1036
            url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
1037
            params = {
1038
                sesskey: M.cfg.sesskey,
1039
                contextid: this.get('contextid'),
1040
                action: 'save',
1041
                drafttext: newText,
1042
                elementid: this.get('elementid'),
1043
                pagehash: this.get('pageHash'),
1044
                pageinstance: this.autosaveInstance
1045
            };
1046
 
1047
            // Reusable error handler - must be passed the correct context.
1048
            var ajaxErrorFunction = function(response) {
1049
                var errorDuration = parseInt(this.get('autosaveFrequency'), 10) * 1000;
1050
                this.showMessage(M.util.get_string('autosavefailed', 'editor_atto'), NOTIFY_WARNING, errorDuration);
1051
            };
1052
 
1053
            this.autosaveIo(params, this, {
1054
                failure: ajaxErrorFunction,
1055
                success: function(response) {
1056
                    if (response && response.error) {
1057
                        Y.soon(Y.bind(ajaxErrorFunction, this, [response]));
1058
                    } else {
1059
                        // All working.
1060
                        this.lastText = newText;
1061
                        this.showMessage(M.util.get_string('autosavesucceeded', 'editor_atto'),
1062
                                NOTIFY_INFO, SUCCESS_MESSAGE_TIMEOUT);
1063
                    }
1064
                }
1065
            });
1066
        }
1067
        return this;
1068
    }
1069
};
1070
 
1071
Y.Base.mix(Y.M.editor_atto.Editor, [EditorAutosave]);
1072
// This file is part of Moodle - http://moodle.org/
1073
//
1074
// Moodle is free software: you can redistribute it and/or modify
1075
// it under the terms of the GNU General Public License as published by
1076
// the Free Software Foundation, either version 3 of the License, or
1077
// (at your option) any later version.
1078
//
1079
// Moodle is distributed in the hope that it will be useful,
1080
// but WITHOUT ANY WARRANTY; without even the implied warranty of
1081
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1082
// GNU General Public License for more details.
1083
//
1084
// You should have received a copy of the GNU General Public License
1085
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1086
 
1087
/**
1088
 * A autosave function for the Atto editor.
1089
 *
1090
 * @module     moodle-editor_atto-autosave-io
1091
 * @submodule  autosave-io
1092
 * @package    editor_atto
1093
 * @copyright  2016 Frédéric Massart
1094
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1095
 */
1096
 
1097
var EditorAutosaveIoDispatcherInstance = null;
1098
 
1099
function EditorAutosaveIoDispatcher() {
1100
    EditorAutosaveIoDispatcher.superclass.constructor.apply(this, arguments);
1101
    this._submitEvents = {};
1102
    this._queue = [];
1103
    this._throttle = null;
1104
}
1105
EditorAutosaveIoDispatcher.NAME = 'EditorAutosaveIoDispatcher';
1106
EditorAutosaveIoDispatcher.ATTRS = {
1107
 
1108
    /**
1109
     * The relative path to the ajax script.
1110
     *
1111
     * @attribute autosaveAjaxScript
1112
     * @type String
1113
     * @default '/lib/editor/atto/autosave-ajax.php'
1114
     * @readOnly
1115
     */
1116
    autosaveAjaxScript: {
1117
        value: '/lib/editor/atto/autosave-ajax.php',
1118
        readOnly: true
1119
    },
1120
 
1121
    /**
1122
     * The time buffer for the throttled requested.
1123
     *
1124
     * @attribute delay
1125
     * @type Number
1126
     * @default 50
1127
     * @readOnly
1128
     */
1129
    delay: {
1130
        value: 50,
1131
        readOnly: true
1132
    }
1133
 
1134
};
1135
Y.extend(EditorAutosaveIoDispatcher, Y.Base, {
1136
 
1137
    /**
1138
     * Dispatch an IO request.
1139
     *
1140
     * This method will put the requests in a queue in order to attempt to bulk them.
1141
     *
1142
     * @param  {Object} params    The parameters of the request.
1143
     * @param  {Object} context   The context in which the callbacks are called.
1144
     * @param  {Object} callbacks Object with 'success', 'complete', 'end', 'failure' and 'start' as
1145
     *                            optional keys defining the callbacks to call. Success and Complete
1146
     *                            functions will receive the response as parameter. Success and Complete
1147
     *                            may receive an object containing the error key, use this to confirm
1148
     *                            that no errors occured.
1149
     * @return {Void}
1150
     */
1151
    dispatch: function(params, context, callbacks) {
1152
        if (this._throttle) {
1153
            this._throttle.cancel();
1154
        }
1155
 
1156
        this._throttle = Y.later(this.get('delay'), this, this._processDispatchQueue);
1157
        this._queue.push([params, context, callbacks]);
1158
    },
1159
 
1160
    /**
1161
     * Dispatches the requests in the queue.
1162
     *
1163
     * @return {Void}
1164
     */
1165
    _processDispatchQueue: function() {
1166
        var queue = this._queue,
1167
            data = {};
1168
 
1169
        this._queue = [];
1170
        if (queue.length < 1) {
1171
            return;
1172
        }
1173
 
1174
        Y.Array.each(queue, function(item, index) {
1175
            data[index] = item[0];
1176
        });
1177
 
1178
        Y.io(M.cfg.wwwroot + this.get('autosaveAjaxScript'), {
1179
            method: 'POST',
1180
            data: Y.QueryString.stringify({
1181
                actions: data,
1182
                sesskey: M.cfg.sesskey
1183
            }),
1184
            on: {
1185
                start: this._makeIoEventCallback('start', queue),
1186
                complete: this._makeIoEventCallback('complete', queue),
1187
                failure: this._makeIoEventCallback('failure', queue),
1188
                end: this._makeIoEventCallback('end', queue),
1189
                success: this._makeIoEventCallback('success', queue)
1190
            }
1191
        });
1192
    },
1193
 
1194
    /**
1195
     * Creates a function that dispatches an IO response to callbacks.
1196
     *
1197
     * @param  {String} event The type of event.
1198
     * @param  {Array} queue The queue.
1199
     * @return {Function}
1200
     */
1201
    _makeIoEventCallback: function(event, queue) {
1202
        var noop = function() {};
1203
        return function() {
1204
            var response = arguments[1],
1205
                parsed = {};
1206
 
1207
            if ((event == 'complete' || event == 'success') && (typeof response !== 'undefined'
1208
                    && typeof response.responseText !== 'undefined' && response.responseText !== '')) {
1209
 
1210
                // Success and complete events need to parse the response.
1211
                parsed = JSON.parse(response.responseText) || {};
1212
            }
1213
 
1214
            Y.Array.each(queue, function(item, index) {
1215
                var context = item[1],
1216
                    cb = (item[2] && item[2][event]) || noop,
1217
                    arg;
1218
 
1219
                if (parsed && parsed.error) {
1220
                    // The response is an error, we send it to everyone.
1221
                    arg = parsed;
1222
                } else if (parsed) {
1223
                    // The response was parsed, we only communicate the relevant portion of the response.
1224
                    arg = parsed[index];
1225
                }
1226
 
1227
                cb.apply(context, [arg]);
1228
            });
1229
        };
1230
    },
1231
 
1232
    /**
1233
     * Form submit handler.
1234
     *
1235
     * @param  {EventFacade} e The event.
1236
     * @return {Void}
1237
     */
1238
    _onSubmit: function(e) {
1239
        var data = {},
1240
            id = e.currentTarget.generateID(),
1241
            params = this._submitEvents[id];
1242
 
1243
        if (!params || params.ios.length < 1) {
1244
            return;
1245
        }
1246
 
1247
        Y.Array.each(params.ios, function(param, index) {
1248
            data[index] = param;
1249
        });
1250
 
1251
        Y.io(M.cfg.wwwroot + this.get('autosaveAjaxScript'), {
1252
            method: 'POST',
1253
            data: Y.QueryString.stringify({
1254
                actions: data,
1255
                sesskey: M.cfg.sesskey
1256
            }),
1257
            sync: true
1258
        });
1259
    },
1260
 
1261
    /**
1262
     * Registers a request to be made on form submission.
1263
     *
1264
     * @param  {Node} node The forum node we will listen to.
1265
     * @param  {Object} params Parameters for the IO request.
1266
     * @return {Void}
1267
     */
1268
    whenSubmit: function(node, params) {
1269
        if (typeof this._submitEvents[node.generateID()] === 'undefined') {
1270
            this._submitEvents[node.generateID()] = {
1271
                event: node.on('submit', this._onSubmit, this),
1272
                ajaxEvent: node.on(M.core.event.FORM_SUBMIT_AJAX, this._onSubmit, this),
1273
                ios: []
1274
            };
1275
        }
1276
        this._submitEvents[node.get('id')].ios.push([params]);
1277
    }
1278
 
1279
});
1280
EditorAutosaveIoDispatcherInstance = new EditorAutosaveIoDispatcher();
1281
 
1282
 
1283
function EditorAutosaveIo() {}
1284
EditorAutosaveIo.prototype = {
1285
 
1286
    /**
1287
     * Dispatch an IO request.
1288
     *
1289
     * This method will put the requests in a queue in order to attempt to bulk them.
1290
     *
1291
     * @param  {Object} params    The parameters of the request.
1292
     * @param  {Object} context   The context in which the callbacks are called.
1293
     * @param  {Object} callbacks Object with 'success', 'complete', 'end', 'failure' and 'start' as
1294
     *                            optional keys defining the callbacks to call. Success and Complete
1295
     *                            functions will receive the response as parameter. Success and Complete
1296
     *                            may receive an object containing the error key, use this to confirm
1297
     *                            that no errors occured.
1298
     * @return {Void}
1299
     */
1300
    autosaveIo: function(params, context, callbacks) {
1301
        EditorAutosaveIoDispatcherInstance.dispatch(params, context, callbacks);
1302
    },
1303
 
1304
    /**
1305
     * Registers a request to be made on form submission.
1306
     *
1307
     * @param  {Node} form The forum node we will listen to.
1308
     * @param  {Object} params Parameters for the IO request.
1309
     * @return {Void}
1310
     */
1311
    autosaveIoOnSubmit: function(form, params) {
1312
        EditorAutosaveIoDispatcherInstance.whenSubmit(form, params);
1313
    }
1314
 
1315
};
1316
Y.Base.mix(Y.M.editor_atto.Editor, [EditorAutosaveIo]);
1317
// This file is part of Moodle - http://moodle.org/
1318
//
1319
// Moodle is free software: you can redistribute it and/or modify
1320
// it under the terms of the GNU General Public License as published by
1321
// the Free Software Foundation, either version 3 of the License, or
1322
// (at your option) any later version.
1323
//
1324
// Moodle is distributed in the hope that it will be useful,
1325
// but WITHOUT ANY WARRANTY; without even the implied warranty of
1326
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1327
// GNU General Public License for more details.
1328
//
1329
// You should have received a copy of the GNU General Public License
1330
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1331
 
1332
/**
1333
 * @module moodle-editor_atto-editor
1334
 * @submodule clean
1335
 */
1336
 
1337
/**
1338
 * Functions for the Atto editor to clean the generated content.
1339
 *
1340
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1341
 *
1342
 * @namespace M.editor_atto
1343
 * @class EditorClean
1344
 */
1345
 
1346
function EditorClean() {}
1347
 
1348
EditorClean.ATTRS = {
1349
};
1350
 
1351
EditorClean.prototype = {
1352
    /**
1353
     * Clean the generated HTML content without modifying the editor content.
1354
     *
1355
     * This includes removes all YUI ids from the generated content.
1356
     *
1357
     * @return {string} The cleaned HTML content.
1358
     */
1359
    getCleanHTML: function() {
1360
        // Clone the editor so that we don't actually modify the real content.
1361
        var editorClone = this.editor.cloneNode(true),
1362
            html;
1363
 
1364
        // Remove all YUI IDs.
1365
        Y.each(editorClone.all('[id^="yui"]'), function(node) {
1366
            node.removeAttribute('id');
1367
        });
1368
 
1369
        editorClone.all('.atto_control').remove(true);
1370
        html = editorClone.get('innerHTML');
1371
 
1372
        // Revert untouched editor contents to an empty string.
1373
        var emptyContents = [
1374
            // For FF and Chrome.
1375
            '<p></p>',
1376
            '<p><br></p>',
1377
            '<br>',
1378
            '<p dir="rtl" style="text-align: right;"></p>',
1379
            '<p dir="rtl" style="text-align: right;"><br></p>',
1380
            '<p dir="ltr" style="text-align: left;"></p>',
1381
            '<p dir="ltr" style="text-align: left;"><br></p>',
1382
            // For IE 9 and 10.
1383
            '<p>&nbsp;</p>',
1384
            '<p><br>&nbsp;</p>',
1385
            '<p dir="rtl" style="text-align: right;">&nbsp;</p>',
1386
            '<p dir="rtl" style="text-align: right;"><br>&nbsp;</p>',
1387
            '<p dir="ltr" style="text-align: left;">&nbsp;</p>',
1388
            '<p dir="ltr" style="text-align: left;"><br>&nbsp;</p>'
1389
        ];
1390
        if (emptyContents.includes(html)) {
1391
            return '';
1392
        }
1393
 
1394
        // Remove any and all nasties from source.
1395
        return this._cleanHTML(html);
1396
    },
1397
 
1398
    /**
1399
     * Clean the HTML content of the editor.
1400
     *
1401
     * @method cleanEditorHTML
1402
     * @chainable
1403
     */
1404
    cleanEditorHTML: function() {
1405
        var startValue = this.editor.get('innerHTML');
1406
        this.editor.set('innerHTML', this._cleanHTML(startValue));
1407
 
1408
        return this;
1409
    },
1410
 
1411
    /**
1412
     * Clean the specified HTML content and remove any content which could cause issues.
1413
     *
1414
     * @method _cleanHTML
1415
     * @private
1416
     * @param {String} content The content to clean
1417
     * @param {Boolean} deepClean If true, do a more in depth (and resource intensive) cleaning of the HTML.
1418
     * @return {String} The cleaned HTML
1419
     */
1420
    _cleanHTML: function(content, deepClean) {
1421
        // Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
1422
 
1423
        var rules = [
1424
            // Remove any style blocks. Some browsers do not work well with them in a contenteditable.
1425
            // Plus style blocks are not allowed in body html, except with "scoped", which most browsers don't support as of 2015.
1426
            // Reference: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
1427
            {regex: /<style[^>]*>[\s\S]*?<\/style>/gi, replace: ""},
1428
 
1429
            // Remove any open HTML comment opens that are not followed by a close. This can completely break page layout.
1430
            {regex: /<!--(?![\s\S]*?-->)/gi, replace: ""},
1431
 
1432
            // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
1433
            // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body, link.
1434
            {regex: /<\/?(?:title|meta|style|st\d|head\b|font|html|body|link)[^>]*?>/gi, replace: ""}
1435
        ];
1436
 
1437
        content = this._filterContentWithRules(content, rules);
1438
 
1439
        if (deepClean) {
1440
            content = this._cleanHTMLLists(content);
1441
        }
1442
 
1443
        return content;
1444
    },
1445
 
1446
    /**
1447
     * Take the supplied content and run on the supplied regex rules.
1448
     *
1449
     * @method _filterContentWithRules
1450
     * @private
1451
     * @param {String} content The content to clean
1452
     * @param {Array} rules An array of structures: [ {regex: /something/, replace: "something"}, {...}, ...]
1453
     * @return {String} The cleaned content
1454
     */
1455
    _filterContentWithRules: function(content, rules) {
1456
        var i = 0;
1457
        for (i = 0; i < rules.length; i++) {
1458
            content = content.replace(rules[i].regex, rules[i].replace);
1459
        }
1460
 
1461
        return content;
1462
    },
1463
 
1464
    /**
1465
     * Intercept and clean html paste events.
1466
     *
1467
     * @method pasteCleanup
1468
     * @param {Object} sourceEvent The YUI EventFacade  object
1469
     * @return {Boolean} True if the passed event should continue, false if not.
1470
     */
1471
    pasteCleanup: function(sourceEvent) {
1472
        // We only expect paste events, but we will check anyways.
1473
        if (sourceEvent.type === 'paste') {
1474
            // Register the delayed paste cleanup. We will cancel it if we register the fallback cleanup.
1475
            var delayedCleanup = this.postPasteCleanupDelayed();
1476
            // The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
1477
            var event = sourceEvent._event;
1478
            // Check if we have a valid clipboardData object in the event.
1479
            // IE has a clipboard object at window.clipboardData, but as of IE 11, it does not provide HTML content access.
1480
            if (event && event.clipboardData && event.clipboardData.getData && event.clipboardData.types) {
1481
                // Check if there is HTML type to be pasted, if we can get it, we want to scrub before insert.
1482
                var types = event.clipboardData.types;
1483
                var isHTML = false;
1484
                // Different browsers use different containers to hold the types, so test various functions.
1485
                if (typeof types.contains === 'function') {
1486
                    isHTML = types.contains('text/html');
1487
                } else if (typeof types.indexOf === 'function') {
1488
                    isHTML = (types.indexOf('text/html') > -1);
1489
                }
1490
 
1491
                var content;
1492
                if (isHTML) {
1493
                    // Get the clipboard content.
1494
                    try {
1495
                        content = event.clipboardData.getData('text/html');
1496
                    } catch (error) {
1497
                        // Something went wrong. Fallback.
1498
                        delayedCleanup.cancel();
1499
                        this.fallbackPasteCleanupDelayed();
1500
                        return true;
1501
                    }
1502
 
1503
                    // Stop the original paste.
1504
                    sourceEvent.preventDefault();
1505
 
1506
                    // Scrub the paste content.
1507
                    content = this._cleanPasteHTML(content);
1508
 
1509
                    // Insert the content.
1510
                    this.insertContentAtFocusPoint(content);
1511
 
1512
                    // Update the text area.
1513
                    this.updateOriginal();
1514
                    return false;
1515
                } else {
1516
                    try {
1517
                        // Plaintext clipboard content can only be retrieved this way.
1518
                        content = event.clipboardData.getData('text');
1519
                    } catch (error) {
1520
                        // Something went wrong. Fallback.
1521
                        // Due to poor cross browser clipboard compatibility, the failure to find html doesn't mean it isn't there.
1522
                        // Wait for the clipboard event to finish then fallback clean the entire editor.
1523
                        delayedCleanup.cancel();
1524
                        this.fallbackPasteCleanupDelayed();
1525
                        return true;
1526
                    }
1527
                }
1528
            } else {
1529
                // If we reached a here, this probably means the browser has limited (or no) clipboard support.
1530
                // Wait for the clipboard event to finish then fallback clean the entire editor.
1531
                this.fallbackPasteCleanupDelayed();
1532
                return true;
1533
            }
1534
        }
1535
 
1536
        // We should never get here - we must have received a non-paste event for some reason.
1537
        // Um, just call updateOriginalDelayed() - it's safe.
1538
        this.updateOriginalDelayed();
1539
        return true;
1540
    },
1541
 
1542
    /**
1543
     * Calls postPasteCleanup on a short timer to allow the paste event handlers to complete, then deep clean the content.
1544
     *
1545
     * @method postPasteCleanupDelayed
1546
     * @return {object}
1547
     * @chainable
1548
     */
1549
    postPasteCleanupDelayed: function() {
1550
        Y.soon(Y.bind(this.postPasteCleanup, this));
1551
 
1552
        return this;
1553
    },
1554
 
1555
    /**
1556
     * Do additional cleanup after the paste is complete.
1557
     *
1558
     * @method postPasteCleanup
1559
     * @return {object}
1560
     * @chainable
1561
     */
1562
    postPasteCleanup: function() {
1563
 
1564
        // Save the current selection (cursor position).
1565
        var selection = window.rangy.saveSelection();
1566
 
1567
        // Get, clean, and replace the content in the editable.
1568
        var content = this.editor.get('innerHTML');
1569
        this.editor.set('innerHTML', this._cleanHTML(content, true));
1570
 
1571
        // Update the textarea.
1572
        this.updateOriginal();
1573
 
1574
        // Restore the selection (cursor position).
1575
        window.rangy.restoreSelection(selection);
1576
 
1577
        return this;
1578
    },
1579
 
1580
    /**
1581
     * Cleanup code after a paste event if we couldn't intercept the paste content.
1582
     *
1583
     * @method fallbackPasteCleanup
1584
     * @return {object}
1585
     * @chainable
1586
     */
1587
    fallbackPasteCleanup: function() {
1588
 
1589
        // Save the current selection (cursor position).
1590
        var selection = window.rangy.saveSelection();
1591
 
1592
        // Get, clean, and replace the content in the editable.
1593
        var content = this.editor.get('innerHTML');
1594
        this.editor.set('innerHTML', this._cleanHTML(this._cleanPasteHTML(content), true));
1595
 
1596
        // Update the textarea.
1597
        this.updateOriginal();
1598
 
1599
        // Restore the selection (cursor position).
1600
        window.rangy.restoreSelection(selection);
1601
 
1602
        return this;
1603
    },
1604
 
1605
    /**
1606
     * Calls fallbackPasteCleanup on a short timer to allow the paste event handlers to complete.
1607
     *
1608
     * @method fallbackPasteCleanupDelayed
1609
     * @chainable
1610
     */
1611
    fallbackPasteCleanupDelayed: function() {
1612
        Y.soon(Y.bind(this.fallbackPasteCleanup, this));
1613
 
1614
        return this;
1615
    },
1616
 
1617
    /**
1618
     * Cleanup html that comes from WYSIWYG paste events. These are more likely to contain messy code that we should strip.
1619
     *
1620
     * @method _cleanPasteHTML
1621
     * @private
1622
     * @param {String} content The html content to clean
1623
     * @return {String} The cleaned HTML
1624
     */
1625
    _cleanPasteHTML: function(content) {
1626
        // Return an empty string if passed an invalid or empty object.
1627
        if (!content || content.length === 0) {
1628
            return "";
1629
        }
1630
 
1631
        // Rules that get rid of the real-nasties and don't care about normalize code (correct quotes, white spaces, etc).
1632
        var rules = [
1633
            // Stuff that is specifically from MS Word and similar office packages.
1634
            // Remove all garbage after closing html tag.
1635
            {regex: /<\s*\/html\s*>([\s\S]+)$/gi, replace: ""},
1636
            // Remove if comment blocks.
1637
            {regex: /<!--\[if[\s\S]*?endif\]-->/gi, replace: ""},
1638
            // Remove start and end fragment comment blocks.
1639
            {regex: /<!--(Start|End)Fragment-->/gi, replace: ""},
1640
            // Remove any xml blocks.
1641
            {regex: /<xml[^>]*>[\s\S]*?<\/xml>/gi, replace: ""},
1642
            // Remove any <?xml><\?xml> blocks.
1643
            {regex: /<\?xml[^>]*>[\s\S]*?<\\\?xml>/gi, replace: ""},
1644
            // Remove <o:blah>, <\o:blah>.
1645
            {regex: /<\/?\w+:[^>]*>/gi, replace: ""}
1646
        ];
1647
 
1648
        // Apply the first set of harsher rules.
1649
        content = this._filterContentWithRules(content, rules);
1650
 
1651
        // Apply the standard rules, which mainly cleans things like headers, links, and style blocks.
1652
        content = this._cleanHTML(content);
1653
 
1654
        // Check if the string is empty or only contains whitespace.
1655
        if (content.length === 0 || !content.match(/\S/)) {
1656
            return content;
1657
        }
1658
 
1659
        // Now we let the browser normalize the code by loading it into the DOM and then get the html back.
1660
        // This gives us well quoted, well formatted code to continue our work on. Word may provide very poorly formatted code.
1661
        var holder = document.createElement('div');
1662
        holder.innerHTML = content;
1663
        content = holder.innerHTML;
1664
        // Free up the DOM memory.
1665
        holder.innerHTML = "";
1666
 
1667
        // Run some more rules that care about quotes and whitespace.
1668
        rules = [
1669
            // Get all class attributes so we can work on them.
1670
            {regex: /(<[^>]*?class\s*?=\s*?")([^>"]*)(")/gi, replace: function(match, group1, group2, group3) {
1671
                    // Remove MSO classes.
1672
                    group2 = group2.replace(/(?:^|[\s])[\s]*MSO[_a-zA-Z0-9\-]*/gi, "");
1673
                    // Remove Apple- classes.
1674
                    group2 = group2.replace(/(?:^|[\s])[\s]*Apple-[_a-zA-Z0-9\-]*/gi, "");
1675
                    return group1 + group2 + group3;
1676
                }},
1677
            // Remove OLE_LINK# anchors that may litter the code.
1678
            {regex: /<a [^>]*?name\s*?=\s*?"OLE_LINK\d*?"[^>]*?>\s*?<\/a>/gi, replace: ""}
1679
        ];
1680
 
1681
        // Clean all style attributes from the text.
1682
        content = this._cleanStyles(content);
1683
 
1684
        // Apply the rules.
1685
        content = this._filterContentWithRules(content, rules);
1686
 
1687
        // Reapply the standard cleaner to the content.
1688
        content = this._cleanHTML(content);
1689
 
1690
        // Clean unused spans out of the content.
1691
        content = this._cleanSpans(content);
1692
 
1693
        return content;
1694
    },
1695
 
1696
    /**
1697
     * Clean all inline styles from pasted text.
1698
     *
1699
     * This code intentionally doesn't use YUI Nodes. YUI was quite a bit slower at this, so using raw DOM objects instead.
1700
     *
1701
     * @method _cleanStyles
1702
     * @private
1703
     * @param {String} content The content to clean
1704
     * @return {String} The cleaned HTML
1705
     */
1706
    _cleanStyles: function(content) {
1707
        var holder = document.createElement('div');
1708
        holder.innerHTML = content;
1709
        var elementsWithStyle = holder.querySelectorAll('[style]');
1710
        var i = 0;
1711
 
1712
        for (i = 0; i < elementsWithStyle.length; i++) {
1713
            elementsWithStyle[i].removeAttribute('style');
1714
        }
1715
 
1716
        var elementsWithClass = holder.querySelectorAll('[class]');
1717
        for (i = 0; i < elementsWithClass.length; i++) {
1718
            elementsWithClass[i].removeAttribute('class');
1719
        }
1720
 
1721
        return holder.innerHTML;
1722
    },
1723
    /**
1724
     * Clean empty or un-unused spans from passed HTML.
1725
     *
1726
     * This code intentionally doesn't use YUI Nodes. YUI was quite a bit slower at this, so using raw DOM objects instead.
1727
     *
1728
     * @method _cleanSpans
1729
     * @private
1730
     * @param {String} content The content to clean
1731
     * @return {String} The cleaned HTML
1732
     */
1733
    _cleanSpans: function(content) {
1734
        // Return an empty string if passed an invalid or empty object.
1735
        if (!content || content.length === 0) {
1736
            return "";
1737
        }
1738
        // Check if the string is empty or only contains whitespace.
1739
        if (content.length === 0 || !content.match(/\S/)) {
1740
            return content;
1741
        }
1742
 
1743
        var rules = [
1744
            // Remove unused class, style, or id attributes. This will make empty tag detection easier later.
1745
            {regex: /(<[^>]*?)(?:[\s]*(?:class|style|id)\s*?=\s*?"\s*?")+/gi, replace: "$1"}
1746
        ];
1747
        // Apply the rules.
1748
        content = this._filterContentWithRules(content, rules);
1749
 
1750
        // Reference: "http://stackoverflow.com/questions/8131396/remove-nested-span-without-id"
1751
 
1752
        // This is better to run detached from the DOM, so the browser doesn't try to update on each change.
1753
        var holder = document.createElement('div');
1754
        holder.innerHTML = content;
1755
        var spans = holder.getElementsByTagName('span');
1756
 
1757
        // Since we will be removing elements from the list, we should copy it to an array, making it static.
1758
        var spansarr = Array.prototype.slice.call(spans, 0);
1759
 
1760
        spansarr.forEach(function(span) {
1761
            if (!span.hasAttributes()) {
1762
                // If no attributes (id, class, style, etc), this span is has no effect.
1763
                // Move each child (if they exist) to the parent in place of this span.
1764
                while (span.firstChild) {
1765
                    span.parentNode.insertBefore(span.firstChild, span);
1766
                }
1767
 
1768
                // Remove the now empty span.
1769
                span.parentNode.removeChild(span);
1770
            }
1771
        });
1772
 
1773
        return holder.innerHTML;
1774
    },
1775
 
1776
    /**
1777
     * This is a function that searches for, and attempts to correct certain issues with ul/ol html lists.
1778
     * This is needed because these lists are used heavily in page layout, and content with bad tags can
1779
     * lead to broke course pages.
1780
     *
1781
     * The theory of operation here is to linearly process the incoming content, counting the opening and closing
1782
     * of list tags, and determining when there is a mismatch.
1783
     *
1784
     * The specific issues this should be able to correct are:
1785
     * - Orphaned li elements will be wrapped in a set of ul tags.
1786
     * - li elements inside li elements.
1787
     * - An extra closing ul, or ol tag will be discarded.
1788
     * - An extra closing li tag will have an opening tag added if appropriate, or will be discarded.
1789
     * - If there is an unmatched list open tag, a matching close tag will be inserted.
1790
     *
1791
     * It does it's best to match the case of corrected tags. Even though not required by html spec,
1792
     * it seems like the safer route.
1793
     *
1794
     * A note on parent elements of li. This code assumes that li must have a ol or ul parent.
1795
     * There are two other potential other parents of li. They are menu and dir. The dir tag was deprecated in
1796
     * HTML4, and removed in HTML5. The menu tag is experimental as of this writing, and basically doesn't work
1797
     * in any browsers, even Firefox, which theoretically has limited support for it. If other parents of li
1798
     * become viable, they will need to be added to this code.
1799
     *
1800
     * @method _cleanHTMLLists
1801
     * @private
1802
     * @param {String} content The content to clean
1803
     * @return {String} The cleaned content
1804
     */
1805
    _cleanHTMLLists: function(content) {
1806
        var output = '',
1807
            toProcess = content,
1808
            match = null,
1809
            openTags = [],
1810
            currentTag = null,
1811
            previousTag = null;
1812
 
1813
        // Use a regular expression to find the next open or close li, ul, or ol tag.
1814
        // Keep going until there are no more matching tags left.
1815
        // This expression looks for whole words by employing the word boundary (\b) metacharacter.
1816
        while ((match = toProcess.match(/<(\/?)(li|ul|ol)\b[^>]*>/i))) {
1817
            currentTag = {
1818
                tag: match[2],
1819
                tagLowerCase: match[2].toLowerCase(),
1820
                fullTag: match[0],
1821
                isOpen: (match[1].length == 1) ? false : true
1822
            };
1823
 
1824
            // Get the most recent open tag.
1825
            previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
1826
 
1827
            // Slice up the content based on the match and add content before the match to output.
1828
            output += toProcess.slice(0, match.index);
1829
            toProcess = toProcess.slice(match.index + match[0].length);
1830
 
1831
            // Now the full content is in output + currentTag.fullTag + toProcess. When making fixes, it is best to push the fix and
1832
            // fullTag back onto the front or toProcess, then restart the loop. This allows processing to follow the normal path
1833
            // most often. But sometimes we will need to modify output to insert or remove tags in the already complete code.
1834
 
1835
            if (currentTag.isOpen) {
1836
                // We are at the opening phase of a tag.
1837
                // We have to do special processing for list items, as they can only be children of ul and ol tags.
1838
                if (currentTag.tagLowerCase === 'li') {
1839
                    if (!previousTag) {
1840
                        // This means we have are opening a li, but aren't in a list. This is not allowed!
1841
 
1842
                        // We are going to check for the count of open and close ol tags ahead to decide what to do.
1843
                        var closeCount = (toProcess.match(/<\/(ol)[ >]/ig) || []).length;
1844
                        var openCount = (toProcess.match(/<(ol)[ >]/ig) || []).length;
1845
 
1846
                        if (closeCount > openCount) {
1847
                            // There are more close ol's ahead than opens ahead. So open the ol and try again.
1848
                            toProcess = '<ol>' + currentTag.fullTag + toProcess;
1849
                            continue;
1850
                        }
1851
 
1852
                        // For the other cases, just open a ul and try again. Later the closing ul will get matched if it exists,
1853
                        // or if it doesn't one will automatically get inserted.
1854
                        toProcess = '<ul>' + currentTag.fullTag + toProcess;
1855
                        continue;
1856
                    }
1857
 
1858
                    if (previousTag.tagLowerCase === 'li') {
1859
                        // You aren't allowed to nest li tags. Close the current one before starting the new one.
1860
                        toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
1861
                        continue;
1862
                    }
1863
 
1864
                    // Previous tag must be a list at this point, so we can continue.
1865
                }
1866
 
1867
                // If we made it this far, record the tag to the open tags list.
1868
                openTags.push({
1869
                    tag: currentTag.tag,
1870
                    tagLowerCase: currentTag.tagLowerCase,
1871
                    position: output.length,
1872
                    length: currentTag.fullTag.length
1873
                });
1874
            } else {
1875
                // We are processing a closing tag.
1876
 
1877
                if (openTags.length == 0) {
1878
                    // We are closing a tag that isn't open. That's a problem. Just discarding should be safe.
1879
                    continue;
1880
                }
1881
 
1882
                if (previousTag.tagLowerCase === currentTag.tagLowerCase) {
1883
                    // Closing a tag that matches the open tag. This is the nominal case. Pop it off, and update previousTag.
1884
                    if (currentTag.tag != previousTag.tag) {
1885
                        // This would mean cases don't match between the opening and closing tag.
1886
                        // We are going to swap them to match, even though not required.
1887
                        currentTag.fullTag = currentTag.fullTag.replace(currentTag.tag, previousTag.tag);
1888
                    }
1889
 
1890
                    openTags.pop();
1891
                    previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
1892
                } else {
1893
                    // We are closing a tag that isn't the most recent open one open, so we have a mismatch.
1894
                    if (currentTag.tagLowerCase === 'li' && previousTag.liEnd && (previousTag.liEnd < output.length)) {
1895
                        // We are closing an unopened li, but the parent list has complete li tags more than 0 chars ago.
1896
                        // Assume we are missing an open li at the end of the previous li, and insert there.
1897
                        output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.liEnd);
1898
                    } else if (currentTag.tagLowerCase === 'li' && !previousTag.liEnd &&
1899
                            ((previousTag.position + previousTag.length) < output.length)) {
1900
                        // We are closing an unopened li, and the parent has no previous lis in it, but opened more than 0
1901
                        // chars ago. Assume we are missing a starting li, and insert it right after the list opened.
1902
                        output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.position + previousTag.length);
1903
                    } else if (previousTag.tagLowerCase === 'li') {
1904
                        // We must be trying to close a ul/ol while in a li. Just assume we are missing a closing li.
1905
                        toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
1906
                        continue;
1907
                    } else {
1908
                        // Here we must be trying to close a tag that isn't open, or is open higher up. Just discard.
1909
                        // If there ends up being a missing close tag later on, that will get fixed separately.
1910
                        continue;
1911
                    }
1912
                }
1913
 
1914
                // If we have a valid closing li tag, and a list, record where the li ended.
1915
                if (currentTag.tagLowerCase === 'li' && previousTag) {
1916
                    previousTag.liEnd = output.length + currentTag.fullTag.length;
1917
                }
1918
 
1919
            }
1920
 
1921
            // Now we can add the tag to the output.
1922
            output += currentTag.fullTag;
1923
        }
1924
 
1925
        // Add anything left in toProcess to the output.
1926
        output += toProcess;
1927
 
1928
        // Anything still in the openTags list are extra and need to be dealt with.
1929
        if (openTags.length) {
1930
            // Work on the list in reverse order so positions stay correct.
1931
            while ((currentTag = openTags.pop())) {
1932
                if (currentTag.liEnd) {
1933
                    // We have a position for the last list item in this element. Insert the closing it after that.
1934
                    output = this._insertString(output, '</' + currentTag.tag + '>', currentTag.liEnd);
1935
                } else {
1936
                    // If there weren't any children list items, then we should just remove the tag where it started.
1937
                    // This will also remote an open li tag that runs to the end of the content, since it has no children lis.
1938
                    output = output.slice(0, currentTag.position) + output.slice(currentTag.position + currentTag.length);
1939
                }
1940
            }
1941
        }
1942
 
1943
        return output;
1944
    },
1945
 
1946
    /**
1947
     * Insert a string in the middle of an existing string at the specified location.
1948
     *
1949
     * @method _insertString
1950
     * @param {String} content The subject of the insertion.
1951
     * @param {String} insert The string that will be inserted.
1952
     * @param {Number} position The location to make the insertion.
1953
     * @return {String} The string with the new content inserted.
1954
     */
1955
    _insertString: function(content, insert, position) {
1956
        return content.slice(0, position) + insert + content.slice(position);
1957
    }
1958
};
1959
 
1960
Y.Base.mix(Y.M.editor_atto.Editor, [EditorClean]);
1961
// This file is part of Moodle - http://moodle.org/
1962
//
1963
// Moodle is free software: you can redistribute it and/or modify
1964
// it under the terms of the GNU General Public License as published by
1965
// the Free Software Foundation, either version 3 of the License, or
1966
// (at your option) any later version.
1967
//
1968
// Moodle is distributed in the hope that it will be useful,
1969
// but WITHOUT ANY WARRANTY; without even the implied warranty of
1970
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1971
// GNU General Public License for more details.
1972
//
1973
// You should have received a copy of the GNU General Public License
1974
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1975
 
1976
/**
1977
 * @module moodle-editor_atto-editor
1978
 * @submodule commands
1979
 */
1980
 
1981
/**
1982
 * Selection functions for the Atto editor.
1983
 *
1984
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1985
 *
1986
 * @namespace M.editor_atto
1987
 * @class EditorCommand
1988
 */
1989
 
1990
function EditorCommand() {}
1991
 
1992
EditorCommand.ATTRS = {
1993
};
1994
 
1995
EditorCommand.prototype = {
1996
    /**
1997
     * Applies a callback method to editor if selection is uncollapsed or waits for input to select first.
1998
     * @method applyFormat
1999
     * @param e EventTarget Event to be passed to callback if selection is uncollapsed
2000
     * @param method callback A callback method which changes editor when text is selected.
2001
     * @param object context Context to be used for callback method
2002
     * @param array args Array of arguments to pass to callback
2003
     */
2004
    applyFormat: function(e, callback, context, args) {
2005
        function handleInsert(e, callback, context, args, anchorNode, anchorOffset) {
2006
            // After something is inputed, select it and apply the formating function.
2007
            Y.soon(Y.bind(function(e, callback, context, args, anchorNode, anchorOffset) {
2008
                var selection = window.rangy.getSelection();
2009
 
2010
                // Set the start of the selection to where it was when the method was first called.
2011
                var range = selection.getRangeAt(0);
2012
                range.setStart(anchorNode, anchorOffset);
2013
                selection.setSingleRange(range);
2014
 
2015
                // Now apply callback to the new text that is selected.
2016
                callback.apply(context, [e, args]);
2017
 
2018
                // Collapse selection so cursor is at end of inserted material.
2019
                selection.collapseToEnd();
2020
 
2021
                // Save save selection and editor contents.
2022
                this.saveSelection();
2023
                this.updateOriginal();
2024
            }, this, e, callback, context, args, anchorNode, anchorOffset));
2025
        }
2026
 
2027
        // Set default context for the method.
2028
        context = context || this;
2029
 
2030
        // Check whether range is collapsed.
2031
        var selection = window.rangy.getSelection();
2032
 
2033
        if (selection.isCollapsed) {
2034
            // Selection is collapsed so listen for input into editor.
2035
            var handle = this.editor.once('input', handleInsert, this, callback, context, args,
2036
                    selection.anchorNode, selection.anchorOffset);
2037
 
2038
            // Cancel if selection changes before input.
2039
            this.editor.onceAfter(['click', 'selectstart'], handle.detach, handle);
2040
 
2041
            return;
2042
        }
2043
 
2044
        // The range is not collapsed; so apply callback method immediately.
2045
        callback.apply(context, [e, args]);
2046
 
2047
        // Save save selection and editor contents.
2048
        this.saveSelection();
2049
        this.updateOriginal();
2050
    },
2051
 
2052
    /**
2053
     * Replaces all the tags in a node list with new type.
2054
     * @method replaceTags
2055
     * @param NodeList nodelist
2056
     * @param String tag
2057
     */
2058
    replaceTags: function(nodelist, tag) {
2059
        // We mark elements in the node list for iterations.
2060
        nodelist.setAttribute('data-iterate', true);
2061
        var node = this.editor.one('[data-iterate="true"]');
2062
        while (node) {
2063
            var clone = Y.Node.create('<' + tag + ' />')
2064
                .setAttrs(node.getAttrs())
2065
                .removeAttribute('data-iterate');
2066
            // Copy class and style if not blank.
2067
            if (node.getAttribute('style')) {
2068
                clone.setAttribute('style', node.getAttribute('style'));
2069
            }
2070
            if (node.getAttribute('class')) {
2071
                clone.setAttribute('class', node.getAttribute('class'));
2072
            }
2073
            // We use childNodes here because we are interested in both type 1 and 3 child nodes.
2074
            var children = node.getDOMNode().childNodes;
2075
            var child;
2076
            child = children[0];
2077
            while (typeof child !== "undefined") {
2078
                clone.append(child);
2079
                child = children[0];
2080
            }
2081
            node.replace(clone);
2082
            node = this.editor.one('[data-iterate="true"]');
2083
        }
2084
    },
2085
 
2086
    /**
2087
     * Change all tags with given type to a span with CSS class attribute.
2088
     * @method changeToCSS
2089
     * @param String tag Tag type to be changed to span
2090
     * @param String markerClass CSS class that corresponds to desired tag
2091
     */
2092
    changeToCSS: function(tag, markerClass) {
2093
        // Save the selection.
2094
        var selection = window.rangy.saveSelection();
2095
 
2096
        // Remove display:none from rangy markers so browser doesn't delete them.
2097
        this.editor.all('.rangySelectionBoundary').setStyle('display', null);
2098
 
2099
        // Replace tags with CSS classes.
2100
        this.editor.all(tag).addClass(markerClass);
2101
        this.replaceTags(this.editor.all('.' + markerClass), 'span');
2102
 
2103
        // Restore selection and toggle class.
2104
        window.rangy.restoreSelection(selection);
2105
    },
2106
 
2107
    /**
2108
     * Change spans with CSS classes in editor into elements with given tag.
2109
     * @method changeToCSS
2110
     * @param String markerClass CSS class that corresponds to desired tag
2111
     * @param String tag New tag type to be created
2112
     */
2113
    changeToTags: function(markerClass, tag) {
2114
        // Save the selection.
2115
        var selection = window.rangy.saveSelection();
2116
 
2117
        // Remove display:none from rangy markers so browser doesn't delete them.
2118
        this.editor.all('.rangySelectionBoundary').setStyle('display', null);
2119
 
2120
        // Replace spans with given tag.
2121
        this.replaceTags(this.editor.all('span[class="' + markerClass + '"]'), tag);
2122
        this.editor.all(tag + '[class="' + markerClass + '"]').removeAttribute('class');
2123
        this.editor.all('.' + markerClass).each(function(n) {
2124
            n.wrap('<' + tag + '/>');
2125
            n.removeClass(markerClass);
2126
        });
2127
 
2128
        // Remove CSS classes.
2129
        this.editor.all('[class="' + markerClass + '"]').removeAttribute('class');
2130
        this.editor.all(tag).removeClass(markerClass);
2131
 
2132
        // Restore selection.
2133
        window.rangy.restoreSelection(selection);
2134
    }
2135
};
2136
 
2137
Y.Base.mix(Y.M.editor_atto.Editor, [EditorCommand]);
2138
// This file is part of Moodle - http://moodle.org/
2139
//
2140
// Moodle is free software: you can redistribute it and/or modify
2141
// it under the terms of the GNU General Public License as published by
2142
// the Free Software Foundation, either version 3 of the License, or
2143
// (at your option) any later version.
2144
//
2145
// Moodle is distributed in the hope that it will be useful,
2146
// but WITHOUT ANY WARRANTY; without even the implied warranty of
2147
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
2148
// GNU General Public License for more details.
2149
//
2150
// You should have received a copy of the GNU General Public License
2151
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
2152
 
2153
/**
2154
 * @module moodle-editor_atto-editor
2155
 * @submodule toolbar
2156
 */
2157
 
2158
/**
2159
 * Toolbar functions for the Atto editor.
2160
 *
2161
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2162
 *
2163
 * @namespace M.editor_atto
2164
 * @class EditorToolbar
2165
 */
2166
 
2167
function EditorToolbar() {}
2168
 
2169
EditorToolbar.ATTRS = {
2170
};
2171
 
2172
EditorToolbar.prototype = {
2173
    /**
2174
     * A reference to the toolbar Node.
2175
     *
2176
     * @property toolbar
2177
     * @type Node
2178
     */
2179
    toolbar: null,
2180
 
2181
    /**
2182
     * A reference to any currently open menus in the toolbar.
2183
     *
2184
     * @property openMenus
2185
     * @type Array
2186
     */
2187
    openMenus: null,
2188
 
2189
    /**
2190
     * Setup the toolbar on the editor.
2191
     *
2192
     * @method setupToolbar
2193
     * @chainable
2194
     */
2195
    setupToolbar: function() {
2196
        this.toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" role="toolbar" aria-live="off"/>');
2197
        this.openMenus = [];
2198
        this._wrapper.appendChild(this.toolbar);
2199
 
2200
        if (this.textareaLabel) {
2201
            this.toolbar.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
2202
        }
2203
 
2204
        // Add keyboard navigation for the toolbar.
2205
        this.setupToolbarNavigation();
2206
 
2207
        return this;
2208
    }
2209
};
2210
 
2211
Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbar]);
2212
// This file is part of Moodle - http://moodle.org/
2213
//
2214
// Moodle is free software: you can redistribute it and/or modify
2215
// it under the terms of the GNU General Public License as published by
2216
// the Free Software Foundation, either version 3 of the License, or
2217
// (at your option) any later version.
2218
//
2219
// Moodle is distributed in the hope that it will be useful,
2220
// but WITHOUT ANY WARRANTY; without even the implied warranty of
2221
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
2222
// GNU General Public License for more details.
2223
//
2224
// You should have received a copy of the GNU General Public License
2225
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
2226
 
2227
/**
2228
 * @module moodle-editor_atto-editor
2229
 * @submodule toolbarnav
2230
 */
2231
 
2232
/**
2233
 * Toolbar Navigation functions for the Atto editor.
2234
 *
2235
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2236
 *
2237
 * @namespace M.editor_atto
2238
 * @class EditorToolbarNav
2239
 */
2240
 
2241
function EditorToolbarNav() {}
2242
 
2243
EditorToolbarNav.ATTRS = {
2244
};
2245
 
2246
EditorToolbarNav.prototype = {
2247
    /**
2248
     * The current focal point for tabbing.
2249
     *
2250
     * @property _tabFocus
2251
     * @type Node
2252
     * @default null
2253
     * @private
2254
     */
2255
    _tabFocus: null,
2256
 
2257
    /**
2258
     * Set up the watchers for toolbar navigation.
2259
     *
2260
     * @method setupToolbarNavigation
2261
     * @chainable
2262
     */
2263
    setupToolbarNavigation: function() {
2264
        // Listen for Arrow left and Arrow right keys.
2265
        this._wrapper.delegate('key',
2266
                this.toolbarKeyboardNavigation,
2267
                'down:37,39',
2268
                '.' + CSS.TOOLBAR,
2269
                this);
2270
        this._wrapper.delegate('focus',
2271
                function(e) {
2272
                    this._setTabFocus(e.currentTarget);
2273
                }, '.' + CSS.TOOLBAR + ' button', this);
2274
 
2275
        return this;
2276
    },
2277
 
2278
    /**
2279
     * Implement arrow key navigation for the buttons in the toolbar.
2280
     *
2281
     * @method toolbarKeyboardNavigation
2282
     * @param {EventFacade} e - the keyboard event.
2283
     */
2284
    toolbarKeyboardNavigation: function(e) {
2285
        // Prevent the default browser behaviour.
2286
        e.preventDefault();
2287
 
2288
        // On cursor moves we loops through the buttons.
2289
        var buttons = this.toolbar.all('button'),
2290
            direction = 1,
2291
            button,
2292
            current = e.target.ancestor('button', true),
2293
            innerButtons = e.target.all('button');
2294
 
2295
        // If we are not on a button and the element we are on contains some buttons, then move between the inner buttons.
2296
        if (!current && innerButtons) {
2297
            buttons = innerButtons;
2298
        }
2299
 
2300
        if (e.keyCode === 37) {
2301
            // Moving left so reverse the direction.
2302
            direction = -1;
2303
        }
2304
 
2305
        button = this._findFirstFocusable(buttons, current, direction);
2306
        if (button) {
2307
            button.focus();
2308
            this._setTabFocus(button);
2309
        } else {
2310
        }
2311
    },
2312
 
2313
    /**
2314
     * Find the first focusable button.
2315
     *
2316
     * @param {NodeList} buttons A list of nodes.
2317
     * @param {Node} startAt The node in the list to start the search from.
2318
     * @param {Number} direction The direction in which to search (1 or -1).
2319
     * @return {Node | Undefined} The Node or undefined.
2320
     * @method _findFirstFocusable
2321
     * @private
2322
     */
2323
    _findFirstFocusable: function(buttons, startAt, direction) {
2324
        var checkCount = 0,
2325
            candidate,
2326
            button,
2327
            index;
2328
 
2329
        // Determine which button to start the search from.
2330
        index = buttons.indexOf(startAt);
2331
        if (index < -1) {
2332
            index = 0;
2333
        }
2334
 
2335
        // Try to find the next.
2336
        while (checkCount < buttons.size()) {
2337
            index += direction;
2338
            if (index < 0) {
2339
                index = buttons.size() - 1;
2340
            } else if (index >= buttons.size()) {
2341
                // Handle wrapping.
2342
                index = 0;
2343
            }
2344
 
2345
            candidate = buttons.item(index);
2346
 
2347
            // Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
2348
            checkCount++;
2349
 
2350
            // Loop while:
2351
            // * we haven't checked every button;
2352
            // * the button is hidden or disabled;
2353
            // * the button is inside a hidden wrapper element.
2354
            if (candidate.hasAttribute('hidden') || candidate.hasAttribute('disabled') || candidate.ancestor('[hidden]')) {
2355
                continue;
2356
            }
2357
 
2358
            button = candidate;
2359
            break;
2360
        }
2361
 
2362
        return button;
2363
    },
2364
 
2365
    /**
2366
     * Check the tab focus.
2367
     *
2368
     * When we disable or hide a button, we should call this method to ensure that the
2369
     * focus is not currently set on an inaccessible button, otherwise tabbing to the toolbar
2370
     * would be impossible.
2371
     *
2372
     * @method checkTabFocus
2373
     * @chainable
2374
     */
2375
    checkTabFocus: function() {
2376
        if (this._tabFocus) {
2377
            if (this._tabFocus.hasAttribute('disabled') || this._tabFocus.hasAttribute('hidden')
2378
                    || this._tabFocus.ancestor('.atto_group').hasAttribute('hidden')) {
2379
                // Find first available button.
2380
                var button = this._findFirstFocusable(this.toolbar.all('button'), this._tabFocus, -1);
2381
                if (button) {
2382
                    if (this._tabFocus.compareTo(document.activeElement)) {
2383
                        // We should also move the focus, because the inaccessible button also has the focus.
2384
                        button.focus();
2385
                    }
2386
                    this._setTabFocus(button);
2387
                }
2388
            }
2389
        }
2390
        return this;
2391
    },
2392
 
2393
    /**
2394
     * Sets tab focus for the toolbar to the specified Node.
2395
     *
2396
     * @method _setTabFocus
2397
     * @param {Node} button The node that focus should now be set to
2398
     * @chainable
2399
     * @private
2400
     */
2401
    _setTabFocus: function(button) {
2402
        if (this._tabFocus) {
2403
            // Unset the previous entry.
2404
            this._tabFocus.setAttribute('tabindex', '-1');
2405
        }
2406
 
2407
        // Set up the new entry.
2408
        this._tabFocus = button;
2409
        this._tabFocus.setAttribute('tabindex', 0);
2410
 
2411
        // And update the activedescendant to point at the currently selected button.
2412
        this.toolbar.setAttribute('aria-activedescendant', this._tabFocus.generateID());
2413
 
2414
        return this;
2415
    }
2416
};
2417
 
2418
Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbarNav]);
2419
// This file is part of Moodle - http://moodle.org/
2420
//
2421
// Moodle is free software: you can redistribute it and/or modify
2422
// it under the terms of the GNU General Public License as published by
2423
// the Free Software Foundation, either version 3 of the License, or
2424
// (at your option) any later version.
2425
//
2426
// Moodle is distributed in the hope that it will be useful,
2427
// but WITHOUT ANY WARRANTY; without even the implied warranty of
2428
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
2429
// GNU General Public License for more details.
2430
//
2431
// You should have received a copy of the GNU General Public License
2432
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
2433
/**
2434
 * @module moodle-editor_atto-editor
2435
 * @submodule selection
2436
 */
2437
 
2438
/**
2439
 * Selection functions for the Atto editor.
2440
 *
2441
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2442
 *
2443
 * @namespace M.editor_atto
2444
 * @class EditorSelection
2445
 */
2446
 
2447
function EditorSelection() {}
2448
 
2449
EditorSelection.ATTRS = {
2450
};
2451
 
2452
EditorSelection.prototype = {
2453
 
2454
    /**
2455
     * List of saved selections per editor instance.
2456
     *
2457
     * @property _selections
2458
     * @private
2459
     */
2460
    _selections: null,
2461
 
2462
    /**
2463
     * A unique identifier for the last selection recorded.
2464
     *
2465
     * @property _lastSelection
2466
     * @param lastselection
2467
     * @type string
2468
     * @private
2469
     */
2470
    _lastSelection: null,
2471
 
2472
    /**
2473
     * Whether focus came from a click event.
2474
     *
2475
     * This is used to determine whether to restore the selection or not.
2476
     *
2477
     * @property _focusFromClick
2478
     * @type Boolean
2479
     * @default false
2480
     * @private
2481
     */
2482
    _focusFromClick: false,
2483
 
2484
    /**
2485
     * Whether if the last gesturemovestart event target was contained in this editor or not.
2486
     *
2487
     * @property _gesturestartededitor
2488
     * @type Boolean
2489
     * @default false
2490
     * @private
2491
     */
2492
    _gesturestartededitor: false,
2493
 
2494
    /**
2495
     * Set up the watchers for selection save and restoration.
2496
     *
2497
     * @method setupSelectionWatchers
2498
     * @chainable
2499
     */
2500
    setupSelectionWatchers: function() {
2501
        // Save the selection when a change was made.
2502
        this.on('atto:selectionchanged', this.saveSelection, this);
2503
 
2504
        this.editor.on('focus', this.restoreSelection, this);
2505
 
2506
        // Do not restore selection when focus is from a click event.
2507
        this.editor.on('mousedown', function() {
2508
            this._focusFromClick = true;
2509
        }, this);
2510
 
2511
        // Copy the current value back to the textarea when focus leaves us and save the current selection.
2512
        this.editor.on('blur', function() {
2513
            // Clear the _focusFromClick value.
2514
            this._focusFromClick = false;
2515
 
2516
            // Update the original text area.
2517
            this.updateOriginal();
2518
        }, this);
2519
 
2520
        this.editor.on(['keyup', 'focus'], function(e) {
2521
                Y.soon(Y.bind(this._hasSelectionChanged, this, e));
2522
            }, this);
2523
 
2524
        Y.one(document.body).on('gesturemovestart', function(e) {
2525
            if (this._wrapper.contains(e.target._node)) {
2526
                this._gesturestartededitor = true;
2527
            } else {
2528
                this._gesturestartededitor = false;
2529
            }
2530
        }, null, this);
2531
 
2532
        Y.one(document.body).on('gesturemoveend', function(e) {
2533
            if (!this._gesturestartededitor) {
2534
                // Ignore the event if movestart target was not contained in the editor.
2535
                return;
2536
            }
2537
            Y.soon(Y.bind(this._hasSelectionChanged, this, e));
2538
        }, {
2539
            // Standalone will make sure all editors receive the end event.
2540
            standAlone: true
2541
        }, this);
2542
 
2543
        return this;
2544
    },
2545
 
2546
    /**
2547
     * Work out if the cursor is in the editable area for this editor instance.
2548
     *
2549
     * @method isActive
2550
     * @return {boolean}
2551
     */
2552
    isActive: function() {
2553
        var range = rangy.createRange(),
2554
            selection = rangy.getSelection();
2555
 
2556
        if (!selection.rangeCount) {
2557
            // If there was no range count, then there is no selection.
2558
            return false;
2559
        }
2560
 
2561
        // We can't be active if the editor doesn't have focus at the moment.
2562
        if (!document.activeElement ||
2563
                !(this.editor.compareTo(document.activeElement) || this.editor.contains(document.activeElement))) {
2564
            return false;
2565
        }
2566
 
2567
        // Check whether the range intersects the editor selection.
2568
        range.selectNode(this.editor.getDOMNode());
2569
        return range.intersectsRange(selection.getRangeAt(0));
2570
    },
2571
 
2572
    /**
2573
     * Create a cross browser selection object that represents a YUI node.
2574
     *
2575
     * @method getSelectionFromNode
2576
     * @param {Node} YUI Node to base the selection upon.
2577
     * @return {[rangy.Range]}
2578
     */
2579
    getSelectionFromNode: function(node) {
2580
        var range = rangy.createRange();
2581
        range.selectNode(node.getDOMNode());
2582
        return [range];
2583
    },
2584
 
2585
    /**
2586
     * Save the current selection to an internal property.
2587
     *
2588
     * This allows more reliable return focus, helping improve keyboard navigation.
2589
     *
2590
     * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/restoreSelection"}}{{/crossLink}}.
2591
     *
2592
     * @method saveSelection
2593
     */
2594
    saveSelection: function() {
2595
        if (this.isActive()) {
2596
            this._selections = this.getSelection();
2597
        }
2598
    },
2599
 
2600
    /**
2601
     * Restore any stored selection when the editor gets focus again.
2602
     *
2603
     * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/saveSelection"}}{{/crossLink}}.
2604
     *
2605
     * @method restoreSelection
2606
     */
2607
    restoreSelection: function() {
2608
        if (!this._focusFromClick) {
2609
            if (this._selections) {
2610
                this.setSelection(this._selections);
2611
            }
2612
        }
2613
        this._focusFromClick = false;
2614
    },
2615
 
2616
    /**
2617
     * Get the selection object that can be passed back to setSelection.
2618
     *
2619
     * @method getSelection
2620
     * @return {array} An array of rangy ranges.
2621
     */
2622
    getSelection: function() {
2623
        return rangy.getSelection().getAllRanges();
2624
    },
2625
 
2626
    /**
2627
     * Check that a YUI node it at least partly contained by the current selection.
2628
     *
2629
     * @method selectionContainsNode
2630
     * @param {Node} The node to check.
2631
     * @return {boolean}
2632
     */
2633
    selectionContainsNode: function(node) {
2634
        return rangy.getSelection().containsNode(node.getDOMNode(), true);
2635
    },
2636
 
2637
    /**
2638
     * Runs a filter on each node in the selection, and report whether the
2639
     * supplied selector(s) were found in the supplied Nodes.
2640
     *
2641
     * By default, all specified nodes must match the selection, but this
2642
     * can be controlled with the requireall property.
2643
     *
2644
     * @method selectionFilterMatches
2645
     * @param {String} selector
2646
     * @param {NodeList} [selectednodes] For performance this should be passed. If not passed, this will be looked up each time.
2647
     * @param {Boolean} [requireall=true] Used to specify that "any" match is good enough.
2648
     * @return {Boolean}
2649
     */
2650
    selectionFilterMatches: function(selector, selectednodes, requireall) {
2651
        if (typeof requireall === 'undefined') {
2652
            requireall = true;
2653
        }
2654
        if (!selectednodes) {
2655
            // Find this because it was not passed as a param.
2656
            selectednodes = this.getSelectedNodes();
2657
        }
2658
        var allmatch = selectednodes.size() > 0,
2659
            anymatch = false;
2660
 
2661
        var editor = this.editor,
2662
            stopFn = function(node) {
2663
                // The function getSelectedNodes only returns nodes within the editor, so this test is safe.
2664
                return node === editor;
2665
            };
2666
 
2667
        // If we do not find at least one match in the editor, no point trying to find them in the selection.
2668
        if (!editor.one(selector)) {
2669
            return false;
2670
        }
2671
 
2672
        selectednodes.each(function(node) {
2673
            // Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
2674
            if (requireall) {
2675
                // Check for at least one failure.
2676
                if (!allmatch || !node.ancestor(selector, true, stopFn)) {
2677
                    allmatch = false;
2678
                }
2679
            } else {
2680
                // Check for at least one match.
2681
                if (!anymatch && node.ancestor(selector, true, stopFn)) {
2682
                    anymatch = true;
2683
                }
2684
            }
2685
        }, this);
2686
        if (requireall) {
2687
            return allmatch;
2688
        } else {
2689
            return anymatch;
2690
        }
2691
    },
2692
 
2693
    /**
2694
     * Get the deepest possible list of nodes in the current selection.
2695
     *
2696
     * @method getSelectedNodes
2697
     * @return {NodeList}
2698
     */
2699
    getSelectedNodes: function() {
2700
        var results = new Y.NodeList(),
2701
            nodes,
2702
            selection,
2703
            range,
2704
            node,
2705
            i;
2706
 
2707
        selection = rangy.getSelection();
2708
 
2709
        if (selection.rangeCount) {
2710
            range = selection.getRangeAt(0);
2711
        } else {
2712
            // Empty range.
2713
            range = rangy.createRange();
2714
        }
2715
 
2716
        if (range.collapsed) {
2717
            // We do not want to select all the nodes in the editor if we managed to
2718
            // have a collapsed selection directly in the editor.
2719
            // It's also possible for the commonAncestorContainer to be the document, which selectNode does not handle
2720
            // so we must filter that out here too.
2721
            if (range.commonAncestorContainer !== this.editor.getDOMNode()
2722
                    && range.commonAncestorContainer !== Y.config.doc) {
2723
                range = range.cloneRange();
2724
                range.selectNode(range.commonAncestorContainer);
2725
            }
2726
        }
2727
 
2728
        nodes = range.getNodes();
2729
 
2730
        for (i = 0; i < nodes.length; i++) {
2731
            node = Y.one(nodes[i]);
2732
            if (this.editor.contains(node)) {
2733
                results.push(node);
2734
            }
2735
        }
2736
        return results;
2737
    },
2738
 
2739
    /**
2740
     * Check whether the current selection has changed since this method was last called.
2741
     *
2742
     * If the selection has changed, the atto:selectionchanged event is also fired.
2743
     *
2744
     * @method _hasSelectionChanged
2745
     * @private
2746
     * @param {EventFacade} e
2747
     * @return {Boolean}
2748
     */
2749
    _hasSelectionChanged: function(e) {
2750
        var selection = rangy.getSelection(),
2751
            range,
2752
            changed = false;
2753
 
2754
        if (selection.rangeCount) {
2755
            range = selection.getRangeAt(0);
2756
        } else {
2757
            // Empty range.
2758
            range = rangy.createRange();
2759
        }
2760
 
2761
        if (this._lastSelection) {
2762
            if (!this._lastSelection.equals(range)) {
2763
                changed = true;
2764
                return this._fireSelectionChanged(e);
2765
            }
2766
        }
2767
        this._lastSelection = range;
2768
        return changed;
2769
    },
2770
 
2771
    /**
2772
     * Fires the atto:selectionchanged event.
2773
     *
2774
     * When the selectionchanged event is fired, the following arguments are provided:
2775
     *   - event : the original event that lead to this event being fired.
2776
     *   - selectednodes :  an array containing nodes that are entirely selected of contain partially selected content.
2777
     *
2778
     * @method _fireSelectionChanged
2779
     * @private
2780
     * @param {EventFacade} e
2781
     */
2782
    _fireSelectionChanged: function(e) {
2783
        this.fire('atto:selectionchanged', {
2784
            event: e,
2785
            selectedNodes: this.getSelectedNodes()
2786
        });
2787
    },
2788
 
2789
    /**
2790
     * Get the DOM node representing the common anscestor of the selection nodes.
2791
     *
2792
     * @method getSelectionParentNode
2793
     * @return {Element|boolean} The DOM Node for this parent, or false if no seletion was made.
2794
     */
2795
    getSelectionParentNode: function() {
2796
        var selection = rangy.getSelection();
2797
        if (selection.rangeCount) {
2798
            return selection.getRangeAt(0).commonAncestorContainer;
2799
        }
2800
        return false;
2801
    },
2802
 
2803
    /**
2804
     * Set the current selection. Used to restore a selection.
2805
     *
2806
     * @method selection
2807
     * @param {array} ranges A list of rangy.range objects in the selection.
2808
     */
2809
    setSelection: function(ranges) {
2810
        var selection = rangy.getSelection();
2811
        selection.setRanges(ranges);
2812
    },
2813
 
2814
    /**
2815
     * Inserts the given HTML into the editable content at the currently focused point.
2816
     *
2817
     * @method insertContentAtFocusPoint
2818
     * @param {String} html
2819
     * @return {Node} The YUI Node object added to the DOM.
2820
     */
2821
    insertContentAtFocusPoint: function(html) {
2822
        var selection = rangy.getSelection(),
2823
            range,
2824
            node = Y.Node.create(html);
2825
        if (selection.rangeCount) {
2826
            range = selection.getRangeAt(0);
2827
        }
2828
        if (range) {
2829
            range.deleteContents();
2830
            range.collapse(false);
2831
            var currentnode = node.getDOMNode(),
2832
                last = currentnode.lastChild || currentnode;
2833
            range.insertNode(currentnode);
2834
            range.collapseAfter(last);
2835
            selection.setSingleRange(range);
2836
        }
2837
        return node;
2838
    }
2839
 
2840
};
2841
 
2842
Y.Base.mix(Y.M.editor_atto.Editor, [EditorSelection]);
2843
// This file is part of Moodle - http://moodle.org/
2844
//
2845
// Moodle is free software: you can redistribute it and/or modify
2846
// it under the terms of the GNU General Public License as published by
2847
// the Free Software Foundation, either version 3 of the License, or
2848
// (at your option) any later version.
2849
//
2850
// Moodle is distributed in the hope that it will be useful,
2851
// but WITHOUT ANY WARRANTY; without even the implied warranty of
2852
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
2853
// GNU General Public License for more details.
2854
//
2855
// You should have received a copy of the GNU General Public License
2856
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
2857
/**
2858
 * @module moodle-editor_atto-editor
2859
 * @submodule styling
2860
 */
2861
 
2862
/**
2863
 * Editor styling functions for the Atto editor.
2864
 *
2865
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2866
 *
2867
 * @namespace M.editor_atto
2868
 * @class EditorStyling
2869
 */
2870
 
2871
function EditorStyling() {}
2872
 
2873
EditorStyling.ATTRS = {
2874
};
2875
 
2876
EditorStyling.prototype = {
2877
    /**
2878
     * Disable CSS styling.
2879
     *
2880
     * @method disableCssStyling
2881
     */
2882
    disableCssStyling: function() {
2883
        try {
2884
            document.execCommand("styleWithCSS", 0, false);
2885
        } catch (e1) {
2886
            try {
2887
                document.execCommand("useCSS", 0, true);
2888
            } catch (e2) {
2889
                try {
2890
                    document.execCommand('styleWithCSS', false, false);
2891
                } catch (e3) {
2892
                    // We did our best.
2893
                }
2894
            }
2895
        }
2896
    },
2897
 
2898
    /**
2899
     * Enable CSS styling.
2900
     *
2901
     * @method enableCssStyling
2902
     */
2903
    enableCssStyling: function() {
2904
        try {
2905
            document.execCommand("styleWithCSS", 0, true);
2906
        } catch (e1) {
2907
            try {
2908
                document.execCommand("useCSS", 0, false);
2909
            } catch (e2) {
2910
                try {
2911
                    document.execCommand('styleWithCSS', false, true);
2912
                } catch (e3) {
2913
                    // We did our best.
2914
                }
2915
            }
2916
        }
2917
    },
2918
 
2919
    /**
2920
     * Change the formatting for the current selection.
2921
     *
2922
     * This will wrap the selection in span tags, adding the provided classes.
2923
     *
2924
     * If the selection covers multiple block elements, multiple spans will be inserted to preserve the original structure.
2925
     *
2926
     * @method toggleInlineSelectionClass
2927
     * @param {Array} toggleclasses - Class names to be toggled on or off.
2928
     */
2929
    toggleInlineSelectionClass: function(toggleclasses) {
2930
        var classname = toggleclasses.join(" ");
2931
        var cssApplier = rangy.createClassApplier(classname, {normalize: true});
2932
 
2933
        cssApplier.toggleSelection();
2934
    },
2935
 
2936
    /**
2937
     * Change the formatting for the current selection.
2938
     *
2939
     * This will set inline styles on the current selection.
2940
     *
2941
     * @method formatSelectionInlineStyle
2942
     * @param {Array} styles - Style attributes to set on the nodes.
2943
     */
2944
    formatSelectionInlineStyle: function(styles) {
2945
        var classname = this.PLACEHOLDER_CLASS;
2946
        var cssApplier = rangy.createClassApplier(classname, {normalize: true});
2947
 
2948
        cssApplier.applyToSelection();
2949
 
2950
        this.editor.all('.' + classname).each(function(node) {
2951
            node.removeClass(classname).setStyles(styles);
2952
        }, this);
2953
 
2954
    },
2955
 
2956
    /**
2957
     * Change the formatting for the current selection.
2958
     *
2959
     * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
2960
     *
2961
     * @method formatSelectionBlock
2962
     * @param {String} [blocktag] Change the block level tag to this. Empty string, means do not change the tag.
2963
     * @param {Object} [attributes] The keys and values for attributes to be added/changed in the block tag.
2964
     * @return {Node|boolean} The Node that was formatted if a change was made, otherwise false.
2965
     */
2966
    formatSelectionBlock: function(blocktag, attributes) {
2967
        // First find the nearest ancestor of the selection that is a block level element.
2968
        var selectionparentnode = this.getSelectionParentNode(),
2969
            boundary,
2970
            cell,
2971
            nearestblock,
2972
            newcontent,
2973
            match,
2974
            replacement;
2975
 
2976
        if (!selectionparentnode) {
2977
            // No selection, nothing to format.
2978
            return false;
2979
        }
2980
 
2981
        boundary = this.editor;
2982
 
2983
        selectionparentnode = Y.one(selectionparentnode);
2984
 
2985
        // If there is a table cell in between the selectionparentnode and the boundary,
2986
        // move the boundary to the table cell.
2987
        // This is because we might have a table in a div, and we select some text in a cell,
2988
        // want to limit the change in style to the table cell, not the entire table (via the outer div).
2989
        cell = selectionparentnode.ancestor(function(node) {
2990
            var tagname = node.get('tagName');
2991
            if (tagname) {
2992
                tagname = tagname.toLowerCase();
2993
            }
2994
            return (node === boundary) ||
2995
                   (tagname === 'td') ||
2996
                   (tagname === 'th');
2997
        }, true);
2998
 
2999
        if (cell) {
3000
            // Limit the scope to the table cell.
3001
            boundary = cell;
3002
        }
3003
 
3004
        nearestblock = selectionparentnode.ancestor(this.BLOCK_TAGS.join(', '), true);
3005
        if (nearestblock) {
3006
            // Check that the block is contained by the boundary.
3007
            match = nearestblock.ancestor(function(node) {
3008
                return node === boundary;
3009
            }, false);
3010
 
3011
            if (!match) {
3012
                nearestblock = false;
3013
            }
3014
        }
3015
 
3016
        // No valid block element - make one.
3017
        if (!nearestblock) {
3018
            var alignment;
3019
            if (this.coreDirection === 'rtl') {
3020
                alignment = 'style="text-align: right;"';
3021
            } else {
3022
                alignment = 'style="text-align: left;"';
3023
            }
3024
            // There is no block node in the content, wrap the content in a p and use that.
3025
            newcontent = Y.Node.create('<p dir="' + this.coreDirection + '" ' + alignment + '></p>');
3026
            boundary.get('childNodes').each(function(child) {
3027
                newcontent.append(child.remove());
3028
            });
3029
            boundary.append(newcontent);
3030
            nearestblock = newcontent;
3031
        }
3032
 
3033
        // Guaranteed to have a valid block level element contained in the contenteditable region.
3034
        // Change the tag to the new block level tag.
3035
        if (blocktag && blocktag !== '') {
3036
            // Change the block level node for a new one.
3037
            replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
3038
            // Copy all attributes.
3039
            replacement.setAttrs(nearestblock.getAttrs());
3040
            // Copy all children.
3041
            nearestblock.get('childNodes').each(function(child) {
3042
                child.remove();
3043
                replacement.append(child);
3044
            });
3045
 
3046
            nearestblock.replace(replacement);
3047
            nearestblock = replacement;
3048
        }
3049
 
3050
        // Set the attributes on the block level tag.
3051
        if (attributes) {
3052
            nearestblock.setAttrs(attributes);
3053
        }
3054
 
3055
        // Change the selection to the modified block. This makes sense when we might apply multiple styles
3056
        // to the block.
3057
        var selection = this.getSelectionFromNode(nearestblock);
3058
        this.setSelection(selection);
3059
 
3060
        return nearestblock;
3061
    }
3062
 
3063
};
3064
 
3065
Y.Base.mix(Y.M.editor_atto.Editor, [EditorStyling]);
3066
// This file is part of Moodle - http://moodle.org/
3067
//
3068
// Moodle is free software: you can redistribute it and/or modify
3069
// it under the terms of the GNU General Public License as published by
3070
// the Free Software Foundation, either version 3 of the License, or
3071
// (at your option) any later version.
3072
//
3073
// Moodle is distributed in the hope that it will be useful,
3074
// but WITHOUT ANY WARRANTY; without even the implied warranty of
3075
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
3076
// GNU General Public License for more details.
3077
//
3078
// You should have received a copy of the GNU General Public License
3079
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
3080
 
3081
/**
3082
 * @module moodle-editor_atto-editor
3083
 * @submodule filepicker
3084
 */
3085
 
3086
/**
3087
 * Filepicker options for the Atto editor.
3088
 *
3089
 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
3090
 *
3091
 * @namespace M.editor_atto
3092
 * @class EditorFilepicker
3093
 */
3094
 
3095
function EditorFilepicker() {}
3096
 
3097
EditorFilepicker.ATTRS = {
3098
    /**
3099
     * The options for the filepicker.
3100
     *
3101
     * @attribute filepickeroptions
3102
     * @type object
3103
     * @default {}
3104
     */
3105
    filepickeroptions: {
3106
        value: {}
3107
    }
3108
};
3109
 
3110
EditorFilepicker.prototype = {
3111
    /**
3112
     * Should we show the filepicker for this filetype?
3113
     *
3114
     * @method canShowFilepicker
3115
     * @param string type The media type for the file picker.
3116
     * @return {boolean}
3117
     */
3118
    canShowFilepicker: function(type) {
3119
        return (typeof this.get('filepickeroptions')[type] !== 'undefined');
3120
    },
3121
 
3122
    /**
3123
     * Show the filepicker.
3124
     *
3125
     * This depends on core_filepicker, and then call that modules show function.
3126
     *
3127
     * @method showFilepicker
3128
     * @param {string} type The media type for the file picker.
3129
     * @param {function} callback The callback to use when selecting an item of media.
3130
     * @param {object} [context] The context from which to call the callback.
3131
     */
3132
    showFilepicker: function(type, callback, context) {
3133
        var self = this;
3134
        Y.use('core_filepicker', function(Y) {
3135
            var options = Y.clone(self.get('filepickeroptions')[type], true);
3136
            options.formcallback = callback;
3137
            if (context) {
3138
                options.magicscope = context;
3139
            }
3140
 
3141
            M.core_filepicker.show(Y, options);
3142
        });
3143
    }
3144
};
3145
 
3146
Y.Base.mix(Y.M.editor_atto.Editor, [EditorFilepicker]);
3147
 
3148
 
3149
}, '@VERSION@', {
3150
    "requires": [
3151
        "node",
3152
        "transition",
3153
        "io",
3154
        "overlay",
3155
        "escape",
3156
        "event",
3157
        "event-simulate",
3158
        "event-custom",
3159
        "node-event-html5",
3160
        "node-event-simulate",
3161
        "yui-throttle",
3162
        "moodle-core-notification-dialogue",
3163
        "moodle-editor_atto-rangy",
3164
        "handlebars",
3165
        "timers",
3166
        "querystring-stringify"
3167
    ]
3168
});