1 YUI.add('moodle-editor_atto-editor', function (Y, NAME) {
3 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
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
30 CONTENT: 'editor_atto_content',
31 CONTENTWRAPPER: 'editor_atto_content_wrap',
32 TOOLBAR: 'editor_atto_toolbar',
33 WRAPPER: 'editor_atto'
37 * Atto editor main class.
38 * Common functions required by editor plugins.
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
44 M.editor_atto = M.editor_atto || {
47 * List of attached button handlers to prevent duplicates.
52 * List of YUI overlays for custom menus.
57 * List of attached menu handlers to prevent duplicates.
62 * List of file picker options for specific editor instances.
64 filepickeroptions : {},
67 * List of buttons and menus that have been added to the toolbar.
75 showhide_menu_handler : function(e) {
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) {
83 menu.detach('clickoutside');
85 menu.on('clickoutside', function(ev) {
86 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
87 if (overlay.get('visible')) {
88 menu.detach('clickoutside');
98 * Handle clicks on editor buttons.
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];
111 if (M.editor_atto.is_enabled(elementid, plugin)) {
113 handler = M.editor_atto.buttonhandlers[handler];
114 return handler(e, elementid);
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.
123 is_enabled : function(elementid, plugin) {
124 var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
126 return !element.hasAttribute('disabled');
129 * Disable all buttons and menus in the toolbar.
130 * @param string elementid, the element id of this editor.
132 disable_all_widgets : function(elementid) {
134 for (plugin in M.editor_atto.widgets) {
135 element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
138 element.setAttribute('disabled', 'true');
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.
148 enable_widget : function(elementid, plugin) {
149 var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
152 element.removeAttribute('disabled');
157 * Enable all buttons and menus in the toolbar.
158 * @param string elementid, the element id of this editor.
160 enable_all_widgets : function(elementid) {
162 for (plugin in M.editor_atto.widgets) {
163 element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
166 element.removeAttribute('disabled');
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.
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'),
186 group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
187 toolbar.append(group);
189 button = Y.Node.create('<button class="atto_' + plugin + '_button atto_hasmenu" ' +
190 'data-editor="' + Y.Escape.html(elementid) + '" ' +
192 'data-menu="' + plugin + '_' + elementid + '" >' +
196 group.append(button);
198 currentfocus = toolbar.getAttribute('aria-activedescendant');
200 button.setAttribute('tabindex', '0');
201 toolbar.setAttribute('aria-activedescendant', button.generateID());
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++) {
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) + '">' +
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;
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;
233 var overlay = new M.core.dialogue({
241 align: {node: button, points: [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]}
244 M.editor_atto.menus[plugin + '_' + elementid] = overlay;
247 overlay.headerNode.hide();
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.
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'),
265 group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
266 toolbar.append(group);
268 button = Y.Node.create('<button class="atto_' + plugin + '_button" ' +
269 'data-editor="' + Y.Escape.html(elementid) + '" ' +
270 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
272 'data-handler="' + Y.Escape.html(plugin) + '">' +
276 group.append(button);
278 currentfocus = toolbar.getAttribute('aria-activedescendant');
280 button.setAttribute('tabindex', '0');
281 toolbar.setAttribute('aria-activedescendant', button.generateID());
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;
290 // Save the name of the plugin.
291 M.editor_atto.widgets[plugin] = plugin;
296 * Work out if the cursor is in the editable area for this editor instance.
297 * @param string elementid of this editor
300 is_active : function(elementid) {
301 var selection = M.editor_atto.get_selection();
303 if (selection.length) {
304 selection = selection.pop();
308 if (selection.parentElement) {
309 node = Y.one(selection.parentElement());
311 node = Y.one(selection.startContainer);
314 return node && node.ancestor('#' + elementid + 'editable') !== null;
318 * Focus on the editable area for this editor.
319 * @param string elementid of this editor
321 focus : function(elementid) {
322 Y.one('#' + elementid + 'editable').focus();
326 * Initialise the editor
327 * @param object params for this editor instance.
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 + '" />');
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:');
352 cssfont = css.responseText.substring(pos + 'font:'.length, css.responseText.length - 1);
353 atto.setStyle('font', cssfont);
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.
368 // Copy the current value back to the textarea when focus leaves us.
369 atto.on('blur', function() {
370 textarea.set('value', atto.getHTML());
373 // Listen for Arrow left and Arrow right keys.
374 Y.one(Y.config.doc.body).delegate('key',
375 this.keyboard_navigation,
377 '#' + params.elementid + '_toolbar',
381 // Save the file picker options for later.
382 M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
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.
390 keyboard_navigation : function(e, elementid) {
398 buttons = Y.all('#' + elementid + '_toolbar button');
399 currentid = Y.one('#' + elementid + '_toolbar').getAttribute('aria-activedescendant');
403 current = Y.one('#' + currentid);
404 current.setAttribute('tabindex', '-1');
406 currentindex = buttons.indexOf(current);
408 if (e.keyCode === 37) {
411 if (currentindex < 0) {
412 currentindex = buttons.size()-1;
417 if (currentindex >= buttons.size()) {
422 current = buttons.item(currentindex);
423 current.setAttribute('tabindex', '0');
425 Y.one('#' + elementid + '_toolbar').setAttribute('aria-activedescendant', current.generateID());
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
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);
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)
450 get_selection_from_node: function(node) {
453 if (window.getSelection) {
454 range = document.createRange();
456 range.setStartBefore(node.getDOMNode());
457 range.setEndAfter(node.getDOMNode());
459 } else if (document.selection) {
460 range = document.body.createTextRange();
461 range.moveToElementText(node.getDOMNode());
468 * Get the selection object that can be passed back to set_selection.
469 * @return range (browser dependent)
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));
479 } else if (document.selection) {
481 if (document.selection.createRange) {
482 return document.selection.createRange();
489 * Check that a YUI node it at least partly contained by the selection.
490 * @param Range selection
494 selection_contains_node : function(node) {
496 if (window.getSelection) {
497 sel = window.getSelection();
499 if (sel.containsNode) {
500 return sel.containsNode(node.getDOMNode(), true);
503 sel = document.selection.createRange();
504 range = sel.duplicate();
505 range.moveToElementText(node.getDOMNode());
506 return sel.inRange(range);
510 * Get the dom node representing the common anscestor of the selection nodes.
513 get_selection_parent_node : function() {
514 var selection = M.editor_atto.get_selection();
515 if (selection.length > 0) {
516 return selection[0].commonAncestorContainer;
521 * Get the list of child nodes of the selection.
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();
532 * Set the current selection. Used to restore a selection.
534 set_selection : function(selection) {
537 if (window.getSelection) {
538 sel = window.getSelection();
539 sel.removeAllRanges();
540 for (i = 0; i < selection.length; i++) {
541 sel.addRange(selection[i]);
543 } else if (document.selection) {
545 if (selection.select) {
554 }, '@VERSION@', {"requires": ["node", "io", "overlay", "escape", "event-key", "moodle-core-notification"]});