Merge branch 'MDL-41241-master-fix3' of git://github.com/damyon/moodle
[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  * Atto editor.
20  *
21  * @package    editor_atto
22  * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 /**
27  * Classes constants.
28  */
29 CSS = {
30     CONTENT: 'editor_atto_content',
31     CONTENTWRAPPER: 'editor_atto_content_wrap',
32     TOOLBAR: 'editor_atto_toolbar',
33     WRAPPER: 'editor_atto'
34 };
36 /**
37  * Atto editor main class.
38  * Common functions required by editor plugins.
39  *
40  * @package    editor_atto
41  * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
42  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43  */
44 M.editor_atto = M.editor_atto || {
46     /**
47      * List of attached button handlers to prevent duplicates.
48      */
49     buttonhandlers : {},
51     /**
52      * List of YUI overlays for custom menus.
53      */
54     menus : {},
56     /**
57      * List of attached menu handlers to prevent duplicates.
58      */
59     menuhandlers : {},
61     /**
62      * List of file picker options for specific editor instances.
63      */
64     filepickeroptions : {},
66     /**
67      * List of buttons and menus that have been added to the toolbar.
68      */
69     widgets : {},
71     /**
72      * Toggle a menu.
73      * @param event e
74      */
75     showhide_menu_handler : function(e) {
76         e.preventDefault();
77         var disabled = this.getAttribute('disabled');
78         var overlayid = this.getAttribute('data-menu');
79         var overlay = M.editor_atto.menus[overlayid];
80         var menu = overlay.get('bodyContent');
81         if (overlay.get('visible') || disabled) {
82             overlay.hide();
83             menu.detach('clickoutside');
84         } else {
85             menu.on('clickoutside', function(ev) {
86                 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
87                     if (overlay.get('visible')) {
88                         menu.detach('clickoutside');
89                         overlay.hide();
90                     }
91                 }
92             }, this);
93             overlay.show();
94         }
95     },
97     /**
98      * Handle clicks on editor buttons.
99      * @param event e
100      */
101     buttonclicked_handler : function(e) {
102         var elementid = this.getAttribute('data-editor');
103         var plugin = this.getAttribute('data-plugin');
104         var handler = this.getAttribute('data-handler');
105         var overlay = M.editor_atto.menus[plugin + '_' + elementid];
107         if (overlay) {
108             overlay.hide();
109         }
111         if (M.editor_atto.is_enabled(elementid, plugin)) {
112             // Pass it on.
113             handler = M.editor_atto.buttonhandlers[handler];
114             return handler(e, elementid);
115         }
116     },
118     /**
119      * Determine if the specified toolbar button/menu is enabled.
120      * @param string elementid, the element id of this editor.
121      * @param string plugin, the plugin that created the button/menu.
122      */
123     is_enabled : function(elementid, plugin) {
124         var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
126         return !element.hasAttribute('disabled');
127     },
128     /**
129      * Disable all buttons and menus in the toolbar.
130      * @param string elementid, the element id of this editor.
131      */
132     disable_all_widgets : function(elementid) {
133         var plugin, element;
134         for (plugin in M.editor_atto.widgets) {
135             element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
137             if (element) {
138                 element.setAttribute('disabled', 'true');
139             }
140         }
141     },
143     /**
144      * Enable a single widget in the toolbar.
145      * @param string elementid, the element id of this editor.
146      * @param string plugin, the name of the plugin that created the widget.
147      */
148     enable_widget : function(elementid, plugin) {
149         var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
151         if (element) {
152             element.removeAttribute('disabled');
153         }
154     },
156     /**
157      * Enable all buttons and menus in the toolbar.
158      * @param string elementid, the element id of this editor.
159      */
160     enable_all_widgets : function(elementid) {
161         var plugin, element;
162         for (plugin in M.editor_atto.widgets) {
163             element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
165             if (element) {
166                 element.removeAttribute('disabled');
167             }
168         }
169     },
171     /**
172      * Add a button to the toolbar belonging to the editor for element with id "elementid".
173      * @param string elementid - the id of the textarea we created this editor from.
174      * @param string plugin - the plugin defining the button
175      * @param string icon - the html used for the content of the button
176      * @param string groupname - the group the button should be appended to.
177      * @handler function handler- A function to call when the button is clicked.
178      */
179     add_toolbar_menu : function(elementid, plugin, icon, groupname, entries) {
180         var toolbar = Y.one('#' + elementid + '_toolbar'),
181             group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
182             currentfocus,
183             button;
185         if (!group) {
186             group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
187             toolbar.append(group);
188         }
189         button = Y.Node.create('<button class="atto_' + plugin + '_button atto_hasmenu" ' +
190                                     'data-editor="' + Y.Escape.html(elementid) + '" ' +
191                                     'tabindex="-1" ' +
192                                     'data-menu="' + plugin + '_' + elementid + '" >' +
193                                     icon +
194                                     '</button>');
196         group.append(button);
198         currentfocus = toolbar.getAttribute('aria-activedescendant');
199         if (!currentfocus) {
200             button.setAttribute('tabindex', '0');
201             toolbar.setAttribute('aria-activedescendant', button.generateID());
202         }
204         // Save the name of the plugin.
205         M.editor_atto.widgets[plugin] = plugin;
207         var menu = Y.Node.create('<div class="atto_' + plugin + '_menu' +
208                                  ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"></div>');
209         var i = 0, entry = {};
211         for (i = 0; i < entries.length; i++) {
212             entry = entries[i];
214             menu.append(Y.Node.create('<div class="atto_menuentry">' +
215                                        '<a href="#" class="atto_' + plugin + '_action_' + i + '" ' +
216                                        'data-editor="' + Y.Escape.html(elementid) + '" ' +
217                                        'data-plugin="' + Y.Escape.html(plugin) + '" ' +
218                                        'data-handler="' + Y.Escape.html(plugin + '_action_' + i) + '">' +
219                                        entry.text +
220                                        '</a>' +
221                                        '</div>'));
222             if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
223                 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_action_' + i);
224                 M.editor_atto.buttonhandlers[plugin + '_action_' + i] = entry.handler;
225             }
226         }
228         if (!M.editor_atto.buttonhandlers[plugin]) {
229             Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + plugin + '_button');
230             M.editor_atto.buttonhandlers[plugin] = true;
231         }
233         var overlay = new M.core.dialogue({
234             bodyContent : menu,
235             visible : false,
236             width: '14em',
237             zindex: 100,
238             lightbox: false,
239             closeButton: false,
240             centered : false,
241             align: {node: button, points: [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]}
242         });
244         M.editor_atto.menus[plugin + '_' + elementid] = overlay;
245         overlay.render();
246         overlay.hide();
247         overlay.headerNode.hide();
248     },
250     /**
251      * Add a button to the toolbar belonging to the editor for element with id "elementid".
252      * @param string elementid - the id of the textarea we created this editor from.
253      * @param string plugin - the plugin defining the button.
254      * @param string icon - the html used for the content of the button.
255      * @param string groupname - the group the button should be appended to.
256      * @handler function handler- A function to call when the button is clicked.
257      */
258     add_toolbar_button : function(elementid, plugin, icon, groupname, handler) {
259         var toolbar = Y.one('#' + elementid + '_toolbar'),
260             group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
261             button,
262             currentfocus;
264         if (!group) {
265             group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
266             toolbar.append(group);
267         }
268         button = Y.Node.create('<button class="atto_' + plugin + '_button" ' +
269                                'data-editor="' + Y.Escape.html(elementid) + '" ' +
270                                'data-plugin="' + Y.Escape.html(plugin) + '" ' +
271                                'tabindex="-1" ' +
272                                'data-handler="' + Y.Escape.html(plugin) + '">' +
273                                icon +
274                                '</button>');
276         group.append(button);
278         currentfocus = toolbar.getAttribute('aria-activedescendant');
279         if (!currentfocus) {
280             button.setAttribute('tabindex', '0');
281             toolbar.setAttribute('aria-activedescendant', button.generateID());
282         }
284         // We only need to attach this once.
285         if (!M.editor_atto.buttonhandlers[plugin]) {
286             Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_button');
287             M.editor_atto.buttonhandlers[plugin] = handler;
288         }
290         // Save the name of the plugin.
291         M.editor_atto.widgets[plugin] = plugin;
293     },
295     /**
296      * Work out if the cursor is in the editable area for this editor instance.
297      * @param string elementid of this editor
298      * @return bool
299      */
300     is_active : function(elementid) {
301         var selection = M.editor_atto.get_selection();
303         if (selection.length) {
304             selection = selection.pop();
305         }
307         var node = null;
308         if (selection.parentElement) {
309             node = Y.one(selection.parentElement());
310         } else {
311             node = Y.one(selection.startContainer);
312         }
314         return node && node.ancestor('#' + elementid + 'editable') !== null;
315     },
317     /**
318      * Focus on the editable area for this editor.
319      * @param string elementid of this editor
320      */
321     focus : function(elementid) {
322         Y.one('#' + elementid + 'editable').focus();
323     },
325     /**
326      * Initialise the editor
327      * @param object params for this editor instance.
328      */
329     init : function(params) {
330         var textarea = Y.one('#' +params.elementid);
331         var wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
332         var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
333                                             'contenteditable="true" ' +
334                                             'spellcheck="true" ' +
335                                             'class="' + CSS.CONTENT + '" />');
337         var cssfont = '';
338         var toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" id="' + params.elementid + '_toolbar" role="toolbar"/>');
340         // Editable content wrapper.
341         var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
342         content.appendChild(atto);
344         // Add everything to the wrapper.
345         wrapper.appendChild(toolbar);
346         wrapper.appendChild(content);
348         // Bleh - why are we sent a url and not the css to apply directly?
349         var css = Y.io(params.content_css, { sync: true });
350         var pos = css.responseText.indexOf('font:');
351         if (pos) {
352             cssfont = css.responseText.substring(pos + 'font:'.length, css.responseText.length - 1);
353             atto.setStyle('font', cssfont);
354         }
355         atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows'))) + 'em');
357         // Copy text to editable div.
358         atto.append(textarea.get('value'));
360         // Add the toolbar and editable zone to the page.
361         textarea.get('parentNode').insert(wrapper, textarea);
362         atto.setStyle('color', textarea.getStyle('color'));
363         atto.setStyle('lineHeight', textarea.getStyle('lineHeight'));
364         atto.setStyle('fontSize', textarea.getStyle('fontSize'));
365         // Hide the old textarea.
366         textarea.hide();
368         // Copy the current value back to the textarea when focus leaves us.
369         atto.on('blur', function() {
370             textarea.set('value', atto.getHTML());
371         });
373         // Listen for Arrow left and Arrow right keys.
374         Y.one(Y.config.doc.body).delegate('key',
375                                           this.keyboard_navigation,
376                                           'down:37,39',
377                                           '#' + params.elementid + '_toolbar',
378                                           this,
379                                           params.elementid);
381         // Save the file picker options for later.
382         M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
383     },
385     /**
386      * Implement arrow key navigation for the buttons in the toolbar.
387      * @param Event e - the keyboard event.
388      * @param string elementid - the id of the textarea we created this editor from.
389      */
390     keyboard_navigation : function(e, elementid) {
391         var buttons,
392             current,
393             currentid,
394             currentindex;
396         e.preventDefault();
398         buttons = Y.all('#' + elementid + '_toolbar button');
399         currentid = Y.one('#' + elementid + '_toolbar').getAttribute('aria-activedescendant');
400         if (!currentid) {
401             return;
402         }
403         current = Y.one('#' + currentid);
404         current.setAttribute('tabindex', '-1');
406         currentindex = buttons.indexOf(current);
408         if (e.keyCode === 37) {
409             // Left
410             currentindex--;
411             if (currentindex < 0) {
412                 currentindex = buttons.size()-1;
413             }
414         } else {
415             // Right
416             currentindex++;
417             if (currentindex >= buttons.size()) {
418                 currentindex = 0;
419             }
420         }
422         current = buttons.item(currentindex);
423         current.setAttribute('tabindex', '0');
424         current.focus();
425         Y.one('#' + elementid + '_toolbar').setAttribute('aria-activedescendant', current.generateID());
426     },
428     /**
429      * Show the filepicker.
430      * @param string elementid for this editor instance.
431      * @param string type The media type for the file picker
432      * @param function callback
433      */
434     show_filepicker : function(elementid, type, callback) {
435         Y.use('core_filepicker', function (Y) {
436             var options = M.editor_atto.filepickeroptions[elementid][type];
438             options.formcallback = callback;
439             options.editor_target = Y.one(elementid);
441             M.core_filepicker.show(Y, options);
442         });
443     },
445     /**
446      * Create a cross browser selection object that represents a yui node.
447      * @param Node yui node for the selection
448      * @return range (browser dependent)
449      */
450     get_selection_from_node: function(node) {
451         var range;
453         if (window.getSelection) {
454             range = document.createRange();
456             range.setStartBefore(node.getDOMNode());
457             range.setEndAfter(node.getDOMNode());
458             return [range];
459         } else if (document.selection) {
460             range = document.body.createTextRange();
461             range.moveToElementText(node.getDOMNode());
462             return range;
463         }
464         return false;
465     },
467     /**
468      * Get the selection object that can be passed back to set_selection.
469      * @return range (browser dependent)
470      */
471     get_selection : function() {
472         if (window.getSelection) {
473             var sel = window.getSelection();
474             var ranges = [], i = 0;
475             for (i = 0; i < sel.rangeCount; i++) {
476                 ranges.push(sel.getRangeAt(i));
477             }
478             return ranges;
479         } else if (document.selection) {
480             // IE < 9
481             if (document.selection.createRange) {
482                 return document.selection.createRange();
483             }
484         }
485         return false;
486     },
488     /**
489      * Check that a YUI node it at least partly contained by the selection.
490      * @param Range selection
491      * @param Y.Node node
492      * @return boolean
493      */
494     selection_contains_node : function(node) {
495         var range, sel;
496         if (window.getSelection) {
497             sel = window.getSelection();
499             if (sel.containsNode) {
500                 return sel.containsNode(node.getDOMNode(), true);
501             }
502         }
503         sel = document.selection.createRange();
504         range = sel.duplicate();
505         range.moveToElementText(node.getDOMNode());
506         return sel.inRange(range);
507     },
509     /**
510      * Get the dom node representing the common anscestor of the selection nodes.
511      * @return DOMNode
512      */
513     get_selection_parent_node : function() {
514         var selection = M.editor_atto.get_selection();
515         if (selection.length > 0) {
516             return selection[0].commonAncestorContainer;
517         }
518     },
520     /**
521      * Get the list of child nodes of the selection.
522      * @return DOMNode[]
523      */
524     get_selection_text : function() {
525         var selection = M.editor_atto.get_selection();
526         if (selection.length > 0 && selection[0].cloneContents) {
527             return selection[0].cloneContents();
528         }
529     },
531     /**
532      * Set the current selection. Used to restore a selection.
533      */
534     set_selection : function(selection) {
535         var sel, i;
537         if (window.getSelection) {
538             sel = window.getSelection();
539             sel.removeAllRanges();
540             for (i = 0; i < selection.length; i++) {
541                 sel.addRange(selection[i]);
542             }
543         } else if (document.selection) {
544             // IE < 9
545             if (selection.select) {
546                 selection.select();
547             }
548         }
549     }
551 };
554 }, '@VERSION@', {"requires": ["node", "io", "overlay", "escape", "event-key", "moodle-core-notification"]});