MDL-42026 Atto: Remove the styles from the headings menu + focus when opened
[moodle.git] / lib / editor / atto / yui / src / editor / js / editor.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * Atto editor main class.
18  * Common functions required by editor plugins.
19  *
20  * @package    editor-atto
21  * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 M.editor_atto = M.editor_atto || {
25     /**
26      * List of attached button handlers to prevent duplicates.
27      */
28     buttonhandlers : {},
30     /**
31      * List of YUI overlays for custom menus.
32      */
33     menus : {},
35     /**
36      * List of attached menu handlers to prevent duplicates.
37      */
38     menuhandlers : {},
40     /**
41      * List of file picker options for specific editor instances.
42      */
43     filepickeroptions : {},
45     /**
46      * List of buttons and menus that have been added to the toolbar.
47      */
48     widgets : {},
50     /**
51      * Toggle a menu.
52      * @param event e
53      */
54     showhide_menu_handler : function(e) {
55         e.preventDefault();
56         var disabled = this.getAttribute('disabled');
57         var overlayid = this.getAttribute('data-menu');
58         var overlay = M.editor_atto.menus[overlayid];
59         var menu = overlay.get('bodyContent');
60         if (overlay.get('visible') || disabled) {
61             overlay.hide();
62             menu.detach('clickoutside');
63         } else {
64             menu.on('clickoutside', function(ev) {
65                 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
66                     if (overlay.get('visible')) {
67                         menu.detach('clickoutside');
68                         overlay.hide();
69                     }
70                 }
71             }, this);
72             overlay.show();
73             overlay.bodyNode.one('a').focus();
74         }
75     },
77     /**
78      * Handle clicks on editor buttons.
79      * @param event e
80      */
81     buttonclicked_handler : function(e) {
82         var elementid = this.getAttribute('data-editor');
83         var plugin = this.getAttribute('data-plugin');
84         var handler = this.getAttribute('data-handler');
85         var overlay = M.editor_atto.menus[plugin + '_' + elementid];
87         if (overlay) {
88             overlay.hide();
89         }
91         if (M.editor_atto.is_enabled(elementid, plugin)) {
92             // Pass it on.
93             handler = M.editor_atto.buttonhandlers[handler];
94             return handler(e, elementid);
95         }
96     },
98     /**
99      * Determine if the specified toolbar button/menu is enabled.
100      * @param string elementid, the element id of this editor.
101      * @param string plugin, the plugin that created the button/menu.
102      */
103     is_enabled : function(elementid, plugin) {
104         var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
106         return !element.hasAttribute('disabled');
107     },
108     /**
109      * Disable all buttons and menus in the toolbar.
110      * @param string elementid, the element id of this editor.
111      */
112     disable_all_widgets : function(elementid) {
113         var plugin, element;
114         for (plugin in M.editor_atto.widgets) {
115             element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
117             if (element) {
118                 element.setAttribute('disabled', 'true');
119             }
120         }
121     },
123     /**
124      * Enable a single widget in the toolbar.
125      * @param string elementid, the element id of this editor.
126      * @param string plugin, the name of the plugin that created the widget.
127      */
128     enable_widget : function(elementid, plugin) {
129         var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
131         if (element) {
132             element.removeAttribute('disabled');
133         }
134     },
136     /**
137      * Enable all buttons and menus in the toolbar.
138      * @param string elementid, the element id of this editor.
139      */
140     enable_all_widgets : function(elementid) {
141         var plugin, element;
142         for (plugin in M.editor_atto.widgets) {
143             element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
145             if (element) {
146                 element.removeAttribute('disabled');
147             }
148         }
149     },
151     /**
152      * Add a button to the toolbar belonging to the editor for element with id "elementid".
153      * @param string elementid - the id of the textarea we created this editor from.
154      * @param string plugin - the plugin defining the button
155      * @param string icon - the html used for the content of the button
156      * @param string groupname - the group the button should be appended to.
157      * @handler function handler- A function to call when the button is clicked.
158      */
159     add_toolbar_menu : function(elementid, plugin, icon, groupname, entries) {
160         var toolbar = Y.one('#' + elementid + '_toolbar'),
161             group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
162             currentfocus,
163             button;
165         if (!group) {
166             group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
167             toolbar.append(group);
168         }
169         button = Y.Node.create('<button class="atto_' + plugin + '_button atto_hasmenu" ' +
170                                     'data-editor="' + Y.Escape.html(elementid) + '" ' +
171                                     'tabindex="-1" ' +
172                                     'data-menu="' + plugin + '_' + elementid + '" >' +
173                                     icon +
174                                     '</button>');
176         group.append(button);
178         currentfocus = toolbar.getAttribute('aria-activedescendant');
179         if (!currentfocus) {
180             button.setAttribute('tabindex', '0');
181             toolbar.setAttribute('aria-activedescendant', button.generateID());
182         }
184         // Save the name of the plugin.
185         M.editor_atto.widgets[plugin] = plugin;
187         var menu = Y.Node.create('<div class="atto_' + plugin + '_menu' +
188                                  ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"></div>');
189         var i = 0, entry = {};
191         for (i = 0; i < entries.length; i++) {
192             entry = entries[i];
194             menu.append(Y.Node.create('<div class="atto_menuentry">' +
195                                        '<a href="#" class="atto_' + plugin + '_action_' + i + '" ' +
196                                        'data-editor="' + Y.Escape.html(elementid) + '" ' +
197                                        'data-plugin="' + Y.Escape.html(plugin) + '" ' +
198                                        'data-handler="' + Y.Escape.html(plugin + '_action_' + i) + '">' +
199                                        entry.text +
200                                        '</a>' +
201                                        '</div>'));
202             if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
203                 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_action_' + i);
204                 Y.one('body').delegate('key', M.editor_atto.buttonclicked_handler, 'space,enter', '.atto_' + plugin + '_action_' + i);
205                 M.editor_atto.buttonhandlers[plugin + '_action_' + i] = entry.handler;
206             }
207         }
209         if (!M.editor_atto.buttonhandlers[plugin]) {
210             Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + plugin + '_button');
211             M.editor_atto.buttonhandlers[plugin] = true;
212         }
214         var overlay = new M.core.dialogue({
215             bodyContent : menu,
216             visible : false,
217             width: '14em',
218             zindex: 100,
219             lightbox: false,
220             closeButton: false,
221             centered : false,
222             align: {node: button, points: [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]}
223         });
225         M.editor_atto.menus[plugin + '_' + elementid] = overlay;
226         overlay.render();
227         overlay.hide();
228         overlay.headerNode.hide();
229     },
231     /**
232      * Add a button to the toolbar belonging to the editor for element with id "elementid".
233      * @param string elementid - the id of the textarea we created this editor from.
234      * @param string plugin - the plugin defining the button.
235      * @param string icon - the html used for the content of the button.
236      * @param string groupname - the group the button should be appended to.
237      * @handler function handler- A function to call when the button is clicked.
238      */
239     add_toolbar_button : function(elementid, plugin, icon, groupname, handler) {
240         var toolbar = Y.one('#' + elementid + '_toolbar'),
241             group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
242             button,
243             currentfocus;
245         if (!group) {
246             group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
247             toolbar.append(group);
248         }
249         button = Y.Node.create('<button class="atto_' + plugin + '_button" ' +
250                                'data-editor="' + Y.Escape.html(elementid) + '" ' +
251                                'data-plugin="' + Y.Escape.html(plugin) + '" ' +
252                                'tabindex="-1" ' +
253                                'data-handler="' + Y.Escape.html(plugin) + '">' +
254                                icon +
255                                '</button>');
257         group.append(button);
259         currentfocus = toolbar.getAttribute('aria-activedescendant');
260         if (!currentfocus) {
261             button.setAttribute('tabindex', '0');
262             toolbar.setAttribute('aria-activedescendant', button.generateID());
263         }
265         // We only need to attach this once.
266         if (!M.editor_atto.buttonhandlers[plugin]) {
267             Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_button');
268             M.editor_atto.buttonhandlers[plugin] = handler;
269         }
271         // Save the name of the plugin.
272         M.editor_atto.widgets[plugin] = plugin;
274     },
276     /**
277      * Work out if the cursor is in the editable area for this editor instance.
278      * @param string elementid of this editor
279      * @return bool
280      */
281     is_active : function(elementid) {
282         var selection = M.editor_atto.get_selection();
284         if (selection.length) {
285             selection = selection.pop();
286         }
288         var node = null;
289         if (selection.parentElement) {
290             node = Y.one(selection.parentElement());
291         } else {
292             node = Y.one(selection.startContainer);
293         }
295         return node && node.ancestor('#' + elementid + 'editable') !== null;
296     },
298     /**
299      * Focus on the editable area for this editor.
300      * @param string elementid of this editor
301      */
302     focus : function(elementid) {
303         Y.one('#' + elementid + 'editable').focus();
304     },
306     /**
307      * Initialise the editor
308      * @param object params for this editor instance.
309      */
310     init : function(params) {
311         var textarea = Y.one('#' +params.elementid);
312         var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
313                                             'contenteditable="true" ' +
314                                             'spellcheck="true" ' +
315                                             'class="editor_atto"/>');
316         var cssfont = '';
317         var toolbar = Y.Node.create('<div class="editor_atto_toolbar" id="' + params.elementid + '_toolbar" role="toolbar"/>');
319         // Bleh - why are we sent a url and not the css to apply directly?
320         var css = Y.io(params.content_css, { sync: true });
321         var pos = css.responseText.indexOf('font:');
322         if (pos) {
323             cssfont = css.responseText.substring(pos + 'font:'.length, css.responseText.length - 1);
324             atto.setStyle('font', cssfont);
325         }
326         atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows') - 1)) + 'em');
328         // Copy text to editable div.
329         atto.append(textarea.get('value'));
331         // Add the toolbar to the page.
332         textarea.get('parentNode').insert(toolbar, textarea);
333         // Add the editable div to the page.
334         textarea.get('parentNode').insert(atto, textarea);
335         atto.setStyle('color', textarea.getStyle('color'));
336         atto.setStyle('lineHeight', textarea.getStyle('lineHeight'));
337         atto.setStyle('fontSize', textarea.getStyle('fontSize'));
338         // Hide the old textarea.
339         textarea.hide();
341         // Copy the current value back to the textarea when focus leaves us.
342         atto.on('blur', function() {
343             textarea.set('value', atto.getHTML());
344         });
346         // Listen for Arrow left and Arrow right keys.
347         Y.one(Y.config.doc.body).delegate('key',
348                                           this.keyboard_navigation,
349                                           'down:37,39',
350                                           '#' + params.elementid + '_toolbar',
351                                           this,
352                                           params.elementid);
354         // Save the file picker options for later.
355         M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
356     },
358     /**
359      * Implement arrow key navigation for the buttons in the toolbar.
360      * @param Event e - the keyboard event.
361      * @param string elementid - the id of the textarea we created this editor from.
362      */
363     keyboard_navigation : function(e, elementid) {
364         var buttons,
365             current,
366             currentid,
367             currentindex;
369         e.preventDefault();
371         buttons = Y.all('#' + elementid + '_toolbar button');
372         currentid = Y.one('#' + elementid + '_toolbar').getAttribute('aria-activedescendant');
373         if (!currentid) {
374             return;
375         }
376         current = Y.one('#' + currentid);
377         current.setAttribute('tabindex', '-1');
379         currentindex = buttons.indexOf(current);
381         if (e.keyCode === 37) {
382             // Left
383             currentindex--;
384             if (currentindex < 0) {
385                 currentindex = buttons.size()-1;
386             }
387         } else {
388             // Right
389             currentindex++;
390             if (currentindex >= buttons.size()) {
391                 currentindex = 0;
392             }
393         }
395         current = buttons.item(currentindex);
396         current.setAttribute('tabindex', '0');
397         current.focus();
398         Y.one('#' + elementid + '_toolbar').setAttribute('aria-activedescendant', current.generateID());
399     },
401     /**
402      * Show the filepicker.
403      * @param string elementid for this editor instance.
404      * @param string type The media type for the file picker
405      * @param function callback
406      */
407     show_filepicker : function(elementid, type, callback) {
408         Y.use('core_filepicker', function (Y) {
409             var options = M.editor_atto.filepickeroptions[elementid][type];
411             options.formcallback = callback;
412             options.editor_target = Y.one(elementid);
414             M.core_filepicker.show(Y, options);
415         });
416     },
418     /**
419      * Create a cross browser selection object that represents a yui node.
420      * @param Node yui node for the selection
421      * @return range (browser dependent)
422      */
423     get_selection_from_node: function(node) {
424         var range;
426         if (window.getSelection) {
427             range = document.createRange();
429             range.setStartBefore(node.getDOMNode());
430             range.setEndAfter(node.getDOMNode());
431             return [range];
432         } else if (document.selection) {
433             range = document.body.createTextRange();
434             range.moveToElementText(node.getDOMNode());
435             return range;
436         }
437         return false;
438     },
440     /**
441      * Get the selection object that can be passed back to set_selection.
442      * @return range (browser dependent)
443      */
444     get_selection : function() {
445         if (window.getSelection) {
446             var sel = window.getSelection();
447             var ranges = [], i = 0;
448             for (i = 0; i < sel.rangeCount; i++) {
449                 ranges.push(sel.getRangeAt(i));
450             }
451             return ranges;
452         } else if (document.selection) {
453             // IE < 9
454             if (document.selection.createRange) {
455                 return document.selection.createRange();
456             }
457         }
458         return false;
459     },
461     /**
462      * Get the dom node representing the common anscestor of the selection nodes.
463      * @return DOMNode
464      */
465     get_selection_parent_node : function() {
466         var selection = M.editor_atto.get_selection();
467         if (selection.length > 0) {
468             return selection[0].commonAncestorContainer;
469         }
470     },
472     /**
473      * Get the list of child nodes of the selection.
474      * @return DOMNode[]
475      */
476     get_selection_text : function() {
477         var selection = M.editor_atto.get_selection();
478         if (selection.length > 0 && selection[0].cloneContents) {
479             return selection[0].cloneContents();
480         }
481     },
483     /**
484      * Set the current selection. Used to restore a selection.
485      */
486     set_selection : function(selection) {
487         var sel, i;
489         if (window.getSelection) {
490             sel = window.getSelection();
491             sel.removeAllRanges();
492             for (i = 0; i < selection.length; i++) {
493                 sel.addRange(selection[i]);
494             }
495         } else if (document.selection) {
496             // IE < 9
497             if (selection.select) {
498                 selection.select();
499             }
500         }
501     }
503 };