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