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