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