ce3a74253127ad0dea081d8711452919efe66060
[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 known block level tags.
46      * Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
47      *
48      * @type {Array}
49      */
50     BLOCK_TAGS : [
51         'address',
52         'article',
53         'aside',
54         'audio',
55         'blockquote',
56         'canvas',
57         'dd',
58         'div',
59         'dl',
60         'fieldset',
61         'figcaption',
62         'figure',
63         'footer',
64         'form',
65         'h1',
66         'h2',
67         'h3',
68         'h4',
69         'h5',
70         'h6',
71         'header',
72         'hgroup',
73         'hr',
74         'noscript',
75         'ol',
76         'output',
77         'p',
78         'pre',
79         'section',
80         'table',
81         'tfoot',
82         'ul',
83         'video'],
85     /**
86      * List of attached button handlers to prevent duplicates.
87      */
88     buttonhandlers : {},
90     /**
91      * List of attached handlers to add inline editing controls to content.
92      */
93     textupdatedhandlers : {},
95     /**
96      * List of YUI overlays for custom menus.
97      */
98     menus : {},
100     /**
101      * List of attached menu handlers to prevent duplicates.
102      */
103     menuhandlers : {},
105     /**
106      * List of file picker options for specific editor instances.
107      */
108     filepickeroptions : {},
110     /**
111      * List of buttons and menus that have been added to the toolbar.
112      */
113     widgets : {},
115     /**
116      * List of saved selections per editor instance.
117      */
118     selections : {},
120     focusfromclick : false,
122     /**
123      * Toggle a menu.
124      * @param event e
125      */
126     showhide_menu_handler : function(e) {
127         e.preventDefault();
128         var disabled = this.getAttribute('disabled');
129         var overlayid = this.getAttribute('data-menu');
130         var overlay = M.editor_atto.menus[overlayid];
131         var menu = overlay.get('bodyContent');
132         if (overlay.get('visible') || disabled) {
133             overlay.hide();
134             menu.detach('clickoutside');
135         } else {
136             menu.on('clickoutside', function(ev) {
137                 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
138                     if (overlay.get('visible')) {
139                         menu.detach('clickoutside');
140                         overlay.hide();
141                     }
142                 }
143             }, this);
145             overlay.align(Y.one(Y.config.doc.body), [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
146             overlay.show();
147             var icon = e.target.ancestor('button', true).one('img');
148             overlay.align(icon, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
149             overlay.get('boundingBox').one('a').focus();
150         }
151     },
153     /**
154      * Handle clicks on editor buttons.
155      * @param event e
156      */
157     buttonclicked_handler : function(e) {
158         var elementid = this.getAttribute('data-editor');
159         var plugin = this.getAttribute('data-plugin');
160         var handler = this.getAttribute('data-handler');
161         var overlay = M.editor_atto.menus[plugin + '_' + elementid];
162         var toolbar = M.editor_atto.get_toolbar_node(elementid);
163         var currentid = toolbar.getAttribute('aria-activedescendant');
165         // Right now, currentid is the id of the previously selected button.
166         if (currentid) {
167             current = Y.one('#' + currentid);
168             // We only ever want one button with a tabindex of 0 at any one time.
169             current.setAttribute('tabindex', '-1');
170         }
171         this.setAttribute('tabindex', 0);
172         // And update the activedescendant to point at the currently selected button.
173         toolbar.setAttribute('aria-activedescendant', this.generateID());
175         if (overlay) {
176             overlay.hide();
177         }
179         if (M.editor_atto.is_enabled(elementid, plugin)) {
180             // Pass it on.
181             handler = M.editor_atto.buttonhandlers[handler];
182             return handler(e, elementid);
183         }
184     },
186     /**
187      * Disable all buttons and menus in the toolbar.
188      * @param string elementid, the element id of this editor.
189      */
190     disable_all_widgets : function(elementid) {
191         var plugin, element, toolbar = M.editor_atto.get_toolbar_node(elementid);
192         for (plugin in M.editor_atto.widgets) {
193             element = toolbar.one('.atto_' + plugin + '_button');
195             if (element) {
196                 element.setAttribute('disabled', 'true');
197             }
198         }
199     },
201     /**
202      * Get the node of the original textarea element that this editor replaced.
203      *
204      * @param string elementid, the element id of this editor.
205      * @return Y.Node
206      */
207     get_textarea_node : function(elementid) {
208         // Note - it is not safe to use a CSS selector like '#' + elementid
209         // because the id may have colons in it - e.g. quiz.
210         return Y.one(document.getElementById(elementid));
211     },
213     /**
214      * Get the node of the toolbar container for this editor.
215      *
216      * @param string elementid, the element id of this editor.
217      * @return Y.Node
218      */
219     get_toolbar_node : function(elementid) {
220         // Note - it is not safe to use a CSS selector like '#' + elementid
221         // because the id may have colons in it - e.g. quiz.
222         return Y.one(document.getElementById(elementid + '_toolbar'));
223     },
225     /**
226      * Get the node of the contenteditable container for this editor.
227      *
228      * @param string elementid, the element id of this editor.
229      * @return Y.Node
230      */
231     get_editable_node : function(elementid) {
232         // Note - it is not safe to use a CSS selector like '#' + elementid
233         // because the id may have colons in it - e.g. quiz.
234         return Y.one(document.getElementById(elementid + 'editable'));
235     },
237     /**
238      * Determine if the specified toolbar button/menu is enabled.
239      * @param string elementid, the element id of this editor.
240      * @param string plugin, the plugin that created the button/menu.
241      */
242     is_enabled : function(elementid, plugin) {
243         var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + plugin + '_button');
245         return !element.hasAttribute('disabled');
246     },
248     /**
249      * Enable a single widget in the toolbar.
250      * @param string elementid, the element id of this editor.
251      * @param string plugin, the name of the plugin that created the widget.
252      */
253     enable_widget : function(elementid, plugin) {
254         var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + plugin + '_button');
256         if (element) {
257             element.removeAttribute('disabled');
258         }
259     },
261     /**
262      * Enable all buttons and menus in the toolbar.
263      * @param string elementid, the element id of this editor.
264      */
265     enable_all_widgets : function(elementid) {
266         var plugin, element;
267         for (plugin in M.editor_atto.widgets) {
268             element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + plugin + '_button');
270             if (element) {
271                 element.removeAttribute('disabled');
272             }
273         }
274     },
276     /**
277      * Add a content update handler to be called whenever the content is updated.
278      * This is used to add inline editing controls to the content that are cleaned on submission.
279      *
280      * @param string elementid - the id of the textarea we created this editor from.
281      * @handler function callback - The function to do the cleaning.
282      * @param object context - the context to set for the callback.
283      * @handler function handler - A function to call when the button is clicked.
284      */
285     add_text_updated_handler : function(elementid, callback) {
286         if (!(elementid in M.editor_atto.textupdatedhandlers)) {
287             M.editor_atto.textupdatedhandlers[elementid] = [];
288         }
289         M.editor_atto.textupdatedhandlers[elementid].push(callback);
290     },
292     /**
293      * Add a button to the toolbar belonging to the editor for element with id "elementid".
294      * @param string elementid - the id of the textarea we created this editor from.
295      * @param string plugin - the plugin defining the button
296      * @param string icon - the html used for the content of the button
297      * @param string groupname - the group the button should be appended to.
298      * @param array entries - List of menu entries with the string (entry.text) and the handlers (entry.handler).
299      * @param int overlaywidth - the overlay width size in 'ems'.
300      * @param string menucolor - menu icon background color
301      */
302     add_toolbar_menu : function(elementid, plugin, iconurl, groupname, entries, overlaywidth, menucolor) {
303         var toolbar = M.editor_atto.get_toolbar_node(elementid),
304             group = toolbar.one('.atto_group.' + groupname + '_group'),
305             currentfocus,
306             button,
307             expimgurl;
309         if ((typeof overlaywidth) === 'undefined') {
310             overlaywidth = '14';
311         }
312         if ((typeof menucolor) === 'undefined') {
313             menucolor = 'transparent';
314         }
316         if (!group) {
317             group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
318             toolbar.append(group);
319         }
320         expimgurl = M.util.image_url('t/expanded', 'moodle');
321         button = Y.Node.create('<button class="atto_' + plugin + '_button atto_hasmenu" ' +
322                                     'data-editor="' + Y.Escape.html(elementid) + '" ' +
323                                     'tabindex="-1" ' +
324                                     'type="button" ' +
325                                     'data-menu="' + plugin + '_' + elementid + '" ' +
326                                     'title="' + Y.Escape.html(M.util.get_string('pluginname', 'atto_' + plugin)) + '">' +
327                                     '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" '+
328                                     'style="background-color:' + menucolor + ';" src="' + iconurl + '"/>' +
329                                     '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + expimgurl + '"/>' +
330                                     '</button>');
332         group.append(button);
334         currentfocus = toolbar.getAttribute('aria-activedescendant');
335         if (!currentfocus) {
336             // Initially set the first button in the toolbar to be the default on keyboard focus.
337             button.setAttribute('tabindex', '0');
338             toolbar.setAttribute('aria-activedescendant', button.generateID());
339         }
341         // Save the name of the plugin.
342         M.editor_atto.widgets[plugin] = plugin;
344         var menu = Y.Node.create('<div class="atto_' + plugin + '_menu' +
345                                  ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"' +
346                                  ' style="min-width:' + (overlaywidth-2) + 'em"' +
347                                  '"></div>');
348         var i = 0, entry = {};
350         for (i = 0; i < entries.length; i++) {
351             entry = entries[i];
353             menu.append(Y.Node.create('<div class="atto_menuentry">' +
354                                        '<a href="#" class="atto_' + plugin + '_action_' + i + '" ' +
355                                        'data-editor="' + Y.Escape.html(elementid) + '" ' +
356                                        'data-plugin="' + Y.Escape.html(plugin) + '" ' +
357                                        'data-handler="' + Y.Escape.html(plugin + '_action_' + i) + '">' +
358                                        entry.text +
359                                        '</a>' +
360                                        '</div>'));
361             if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
362                 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_action_' + i);
363                 Y.one('body').delegate('key', M.editor_atto.buttonclicked_handler, 'space,enter', '.atto_' + plugin + '_action_' + i);
364                 M.editor_atto.buttonhandlers[plugin + '_action_' + i] = entry.handler;
365             }
366         }
368         if (!M.editor_atto.buttonhandlers[plugin]) {
369             Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + plugin + '_button');
370             M.editor_atto.buttonhandlers[plugin] = true;
371         }
373         var overlay = new M.core.dialogue({
374             bodyContent : menu,
375             visible : false,
376             width: overlaywidth + 'em',
377             lightbox: false,
378             closeButton: false,
379             center : false
380         });
382         M.editor_atto.menus[plugin + '_' + elementid] = overlay;
383         overlay.align(button, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
384         overlay.hide();
385         overlay.headerNode.hide();
386         overlay.render();
387     },
389     /**
390      * Add a button to the toolbar belonging to the editor for element with id "elementid".
391      * @param string elementid - the id of the textarea we created this editor from.
392      * @param string plugin - the plugin defining the button.
393      * @param string icon - the url to the image for the icon
394      * @param string groupname - the group the button should be appended to.
395      * @handler function handler- A function to call when the button is clicked.
396      */
397     add_toolbar_button : function(elementid, plugin, iconurl, groupname, handler) {
398         var toolbar = M.editor_atto.get_toolbar_node(elementid),
399             group = toolbar.one('.atto_group.' + groupname + '_group'),
400             button,
401             currentfocus;
403         if (!group) {
404             group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
405             toolbar.append(group);
406         }
407         button = Y.Node.create('<button class="atto_' + plugin + '_button" ' +
408                                'data-editor="' + Y.Escape.html(elementid) + '" ' +
409                                'data-plugin="' + Y.Escape.html(plugin) + '" ' +
410                                'tabindex="-1" ' +
411                                'data-handler="' + Y.Escape.html(plugin) + '" ' +
412                                'title="' + Y.Escape.html(M.util.get_string('pluginname', 'atto_' + plugin)) + '">' +
413                                     '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + iconurl + '"/>' +
414                                '</button>');
416         group.append(button);
418         currentfocus = toolbar.getAttribute('aria-activedescendant');
419         if (!currentfocus) {
420             // Initially set the first button in the toolbar to be the default on keyboard focus.
421             button.setAttribute('tabindex', '0');
422             toolbar.setAttribute('aria-activedescendant', button.generateID());
423         }
425         // We only need to attach this once.
426         if (!M.editor_atto.buttonhandlers[plugin]) {
427             Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_button');
428             M.editor_atto.buttonhandlers[plugin] = handler;
429         }
431         // Save the name of the plugin.
432         M.editor_atto.widgets[plugin] = plugin;
434     },
436     /**
437      * Work out if the cursor is in the editable area for this editor instance.
438      * @param string elementid of this editor
439      * @return bool
440      */
441     is_active : function(elementid) {
442         var selection = M.editor_atto.get_selection();
444         if (selection.length) {
445             selection = selection.pop();
446         }
448         var node = null;
449         if (selection.parentElement) {
450             node = Y.one(selection.parentElement());
451         } else {
452             node = Y.one(selection.startContainer);
453         }
455         var editable = M.editor_atto.get_editable_node(elementid);
457         return node && editable.contains(node);
458     },
460     /**
461      * Focus on the editable area for this editor.
462      * @param string elementid of this editor
463      */
464     focus : function(elementid) {
465         M.editor_atto.get_editable_node(elementid).focus();
466     },
468     /**
469      * Initialise the editor
470      * @param object params for this editor instance.
471      */
472     init : function(params) {
473         var wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
474         var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
475                                             'contenteditable="true" ' +
476                                             'role="textbox" ' +
477                                             'spellcheck="true" ' +
478                                             'aria-live="off" ' +
479                                             'class="' + CSS.CONTENT + '" />');
481         var toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" id="' + params.elementid + '_toolbar" role="toolbar" aria-live="off"/>');
483         // Editable content wrapper.
484         var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
485         var textarea = M.editor_atto.get_textarea_node(params.elementid);
486         var label = Y.one('[for="' + params.elementid + '"]');
488         // Add a labelled-by attribute to the contenteditable.
489         if (label) {
490             label.generateID();
491             atto.setAttribute('aria-labelledby', label.get("id"));
492             toolbar.setAttribute('aria-labelledby', label.get("id"));
493         }
495         content.appendChild(atto);
497         // Add everything to the wrapper.
498         wrapper.appendChild(toolbar);
499         wrapper.appendChild(content);
501         // Style the editor.
502         atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows'))) + 'em');
504         // Copy text to editable div.
505         atto.append(textarea.get('value'));
507         // Clean it.
508         atto.cleanHTML();
510         // Add the toolbar and editable zone to the page.
511         textarea.get('parentNode').insert(wrapper, textarea);
513         // Disable odd inline CSS styles.
514         try {
515             document.execCommand("styleWithCSS", 0, false);
516         } catch (e1) {
517             try {
518                 document.execCommand("useCSS", 0, true);
519             } catch (e2) {
520                 try {
521                     document.execCommand('styleWithCSS', false, false);
522                 }
523                 catch (e3) {
524                     // We did our best.
525                 }
526             }
527         }
529         // Hide the old textarea.
530         textarea.hide();
531         atto.on('keydown', this.save_selection, this, params.elementid);
532         atto.on('mouseup', this.save_selection, this, params.elementid);
533         atto.on('focus', this.restore_selection, this, params.elementid);
534         // Do not restore selection when focus is from a click event.
535         atto.on('mousedown', function() { this.focusfromclick = true; }, this);
537         // Copy the current value back to the textarea when focus leaves us and save the current selection.
538         atto.on('blur', function() {
539             this.focusfromclick = false;
540             this.text_updated(params.elementid);
541         }, this);
543         // Listen for Arrow left and Arrow right keys.
544         Y.one(Y.config.doc.body).delegate('key',
545                                           this.keyboard_navigation,
546                                           'down:37,39',
547                                           '#' + params.elementid + '_toolbar',
548                                           this,
549                                           params.elementid);
551         // Save the file picker options for later.
552         M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
554         // Init each of the plugins
555         var i, j, group, plugin;
556         for (i = 0; i < params.plugins.length; i++) {
557             group = params.plugins[i].group;
558             for (j = 0; j < params.plugins[i].plugins.length; j++) {
559                 plugin = params.plugins[i].plugins[j];
560                 plugin.params.elementid = params.elementid;
561                 plugin.params.group = group;
563                 M['atto_' + plugin.name].init(plugin.params);
564             }
565         }
567         // Let the plugins run some init code once all plugins are in the page.
568         for (i = 0; i < params.plugins.length; i++) {
569             group = params.plugins[i].group;
570             for (j = 0; j < params.plugins[i].plugins.length; j++) {
571                 plugin = params.plugins[i].plugins[j];
572                 plugin.params.elementid = params.elementid;
573                 plugin.params.group = group;
575                 if (typeof M['atto_' + plugin.name].after_init !== 'undefined') {
576                     M['atto_' + plugin.name].after_init(plugin.params);
577                 }
578             }
579         }
580     },
582     /**
583      * The text in the contenteditable region has been updated,
584      * clean and copy the buffer to the text area.
585      * @param string elementid - the id of the textarea we created this editor from.
586      */
587     text_updated : function(elementid) {
588         var textarea = M.editor_atto.get_textarea_node(elementid),
589             cleancontent = this.get_clean_html(elementid);
590         textarea.set('value', cleancontent);
591         // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
592         textarea.simulate('change');
593         // Trigger handlers for this action.
594         var i = 0;
595         if (elementid in M.editor_atto.textupdatedhandlers) {
596             for (i = 0; i < M.editor_atto.textupdatedhandlers[elementid].length; i++) {
597                 var callback = M.editor_atto.textupdatedhandlers[elementid][i];
598                 callback(elementid);
599             }
600         }
601     },
603     /**
604      * Remove all YUI ids from the generated HTML.
605      * @param string elementid - the id of the textarea we created this editor from.
606      * @return string HTML stripped of YUI ids
607      */
608     get_clean_html : function(elementid) {
609         var atto = M.editor_atto.get_editable_node(elementid).cloneNode(true);
611         Y.each(atto.all('[id]'), function(node) {
612             var id = node.get('id');
613             if (id.indexOf('yui') === 0) {
614                 node.removeAttribute('id');
615             }
616         });
618         Y.each(atto.all('.atto_control'), function(node) {
619             node.remove(true);
620         });
622         // Remove any and all nasties from source.
623         atto.cleanHTML();
625         return atto.getHTML();
626     },
628     /**
629      * Implement arrow key navigation for the buttons in the toolbar.
630      * @param Event e - the keyboard event.
631      * @param string elementid - the id of the textarea we created this editor from.
632      */
633     keyboard_navigation : function(e, elementid) {
634         var buttons,
635             current,
636             currentid,
637             currentindex,
638             toolbar = M.editor_atto.get_toolbar_node(elementid);
640         e.preventDefault();
642         // This workaround is because we cannot do ".atto_group:not([hidden]) button" in ie8 (even with selector-css3).
643         // Create an empty NodeList.
644         buttons = toolbar.all('empty');
645         toolbar.all('.atto_group').each(function(group) {
646             if (!group.hasAttribute('hidden')) {
647                 // Append the visible buttons to the buttons list.
648                 buttons = buttons.concat(group.all('button'));
649             }
650         });
651         // The currentid is the id of the previously selected button.
652         currentid = toolbar.getAttribute('aria-activedescendant');
653         if (!currentid) {
654             return;
655         }
656         // We only ever want one button with a tabindex of 0.
657         current = Y.one('#' + currentid);
658         current.setAttribute('tabindex', '-1');
660         currentindex = buttons.indexOf(current);
662         if (e.keyCode === 37) {
663             // Left
664             currentindex--;
665             if (currentindex < 0) {
666                 currentindex = buttons.size()-1;
667             }
668         } else {
669             // Right
670             currentindex++;
671             if (currentindex >= buttons.size()) {
672                 currentindex = 0;
673             }
674         }
675         // Currentindex has been updated to point to the new button.
676         current = buttons.item(currentindex);
677         current.setAttribute('tabindex', '0');
678         current.focus();
679         toolbar.setAttribute('aria-activedescendant', current.generateID());
680     },
682     /**
683      * Should we show the filepicker for this filetype?
684      *
685      * @param string elementid for this editor instance.
686      * @param string type The media type for the file picker
687      * @return boolean
688      */
689     can_show_filepicker : function(elementid, type) {
690         var options = M.editor_atto.filepickeroptions[elementid];
691         return ((typeof options[type]) !== "undefined");
692     },
694     /**
695      * Show the filepicker.
696      * @param string elementid for this editor instance.
697      * @param string type The media type for the file picker
698      * @param function callback
699      */
700     show_filepicker : function(elementid, type, callback) {
701         Y.use('core_filepicker', function (Y) {
702             var options = M.editor_atto.filepickeroptions[elementid][type];
704             options.formcallback = callback;
706             M.core_filepicker.show(Y, options);
707         });
708     },
710     /**
711      * Create a cross browser selection object that represents a yui node.
712      * @param Node yui node for the selection
713      * @return range (browser dependent)
714      */
715     get_selection_from_node: function(node) {
716         var range;
718         if (window.getSelection) {
719             range = document.createRange();
721             range.setStartBefore(node.getDOMNode());
722             range.setEndAfter(node.getDOMNode());
723             return [range];
724         } else if (document.selection) {
725             range = document.body.createTextRange();
726             range.moveToElementText(node.getDOMNode());
727             return range;
728         }
729         return false;
730     },
732     /**
733      * Save the current selection on blur, allows more reliable keyboard navigation.
734      * @param Y.Event event
735      * @param string elementid
736      */
737     save_selection : function(event, elementid) {
738         if (this.is_active(elementid)) {
739             var sel = this.get_selection();
740             if (sel.length > 0) {
741                 this.selections[elementid] = sel;
742             }
743         }
744     },
746     /**
747      * Restore any current selection when the editor gets focus again.
748      * @param Y.Event event
749      * @param string elementid
750      */
751     restore_selection : function(event, elementid) {
752         event.preventDefault();
753         if (!this.focusfromclick) {
754             if (typeof this.selections[elementid] !== "undefined") {
755                 this.set_selection(this.selections[elementid]);
756             }
757         }
758         this.focusfromclick = false;
759     },
761     /**
762      * Get the selection object that can be passed back to set_selection.
763      * @return range (browser dependent)
764      */
765     get_selection : function() {
766         if (window.getSelection) {
767             var sel = window.getSelection();
768             var ranges = [], i = 0;
769             for (i = 0; i < sel.rangeCount; i++) {
770                 ranges.push(sel.getRangeAt(i));
771             }
772             return ranges;
773         } else if (document.selection) {
774             // IE < 9
775             if (document.selection.createRange) {
776                 return document.selection.createRange();
777             }
778         }
779         return false;
780     },
782     /**
783      * Check that a YUI node it at least partly contained by the selection.
784      * @param Y.Node node
785      * @return boolean
786      */
787     selection_contains_node : function(node) {
788         var range, sel;
789         if (window.getSelection) {
790             sel = window.getSelection();
792             if (sel.containsNode) {
793                 return sel.containsNode(node.getDOMNode(), true);
794             }
795         }
796         sel = document.selection.createRange();
797         range = sel.duplicate();
798         range.moveToElementText(node.getDOMNode());
799         return sel.inRange(range);
800     },
802     /**
803      * Get the dom node representing the common anscestor of the selection nodes.
804      * @return DOMNode or false
805      */
806     get_selection_parent_node : function() {
807         var selection = M.editor_atto.get_selection();
808         if (selection.length) {
809             selection = selection.pop();
810         }
812         if (selection.commonAncestorContainer) {
813             return selection.commonAncestorContainer;
814         } else if (selection.parentElement) {
815             return selection.parentElement();
816         }
817         // No selection
818         return false;
819     },
821     /**
822      * Get the list of child nodes of the selection.
823      * @return DOMNode[]
824      */
825     get_selection_text : function() {
826         var selection = M.editor_atto.get_selection();
827         if (selection.length > 0 && selection[0].cloneContents) {
828             return selection[0].cloneContents();
829         }
830     },
832     /**
833      * Set the current selection. Used to restore a selection.
834      */
835     set_selection : function(selection) {
836         var sel, i;
838         if (window.getSelection) {
839             sel = window.getSelection();
840             sel.removeAllRanges();
841             for (i = 0; i < selection.length; i++) {
842                 sel.addRange(selection[i]);
843             }
844         } else if (document.selection) {
845             // IE < 9
846             if (selection.select) {
847                 selection.select();
848             }
849         }
850     },
852     /**
853      * Change the formatting for the current selection.
854      * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
855      *
856      * @param {String} elementid - The editor elementid.
857      * @param {String} blocktag - Change the block level tag to this. Empty string, means do not change the tag.
858      * @param {Object} attributes - The keys and values for attributes to be added/changed in the block tag.
859      * @return Y.Node - if there was a selection.
860      */
861     format_selection_block : function(elementid, blocktag, attributes) {
862         // First find the nearest ancestor of the selection that is a block level element.
863         var selectionparentnode = M.editor_atto.get_selection_parent_node(),
864             boundary,
865             cell,
866             nearestblock,
867             newcontent,
868             match,
869             replacement;
871         if (!selectionparentnode) {
872             // No selection, nothing to format.
873             return;
874         }
876         boundary = M.editor_atto.get_editable_node(elementid);
878         selectionparentnode = Y.one(selectionparentnode);
880         // If there is a table cell in between the selectionparentnode and the boundary,
881         // move the boundary to the table cell.
882         // This is because we might have a table in a div, and we select some text in a cell,
883         // want to limit the change in style to the table cell, not the entire table (via the outer div).
884         cell = selectionparentnode.ancestor(function (node) {
885             var tagname = node.get('tagName');
886             if (tagname) {
887                 tagname = tagname.toLowerCase();
888             }
889             return (node === boundary) ||
890                    (tagname === 'td') ||
891                    (tagname === 'th');
892         }, true);
894         if (cell) {
895             // Limit the scope to the table cell.
896             boundary = cell;
897         }
899         nearestblock = selectionparentnode.ancestor(M.editor_atto.BLOCK_TAGS.join(', '), true);
900         if (nearestblock) {
901             // Check that the block is contained by the boundary.
902             match = nearestblock.ancestor(function (node) {
903                 return node === boundary;
904             }, false);
906             if (!match) {
907                 nearestblock = false;
908             }
909         }
911         // No valid block element - make one.
912         if (!nearestblock) {
913             // There is no block node in the content, wrap the content in a p and use that.
914             newcontent = Y.Node.create('<p></p>');
915             boundary.get('childNodes').each(function (child) {
916                 newcontent.append(child.remove());
917             });
918             boundary.append(newcontent);
919             nearestblock = newcontent;
920         }
922         // Guaranteed to have a valid block level element contained in the contenteditable region.
923         // Change the tag to the new block level tag.
924         if (blocktag && blocktag !== '') {
925             // Change the block level node for a new one.
926             replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
927             // Copy all attributes.
928             replacement.setAttrs(nearestblock.getAttrs());
929             // Copy all children.
930             nearestblock.get('childNodes').each(function (child) {
931                 child.remove();
932                 replacement.append(child);
933             });
935             nearestblock.replace(replacement);
936             nearestblock = replacement;
937         }
939         // Set the attributes on the block level tag.
940         if (attributes) {
941             nearestblock.setAttrs(attributes);
942         }
944         // Change the selection to the modified block. This makes sense when we might apply multiple styles
945         // to the block.
946         var selection = M.editor_atto.get_selection_from_node(nearestblock);
947         M.editor_atto.set_selection(selection);
949         return nearestblock;
950     }
952 };