MDL-44834 editor_atto: Delegate change event to one editor
[moodle.git] / lib / editor / atto / yui / build / moodle-editor_atto-editor / moodle-editor_atto-editor.js
1 YUI.add('moodle-editor_atto-editor', function (Y, NAME) {
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * The Atto WYSIWG pluggable editor, written for Moodle.
20  *
21  * @module     moodle-editor_atto-editor
22  * @package    editor_atto
23  * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  * @main       moodle-editor_atto-editor
26  */
28 /**
29  * @module moodle-editor_atto-editor
30  * @submodule editor-base
31  */
33 var LOGNAME = 'moodle-editor_atto-editor';
34 var CSS = {
35         CONTENT: 'editor_atto_content',
36         CONTENTWRAPPER: 'editor_atto_content_wrap',
37         TOOLBAR: 'editor_atto_toolbar',
38         WRAPPER: 'editor_atto',
39         HIGHLIGHT: 'highlight'
40     };
42 /**
43  * The Atto editor for Moodle.
44  *
45  * @namespace M.editor_atto
46  * @class Editor
47  * @constructor
48  * @uses M.editor_atto.EditorClean
49  * @uses M.editor_atto.EditorFilepicker
50  * @uses M.editor_atto.EditorSelection
51  * @uses M.editor_atto.EditorStyling
52  * @uses M.editor_atto.EditorTextArea
53  * @uses M.editor_atto.EditorToolbar
54  * @uses M.editor_atto.EditorToolbarNav
55  */
57 function Editor() {
58     Editor.superclass.constructor.apply(this, arguments);
59 }
61 Y.extend(Editor, Y.Base, {
63     /**
64      * List of known block level tags.
65      * Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
66      *
67      * @property BLOCK_TAGS
68      * @type {Array}
69      */
70     BLOCK_TAGS : [
71         'address',
72         'article',
73         'aside',
74         'audio',
75         'blockquote',
76         'canvas',
77         'dd',
78         'div',
79         'dl',
80         'fieldset',
81         'figcaption',
82         'figure',
83         'footer',
84         'form',
85         'h1',
86         'h2',
87         'h3',
88         'h4',
89         'h5',
90         'h6',
91         'header',
92         'hgroup',
93         'hr',
94         'noscript',
95         'ol',
96         'output',
97         'p',
98         'pre',
99         'section',
100         'table',
101         'tfoot',
102         'ul',
103         'video'
104     ],
106     PLACEHOLDER_FONTNAME: 'yui-tmp',
107     ALL_NODES_SELECTOR: '[style],font[face]',
108     FONT_FAMILY: 'fontFamily',
110     /**
111      * The wrapper containing the editor.
112      *
113      * @property _wrapper
114      * @type Node
115      * @private
116      */
117     _wrapper: null,
119     /**
120      * A reference to the content editable Node.
121      *
122      * @property editor
123      * @type Node
124      */
125     editor: null,
127     /**
128      * A reference to the original text area.
129      *
130      * @property textarea
131      * @type Node
132      */
133     textarea: null,
135     /**
136      * A reference to the label associated with the original text area.
137      *
138      * @property textareaLabel
139      * @type Node
140      */
141     textareaLabel: null,
143     /**
144      * A reference to the list of plugins.
145      *
146      * @property plugins
147      * @type object
148      */
149     plugins: null,
151     initializer: function() {
152         var template;
154         // Note - it is not safe to use a CSS selector like '#' + elementid because the id
155         // may have colons in it - e.g.  quiz.
156         this.textarea = Y.one(document.getElementById(this.get('elementid')));
158         if (!this.textarea) {
159             // No text area found.
160             return;
161         }
163         this._wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
164         template = Y.Handlebars.compile('<div id="{{elementid}}editable" ' +
165                 'contenteditable="true" ' +
166                 'role="textbox" ' +
167                 'spellcheck="true" ' +
168                 'aria-live="off" ' +
169                 'class="{{CSS.CONTENT}}" ' +
170                 '/>');
171         this.editor = Y.Node.create(template({
172             elementid: this.get('elementid'),
173             CSS: CSS
174         }));
176         // Add a labelled-by attribute to the contenteditable.
177         this.textareaLabel = Y.one('[for="' + this.get('elementid') + '"]');
178         if (this.textareaLabel) {
179             this.textareaLabel.generateID();
180             this.editor.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
181         }
183         // Add everything to the wrapper.
184         this.setupToolbar();
186         // Editable content wrapper.
187         var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
188         content.appendChild(this.editor);
189         this._wrapper.appendChild(content);
191         // Style the editor.
192         this.editor.setStyle('minHeight', (1.2 * (this.textarea.getAttribute('rows'))) + 'em');
193         // Disable odd inline CSS styles.
194         this.disableCssStyling();
196         // Add the toolbar and editable zone to the page.
197         this.textarea.get('parentNode').insert(this._wrapper, this.textarea);
199         // Hide the old textarea.
200         this.textarea.hide();
202         // Copy the text to the contenteditable div.
203         this.updateFromTextArea();
205         // Publish the events that are defined by this editor.
206         this.publishEvents();
208         // Add handling for saving and restoring selections on cursor/focus changes.
209         this.setupSelectionWatchers();
211         // Setup plugins.
212         this.setupPlugins();
213     },
215     /**
216      * Focus on the editable area for this editor.
217      *
218      * @method focus
219      * @chainable
220      */
221     focus: function() {
222         this.editor.focus();
224         return this;
225     },
227     /**
228      * Publish events for this editor instance.
229      *
230      * @method publishEvents
231      * @private
232      * @chainable
233      */
234     publishEvents: function() {
235         /**
236          * Fired when changes are made within the editor.
237          *
238          * @event change
239          */
240         this.publish('change', {
241             broadcast: true,
242             preventable: true
243         });
245         /**
246          * Fired when all plugins have completed loading.
247          *
248          * @event pluginsloaded
249          */
250         this.publish('pluginsloaded', {
251             fireOnce: true
252         });
254         this.publish('atto:selectionchanged', {
255             prefix: 'atto'
256         });
258         Y.delegate(['mouseup', 'keyup', 'focus'], this._hasSelectionChanged, document.body, '#' + this.editor.get('id'), this);
260         return this;
261     },
263     setupPlugins: function() {
264         // Clear the list of plugins.
265         this.plugins = {};
267         var plugins = this.get('plugins');
269         var groupIndex,
270             group,
271             pluginIndex,
272             plugin,
273             pluginConfig;
275         for (groupIndex in plugins) {
276             group = plugins[groupIndex];
277             if (!group.plugins) {
278                 // No plugins in this group - skip it.
279                 continue;
280             }
281             for (pluginIndex in group.plugins) {
282                 plugin = group.plugins[pluginIndex];
284                 pluginConfig = Y.mix({
285                     name: plugin.name,
286                     group: group.group,
287                     editor: this.editor,
288                     toolbar: this.toolbar,
289                     host: this
290                 }, plugin);
292                 // Add a reference to the current editor.
293                 if (typeof Y.M['atto_' + plugin.name] === "undefined") {
294                     continue;
295                 }
296                 this.plugins[plugin.name] = new Y.M['atto_' + plugin.name].Button(pluginConfig);
297             }
298         }
300         // Some plugins need to perform actions once all plugins have loaded.
301         this.fire('pluginsloaded');
303         return this;
304     },
306     enablePlugins: function(plugin) {
307         this._setPluginState(true, plugin);
308     },
310     disablePlugins: function(plugin) {
311         this._setPluginState(false, plugin);
312     },
314     _setPluginState: function(enable, plugin) {
315         var target = 'disableButtons';
316         if (enable) {
317             target = 'enableButtons';
318         }
320         if (plugin) {
321             this.plugins[plugin][target]();
322         } else {
323             Y.Object.each(this.plugins, function(currentPlugin) {
324                 currentPlugin[target]();
325             }, this);
326         }
327     }
329 }, {
330     NS: 'editor_atto',
331     ATTRS: {
332         /**
333          * The unique identifier for the form element representing the editor.
334          *
335          * @attribute elementid
336          * @type String
337          * @writeOnce
338          */
339         elementid: {
340             value: null,
341             writeOnce: true
342         },
344         /**
345          * Plugins with their configuration.
346          *
347          * The plugins structure is:
348          *
349          *     [
350          *         {
351          *             "group": "groupName",
352          *             "plugins": [
353          *                 "pluginName": {
354          *                     "configKey": "configValue"
355          *                 },
356          *                 "pluginName": {
357          *                     "configKey": "configValue"
358          *                 }
359          *             ]
360          *         },
361          *         {
362          *             "group": "groupName",
363          *             "plugins": [
364          *                 "pluginName": {
365          *                     "configKey": "configValue"
366          *                 }
367          *             ]
368          *         }
369          *     ]
370          *
371          * @attribute plugins
372          * @type Object
373          * @writeOnce
374          */
375         plugins: {
376             value: {},
377             writeOnce: true
378         }
379     }
380 });
382 // The Editor publishes custom events that can be subscribed to.
383 Y.augment(Editor, Y.EventTarget);
385 Y.namespace('M.editor_atto').Editor = Editor;
387 // Function for Moodle's initialisation.
388 Y.namespace('M.editor_atto.Editor').init = function(config) {
389     return new Y.M.editor_atto.Editor(config);
390 };
391 // This file is part of Moodle - http://moodle.org/
392 //
393 // Moodle is free software: you can redistribute it and/or modify
394 // it under the terms of the GNU General Public License as published by
395 // the Free Software Foundation, either version 3 of the License, or
396 // (at your option) any later version.
397 //
398 // Moodle is distributed in the hope that it will be useful,
399 // but WITHOUT ANY WARRANTY; without even the implied warranty of
400 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
401 // GNU General Public License for more details.
402 //
403 // You should have received a copy of the GNU General Public License
404 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
406 /**
407  * @module moodle-editor_atto-editor
408  * @submodule textarea
409  */
411 /**
412  * Textarea functions for the Atto editor.
413  *
414  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
415  *
416  * @namespace M.editor_atto
417  * @class EditorTextArea
418  */
420 function EditorTextArea() {}
422 EditorTextArea.ATTRS= {
423 };
425 EditorTextArea.prototype = {
426     /**
427      * Copy and clean the text from the textarea into the contenteditable div.
428      *
429      * If the text is empty, provide a default paragraph tag to hold the content.
430      *
431      * @method updateFromTextArea
432      * @chainable
433      */
434     updateFromTextArea: function() {
435         // Clear it first.
436         this.editor.setHTML('');
438         // Copy text to editable div.
439         this.editor.append(this.textarea.get('value'));
441         // Clean it.
442         this.cleanEditorHTML();
444         // Insert a paragraph in the empty contenteditable div.
445         if (this.editor.getHTML() === '') {
446             if (Y.UA.ie && Y.UA.ie < 10) {
447                 this.editor.setHTML('<p></p>');
448             } else {
449                 this.editor.setHTML('<p><br></p>');
450             }
451         }
452     },
454     /**
455      * Copy the text from the contenteditable to the textarea which it replaced.
456      *
457      * @method updateOriginal
458      * @chainable
459      */
460     updateOriginal : function() {
461         // Insert the cleaned content.
462         this.textarea.set('value', this.getCleanHTML());
464         // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
465         this.textarea.simulate('change');
467         // Trigger handlers for this action.
468         this.fire('change');
469     }
470 };
472 Y.Base.mix(Y.M.editor_atto.Editor, [EditorTextArea]);
473 // This file is part of Moodle - http://moodle.org/
474 //
475 // Moodle is free software: you can redistribute it and/or modify
476 // it under the terms of the GNU General Public License as published by
477 // the Free Software Foundation, either version 3 of the License, or
478 // (at your option) any later version.
479 //
480 // Moodle is distributed in the hope that it will be useful,
481 // but WITHOUT ANY WARRANTY; without even the implied warranty of
482 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
483 // GNU General Public License for more details.
484 //
485 // You should have received a copy of the GNU General Public License
486 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
488 /**
489  * @module moodle-editor_atto-editor
490  * @submodule clean
491  */
493 /**
494  * Functions for the Atto editor to clean the generated content.
495  *
496  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
497  *
498  * @namespace M.editor_atto
499  * @class EditorClean
500  */
502 function EditorClean() {}
504 EditorClean.ATTRS= {
505 };
507 EditorClean.prototype = {
508     /**
509      * Clean the generated HTML content without modifying the editor content.
510      *
511      * This includes removes all YUI ids from the generated content.
512      *
513      * @return {string} The cleaned HTML content.
514      */
515     getCleanHTML: function() {
516         // Clone the editor so that we don't actually modify the real content.
517         var editorClone = this.editor.cloneNode(true);
519         // Remove all YUI IDs.
520         Y.each(editorClone.all('[id^="yui"]'), function(node) {
521             node.removeAttribute('id');
522         });
524         editorClone.all('.atto_control').remove(true);
526         // Remove any and all nasties from source.
527        return this._cleanHTML(editorClone.get('innerHTML'));
528     },
530     /**
531      * Clean the HTML content of the editor.
532      *
533      * @method cleanEditorHTML
534      * @chainable
535      */
536     cleanEditorHTML: function() {
537         var startValue = this.editor.get('innerHTML');
538         this.editor.set('innerHTML', this._cleanHTML(startValue));
540         return this;
541     },
543     /**
544      * Clean the specified HTML content and remove any content which could cause issues.
545      *
546      * @method _cleanHTML
547      * @private
548      * @param {String} content The content to clean
549      * @return {String} The cleaned HTML
550      */
551     _cleanHTML: function(content) {
552         // What are we doing ?
553         // We are cleaning random HTML from all over the shop into a set of useful html suitable for content.
554         // We are allowing styles etc, but not e.g. font tags, class="MsoNormal" etc.
556         var rules = [
557             // Source: "http://stackoverflow.com/questions/2875027/clean-microsoft-word-pasted-text-using-javascript"
558             // Source: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
560             // Remove all HTML comments.
561             {regex: /<!--[\s\S]*?-->/gi, replace: ""},
562             // Source: "http://www.1stclassmedia.co.uk/developers/clean-ms-word-formatting.php"
563             // Remove <?xml>, <\?xml>.
564             {regex: /<\\?\?xml[^>]*>/gi, replace: ""},
565             // Remove <o:blah>, <\o:blah>.
566             {regex: /<\/?\w+:[^>]*>/gi, replace: ""}, // e.g. <o:p...
567             // Remove MSO-blah, MSO:blah (e.g. in style attributes)
568             {regex: /\s*MSO[-:][^;"']*;?/gi, replace: ""},
569             // Remove empty spans
570             {regex: /<span[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""},
571             // Remove class="Msoblah"
572             {regex: /class="Mso[^"]*"/gi, replace: ""},
574             // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
575             // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body.
576             {regex: /<(\/?title|\/?meta|\/?style|\/?st\d|\/?head|\/?font|\/?html|\/?body|!\[)[^>]*?>/gi, replace: ""},
578             // Source: "http://www.tim-jarrett.com/labs_javascript_scrub_word.php"
579             // Replace extended chars with simple text.
580             {regex: new RegExp(String.fromCharCode(8220), 'gi'), replace: '"'},
581             {regex: new RegExp(String.fromCharCode(8216), 'gi'), replace: "'"},
582             {regex: new RegExp(String.fromCharCode(8217), 'gi'), replace: "'"},
583             {regex: new RegExp(String.fromCharCode(8211), 'gi'), replace: '-'},
584             {regex: new RegExp(String.fromCharCode(8212), 'gi'), replace: '--'},
585             {regex: new RegExp(String.fromCharCode(189), 'gi'), replace: '1/2'},
586             {regex: new RegExp(String.fromCharCode(188), 'gi'), replace: '1/4'},
587             {regex: new RegExp(String.fromCharCode(190), 'gi'), replace: '3/4'},
588             {regex: new RegExp(String.fromCharCode(169), 'gi'), replace: '(c)'},
589             {regex: new RegExp(String.fromCharCode(174), 'gi'), replace: '(r)'},
590             {regex: new RegExp(String.fromCharCode(8230), 'gi'), replace: '...'}
591         ];
593         var i = 0;
594         for (i = 0; i < rules.length; i++) {
595             content = content.replace(rules[i].regex, rules[i].replace);
596         }
598         return content;
599     }
600 };
602 Y.Base.mix(Y.M.editor_atto.Editor, [EditorClean]);
603 // This file is part of Moodle - http://moodle.org/
604 //
605 // Moodle is free software: you can redistribute it and/or modify
606 // it under the terms of the GNU General Public License as published by
607 // the Free Software Foundation, either version 3 of the License, or
608 // (at your option) any later version.
609 //
610 // Moodle is distributed in the hope that it will be useful,
611 // but WITHOUT ANY WARRANTY; without even the implied warranty of
612 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
613 // GNU General Public License for more details.
614 //
615 // You should have received a copy of the GNU General Public License
616 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
618 /**
619  * @module moodle-editor_atto-editor
620  * @submodule toolbar
621  */
623 /**
624  * Toolbar functions for the Atto editor.
625  *
626  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
627  *
628  * @namespace M.editor_atto
629  * @class EditorToolbar
630  */
632 function EditorToolbar() {}
634 EditorToolbar.ATTRS= {
635 };
637 EditorToolbar.prototype = {
638     /**
639      * A reference to the toolbar Node.
640      *
641      * @property toolbar
642      * @type Node
643      */
644     toolbar: null,
646     /**
647      * Setup the toolbar on the editor.
648      *
649      * @method setupToolbar
650      * @chainable
651      */
652     setupToolbar: function() {
653         this.toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" role="toolbar" aria-live="off"/>');
654         this._wrapper.appendChild(this.toolbar);
656         if (this.textareaLabel) {
657             this.toolbar.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
658         }
660         // Add keyboard navigation for the toolbar.
661         this.setupToolbarNavigation();
663         return this;
664     }
665 };
667 Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbar]);
668 // This file is part of Moodle - http://moodle.org/
669 //
670 // Moodle is free software: you can redistribute it and/or modify
671 // it under the terms of the GNU General Public License as published by
672 // the Free Software Foundation, either version 3 of the License, or
673 // (at your option) any later version.
674 //
675 // Moodle is distributed in the hope that it will be useful,
676 // but WITHOUT ANY WARRANTY; without even the implied warranty of
677 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
678 // GNU General Public License for more details.
679 //
680 // You should have received a copy of the GNU General Public License
681 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
683 /**
684  * @module moodle-editor_atto-editor
685  * @submodule toolbarnav
686  */
688 /**
689  * Toolbar Navigation functions for the Atto editor.
690  *
691  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
692  *
693  * @namespace M.editor_atto
694  * @class EditorToolbarNav
695  */
697 function EditorToolbarNav() {}
699 EditorToolbarNav.ATTRS= {
700 };
702 EditorToolbarNav.prototype = {
703     /**
704      * The current focal point for tabbing.
705      *
706      * @property _tabFocus
707      * @type Node
708      * @default null
709      * @private
710      */
711     _tabFocus: null,
713     /**
714      * Set up the watchers for toolbar navigation.
715      *
716      * @method setupToolbarNavigation
717      * @chainable
718      */
719     setupToolbarNavigation: function() {
720         // Listen for Arrow left and Arrow right keys.
721         this._wrapper.delegate('key',
722                 this.toolbarKeyboardNavigation,
723                 'down:37,39',
724                 '.' + CSS.TOOLBAR,
725                 this);
727         return this;
728     },
730     /**
731      * Implement arrow key navigation for the buttons in the toolbar.
732      *
733      * @method toolbarKeyboardNavigation
734      * @param {EventFacade} e - the keyboard event.
735      */
736     toolbarKeyboardNavigation: function(e) {
737         // Prevent the default browser behaviour.
738         e.preventDefault();
740         var buttons = this.toolbar.all('button');
742         // On cursor moves we loops through the buttons.
743         var found = false,
744             index = 0,
745             direction = 1,
746             checkCount = 0,
747             group,
748             current = e.target.ancestor('button', true);
750         // Determine which button is currently selected.
751         while (!found && index < buttons.size()) {
752             if (buttons.item(index) === current) {
753                 found = true;
754             } else {
755                 index++;
756             }
757         }
759         if (!found) {
760             return;
761         }
763         if (e.keyCode === 37) {
764             // Moving left so reverse the direction.
765             direction = -1;
766         }
768         // Try to find the next
769         do {
770             index += direction;
771             if (index < 0) {
772                 index = buttons.size() - 1;
773             } else if (index >= buttons.size()) {
774                 // Handle wrapping.
775                 index = 0;
776             }
777             next = buttons.item(index);
778             group = next.ancestor('.atto_group');
780             // Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
781             checkCount++;
782             // Loop while:
783             // * we are not in a loop and have not already checked every button; and
784             // * we are on a different button; and
785             // * both the next button and the group it is in are not hidden.
786         } while (checkCount < buttons.size() && next !== current && (next.hasAttribute('hidden') || group.hasAttribute('hidden')));
788         if (next) {
789             next.focus();
790             this._setTabFocus(next);
791         }
792     },
794     /**
795      * Sets tab focus for the toolbar to the specified Node.
796      *
797      * @method _setTabFocus
798      * @param {Node} button The node that focus should now be set to
799      * @chainable
800      * @private
801      */
802     _setTabFocus: function(button) {
803         if (this._tabFocus) {
804             // Unset the previous entry.
805             this._tabFocus.setAttribute('tabindex', '-1');
806         }
808         // Set up the new entry.
809         this._tabFocus = button;
810         this._tabFocus.setAttribute('tabindex', 0);
812         // And update the activedescendant to point at the currently selected button.
813         this.toolbar.setAttribute('aria-activedescendant', this._tabFocus.generateID());
815         return this;
816     }
817 };
819 Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbarNav]);
820 // This file is part of Moodle - http://moodle.org/
821 //
822 // Moodle is free software: you can redistribute it and/or modify
823 // it under the terms of the GNU General Public License as published by
824 // the Free Software Foundation, either version 3 of the License, or
825 // (at your option) any later version.
826 //
827 // Moodle is distributed in the hope that it will be useful,
828 // but WITHOUT ANY WARRANTY; without even the implied warranty of
829 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
830 // GNU General Public License for more details.
831 //
832 // You should have received a copy of the GNU General Public License
833 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
835 /**
836  * @module moodle-editor_atto-editor
837  * @submodule selection
838  */
840 /**
841  * Selection functions for the Atto editor.
842  *
843  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
844  *
845  * @namespace M.editor_atto
846  * @class EditorSelection
847  */
849 function EditorSelection() {}
851 EditorSelection.ATTRS= {
852 };
854 EditorSelection.prototype = {
856     /**
857      * List of saved selections per editor instance.
858      *
859      * @property _selections
860      * @private
861      */
862     _selections: null,
864     /**
865      * A unique identifier for the last selection recorded.
866      *
867      * @property _lastSelection
868      * @param lastselection
869      * @type string
870      * @private
871      */
872     _lastSelection: null,
874     /**
875      * Whether focus came from a click event.
876      *
877      * This is used to determine whether to restore the selection or not.
878      *
879      * @property _focusFromClick
880      * @type Boolean
881      * @default false
882      * @private
883      */
884     _focusFromClick: false,
886     /**
887      * Set up the watchers for selection save and restoration.
888      *
889      * @method setupSelectionWatchers
890      * @chainable
891      */
892     setupSelectionWatchers: function() {
893         // Save the selection when a change was made.
894         this.on('atto:selectionchanged', this.saveSelection, this);
896         this.editor.on('focus', this.restoreSelection, this);
898         // Do not restore selection when focus is from a click event.
899         this.editor.on('mousedown', function() {
900             this._focusFromClick = true;
901         }, this);
903         // Copy the current value back to the textarea when focus leaves us and save the current selection.
904         this.editor.on('blur', function() {
905             // Clear the _focusFromClick value.
906             this._focusFromClick = false;
908             // Update the original text area.
909             this.updateOriginal();
910         }, this);
912         return this;
913     },
915     /**
916      * Work out if the cursor is in the editable area for this editor instance.
917      *
918      * @method isActive
919      * @return {boolean}
920      */
921     isActive: function() {
922         var range = rangy.createRange(),
923             selection = rangy.getSelection();
925         if (!selection.rangeCount) {
926             // If there was no range count, then there is no selection.
927             return false;
928         }
930         // Check whether the range intersects the editor selection.
931         range.selectNode(this.editor.getDOMNode());
932         return range.intersectsRange(selection.getRangeAt(0));
933     },
935     /**
936      * Create a cross browser selection object that represents a YUI node.
937      *
938      * @method getSelectionFromNode
939      * @param {Node} YUI Node to base the selection upon.
940      * @return {[rangy.Range]}
941      */
942     getSelectionFromNode: function(node) {
943         var range = rangy.createRange();
944         range.selectNode(node.getDOMNode());
945         return [range];
946     },
948     /**
949      * Save the current selection to an internal property.
950      *
951      * This allows more reliable return focus, helping improve keyboard navigation.
952      *
953      * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/restoreSelection"}}{{/crossLink}}.
954      *
955      * @method saveSelection
956      */
957     saveSelection: function() {
958         if (this.isActive()) {
959             this._selections = this.getSelection();
960         }
961     },
963     /**
964      * Restore any stored selection when the editor gets focus again.
965      *
966      * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/saveSelection"}}{{/crossLink}}.
967      *
968      * @method restoreSelection
969      */
970     restoreSelection: function() {
971         if (!this._focusFromClick) {
972             if (this._selections) {
973                 this.setSelection(this._selections);
974             }
975         }
976         this._focusFromClick = false;
977     },
979     /**
980      * Get the selection object that can be passed back to setSelection.
981      *
982      * @method getSelection
983      * @return {array} An array of rangy ranges.
984      */
985     getSelection: function() {
986         return rangy.getSelection().getAllRanges();
987     },
989     /**
990      * Check that a YUI node it at least partly contained by the current selection.
991      *
992      * @method selectionContainsNode
993      * @param {Node} The node to check.
994      * @return {boolean}
995      */
996     selectionContainsNode: function(node) {
997         return rangy.getSelection().containsNode(node.getDOMNode(), true);
998     },
1000     /**
1001      * Runs a filter on each node in the selection, and report whether the
1002      * supplied selector(s) were found in the supplied Nodes.
1003      *
1004      * By default, all specified nodes must match the selection, but this
1005      * can be controlled with the requireall property.
1006      *
1007      * @method selectionFilterMatches
1008      * @param {String} selector
1009      * @param {NodeList} [selectednodes] For performance this should be passed. If not passed, this will be looked up each time.
1010      * @param {Boolean} [requireall=true] Used to specify that "any" match is good enough.
1011      * @return {Boolean}
1012      */
1013     selectionFilterMatches: function(selector, selectednodes, requireall) {
1014         if (typeof requireall === 'undefined') {
1015             requireall = true;
1016         }
1017         if (!selectednodes) {
1018             // Find this because it was not passed as a param.
1019             selectednodes = this.getSelectedNodes();
1020         }
1021         var allmatch = selectednodes.size() > 0,
1022             anymatch = false;
1024         var editor = this.editor,
1025             stopFn = function(node) {
1026                 editor.contains(node);
1027             };
1029         selectednodes.each(function(node){
1030             // Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
1031             if (requireall) {
1032                 // Check for at least one failure.
1033                 if (!allmatch || !node.ancestor(selector, true, stopFn)) {
1034                     allmatch = false;
1035                 }
1036             } else {
1037                 // Check for at least one match.
1038                 if (!anymatch && node.ancestor(selector, true, stopFn)) {
1039                     anymatch = true;
1040                 }
1041             }
1042         }, this);
1043         if (requireall) {
1044             return allmatch;
1045         } else {
1046             return anymatch;
1047         }
1048     },
1050     /**
1051      * Get the deepest possible list of nodes in the current selection.
1052      *
1053      * @method getSelectedNodes
1054      * @return {NodeList}
1055      */
1056     getSelectedNodes: function() {
1057         var results = new Y.NodeList(),
1058             nodes,
1059             selection,
1060             range,
1061             node,
1062             i;
1064         selection = rangy.getSelection();
1066         if (selection.rangeCount) {
1067             range = selection.getRangeAt(0);
1068         } else {
1069             // Empty range.
1070             range = rangy.createRange();
1071         }
1073         if (range.collapsed) {
1074             range = range.cloneRange();
1075             range.selectNode(range.commonAncestorContainer);
1076         }
1078         nodes = range.getNodes();
1080         for (i = 0; i < nodes.length; i++) {
1081             node = Y.one(nodes[i]);
1082             if (this.editor.contains(node)) {
1083                 results.push(node);
1084             }
1085         }
1086         return results;
1087     },
1089     /**
1090      * Check whether the current selection has changed since this method was last called.
1091      *
1092      * If the selection has changed, the atto:selectionchanged event is also fired.
1093      *
1094      * @method _hasSelectionChanged
1095      * @private
1096      * @param {EventFacade} e
1097      * @return {Boolean}
1098      */
1099     _hasSelectionChanged: function(e) {
1100         var selection = rangy.getSelection(),
1101             range,
1102             changed = false;
1104         if (selection.rangeCount) {
1105             range = selection.getRangeAt(0);
1106         } else {
1107             // Empty range.
1108             range = rangy.createRange();
1109         }
1111         if (this._lastSelection) {
1112             if (!this._lastSelection.equals(range)) {
1113                 changed = true;
1114                 return this._fireSelectionChanged(e);
1115             }
1116         }
1117         this._lastSelection = range;
1118         return changed;
1119     },
1121     /**
1122      * Fires the atto:selectionchanged event.
1123      *
1124      * When the selectionchanged event is fired, the following arguments are provided:
1125      *   - event : the original event that lead to this event being fired.
1126      *   - selectednodes :  an array containing nodes that are entirely selected of contain partially selected content.
1127      *
1128      * @method _fireSelectionChanged
1129      * @private
1130      * @param {EventFacade} e
1131      */
1132     _fireSelectionChanged: function(e) {
1133         this.fire('atto:selectionchanged', {
1134             event: e,
1135             selectedNodes: this.getSelectedNodes()
1136         });
1137     },
1139     /**
1140      * Get the DOM node representing the common anscestor of the selection nodes.
1141      *
1142      * @method getSelectionParentNode
1143      * @return {Element|boolean} The DOM Node for this parent, or false if no seletion was made.
1144      */
1145     getSelectionParentNode: function() {
1146         var selection = rangy.getSelection();
1147         if (selection.rangeCount) {
1148             return selection.getRangeAt(0).commonAncestorContainer;
1149         }
1150         return false;
1151     },
1153     /**
1154      * Set the current selection. Used to restore a selection.
1155      *
1156      * @method selection
1157      * @param {array} ranges A list of rangy.range objects in the selection.
1158      */
1159     setSelection: function(ranges) {
1160         var selection = rangy.getSelection();
1161         selection.setRanges(ranges);
1162     },
1164     /**
1165      * Change the formatting for the current selection.
1166      *
1167      * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
1168      *
1169      * @method formatSelectionBlock
1170      * @param {String} [blocktag] Change the block level tag to this. Empty string, means do not change the tag.
1171      * @param {Object} [attributes] The keys and values for attributes to be added/changed in the block tag.
1172      * @return {Node|boolean} The Node that was formatted if a change was made, otherwise false.
1173      */
1174     formatSelectionBlock: function(blocktag, attributes) {
1175         // First find the nearest ancestor of the selection that is a block level element.
1176         var selectionparentnode = this.getSelectionParentNode(),
1177             boundary,
1178             cell,
1179             nearestblock,
1180             newcontent,
1181             match,
1182             replacement;
1184         if (!selectionparentnode) {
1185             // No selection, nothing to format.
1186             return false;
1187         }
1189         boundary = this.editor;
1191         selectionparentnode = Y.one(selectionparentnode);
1193         // If there is a table cell in between the selectionparentnode and the boundary,
1194         // move the boundary to the table cell.
1195         // This is because we might have a table in a div, and we select some text in a cell,
1196         // want to limit the change in style to the table cell, not the entire table (via the outer div).
1197         cell = selectionparentnode.ancestor(function (node) {
1198             var tagname = node.get('tagName');
1199             if (tagname) {
1200                 tagname = tagname.toLowerCase();
1201             }
1202             return (node === boundary) ||
1203                    (tagname === 'td') ||
1204                    (tagname === 'th');
1205         }, true);
1207         if (cell) {
1208             // Limit the scope to the table cell.
1209             boundary = cell;
1210         }
1212         nearestblock = selectionparentnode.ancestor(this.BLOCK_TAGS.join(', '), true);
1213         if (nearestblock) {
1214             // Check that the block is contained by the boundary.
1215             match = nearestblock.ancestor(function (node) {
1216                 return node === boundary;
1217             }, false);
1219             if (!match) {
1220                 nearestblock = false;
1221             }
1222         }
1224         // No valid block element - make one.
1225         if (!nearestblock) {
1226             // There is no block node in the content, wrap the content in a p and use that.
1227             newcontent = Y.Node.create('<p></p>');
1228             boundary.get('childNodes').each(function (child) {
1229                 newcontent.append(child.remove());
1230             });
1231             boundary.append(newcontent);
1232             nearestblock = newcontent;
1233         }
1235         // Guaranteed to have a valid block level element contained in the contenteditable region.
1236         // Change the tag to the new block level tag.
1237         if (blocktag && blocktag !== '') {
1238             // Change the block level node for a new one.
1239             replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
1240             // Copy all attributes.
1241             replacement.setAttrs(nearestblock.getAttrs());
1242             // Copy all children.
1243             nearestblock.get('childNodes').each(function (child) {
1244                 child.remove();
1245                 replacement.append(child);
1246             });
1248             nearestblock.replace(replacement);
1249             nearestblock = replacement;
1250         }
1252         // Set the attributes on the block level tag.
1253         if (attributes) {
1254             nearestblock.setAttrs(attributes);
1255         }
1257         // Change the selection to the modified block. This makes sense when we might apply multiple styles
1258         // to the block.
1259         var selection = this.getSelectionFromNode(nearestblock);
1260         this.setSelection(selection);
1262         return nearestblock;
1263     },
1265     /**
1266      * Inserts the given HTML into the editable content at the currently focused point.
1267      *
1268      * @method insertContentAtFocusPoint
1269      * @param {String} html
1270      */
1271     insertContentAtFocusPoint: function(html) {
1272         var selection = rangy.getSelection(),
1273             range,
1274             node = Y.Node.create(html);
1275         if (selection.rangeCount) {
1276             range = selection.getRangeAt(0);
1277         }
1278         if (range) {
1279             range.deleteContents();
1280             range.insertNode(node.getDOMNode());
1281         }
1282     }
1284 };
1286 Y.Base.mix(Y.M.editor_atto.Editor, [EditorSelection]);
1287 // This file is part of Moodle - http://moodle.org/
1288 //
1289 // Moodle is free software: you can redistribute it and/or modify
1290 // it under the terms of the GNU General Public License as published by
1291 // the Free Software Foundation, either version 3 of the License, or
1292 // (at your option) any later version.
1293 //
1294 // Moodle is distributed in the hope that it will be useful,
1295 // but WITHOUT ANY WARRANTY; without even the implied warranty of
1296 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1297 // GNU General Public License for more details.
1298 //
1299 // You should have received a copy of the GNU General Public License
1300 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1302 /**
1303  * @module moodle-editor_atto-editor
1304  * @submodule styling
1305  */
1307 /**
1308  * Editor styling functions for the Atto editor.
1309  *
1310  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1311  *
1312  * @namespace M.editor_atto
1313  * @class EditorStyling
1314  */
1316 function EditorStyling() {}
1318 EditorStyling.ATTRS= {
1319 };
1321 EditorStyling.prototype = {
1322     /**
1323      * Disable CSS styling.
1324      *
1325      * @method disableCssStyling
1326      */
1327     disableCssStyling: function() {
1328         try {
1329             document.execCommand("styleWithCSS", 0, false);
1330         } catch (e1) {
1331             try {
1332                 document.execCommand("useCSS", 0, true);
1333             } catch (e2) {
1334                 try {
1335                     document.execCommand('styleWithCSS', false, false);
1336                 } catch (e3) {
1337                     // We did our best.
1338                 }
1339             }
1340         }
1341     },
1343     /**
1344      * Enable CSS styling.
1345      *
1346      * @method enableCssStyling
1347      */
1348     enableCssStyling: function() {
1349         try {
1350             document.execCommand("styleWithCSS", 0, true);
1351         } catch (e1) {
1352             try {
1353                 document.execCommand("useCSS", 0, false);
1354             } catch (e2) {
1355                 try {
1356                     document.execCommand('styleWithCSS', false, true);
1357                 } catch (e3) {
1358                     // We did our best.
1359                 }
1360             }
1361         }
1362     },
1364     /**
1365      * Change the formatting for the current selection.
1366      *
1367      * This will wrap the selection in span tags, adding the provided classes.
1368      *
1369      * If the selection covers multiple block elements, multiple spans will be inserted to preserve the original structure.
1370      *
1371      * @method toggleInlineSelectionClass
1372      * @param {Array} toggleclasses - Class names to be toggled on or off.
1373      */
1374     toggleInlineSelectionClass: function(toggleclasses) {
1375         var selectionparentnode = this.getSelectionParentNode(),
1376             nodes,
1377             items = [],
1378             parentspan,
1379             currentnode,
1380             newnode,
1381             i = 0;
1383         if (!selectionparentnode) {
1384             // No selection, nothing to format.
1385             return;
1386         }
1388         // Add a bogus fontname as the browsers handle inserting fonts into multiple blocks correctly.
1389         document.execCommand('fontname', false, this.PLACEHOLDER_FONTNAME);
1390         nodes = this.editor.all(this.ALL_NODES_SELECTOR);
1392         // Create a list of all nodes that have our bogus fontname.
1393         nodes.each(function(node, index) {
1394             if (node.getStyle(this.FONT_FAMILY) === this.PLACEHOLDER_FONTNAME) {
1395                 node.setStyle(this.FONT_FAMILY, '');
1396                 if (!node.compareTo(this.editor)) {
1397                     items.push(Y.Node.getDOMNode(nodes.item(index)));
1398                 }
1399             }
1400         });
1402         // Replace the fontname tags with spans
1403         for (i = 0; i < items.length; i++) {
1404             currentnode = Y.one(items[i]);
1406             // Check for an existing span to add the nolink class to.
1407             parentspan = currentnode.ancestor('.editor_atto_content span');
1408             if (!parentspan) {
1409                 newnode = Y.Node.create('<span></span>');
1410                 newnode.append(items[i].innerHTML);
1411                 currentnode.replace(newnode);
1413                 currentnode = newnode;
1414             } else {
1415                 currentnode = parentspan;
1416             }
1418             // Toggle the classes on the spans.
1419             for (var j = 0; j < toggleclasses.length; j++) {
1420                 currentnode.toggleClass(toggleclasses[j]);
1421             }
1422         }
1423     }
1424 };
1426 Y.Base.mix(Y.M.editor_atto.Editor, [EditorStyling]);
1427 // This file is part of Moodle - http://moodle.org/
1428 //
1429 // Moodle is free software: you can redistribute it and/or modify
1430 // it under the terms of the GNU General Public License as published by
1431 // the Free Software Foundation, either version 3 of the License, or
1432 // (at your option) any later version.
1433 //
1434 // Moodle is distributed in the hope that it will be useful,
1435 // but WITHOUT ANY WARRANTY; without even the implied warranty of
1436 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1437 // GNU General Public License for more details.
1438 //
1439 // You should have received a copy of the GNU General Public License
1440 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
1442 /**
1443  * @module moodle-editor_atto-editor
1444  * @submodule filepicker
1445  */
1447 /**
1448  * Filepicker options for the Atto editor.
1449  *
1450  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1451  *
1452  * @namespace M.editor_atto
1453  * @class EditorFilepicker
1454  */
1456 function EditorFilepicker() {}
1458 EditorFilepicker.ATTRS= {
1459     /**
1460      * The options for the filepicker.
1461      *
1462      * @attribute filepickeroptions
1463      * @type object
1464      * @default {}
1465      */
1466     filepickeroptions: {
1467         value: {}
1468     }
1469 };
1471 EditorFilepicker.prototype = {
1472     /**
1473      * Should we show the filepicker for this filetype?
1474      *
1475      * @method canShowFilepicker
1476      * @param string type The media type for the file picker.
1477      * @return {boolean}
1478      */
1479     canShowFilepicker: function(type) {
1480         return (typeof this.get('filepickeroptions')[type] !== 'undefined');
1481     },
1483     /**
1484      * Show the filepicker.
1485      *
1486      * This depends on core_filepicker, and then call that modules show function.
1487      *
1488      * @method showFilepicker
1489      * @param {string} type The media type for the file picker.
1490      * @param {function} callback The callback to use when selecting an item of media.
1491      * @param {object} [context] The context from which to call the callback.
1492      */
1493     showFilepicker: function(type, callback, context) {
1494         var self = this;
1495         Y.use('core_filepicker', function (Y) {
1496             var options = Y.clone(self.get('filepickeroptions')[type], true);
1497             options.formcallback = callback;
1498             if (context) {
1499                 options.magicscope = context;
1500             }
1502             M.core_filepicker.show(Y, options);
1503         });
1504     }
1505 };
1507 Y.Base.mix(Y.M.editor_atto.Editor, [EditorFilepicker]);
1510 }, '@VERSION@', {
1511     "requires": [
1512         "node",
1513         "io",
1514         "overlay",
1515         "escape",
1516         "event",
1517         "event-simulate",
1518         "event-custom",
1519         "yui-throttle",
1520         "moodle-core-notification-dialogue",
1521         "moodle-editor_atto-rangy",
1522         "handlebars"
1523     ]
1524 });