MDL-47002 editor_atto: Remove all after html when pasting
[moodle.git] / lib / editor / atto / yui / build / moodle-editor_atto-editor / moodle-editor_atto-editor-debug.js
1 YUI.add('moodle-editor_atto-editor', function (Y, NAME) {
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/>.
18 /**
19  * The Atto WYSIWG pluggable editor, written for Moodle.
20  *
21  * @module     moodle-editor_atto-editor
22  * @package    editor_atto
23  * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  * @main       moodle-editor_atto-editor
26  */
28 /**
29  * @module moodle-editor_atto-editor
30  * @submodule editor-base
31  */
33 var LOGNAME = 'moodle-editor_atto-editor';
34 var CSS = {
35         CONTENT: 'editor_atto_content',
36         CONTENTWRAPPER: 'editor_atto_content_wrap',
37         TOOLBAR: 'editor_atto_toolbar',
38         WRAPPER: 'editor_atto',
39         HIGHLIGHT: 'highlight'
40     },
41     rangy = window.rangy;
43 /**
44  * The Atto editor for Moodle.
45  *
46  * @namespace M.editor_atto
47  * @class Editor
48  * @constructor
49  * @uses M.editor_atto.EditorClean
50  * @uses M.editor_atto.EditorFilepicker
51  * @uses M.editor_atto.EditorSelection
52  * @uses M.editor_atto.EditorStyling
53  * @uses M.editor_atto.EditorTextArea
54  * @uses M.editor_atto.EditorToolbar
55  * @uses M.editor_atto.EditorToolbarNav
56  */
58 function Editor() {
59     Editor.superclass.constructor.apply(this, arguments);
60 }
62 Y.extend(Editor, Y.Base, {
64     /**
65      * List of known block level tags.
66      * Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
67      *
68      * @property BLOCK_TAGS
69      * @type {Array}
70      */
71     BLOCK_TAGS : [
72         'address',
73         'article',
74         'aside',
75         'audio',
76         'blockquote',
77         'canvas',
78         'dd',
79         'div',
80         'dl',
81         'fieldset',
82         'figcaption',
83         'figure',
84         'footer',
85         'form',
86         'h1',
87         'h2',
88         'h3',
89         'h4',
90         'h5',
91         'h6',
92         'header',
93         'hgroup',
94         'hr',
95         'noscript',
96         'ol',
97         'output',
98         'p',
99         'pre',
100         'section',
101         'table',
102         'tfoot',
103         'ul',
104         'video'
105     ],
107     PLACEHOLDER_CLASS: 'atto-tmp-class',
108     ALL_NODES_SELECTOR: '[style],font[face]',
109     FONT_FAMILY: 'fontFamily',
111     /**
112      * The wrapper containing the editor.
113      *
114      * @property _wrapper
115      * @type Node
116      * @private
117      */
118     _wrapper: null,
120     /**
121      * A reference to the content editable Node.
122      *
123      * @property editor
124      * @type Node
125      */
126     editor: null,
128     /**
129      * A reference to the original text area.
130      *
131      * @property textarea
132      * @type Node
133      */
134     textarea: null,
136     /**
137      * A reference to the label associated with the original text area.
138      *
139      * @property textareaLabel
140      * @type Node
141      */
142     textareaLabel: null,
144     /**
145      * A reference to the list of plugins.
146      *
147      * @property plugins
148      * @type object
149      */
150     plugins: null,
152     /**
153      * Event Handles to clear on editor destruction.
154      *
155      * @property _eventHandles
156      * @private
157      */
158     _eventHandles: null,
160     initializer: function() {
161         var template;
163         // Note - it is not safe to use a CSS selector like '#' + elementid because the id
164         // may have colons in it - e.g.  quiz.
165         this.textarea = Y.one(document.getElementById(this.get('elementid')));
167         if (!this.textarea) {
168             // No text area found.
169             Y.log('Text area not found - unable to setup editor for ' + this.get('elementid'),
170                     'error', LOGNAME);
171             return;
172         }
174         this._eventHandles = [];
176         this._wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
177         template = Y.Handlebars.compile('<div id="{{elementid}}editable" ' +
178                 'contenteditable="true" ' +
179                 'role="textbox" ' +
180                 'spellcheck="true" ' +
181                 'aria-live="off" ' +
182                 'class="{{CSS.CONTENT}}" ' +
183                 '/>');
184         this.editor = Y.Node.create(template({
185             elementid: this.get('elementid'),
186             CSS: CSS
187         }));
189         // Add a labelled-by attribute to the contenteditable.
190         this.textareaLabel = Y.one('[for="' + this.get('elementid') + '"]');
191         if (this.textareaLabel) {
192             this.textareaLabel.generateID();
193             this.editor.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
194         }
196         // Add everything to the wrapper.
197         this.setupToolbar();
199         // Editable content wrapper.
200         var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
201         content.appendChild(this.editor);
202         this._wrapper.appendChild(content);
204         // Style the editor. According to the styles.css: 20 is the line-height, 8 is padding-top + padding-bottom.
205         this.editor.setStyle('minHeight', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
207         if (Y.UA.ie === 0) {
208             // We set a height here to force the overflow because decent browsers allow the CSS property resize.
209             this.editor.setStyle('height', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
210         }
212         // Disable odd inline CSS styles.
213         this.disableCssStyling();
215         // Use paragraphs not divs.
216         if (document.queryCommandSupported('DefaultParagraphSeparator')) {
217             document.execCommand('DefaultParagraphSeparator', false, 'p');
218         }
220         // Add the toolbar and editable zone to the page.
221         this.textarea.get('parentNode').insert(this._wrapper, this.textarea).
222                 setAttribute('class', 'editor_atto_wrap');
224         // Hide the old textarea.
225         this.textarea.hide();
227         // Copy the text to the contenteditable div.
228         this.updateFromTextArea();
230         // Publish the events that are defined by this editor.
231         this.publishEvents();
233         // Add handling for saving and restoring selections on cursor/focus changes.
234         this.setupSelectionWatchers();
236         // Add polling to update the textarea periodically when typing long content.
237         this.setupAutomaticPolling();
239         // Setup plugins.
240         this.setupPlugins();
242         // Initialize the auto-save timer.
243         this.setupAutosave();
244         // Preload the icons for the notifications.
245         this.setupNotifications();
246     },
248     /**
249      * Focus on the editable area for this editor.
250      *
251      * @method focus
252      * @chainable
253      */
254     focus: function() {
255         this.editor.focus();
257         return this;
258     },
260     /**
261      * Publish events for this editor instance.
262      *
263      * @method publishEvents
264      * @private
265      * @chainable
266      */
267     publishEvents: function() {
268         /**
269          * Fired when changes are made within the editor.
270          *
271          * @event change
272          */
273         this.publish('change', {
274             broadcast: true,
275             preventable: true
276         });
278         /**
279          * Fired when all plugins have completed loading.
280          *
281          * @event pluginsloaded
282          */
283         this.publish('pluginsloaded', {
284             fireOnce: true
285         });
287         this.publish('atto:selectionchanged', {
288             prefix: 'atto'
289         });
291         return this;
292     },
294     /**
295      * Set up automated polling of the text area to update the textarea.
296      *
297      * @method setupAutomaticPolling
298      * @chainable
299      */
300     setupAutomaticPolling: function() {
301         this._registerEventHandle(this.editor.on(['keyup', 'cut'], this.updateOriginal, this));
302         this._registerEventHandle(this.editor.on('paste', this.pasteCleanup, this));
304         // Call this.updateOriginal after dropped content has been processed.
305         this._registerEventHandle(this.editor.on('drop', this.updateOriginalDelayed, this));
307         return this;
308     },
310     /**
311      * Calls updateOriginal on a short timer to allow native event handlers to run first.
312      *
313      * @method updateOriginalDelayed
314      * @chainable
315      */
316     updateOriginalDelayed: function() {
317         Y.soon(Y.bind(this.updateOriginal, this));
319         return this;
320     },
322     setupPlugins: function() {
323         // Clear the list of plugins.
324         this.plugins = {};
326         var plugins = this.get('plugins');
328         var groupIndex,
329             group,
330             pluginIndex,
331             plugin,
332             pluginConfig;
334         for (groupIndex in plugins) {
335             group = plugins[groupIndex];
336             if (!group.plugins) {
337                 // No plugins in this group - skip it.
338                 continue;
339             }
340             for (pluginIndex in group.plugins) {
341                 plugin = group.plugins[pluginIndex];
343                 pluginConfig = Y.mix({
344                     name: plugin.name,
345                     group: group.group,
346                     editor: this.editor,
347                     toolbar: this.toolbar,
348                     host: this
349                 }, plugin);
351                 // Add a reference to the current editor.
352                 if (typeof Y.M['atto_' + plugin.name] === "undefined") {
353                     Y.log("Plugin '" + plugin.name + "' could not be found - skipping initialisation", "warn", LOGNAME);
354                     continue;
355                 }
356                 this.plugins[plugin.name] = new Y.M['atto_' + plugin.name].Button(pluginConfig);
357             }
358         }
360         // Some plugins need to perform actions once all plugins have loaded.
361         this.fire('pluginsloaded');
363         return this;
364     },
366     enablePlugins: function(plugin) {
367         this._setPluginState(true, plugin);
368     },
370     disablePlugins: function(plugin) {
371         this._setPluginState(false, plugin);
372     },
374     _setPluginState: function(enable, plugin) {
375         var target = 'disableButtons';
376         if (enable) {
377             target = 'enableButtons';
378         }
380         if (plugin) {
381             this.plugins[plugin][target]();
382         } else {
383             Y.Object.each(this.plugins, function(currentPlugin) {
384                 currentPlugin[target]();
385             }, this);
386         }
387     },
389     /**
390      * Register an event handle for disposal in the destructor.
391      *
392      * @method _registerEventHandle
393      * @param {EventHandle} The Event Handle as returned by Y.on, and Y.delegate.
394      * @private
395      */
396     _registerEventHandle: function(handle) {
397         this._eventHandles.push(handle);
398     }
400 }, {
401     NS: 'editor_atto',
402     ATTRS: {
403         /**
404          * The unique identifier for the form element representing the editor.
405          *
406          * @attribute elementid
407          * @type String
408          * @writeOnce
409          */
410         elementid: {
411             value: null,
412             writeOnce: true
413         },
415         /**
416          * The contextid of the form.
417          *
418          * @attribute contextid
419          * @type Integer
420          * @writeOnce
421          */
422         contextid: {
423             value: null,
424             writeOnce: true
425         },
427         /**
428          * Plugins with their configuration.
429          *
430          * The plugins structure is:
431          *
432          *     [
433          *         {
434          *             "group": "groupName",
435          *             "plugins": [
436          *                 "pluginName": {
437          *                     "configKey": "configValue"
438          *                 },
439          *                 "pluginName": {
440          *                     "configKey": "configValue"
441          *                 }
442          *             ]
443          *         },
444          *         {
445          *             "group": "groupName",
446          *             "plugins": [
447          *                 "pluginName": {
448          *                     "configKey": "configValue"
449          *                 }
450          *             ]
451          *         }
452          *     ]
453          *
454          * @attribute plugins
455          * @type Object
456          * @writeOnce
457          */
458         plugins: {
459             value: {},
460             writeOnce: true
461         }
462     }
463 });
465 // The Editor publishes custom events that can be subscribed to.
466 Y.augment(Editor, Y.EventTarget);
468 Y.namespace('M.editor_atto').Editor = Editor;
470 // Function for Moodle's initialisation.
471 Y.namespace('M.editor_atto.Editor').init = function(config) {
472     return new Y.M.editor_atto.Editor(config);
473 };
474 // This file is part of Moodle - http://moodle.org/
475 //
476 // Moodle is free software: you can redistribute it and/or modify
477 // it under the terms of the GNU General Public License as published by
478 // the Free Software Foundation, either version 3 of the License, or
479 // (at your option) any later version.
480 //
481 // Moodle is distributed in the hope that it will be useful,
482 // but WITHOUT ANY WARRANTY; without even the implied warranty of
483 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
484 // GNU General Public License for more details.
485 //
486 // You should have received a copy of the GNU General Public License
487 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
489 /**
490  * A notify function for the Atto editor.
491  *
492  * @module     moodle-editor_atto-notify
493  * @submodule  notify
494  * @package    editor_atto
495  * @copyright  2014 Damyon Wiese
496  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
497  */
499 var LOGNAME_NOTIFY = 'moodle-editor_atto-editor-notify',
500     NOTIFY_INFO = 'info',
501     NOTIFY_WARNING = 'warning';
503 function EditorNotify() {}
505 EditorNotify.ATTRS= {
506 };
508 EditorNotify.prototype = {
510     /**
511      * A single Y.Node for this editor. There is only ever one - it is replaced if a new message comes in.
512      *
513      * @property messageOverlay
514      * @type {Node}
515      */
516     messageOverlay: null,
518     /**
519      * A single timer object that can be used to cancel the hiding behaviour.
520      *
521      * @property hideTimer
522      * @type {timer}
523      */
524     hideTimer: null,
526     /**
527      * Initialize the notifications.
528      *
529      * @method setupNotifications
530      * @chainable
531      */
532     setupNotifications: function() {
533         var preload1 = new Image(),
534             preload2 = new Image();
536         preload1.src = M.util.image_url('i/warning', 'moodle');
537         preload2.src = M.util.image_url('i/info', 'moodle');
539         return this;
540     },
542     /**
543      * Show a notification in a floaty overlay somewhere in the atto editor text area.
544      *
545      * @method showMessage
546      * @param {String} message The translated message (use get_string)
547      * @param {String} type Must be either "info" or "warning"
548      * @param {Number} timeout Time in milliseconds to show this message for.
549      * @chainable
550      */
551     showMessage: function(message, type, timeout) {
552         var messageTypeIcon = '',
553             intTimeout,
554             bodyContent;
556         if (this.messageOverlay === null) {
557             this.messageOverlay = Y.Node.create('<div class="editor_atto_notification"></div>');
559             this.messageOverlay.hide(true);
560             this.textarea.get('parentNode').append(this.messageOverlay);
562             this.messageOverlay.on('click', function() {
563                 this.messageOverlay.hide(true);
564             }, this);
565         }
567         if (this.hideTimer !== null) {
568             this.hideTimer.cancel();
569         }
571         if (type === NOTIFY_WARNING) {
572             messageTypeIcon = '<img src="' +
573                               M.util.image_url('i/warning', 'moodle') +
574                               '" alt="' + M.util.get_string('warning', 'moodle') + '"/>';
575         } else if (type === NOTIFY_INFO) {
576             messageTypeIcon = '<img src="' +
577                               M.util.image_url('i/info', 'moodle') +
578                               '" alt="' + M.util.get_string('info', 'moodle') + '"/>';
579         } else {
580             Y.log('Invalid message type specified: ' + type + '. Must be either "info" or "warning".', 'debug', LOGNAME_NOTIFY);
581         }
583         // Parse the timeout value.
584         intTimeout = parseInt(timeout, 10);
585         if (intTimeout <= 0) {
586             intTimeout = 60000;
587         }
589         // Convert class to atto_info (for example).
590         type = 'atto_' + type;
592         bodyContent = Y.Node.create('<div class="' + type + '" role="alert" aria-live="assertive">' +
593                                         messageTypeIcon + ' ' +
594                                         Y.Escape.html(message) +
595                                         '</div>');
596         this.messageOverlay.empty();
597         this.messageOverlay.append(bodyContent);
598         this.messageOverlay.show(true);
600         this.hideTimer = Y.later(intTimeout, this, function() {
601             Y.log('Hide Atto notification.', 'debug', LOGNAME_NOTIFY);
602             this.hideTimer = null;
603             this.messageOverlay.hide(true);
604         });
606         return this;
607     }
609 };
611 Y.Base.mix(Y.M.editor_atto.Editor, [EditorNotify]);
612 // This file is part of Moodle - http://moodle.org/
613 //
614 // Moodle is free software: you can redistribute it and/or modify
615 // it under the terms of the GNU General Public License as published by
616 // the Free Software Foundation, either version 3 of the License, or
617 // (at your option) any later version.
618 //
619 // Moodle is distributed in the hope that it will be useful,
620 // but WITHOUT ANY WARRANTY; without even the implied warranty of
621 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
622 // GNU General Public License for more details.
623 //
624 // You should have received a copy of the GNU General Public License
625 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
627 /**
628  * @module moodle-editor_atto-editor
629  * @submodule textarea
630  */
632 /**
633  * Textarea functions for the Atto editor.
634  *
635  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
636  *
637  * @namespace M.editor_atto
638  * @class EditorTextArea
639  */
641 function EditorTextArea() {}
643 EditorTextArea.ATTRS= {
644 };
646 EditorTextArea.prototype = {
648     /**
649      * Return the appropriate empty content value for the current browser.
650      *
651      * Different browsers use a different content when they are empty and
652      * we must set this reliable across the board.
653      *
654      * @method _getEmptyContent
655      * @return String The content to use representing no user-provided content
656      * @private
657      */
658     _getEmptyContent: function() {
659         if (Y.UA.ie && Y.UA.ie < 10) {
660             return '<p></p>';
661         } else {
662             return '<p><br></p>';
663         }
664     },
666     /**
667      * Copy and clean the text from the textarea into the contenteditable div.
668      *
669      * If the text is empty, provide a default paragraph tag to hold the content.
670      *
671      * @method updateFromTextArea
672      * @chainable
673      */
674     updateFromTextArea: function() {
675         // Clear it first.
676         this.editor.setHTML('');
678         // Copy text to editable div.
679         this.editor.append(this.textarea.get('value'));
681         // Clean it.
682         this.cleanEditorHTML();
684         // Insert a paragraph in the empty contenteditable div.
685         if (this.editor.getHTML() === '') {
686             this.editor.setHTML(this._getEmptyContent());
687         }
688     },
690     /**
691      * Copy the text from the contenteditable to the textarea which it replaced.
692      *
693      * @method updateOriginal
694      * @chainable
695      */
696     updateOriginal : function() {
697         // Get the previous and current value to compare them.
698         var oldValue = this.textarea.get('value'),
699             newValue = this.getCleanHTML();
701         if (newValue === "" && this.isActive()) {
702             // The content was entirely empty so get the empty content placeholder.
703             newValue = this._getEmptyContent();
704         }
706         // Only call this when there has been an actual change to reduce processing.
707         if (oldValue !== newValue) {
708             // Insert the cleaned content.
709             this.textarea.set('value', newValue);
711             // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
712             this.textarea.simulate('change');
714             // Trigger handlers for this action.
715             this.fire('change');
716         }
718         return this;
719     }
720 };
722 Y.Base.mix(Y.M.editor_atto.Editor, [EditorTextArea]);
723 // This file is part of Moodle - http://moodle.org/
724 //
725 // Moodle is free software: you can redistribute it and/or modify
726 // it under the terms of the GNU General Public License as published by
727 // the Free Software Foundation, either version 3 of the License, or
728 // (at your option) any later version.
729 //
730 // Moodle is distributed in the hope that it will be useful,
731 // but WITHOUT ANY WARRANTY; without even the implied warranty of
732 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
733 // GNU General Public License for more details.
734 //
735 // You should have received a copy of the GNU General Public License
736 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
738 /**
739  * A autosave function for the Atto editor.
740  *
741  * @module     moodle-editor_atto-autosave
742  * @submodule  autosave-base
743  * @package    editor_atto
744  * @copyright  2014 Damyon Wiese
745  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
746  */
748 var SUCCESS_MESSAGE_TIMEOUT = 5000,
749     RECOVER_MESSAGE_TIMEOUT = 60000,
750     LOGNAME_AUTOSAVE = 'moodle-editor_atto-editor-autosave';
752 function EditorAutosave() {}
754 EditorAutosave.ATTRS= {
755     /**
756      * Enable/Disable auto save for this instance.
757      *
758      * @attribute autosaveEnabled
759      * @type Boolean
760      * @writeOnce
761      */
762     autosaveEnabled: {
763         value: true,
764         writeOnce: true
765     },
767     /**
768      * The time between autosaves (in seconds).
769      *
770      * @attribute autosaveFrequency
771      * @type Number
772      * @default 60
773      * @writeOnce
774      */
775     autosaveFrequency: {
776         value: 60,
777         writeOnce: true
778     },
780     /**
781      * Unique hash for this page instance. Calculated from $PAGE->url in php.
782      *
783      * @attribute pageHash
784      * @type String
785      * @writeOnce
786      */
787     pageHash: {
788         value: '',
789         writeOnce: true
790     },
792     /**
793      * The relative path to the ajax script.
794      *
795      * @attribute autosaveAjaxScript
796      * @type String
797      * @default '/lib/editor/atto/autosave-ajax.php'
798      * @readOnly
799      */
800     autosaveAjaxScript: {
801         value: '/lib/editor/atto/autosave-ajax.php',
802         readOnly: true
803     }
804 };
806 EditorAutosave.prototype = {
808     /**
809      * The text that was auto saved in the last request.
810      *
811      * @property lastText
812      * @type string
813      */
814     lastText: "",
816     /**
817      * Autosave instance.
818      *
819      * @property autosaveInstance
820      * @type string
821      */
822     autosaveInstance: null,
824     /**
825      * Initialize the autosave process
826      *
827      * @method setupAutosave
828      * @chainable
829      */
830     setupAutosave: function() {
831         var draftid = -1,
832             form,
833             optiontype = null,
834             options = this.get('filepickeroptions'),
835             params,
836             url;
838         if (!this.get('autosaveEnabled')) {
839             // Autosave disabled for this instance.
840             return;
841         }
843         this.autosaveInstance = Y.stamp(this);
844         for (optiontype in options) {
845             if (typeof options[optiontype].itemid !== "undefined") {
846                 draftid = options[optiontype].itemid;
847             }
848         }
850         // First see if there are any saved drafts.
851         // Make an ajax request.
852         url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
853         params = {
854             sesskey: M.cfg.sesskey,
855             contextid: this.get('contextid'),
856             action: 'resume',
857             drafttext: '',
858             draftid: draftid,
859             elementid: this.get('elementid'),
860             pageinstance: this.autosaveInstance,
861             pagehash: this.get('pageHash')
862         };
864         Y.io(url, {
865             method: 'POST',
866             data: params,
867             context: this,
868             on: {
869                 success: function(id,o) {
870                     var response_json;
871                     if (typeof o.responseText !== "undefined" && o.responseText !== "") {
872                         response_json = JSON.parse(o.responseText);
874                         // Revert untouched editor contents to an empty string.
875                         // Check for FF and Chrome.
876                         if (response_json.result === '<p></p>' || response_json.result === '<p><br></p>' ||
877                             response_json.result === '<br>') {
878                             response_json.result = '';
879                         }
881                         // Check for IE 9 and 10.
882                         if (response_json.result === '<p>&nbsp;</p>' || response_json.result === '<p><br>&nbsp;</p>') {
883                             response_json.result = '';
884                         }
886                         if (response_json.error || typeof response_json.result === 'undefined') {
887                             Y.log('Error occurred recovering draft text: ' + response_json.error, 'debug', LOGNAME_AUTOSAVE);
888                             this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
889                                     NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
890                         } else if (response_json.result !== this.textarea.get('value') &&
891                                 response_json.result !== '') {
892                             Y.log('Autosave text found - recover it.', 'debug', LOGNAME_AUTOSAVE);
893                             this.recoverText(response_json.result);
894                         }
895                         this._fireSelectionChanged();
896                     }
897                 },
898                 failure: function() {
899                     this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
900                             NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
901                 }
902             }
903         });
905         // Now setup the timer for periodic saves.
907         var delay = parseInt(this.get('autosaveFrequency'), 10) * 1000;
908         Y.later(delay, this, this.saveDraft, false, true);
910         // Now setup the listener for form submission.
911         form = this.textarea.ancestor('form');
912         if (form) {
913             form.on('submit', this.resetAutosave, this);
914         }
915         return this;
916     },
918     /**
919      * Clear the autosave text because the form was submitted normally.
920      *
921      * @method resetAutosave
922      * @chainable
923      */
924     resetAutosave: function() {
925         // Make an ajax request to reset the autosaved text.
926         var url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
927         var params = {
928             sesskey: M.cfg.sesskey,
929             contextid: this.get('contextid'),
930             action: 'reset',
931             elementid: this.get('elementid'),
932             pageinstance: this.autosaveInstance,
933             pagehash: this.get('pageHash')
934         };
936         Y.io(url, {
937             method: 'POST',
938             data: params,
939             sync: true
940         });
941         return this;
942     },
945     /**
946      * Recover a previous version of this text and show a message.
947      *
948      * @method recoverText
949      * @param {String} text
950      * @chainable
951      */
952     recoverText: function(text) {
953         this.editor.setHTML(text);
954         this.saveSelection();
955         this.updateOriginal();
956         this.lastText = text;
958         this.showMessage(M.util.get_string('textrecovered', 'editor_atto'),
959                 NOTIFY_INFO, RECOVER_MESSAGE_TIMEOUT);
961         return this;
962     },
964     /**
965      * Save a single draft via ajax.
966      *
967      * @method saveDraft
968      * @chainable
969      */
970     saveDraft: function() {
971         var url, params;
972         // Only copy the text from the div to the textarea if the textarea is not currently visible.
973         if (!this.editor.get('hidden')) {
974             this.updateOriginal();
975         }
976         var newText = this.textarea.get('value');
978         if (newText !== this.lastText) {
979             Y.log('Autosave text', 'debug', LOGNAME_AUTOSAVE);
981             // Make an ajax request.
982             url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
983             params = {
984                 sesskey: M.cfg.sesskey,
985                 contextid: this.get('contextid'),
986                 action: 'save',
987                 drafttext: newText,
988                 elementid: this.get('elementid'),
989                 pagehash: this.get('pageHash'),
990                 pageinstance: this.autosaveInstance
991             };
993             // Reusable error handler - must be passed the correct context.
994             var ajaxErrorFunction = function(code, response) {
995                 var errorDuration = parseInt(this.get('autosaveFrequency'), 10) * 1000;
996                 Y.log('Error while autosaving text:' + code, 'warn', LOGNAME_AUTOSAVE);
997                 Y.log(response, 'warn', LOGNAME_AUTOSAVE);
998                 this.showMessage(M.util.get_string('autosavefailed', 'editor_atto'), NOTIFY_WARNING, errorDuration);
999             };
1001             Y.io(url, {
1002                 method: 'POST',
1003                 data: params,
1004                 context: this,
1005                 on: {
1006                     error: ajaxErrorFunction,
1007                     failure: ajaxErrorFunction,
1008                     success: function(code, response) {
1009                         if (response.responseText !== "") {
1010                             Y.soon(Y.bind(ajaxErrorFunction, this, [code, response]));
1011                         } else {
1012                             // All working.
1013                             this.lastText = newText;
1014                             this.showMessage(M.util.get_string('autosavesucceeded', 'editor_atto'),
1015                                     NOTIFY_INFO, SUCCESS_MESSAGE_TIMEOUT);
1016                         }
1017                     }
1018                 }
1019             });
1020         }
1021         return this;
1022     }
1023 };
1025 Y.Base.mix(Y.M.editor_atto.Editor, [EditorAutosave]);
1026 // This file is part of Moodle - http://moodle.org/
1027 //
1028 // Moodle is free software: you can redistribute it and/or modify
1029 // it under the terms of the GNU General Public License as published by
1030 // the Free Software Foundation, either version 3 of the License, or
1031 // (at your option) any later version.
1032 //
1033 // Moodle is distributed in the hope that it will be useful,
1034 // but WITHOUT ANY WARRANTY; without even the implied warranty of
1035 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1036 // GNU General Public License for more details.
1037 //
1038 // You should have received a copy of the GNU General Public License
1039 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1041 /**
1042  * @module moodle-editor_atto-editor
1043  * @submodule clean
1044  */
1046 /**
1047  * Functions for the Atto editor to clean the generated content.
1048  *
1049  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1050  *
1051  * @namespace M.editor_atto
1052  * @class EditorClean
1053  */
1055 function EditorClean() {}
1057 EditorClean.ATTRS= {
1058 };
1060 EditorClean.prototype = {
1061     /**
1062      * Clean the generated HTML content without modifying the editor content.
1063      *
1064      * This includes removes all YUI ids from the generated content.
1065      *
1066      * @return {string} The cleaned HTML content.
1067      */
1068     getCleanHTML: function() {
1069         // Clone the editor so that we don't actually modify the real content.
1070         var editorClone = this.editor.cloneNode(true),
1071             html;
1073         // Remove all YUI IDs.
1074         Y.each(editorClone.all('[id^="yui"]'), function(node) {
1075             node.removeAttribute('id');
1076         });
1078         editorClone.all('.atto_control').remove(true);
1079         html = editorClone.get('innerHTML');
1081         // Revert untouched editor contents to an empty string.
1082         if (html === '<p></p>' || html === '<p><br></p>') {
1083             return '';
1084         }
1086         // Remove any and all nasties from source.
1087        return this._cleanHTML(html);
1088     },
1090     /**
1091      * Clean the HTML content of the editor.
1092      *
1093      * @method cleanEditorHTML
1094      * @chainable
1095      */
1096     cleanEditorHTML: function() {
1097         var startValue = this.editor.get('innerHTML');
1098         this.editor.set('innerHTML', this._cleanHTML(startValue));
1100         return this;
1101     },
1103     /**
1104      * Clean the specified HTML content and remove any content which could cause issues.
1105      *
1106      * @method _cleanHTML
1107      * @private
1108      * @param {String} content The content to clean
1109      * @return {String} The cleaned HTML
1110      */
1111     _cleanHTML: function(content) {
1112         // Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
1114         var rules = [
1115             // Remove any style blocks. Some browsers do not work well with them in a contenteditable.
1116             // Plus style blocks are not allowed in body html, except with "scoped", which most browsers don't support as of 2015.
1117             // Reference: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
1118             {regex: /<style[^>]*>[\s\S]*?<\/style>/gi, replace: ""},
1120             // Remove any open HTML comment opens that are not followed by a close. This can completely break page layout.
1121             {regex: /<!--(?![\s\S]*?-->)/gi, replace: ""},
1123             // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
1124             // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body, link.
1125             {regex: /<\/?(?:title|meta|style|st\d|head|font|html|body|link)[^>]*?>/gi, replace: ""}
1126         ];
1128         return this._filterContentWithRules(content, rules);
1129     },
1131     /**
1132      * Take the supplied content and run on the supplied regex rules.
1133      *
1134      * @method _filterContentWithRules
1135      * @private
1136      * @param {String} content The content to clean
1137      * @param {Array} rules An array of structures: [ {regex: /something/, replace: "something"}, {...}, ...]
1138      * @return {String} The cleaned content
1139      */
1140     _filterContentWithRules: function(content, rules) {
1141         var i = 0;
1142         for (i = 0; i < rules.length; i++) {
1143             content = content.replace(rules[i].regex, rules[i].replace);
1144         }
1146         return content;
1147     },
1149     /**
1150      * Intercept and clean html paste events.
1151      *
1152      * @method pasteCleanup
1153      * @param {Object} sourceEvent The YUI EventFacade  object
1154      * @return {Boolean} True if the passed event should continue, false if not.
1155      */
1156     pasteCleanup: function(sourceEvent) {
1157         // We only expect paste events, but we will check anyways.
1158         if (sourceEvent.type === 'paste') {
1159             // The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
1160             var event = sourceEvent._event;
1161             // Check if we have a valid clipboardData object in the event.
1162             // IE has a clipboard object at window.clipboardData, but as of IE 11, it does not provide HTML content access.
1163             if (event && event.clipboardData && event.clipboardData.getData) {
1164                 // Check if there is HTML type to be pasted, this is all we care about.
1165                 var types = event.clipboardData.types;
1166                 var isHTML = false;
1167                 // Different browsers use different things to hold the types, so test various functions.
1168                 if (!types) {
1169                     isHTML = false;
1170                 } else if (typeof types.contains === 'function') {
1171                     isHTML = types.contains('text/html');
1172                 } else if (typeof types.indexOf === 'function') {
1173                     isHTML = (types.indexOf('text/html') > -1);
1174                     if (!isHTML) {
1175                         if ((types.indexOf('com.apple.webarchive') > -1) || (types.indexOf('com.apple.iWork.TSPNativeData') > -1)) {
1176                             // This is going to be a specialized Apple paste paste. We cannot capture this, so clean everything.
1177                             this.fallbackPasteCleanupDelayed();
1178                             return true;
1179                         }
1180                     }
1181                 } else {
1182                     // We don't know how to handle the clipboard info, so wait for the clipboard event to finish then fallback.
1183                     this.fallbackPasteCleanupDelayed();
1184                     return true;
1185                 }
1187                 if (isHTML) {
1188                     // Get the clipboard content.
1189                     var content;
1190                     try {
1191                         content = event.clipboardData.getData('text/html');
1192                     } catch (error) {
1193                         // Something went wrong. Fallback.
1194                         this.fallbackPasteCleanupDelayed();
1195                         return true;
1196                     }
1198                     // Stop the original paste.
1199                     sourceEvent.preventDefault();
1201                     // Scrub the paste content.
1202                     content = this._cleanPasteHTML(content);
1204                     // Save the current selection.
1205                     // Using saveSelection as it produces a more consistent experience.
1206                     var selection = window.rangy.saveSelection();
1208                     // Insert the content.
1209                     this.insertContentAtFocusPoint(content);
1211                     // Restore the selection, and collapse to end.
1212                     window.rangy.restoreSelection(selection);
1213                     window.rangy.getSelection().collapseToEnd();
1215                     // Update the text area.
1216                     this.updateOriginal();
1217                     return false;
1218                 } else {
1219                     // This is a non-html paste event, we can just let this continue on and call updateOriginalDelayed.
1220                     this.updateOriginalDelayed();
1221                     return true;
1222                 }
1223             } else {
1224                 // If we reached a here, this probably means the browser has limited (or no) clipboard support.
1225                 // Wait for the clipboard event to finish then fallback.
1226                 this.fallbackPasteCleanupDelayed();
1227                 return true;
1228             }
1229         }
1231         // We should never get here - we must have received a non-paste event for some reason.
1232         // Um, just call updateOriginalDelayed() - it's safe.
1233         this.updateOriginalDelayed();
1234         return true;
1235     },
1237     /**
1238      * Cleanup code after a paste event if we couldn't intercept the paste content.
1239      *
1240      * @method fallbackPasteCleanup
1241      * @chainable
1242      */
1243     fallbackPasteCleanup: function() {
1244         Y.log('Using fallbackPasteCleanup for atto cleanup', 'debug', LOGNAME);
1246         // Save the current selection (cursor position).
1247         var selection = window.rangy.saveSelection();
1249         // Get, clean, and replace the content in the editable.
1250         var content = this.editor.get('innerHTML');
1251         this.editor.set('innerHTML', this._cleanPasteHTML(content));
1253         // Update the textarea.
1254         this.updateOriginal();
1256         // Restore the selection (cursor position).
1257         window.rangy.restoreSelection(selection);
1259         return this;
1260     },
1262     /**
1263      * Calls fallbackPasteCleanup on a short timer to allow the paste event handlers to complete.
1264      *
1265      * @method fallbackPasteCleanupDelayed
1266      * @chainable
1267      */
1268     fallbackPasteCleanupDelayed: function() {
1269         Y.soon(Y.bind(this.fallbackPasteCleanup, this));
1271         return this;
1272     },
1274     /**
1275      * Cleanup html that comes from WYSIWYG paste events. These are more likely to contain messy code that we should strip.
1276      *
1277      * @method _cleanPasteHTML
1278      * @private
1279      * @param {String} content The html content to clean
1280      * @return {String} The cleaned HTML
1281      */
1282     _cleanPasteHTML: function(content) {
1283         // Return an empty string if passed an invalid or empty object.
1284         if (!content || content.length === 0) {
1285             return "";
1286         }
1288         // Rules that get rid of the real-nasties and don't care about normalize code (correct quotes, white spaces, etc).
1289         var rules = [
1290             // Stuff that is specifically from MS Word and similar office packages.
1291             // Remove all garbage after closing html tag.
1292             {regex: /<\s*\/html\s*>([\s\S]+)$/gi, replace: ""},
1293             // Remove if comment blocks.
1294             {regex: /<!--\[if[\s\S]*?endif\]-->/gi, replace: ""},
1295             // Remove start and end fragment comment blocks.
1296             {regex: /<!--(Start|End)Fragment-->/gi, replace: ""},
1297             // Remove any xml blocks.
1298             {regex: /<xml[^>]*>[\s\S]*?<\/xml>/gi, replace: ""},
1299             // Remove any <?xml><\?xml> blocks.
1300             {regex: /<\?xml[^>]*>[\s\S]*?<\\\?xml>/gi, replace: ""},
1301             // Remove <o:blah>, <\o:blah>.
1302             {regex: /<\/?\w+:[^>]*>/gi, replace: ""}
1303         ];
1305         // Apply the first set of harsher rules.
1306         content = this._filterContentWithRules(content, rules);
1308         // Apply the standard rules, which mainly cleans things like headers, links, and style blocks.
1309         content = this._cleanHTML(content);
1311         // Check if the string is empty or only contains whitespace.
1312         if (content.length === 0 || !content.match(/\S/)) {
1313             return content;
1314         }
1316         // Now we let the browser normalize the code by loading it into the DOM and then get the html back.
1317         // This gives us well quoted, well formatted code to continue our work on. Word may provide very poorly formatted code.
1318         var holder = document.createElement('div');
1319         holder.innerHTML = content;
1320         content = holder.innerHTML;
1321         // Free up the DOM memory.
1322         holder.innerHTML = "";
1324         // Run some more rules that care about quotes and whitespace.
1325         rules = [
1326             // Remove MSO-blah, MSO:blah in style attributes. Only removes one or more that appear in succession.
1327             {regex: /(<[^>]*?style\s*?=\s*?"[^>"]*?)(?:[\s]*MSO[-:][^>;"]*;?)+/gi, replace: "$1"},
1328             // Remove MSO classes in class attributes. Only removes one or more that appear in succession.
1329             {regex: /(<[^>]*?class\s*?=\s*?"[^>"]*?)(?:[\s]*MSO[_a-zA-Z0-9\-]*)+/gi, replace: "$1"},
1330             // Remove Apple- classes in class attributes. Only removes one or more that appear in succession.
1331             {regex: /(<[^>]*?class\s*?=\s*?"[^>"]*?)(?:[\s]*Apple-[_a-zA-Z0-9\-]*)+/gi, replace: "$1"},
1332             // Remove OLE_LINK# anchors that may litter the code.
1333             {regex: /<a [^>]*?name\s*?=\s*?"OLE_LINK\d*?"[^>]*?>\s*?<\/a>/gi, replace: ""},
1334             // Remove empty spans, but not ones from Rangy.
1335             {regex: /<span(?![^>]*?rangySelectionBoundary[^>]*?)[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""}
1336         ];
1338         // Apply the rules.
1339         content = this._filterContentWithRules(content, rules);
1341         // Reapply the standard cleaner to the content.
1342         content = this._cleanHTML(content);
1344         return content;
1345     }
1346 };
1348 Y.Base.mix(Y.M.editor_atto.Editor, [EditorClean]);
1349 // This file is part of Moodle - http://moodle.org/
1350 //
1351 // Moodle is free software: you can redistribute it and/or modify
1352 // it under the terms of the GNU General Public License as published by
1353 // the Free Software Foundation, either version 3 of the License, or
1354 // (at your option) any later version.
1355 //
1356 // Moodle is distributed in the hope that it will be useful,
1357 // but WITHOUT ANY WARRANTY; without even the implied warranty of
1358 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1359 // GNU General Public License for more details.
1360 //
1361 // You should have received a copy of the GNU General Public License
1362 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1364 /**
1365  * @module moodle-editor_atto-editor
1366  * @submodule toolbar
1367  */
1369 /**
1370  * Toolbar functions for the Atto editor.
1371  *
1372  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1373  *
1374  * @namespace M.editor_atto
1375  * @class EditorToolbar
1376  */
1378 function EditorToolbar() {}
1380 EditorToolbar.ATTRS= {
1381 };
1383 EditorToolbar.prototype = {
1384     /**
1385      * A reference to the toolbar Node.
1386      *
1387      * @property toolbar
1388      * @type Node
1389      */
1390     toolbar: null,
1392     /**
1393      * A reference to any currently open menus in the toolbar.
1394      *
1395      * @property openMenus
1396      * @type Array
1397      */
1398     openMenus: null,
1400     /**
1401      * Setup the toolbar on the editor.
1402      *
1403      * @method setupToolbar
1404      * @chainable
1405      */
1406     setupToolbar: function() {
1407         this.toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" role="toolbar" aria-live="off"/>');
1408         this.openMenus = [];
1409         this._wrapper.appendChild(this.toolbar);
1411         if (this.textareaLabel) {
1412             this.toolbar.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
1413         }
1415         // Add keyboard navigation for the toolbar.
1416         this.setupToolbarNavigation();
1418         return this;
1419     }
1420 };
1422 Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbar]);
1423 // This file is part of Moodle - http://moodle.org/
1424 //
1425 // Moodle is free software: you can redistribute it and/or modify
1426 // it under the terms of the GNU General Public License as published by
1427 // the Free Software Foundation, either version 3 of the License, or
1428 // (at your option) any later version.
1429 //
1430 // Moodle is distributed in the hope that it will be useful,
1431 // but WITHOUT ANY WARRANTY; without even the implied warranty of
1432 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1433 // GNU General Public License for more details.
1434 //
1435 // You should have received a copy of the GNU General Public License
1436 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1438 /**
1439  * @module moodle-editor_atto-editor
1440  * @submodule toolbarnav
1441  */
1443 /**
1444  * Toolbar Navigation functions for the Atto editor.
1445  *
1446  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1447  *
1448  * @namespace M.editor_atto
1449  * @class EditorToolbarNav
1450  */
1452 function EditorToolbarNav() {}
1454 EditorToolbarNav.ATTRS= {
1455 };
1457 EditorToolbarNav.prototype = {
1458     /**
1459      * The current focal point for tabbing.
1460      *
1461      * @property _tabFocus
1462      * @type Node
1463      * @default null
1464      * @private
1465      */
1466     _tabFocus: null,
1468     /**
1469      * Set up the watchers for toolbar navigation.
1470      *
1471      * @method setupToolbarNavigation
1472      * @chainable
1473      */
1474     setupToolbarNavigation: function() {
1475         // Listen for Arrow left and Arrow right keys.
1476         this._wrapper.delegate('key',
1477                 this.toolbarKeyboardNavigation,
1478                 'down:37,39',
1479                 '.' + CSS.TOOLBAR,
1480                 this);
1481         this._wrapper.delegate('focus',
1482                 function(e) {
1483                     this._setTabFocus(e.currentTarget);
1484                 }, '.' + CSS.TOOLBAR + ' button', this);
1486         return this;
1487     },
1489     /**
1490      * Implement arrow key navigation for the buttons in the toolbar.
1491      *
1492      * @method toolbarKeyboardNavigation
1493      * @param {EventFacade} e - the keyboard event.
1494      */
1495     toolbarKeyboardNavigation: function(e) {
1496         // Prevent the default browser behaviour.
1497         e.preventDefault();
1499         // On cursor moves we loops through the buttons.
1500         var buttons = this.toolbar.all('button'),
1501             direction = 1,
1502             button,
1503             current = e.target.ancestor('button', true);
1505         if (e.keyCode === 37) {
1506             // Moving left so reverse the direction.
1507             direction = -1;
1508         }
1510         button = this._findFirstFocusable(buttons, current, direction);
1511         if (button) {
1512             button.focus();
1513             this._setTabFocus(button);
1514         } else {
1515             Y.log("Unable to find a button to focus on", 'debug', LOGNAME);
1516         }
1517     },
1519     /**
1520      * Find the first focusable button.
1521      *
1522      * @param {NodeList} buttons A list of nodes.
1523      * @param {Node} startAt The node in the list to start the search from.
1524      * @param {Number} direction The direction in which to search (1 or -1).
1525      * @return {Node | Undefined} The Node or undefined.
1526      * @method _findFirstFocusable
1527      * @private
1528      */
1529     _findFirstFocusable: function(buttons, startAt, direction) {
1530         var checkCount = 0,
1531             group,
1532             candidate,
1533             button,
1534             index;
1536         // Determine which button to start the search from.
1537         index = buttons.indexOf(startAt);
1538         if (index < -1) {
1539             Y.log("Unable to find the button in the list of buttons", 'debug', LOGNAME);
1540             index = 0;
1541         }
1543         // Try to find the next.
1544         while (checkCount < buttons.size()) {
1545             index += direction;
1546             if (index < 0) {
1547                 index = buttons.size() - 1;
1548             } else if (index >= buttons.size()) {
1549                 // Handle wrapping.
1550                 index = 0;
1551             }
1553             candidate = buttons.item(index);
1555             // Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
1556             checkCount++;
1558             // Loop while:
1559             // * we haven't checked every button;
1560             // * the button is hidden or disabled;
1561             // * the group is hidden.
1562             if (candidate.hasAttribute('hidden') || candidate.hasAttribute('disabled')) {
1563                 continue;
1564             }
1565             group = candidate.ancestor('.atto_group');
1566             if (group.hasAttribute('hidden')) {
1567                 continue;
1568             }
1570             button = candidate;
1571             break;
1572         }
1574         return button;
1575     },
1577     /**
1578      * Check the tab focus.
1579      *
1580      * When we disable or hide a button, we should call this method to ensure that the
1581      * focus is not currently set on an inaccessible button, otherwise tabbing to the toolbar
1582      * would be impossible.
1583      *
1584      * @method checkTabFocus
1585      * @chainable
1586      */
1587     checkTabFocus: function() {
1588         if (this._tabFocus) {
1589             if (this._tabFocus.hasAttribute('disabled') || this._tabFocus.hasAttribute('hidden')
1590                     || this._tabFocus.ancestor('.atto_group').hasAttribute('hidden')) {
1591                 // Find first available button.
1592                 var button = this._findFirstFocusable(this.toolbar.all('button'), this._tabFocus, -1);
1593                 if (button) {
1594                     if (this._tabFocus.compareTo(document.activeElement)) {
1595                         // We should also move the focus, because the inaccessible button also has the focus.
1596                         button.focus();
1597                     }
1598                     this._setTabFocus(button);
1599                 }
1600             }
1601         }
1602         return this;
1603     },
1605     /**
1606      * Sets tab focus for the toolbar to the specified Node.
1607      *
1608      * @method _setTabFocus
1609      * @param {Node} button The node that focus should now be set to
1610      * @chainable
1611      * @private
1612      */
1613     _setTabFocus: function(button) {
1614         if (this._tabFocus) {
1615             // Unset the previous entry.
1616             this._tabFocus.setAttribute('tabindex', '-1');
1617         }
1619         // Set up the new entry.
1620         this._tabFocus = button;
1621         this._tabFocus.setAttribute('tabindex', 0);
1623         // And update the activedescendant to point at the currently selected button.
1624         this.toolbar.setAttribute('aria-activedescendant', this._tabFocus.generateID());
1626         return this;
1627     }
1628 };
1630 Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbarNav]);
1631 // This file is part of Moodle - http://moodle.org/
1632 //
1633 // Moodle is free software: you can redistribute it and/or modify
1634 // it under the terms of the GNU General Public License as published by
1635 // the Free Software Foundation, either version 3 of the License, or
1636 // (at your option) any later version.
1637 //
1638 // Moodle is distributed in the hope that it will be useful,
1639 // but WITHOUT ANY WARRANTY; without even the implied warranty of
1640 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1641 // GNU General Public License for more details.
1642 //
1643 // You should have received a copy of the GNU General Public License
1644 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1646 /**
1647  * @module moodle-editor_atto-editor
1648  * @submodule selection
1649  */
1651 /**
1652  * Selection functions for the Atto editor.
1653  *
1654  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1655  *
1656  * @namespace M.editor_atto
1657  * @class EditorSelection
1658  */
1660 function EditorSelection() {}
1662 EditorSelection.ATTRS= {
1663 };
1665 EditorSelection.prototype = {
1667     /**
1668      * List of saved selections per editor instance.
1669      *
1670      * @property _selections
1671      * @private
1672      */
1673     _selections: null,
1675     /**
1676      * A unique identifier for the last selection recorded.
1677      *
1678      * @property _lastSelection
1679      * @param lastselection
1680      * @type string
1681      * @private
1682      */
1683     _lastSelection: null,
1685     /**
1686      * Whether focus came from a click event.
1687      *
1688      * This is used to determine whether to restore the selection or not.
1689      *
1690      * @property _focusFromClick
1691      * @type Boolean
1692      * @default false
1693      * @private
1694      */
1695     _focusFromClick: false,
1697     /**
1698      * Set up the watchers for selection save and restoration.
1699      *
1700      * @method setupSelectionWatchers
1701      * @chainable
1702      */
1703     setupSelectionWatchers: function() {
1704         // Save the selection when a change was made.
1705         this.on('atto:selectionchanged', this.saveSelection, this);
1707         this.editor.on('focus', this.restoreSelection, this);
1709         // Do not restore selection when focus is from a click event.
1710         this.editor.on('mousedown', function() {
1711             this._focusFromClick = true;
1712         }, this);
1714         // Copy the current value back to the textarea when focus leaves us and save the current selection.
1715         this.editor.on('blur', function() {
1716             // Clear the _focusFromClick value.
1717             this._focusFromClick = false;
1719             // Update the original text area.
1720             this.updateOriginal();
1721         }, this);
1723         this.editor.on(['keyup', 'focus'], function(e) {
1724                 Y.soon(Y.bind(this._hasSelectionChanged, this, e));
1725             }, this);
1727         // To capture both mouseup and touchend events, we need to track the gesturemoveend event in standAlone mode. Without
1728         // standAlone, it will only fire if we listened to a gesturemovestart too.
1729         this.editor.on('gesturemoveend', function(e) {
1730                 Y.soon(Y.bind(this._hasSelectionChanged, this, e));
1731             }, {
1732                 standAlone: true
1733             }, this);
1735         return this;
1736     },
1738     /**
1739      * Work out if the cursor is in the editable area for this editor instance.
1740      *
1741      * @method isActive
1742      * @return {boolean}
1743      */
1744     isActive: function() {
1745         var range = rangy.createRange(),
1746             selection = rangy.getSelection();
1748         if (!selection.rangeCount) {
1749             // If there was no range count, then there is no selection.
1750             return false;
1751         }
1753         // We can't be active if the editor doesn't have focus at the moment.
1754         if (!document.activeElement ||
1755                 !(this.editor.compareTo(document.activeElement) || this.editor.contains(document.activeElement))) {
1756             return false;
1757         }
1759         // Check whether the range intersects the editor selection.
1760         range.selectNode(this.editor.getDOMNode());
1761         return range.intersectsRange(selection.getRangeAt(0));
1762     },
1764     /**
1765      * Create a cross browser selection object that represents a YUI node.
1766      *
1767      * @method getSelectionFromNode
1768      * @param {Node} YUI Node to base the selection upon.
1769      * @return {[rangy.Range]}
1770      */
1771     getSelectionFromNode: function(node) {
1772         var range = rangy.createRange();
1773         range.selectNode(node.getDOMNode());
1774         return [range];
1775     },
1777     /**
1778      * Save the current selection to an internal property.
1779      *
1780      * This allows more reliable return focus, helping improve keyboard navigation.
1781      *
1782      * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/restoreSelection"}}{{/crossLink}}.
1783      *
1784      * @method saveSelection
1785      */
1786     saveSelection: function() {
1787         if (this.isActive()) {
1788             this._selections = this.getSelection();
1789         }
1790     },
1792     /**
1793      * Restore any stored selection when the editor gets focus again.
1794      *
1795      * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/saveSelection"}}{{/crossLink}}.
1796      *
1797      * @method restoreSelection
1798      */
1799     restoreSelection: function() {
1800         if (!this._focusFromClick) {
1801             if (this._selections) {
1802                 this.setSelection(this._selections);
1803             }
1804         }
1805         this._focusFromClick = false;
1806     },
1808     /**
1809      * Get the selection object that can be passed back to setSelection.
1810      *
1811      * @method getSelection
1812      * @return {array} An array of rangy ranges.
1813      */
1814     getSelection: function() {
1815         return rangy.getSelection().getAllRanges();
1816     },
1818     /**
1819      * Check that a YUI node it at least partly contained by the current selection.
1820      *
1821      * @method selectionContainsNode
1822      * @param {Node} The node to check.
1823      * @return {boolean}
1824      */
1825     selectionContainsNode: function(node) {
1826         return rangy.getSelection().containsNode(node.getDOMNode(), true);
1827     },
1829     /**
1830      * Runs a filter on each node in the selection, and report whether the
1831      * supplied selector(s) were found in the supplied Nodes.
1832      *
1833      * By default, all specified nodes must match the selection, but this
1834      * can be controlled with the requireall property.
1835      *
1836      * @method selectionFilterMatches
1837      * @param {String} selector
1838      * @param {NodeList} [selectednodes] For performance this should be passed. If not passed, this will be looked up each time.
1839      * @param {Boolean} [requireall=true] Used to specify that "any" match is good enough.
1840      * @return {Boolean}
1841      */
1842     selectionFilterMatches: function(selector, selectednodes, requireall) {
1843         if (typeof requireall === 'undefined') {
1844             requireall = true;
1845         }
1846         if (!selectednodes) {
1847             // Find this because it was not passed as a param.
1848             selectednodes = this.getSelectedNodes();
1849         }
1850         var allmatch = selectednodes.size() > 0,
1851             anymatch = false;
1853         var editor = this.editor,
1854             stopFn = function(node) {
1855                 // The function getSelectedNodes only returns nodes within the editor, so this test is safe.
1856                 return node === editor;
1857             };
1859         // If we do not find at least one match in the editor, no point trying to find them in the selection.
1860         if (!editor.one(selector)) {
1861             return false;
1862         }
1864         selectednodes.each(function(node){
1865             // Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
1866             if (requireall) {
1867                 // Check for at least one failure.
1868                 if (!allmatch || !node.ancestor(selector, true, stopFn)) {
1869                     allmatch = false;
1870                 }
1871             } else {
1872                 // Check for at least one match.
1873                 if (!anymatch && node.ancestor(selector, true, stopFn)) {
1874                     anymatch = true;
1875                 }
1876             }
1877         }, this);
1878         if (requireall) {
1879             return allmatch;
1880         } else {
1881             return anymatch;
1882         }
1883     },
1885     /**
1886      * Get the deepest possible list of nodes in the current selection.
1887      *
1888      * @method getSelectedNodes
1889      * @return {NodeList}
1890      */
1891     getSelectedNodes: function() {
1892         var results = new Y.NodeList(),
1893             nodes,
1894             selection,
1895             range,
1896             node,
1897             i;
1899         selection = rangy.getSelection();
1901         if (selection.rangeCount) {
1902             range = selection.getRangeAt(0);
1903         } else {
1904             // Empty range.
1905             range = rangy.createRange();
1906         }
1908         if (range.collapsed) {
1909             // We do not want to select all the nodes in the editor if we managed to
1910             // have a collapsed selection directly in the editor.
1911             // It's also possible for the commonAncestorContainer to be the document, which selectNode does not handle
1912             // so we must filter that out here too.
1913             if (range.commonAncestorContainer !== this.editor.getDOMNode()
1914                     && range.commonAncestorContainer !== Y.config.doc) {
1915                 range = range.cloneRange();
1916                 range.selectNode(range.commonAncestorContainer);
1917             }
1918         }
1920         nodes = range.getNodes();
1922         for (i = 0; i < nodes.length; i++) {
1923             node = Y.one(nodes[i]);
1924             if (this.editor.contains(node)) {
1925                 results.push(node);
1926             }
1927         }
1928         return results;
1929     },
1931     /**
1932      * Check whether the current selection has changed since this method was last called.
1933      *
1934      * If the selection has changed, the atto:selectionchanged event is also fired.
1935      *
1936      * @method _hasSelectionChanged
1937      * @private
1938      * @param {EventFacade} e
1939      * @return {Boolean}
1940      */
1941     _hasSelectionChanged: function(e) {
1942         var selection = rangy.getSelection(),
1943             range,
1944             changed = false;
1946         if (selection.rangeCount) {
1947             range = selection.getRangeAt(0);
1948         } else {
1949             // Empty range.
1950             range = rangy.createRange();
1951         }
1953         if (this._lastSelection) {
1954             if (!this._lastSelection.equals(range)) {
1955                 changed = true;
1956                 return this._fireSelectionChanged(e);
1957             }
1958         }
1959         this._lastSelection = range;
1960         return changed;
1961     },
1963     /**
1964      * Fires the atto:selectionchanged event.
1965      *
1966      * When the selectionchanged event is fired, the following arguments are provided:
1967      *   - event : the original event that lead to this event being fired.
1968      *   - selectednodes :  an array containing nodes that are entirely selected of contain partially selected content.
1969      *
1970      * @method _fireSelectionChanged
1971      * @private
1972      * @param {EventFacade} e
1973      */
1974     _fireSelectionChanged: function(e) {
1975         this.fire('atto:selectionchanged', {
1976             event: e,
1977             selectedNodes: this.getSelectedNodes()
1978         });
1979     },
1981     /**
1982      * Get the DOM node representing the common anscestor of the selection nodes.
1983      *
1984      * @method getSelectionParentNode
1985      * @return {Element|boolean} The DOM Node for this parent, or false if no seletion was made.
1986      */
1987     getSelectionParentNode: function() {
1988         var selection = rangy.getSelection();
1989         if (selection.rangeCount) {
1990             return selection.getRangeAt(0).commonAncestorContainer;
1991         }
1992         return false;
1993     },
1995     /**
1996      * Set the current selection. Used to restore a selection.
1997      *
1998      * @method selection
1999      * @param {array} ranges A list of rangy.range objects in the selection.
2000      */
2001     setSelection: function(ranges) {
2002         var selection = rangy.getSelection();
2003         selection.setRanges(ranges);
2004     },
2006     /**
2007      * Inserts the given HTML into the editable content at the currently focused point.
2008      *
2009      * @method insertContentAtFocusPoint
2010      * @param {String} html
2011      * @return {Node} The YUI Node object added to the DOM.
2012      */
2013     insertContentAtFocusPoint: function(html) {
2014         var selection = rangy.getSelection(),
2015             range,
2016             node = Y.Node.create(html);
2017         if (selection.rangeCount) {
2018             range = selection.getRangeAt(0);
2019         }
2020         if (range) {
2021             range.deleteContents();
2022             range.insertNode(node.getDOMNode());
2023         }
2024         return node;
2025     }
2027 };
2029 Y.Base.mix(Y.M.editor_atto.Editor, [EditorSelection]);
2030 // This file is part of Moodle - http://moodle.org/
2031 //
2032 // Moodle is free software: you can redistribute it and/or modify
2033 // it under the terms of the GNU General Public License as published by
2034 // the Free Software Foundation, either version 3 of the License, or
2035 // (at your option) any later version.
2036 //
2037 // Moodle is distributed in the hope that it will be useful,
2038 // but WITHOUT ANY WARRANTY; without even the implied warranty of
2039 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
2040 // GNU General Public License for more details.
2041 //
2042 // You should have received a copy of the GNU General Public License
2043 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
2045 /**
2046  * @module moodle-editor_atto-editor
2047  * @submodule styling
2048  */
2050 /**
2051  * Editor styling functions for the Atto editor.
2052  *
2053  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2054  *
2055  * @namespace M.editor_atto
2056  * @class EditorStyling
2057  */
2059 function EditorStyling() {}
2061 EditorStyling.ATTRS= {
2062 };
2064 EditorStyling.prototype = {
2065     /**
2066      * Disable CSS styling.
2067      *
2068      * @method disableCssStyling
2069      */
2070     disableCssStyling: function() {
2071         try {
2072             document.execCommand("styleWithCSS", 0, false);
2073         } catch (e1) {
2074             try {
2075                 document.execCommand("useCSS", 0, true);
2076             } catch (e2) {
2077                 try {
2078                     document.execCommand('styleWithCSS', false, false);
2079                 } catch (e3) {
2080                     // We did our best.
2081                 }
2082             }
2083         }
2084     },
2086     /**
2087      * Enable CSS styling.
2088      *
2089      * @method enableCssStyling
2090      */
2091     enableCssStyling: function() {
2092         try {
2093             document.execCommand("styleWithCSS", 0, true);
2094         } catch (e1) {
2095             try {
2096                 document.execCommand("useCSS", 0, false);
2097             } catch (e2) {
2098                 try {
2099                     document.execCommand('styleWithCSS', false, true);
2100                 } catch (e3) {
2101                     // We did our best.
2102                 }
2103             }
2104         }
2105     },
2107     /**
2108      * Change the formatting for the current selection.
2109      *
2110      * This will wrap the selection in span tags, adding the provided classes.
2111      *
2112      * If the selection covers multiple block elements, multiple spans will be inserted to preserve the original structure.
2113      *
2114      * @method toggleInlineSelectionClass
2115      * @param {Array} toggleclasses - Class names to be toggled on or off.
2116      */
2117     toggleInlineSelectionClass: function(toggleclasses) {
2118         var classname = toggleclasses.join(" ");
2119         var originalSelection = this.getSelection();
2120         var cssApplier = rangy.createCssClassApplier(classname, {normalize: true});
2122         cssApplier.toggleSelection();
2124         this.setSelection(originalSelection);
2125     },
2127     /**
2128      * Change the formatting for the current selection.
2129      *
2130      * This will set inline styles on the current selection.
2131      *
2132      * @method toggleInlineSelectionClass
2133      * @param {Array} styles - Style attributes to set on the nodes.
2134      */
2135     formatSelectionInlineStyle: function(styles) {
2136         var classname = this.PLACEHOLDER_CLASS;
2137         var originalSelection = this.getSelection();
2138         var cssApplier = rangy.createCssClassApplier(classname, {normalize: true});
2140         cssApplier.applyToSelection();
2142         this.editor.all('.' + classname).each(function (node) {
2143             node.removeClass(classname).setStyles(styles);
2144         }, this);
2146         this.setSelection(originalSelection);
2147     },
2149     /**
2150      * Change the formatting for the current selection.
2151      *
2152      * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
2153      *
2154      * @method formatSelectionBlock
2155      * @param {String} [blocktag] Change the block level tag to this. Empty string, means do not change the tag.
2156      * @param {Object} [attributes] The keys and values for attributes to be added/changed in the block tag.
2157      * @return {Node|boolean} The Node that was formatted if a change was made, otherwise false.
2158      */
2159     formatSelectionBlock: function(blocktag, attributes) {
2160         // First find the nearest ancestor of the selection that is a block level element.
2161         var selectionparentnode = this.getSelectionParentNode(),
2162             boundary,
2163             cell,
2164             nearestblock,
2165             newcontent,
2166             match,
2167             replacement;
2169         if (!selectionparentnode) {
2170             // No selection, nothing to format.
2171             return false;
2172         }
2174         boundary = this.editor;
2176         selectionparentnode = Y.one(selectionparentnode);
2178         // If there is a table cell in between the selectionparentnode and the boundary,
2179         // move the boundary to the table cell.
2180         // This is because we might have a table in a div, and we select some text in a cell,
2181         // want to limit the change in style to the table cell, not the entire table (via the outer div).
2182         cell = selectionparentnode.ancestor(function (node) {
2183             var tagname = node.get('tagName');
2184             if (tagname) {
2185                 tagname = tagname.toLowerCase();
2186             }
2187             return (node === boundary) ||
2188                    (tagname === 'td') ||
2189                    (tagname === 'th');
2190         }, true);
2192         if (cell) {
2193             // Limit the scope to the table cell.
2194             boundary = cell;
2195         }
2197         nearestblock = selectionparentnode.ancestor(this.BLOCK_TAGS.join(', '), true);
2198         if (nearestblock) {
2199             // Check that the block is contained by the boundary.
2200             match = nearestblock.ancestor(function (node) {
2201                 return node === boundary;
2202             }, false);
2204             if (!match) {
2205                 nearestblock = false;
2206             }
2207         }
2209         // No valid block element - make one.
2210         if (!nearestblock) {
2211             // There is no block node in the content, wrap the content in a p and use that.
2212             newcontent = Y.Node.create('<p></p>');
2213             boundary.get('childNodes').each(function (child) {
2214                 newcontent.append(child.remove());
2215             });
2216             boundary.append(newcontent);
2217             nearestblock = newcontent;
2218         }
2220         // Guaranteed to have a valid block level element contained in the contenteditable region.
2221         // Change the tag to the new block level tag.
2222         if (blocktag && blocktag !== '') {
2223             // Change the block level node for a new one.
2224             replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
2225             // Copy all attributes.
2226             replacement.setAttrs(nearestblock.getAttrs());
2227             // Copy all children.
2228             nearestblock.get('childNodes').each(function (child) {
2229                 child.remove();
2230                 replacement.append(child);
2231             });
2233             nearestblock.replace(replacement);
2234             nearestblock = replacement;
2235         }
2237         // Set the attributes on the block level tag.
2238         if (attributes) {
2239             nearestblock.setAttrs(attributes);
2240         }
2242         // Change the selection to the modified block. This makes sense when we might apply multiple styles
2243         // to the block.
2244         var selection = this.getSelectionFromNode(nearestblock);
2245         this.setSelection(selection);
2247         return nearestblock;
2248     }
2250 };
2252 Y.Base.mix(Y.M.editor_atto.Editor, [EditorStyling]);
2253 // This file is part of Moodle - http://moodle.org/
2254 //
2255 // Moodle is free software: you can redistribute it and/or modify
2256 // it under the terms of the GNU General Public License as published by
2257 // the Free Software Foundation, either version 3 of the License, or
2258 // (at your option) any later version.
2259 //
2260 // Moodle is distributed in the hope that it will be useful,
2261 // but WITHOUT ANY WARRANTY; without even the implied warranty of
2262 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
2263 // GNU General Public License for more details.
2264 //
2265 // You should have received a copy of the GNU General Public License
2266 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
2268 /**
2269  * @module moodle-editor_atto-editor
2270  * @submodule filepicker
2271  */
2273 /**
2274  * Filepicker options for the Atto editor.
2275  *
2276  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2277  *
2278  * @namespace M.editor_atto
2279  * @class EditorFilepicker
2280  */
2282 function EditorFilepicker() {}
2284 EditorFilepicker.ATTRS= {
2285     /**
2286      * The options for the filepicker.
2287      *
2288      * @attribute filepickeroptions
2289      * @type object
2290      * @default {}
2291      */
2292     filepickeroptions: {
2293         value: {}
2294     }
2295 };
2297 EditorFilepicker.prototype = {
2298     /**
2299      * Should we show the filepicker for this filetype?
2300      *
2301      * @method canShowFilepicker
2302      * @param string type The media type for the file picker.
2303      * @return {boolean}
2304      */
2305     canShowFilepicker: function(type) {
2306         return (typeof this.get('filepickeroptions')[type] !== 'undefined');
2307     },
2309     /**
2310      * Show the filepicker.
2311      *
2312      * This depends on core_filepicker, and then call that modules show function.
2313      *
2314      * @method showFilepicker
2315      * @param {string} type The media type for the file picker.
2316      * @param {function} callback The callback to use when selecting an item of media.
2317      * @param {object} [context] The context from which to call the callback.
2318      */
2319     showFilepicker: function(type, callback, context) {
2320         var self = this;
2321         Y.use('core_filepicker', function (Y) {
2322             var options = Y.clone(self.get('filepickeroptions')[type], true);
2323             options.formcallback = callback;
2324             if (context) {
2325                 options.magicscope = context;
2326             }
2328             M.core_filepicker.show(Y, options);
2329         });
2330     }
2331 };
2333 Y.Base.mix(Y.M.editor_atto.Editor, [EditorFilepicker]);
2336 }, '@VERSION@', {
2337     "requires": [
2338         "node",
2339         "transition",
2340         "io",
2341         "overlay",
2342         "escape",
2343         "event",
2344         "event-simulate",
2345         "event-custom",
2346         "yui-throttle",
2347         "moodle-core-notification-dialogue",
2348         "moodle-core-notification-confirm",
2349         "moodle-editor_atto-rangy",
2350         "handlebars",
2351         "timers"
2352     ]
2353 });