MDL-44128 Atto: fix keyboard navigation for the dropdowns
[moodle.git] / lib / editor / atto / yui / build / moodle-editor_atto-editor / moodle-editor_atto-editor.js
CommitLineData
adca7326
DW
1YUI.add('moodle-editor_atto-editor', function (Y, NAME) {
2
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17
18/**
19 * Atto editor.
20 *
21 * @package editor_atto
22 * @copyright 2013 Damyon Wiese <damyon@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26/**
27 * Classes constants.
28 */
29CSS = {
30 CONTENT: 'editor_atto_content',
31 CONTENTWRAPPER: 'editor_atto_content_wrap',
32 TOOLBAR: 'editor_atto_toolbar',
3ee53a42
DW
33 WRAPPER: 'editor_atto',
34 HIGHLIGHT: 'highlight'
adca7326
DW
35};
36
37/**
38 * Atto editor main class.
39 * Common functions required by editor plugins.
40 *
41 * @package editor_atto
42 * @copyright 2013 Damyon Wiese <damyon@moodle.com>
43 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
44 */
45M.editor_atto = M.editor_atto || {
46
34f5867a
DW
47 /**
48 * List of known block level tags.
49 * Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
50 *
51 * @type {Array}
52 */
53 BLOCK_TAGS : [
54 'address',
55 'article',
56 'aside',
57 'audio',
58 'blockquote',
59 'canvas',
60 'dd',
61 'div',
62 'dl',
63 'fieldset',
64 'figcaption',
65 'figure',
66 'footer',
67 'form',
68 'h1',
69 'h2',
70 'h3',
71 'h4',
72 'h5',
73 'h6',
74 'header',
75 'hgroup',
76 'hr',
77 'noscript',
78 'ol',
79 'output',
80 'p',
81 'pre',
82 'section',
83 'table',
84 'tfoot',
85 'ul',
86 'video'],
d321f68b 87
bed1abbc
AD
88 PLACEHOLDER_FONTNAME: 'yui-tmp',
89 ALL_NODES_SELECTOR: '[style],font[face]',
90 FONT_FAMILY: 'fontFamily',
34f5867a 91
adca7326
DW
92 /**
93 * List of attached button handlers to prevent duplicates.
94 */
95 buttonhandlers : {},
96
97 /**
05843fd3 98 * List of attached handlers.
adca7326
DW
99 */
100 textupdatedhandlers : {},
101
102 /**
103 * List of YUI overlays for custom menus.
104 */
105 menus : {},
106
107 /**
108 * List of attached menu handlers to prevent duplicates.
109 */
110 menuhandlers : {},
111
112 /**
113 * List of file picker options for specific editor instances.
114 */
115 filepickeroptions : {},
116
117 /**
118 * List of buttons and menus that have been added to the toolbar.
119 */
120 widgets : {},
121
26f8822d
DW
122 /**
123 * List of saved selections per editor instance.
124 */
125 selections : {},
126
127 focusfromclick : false,
128
67d3fe45
SH
129 /**
130 * Set to true after events have been published for M.editor_atto.
131 * @param eventspublished
132 * @type boolean
133 */
134 eventspublished : false,
135
136 /**
137 * A unique identifier for the last selection recorded.
138 * @param lastselection
139 * @type string
140 */
141 lastselection : null,
142
67d3fe45
SH
143 /**
144 * Set to true after the first editor has been initialised.
145 *
146 * "The first transport is away. The first transport is away."
147 * ―Lieutenant Romas Navander, during the Battle of Hoth
148 *
149 * @param firstinit
150 * @type boolean
151 */
152 firstinit : true,
153
adca7326
DW
154 /**
155 * Toggle a menu.
156 * @param event e
157 */
158 showhide_menu_handler : function(e) {
159 e.preventDefault();
160 var disabled = this.getAttribute('disabled');
161 var overlayid = this.getAttribute('data-menu');
162 var overlay = M.editor_atto.menus[overlayid];
163 var menu = overlay.get('bodyContent');
164 if (overlay.get('visible') || disabled) {
165 overlay.hide();
166 menu.detach('clickoutside');
167 } else {
168 menu.on('clickoutside', function(ev) {
169 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
170 if (overlay.get('visible')) {
171 menu.detach('clickoutside');
172 overlay.hide();
173 }
174 }
175 }, this);
0fa78b80
DW
176
177 overlay.align(Y.one(Y.config.doc.body), [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
adca7326 178 overlay.show();
0fa78b80
DW
179 var icon = e.target.ancestor('button', true).one('img');
180 overlay.align(icon, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
26f8822d 181 overlay.get('boundingBox').one('a').focus();
adca7326
DW
182 }
183 },
184
185 /**
186 * Handle clicks on editor buttons.
187 * @param event e
188 */
189 buttonclicked_handler : function(e) {
190 var elementid = this.getAttribute('data-editor');
191 var plugin = this.getAttribute('data-plugin');
5ec54dd1 192 var button = this.getAttribute('data-button');
adca7326
DW
193 var handler = this.getAttribute('data-handler');
194 var overlay = M.editor_atto.menus[plugin + '_' + elementid];
fcb5b5c4
DW
195 var toolbar = M.editor_atto.get_toolbar_node(elementid);
196 var currentid = toolbar.getAttribute('aria-activedescendant');
197
198 // Right now, currentid is the id of the previously selected button.
199 if (currentid) {
200 current = Y.one('#' + currentid);
201 // We only ever want one button with a tabindex of 0 at any one time.
202 current.setAttribute('tabindex', '-1');
203 }
204 this.setAttribute('tabindex', 0);
205 // And update the activedescendant to point at the currently selected button.
206 toolbar.setAttribute('aria-activedescendant', this.generateID());
adca7326
DW
207
208 if (overlay) {
209 overlay.hide();
210 }
211
5ec54dd1 212 if (M.editor_atto.is_enabled(elementid, plugin, button)) {
adca7326
DW
213 // Pass it on.
214 handler = M.editor_atto.buttonhandlers[handler];
215 return handler(e, elementid);
216 }
217 },
218
adca7326
DW
219 /**
220 * Disable all buttons and menus in the toolbar.
221 * @param string elementid, the element id of this editor.
222 */
223 disable_all_widgets : function(elementid) {
48bdf86f 224 var plugin, element, toolbar = M.editor_atto.get_toolbar_node(elementid);
adca7326 225 for (plugin in M.editor_atto.widgets) {
48bdf86f 226 element = toolbar.one('.atto_' + plugin + '_button');
adca7326
DW
227
228 if (element) {
229 element.setAttribute('disabled', 'true');
230 }
231 }
232 },
233
48bdf86f
DW
234 /**
235 * Get the node of the original textarea element that this editor replaced.
236 *
237 * @param string elementid, the element id of this editor.
238 * @return Y.Node
239 */
240 get_textarea_node : function(elementid) {
241 // Note - it is not safe to use a CSS selector like '#' + elementid
242 // because the id may have colons in it - e.g. quiz.
243 return Y.one(document.getElementById(elementid));
244 },
245
246 /**
247 * Get the node of the toolbar container for this editor.
248 *
249 * @param string elementid, the element id of this editor.
250 * @return Y.Node
251 */
252 get_toolbar_node : function(elementid) {
253 // Note - it is not safe to use a CSS selector like '#' + elementid
254 // because the id may have colons in it - e.g. quiz.
255 return Y.one(document.getElementById(elementid + '_toolbar'));
256 },
257
258 /**
259 * Get the node of the contenteditable container for this editor.
260 *
261 * @param string elementid, the element id of this editor.
262 * @return Y.Node
263 */
264 get_editable_node : function(elementid) {
265 // Note - it is not safe to use a CSS selector like '#' + elementid
266 // because the id may have colons in it - e.g. quiz.
267 return Y.one(document.getElementById(elementid + 'editable'));
268 },
269
270 /**
271 * Determine if the specified toolbar button/menu is enabled.
272 * @param string elementid, the element id of this editor.
273 * @param string plugin, the plugin that created the button/menu.
5ec54dd1 274 * @param string buttonname, optional - used when a plugin has multiple buttons.
48bdf86f 275 */
5ec54dd1
DW
276 is_enabled : function(elementid, plugin, button) {
277 var buttonpath = plugin;
278 if (button) {
279 buttonpath += '_' + button;
280 }
281 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
48bdf86f
DW
282
283 return !element.hasAttribute('disabled');
284 },
285
3ee53a42
DW
286 /**
287 * Determine if the specified toolbar button/menu is highlighted.
288 * @param string elementid, the element id of this editor.
289 * @param string plugin, the plugin that created the button/menu.
290 * @param string buttonname, optional - used when a plugin has multiple buttons.
291 */
292 is_highlighted : function(elementid, plugin, button) {
293 var buttonpath = plugin;
294 if (button) {
295 buttonpath += '_' + button;
296 }
297 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
298
299 return !element.hasClass(CSS.HIGHLIGHT);
300 },
301
adca7326
DW
302 /**
303 * Enable a single widget in the toolbar.
304 * @param string elementid, the element id of this editor.
305 * @param string plugin, the name of the plugin that created the widget.
5ec54dd1 306 * @param string buttonname, optional - used when a plugin has multiple buttons.
adca7326 307 */
5ec54dd1
DW
308 enable_widget : function(elementid, plugin, button) {
309 var buttonpath = plugin;
310 if (button) {
311 buttonpath += '_' + button;
312 }
313 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
adca7326
DW
314
315 if (element) {
316 element.removeAttribute('disabled');
317 }
318 },
319
3ee53a42
DW
320 /**
321 * Highlight a single widget in the toolbar.
322 * @param string elementid, the element id of this editor.
323 * @param string plugin, the name of the plugin that created the widget.
324 * @param string buttonname, optional - used when a plugin has multiple buttons.
325 */
326 add_widget_highlight : function(elementid, plugin, button) {
327 var buttonpath = plugin;
328 if (button) {
329 buttonpath += '_' + button;
330 }
331 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
332
333 if (element) {
334 element.addClass(CSS.HIGHLIGHT);
335 }
336 },
337
338 /**
339 * Unhighlight a single widget in the toolbar.
340 * @param string elementid, the element id of this editor.
341 * @param string plugin, the name of the plugin that created the widget.
342 * @param string buttonname, optional - used when a plugin has multiple buttons.
343 */
344 remove_widget_highlight : function(elementid, plugin, button) {
345 var buttonpath = plugin;
346 if (button) {
347 buttonpath += '_' + button;
348 }
349 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
350
351 if (element) {
352 element.removeClass(CSS.HIGHLIGHT);
353 }
354 },
355
adca7326
DW
356 /**
357 * Enable all buttons and menus in the toolbar.
358 * @param string elementid, the element id of this editor.
359 */
360 enable_all_widgets : function(elementid) {
5ec54dd1
DW
361 var path, element;
362 for (path in M.editor_atto.widgets) {
363 element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + path + '_button');
adca7326
DW
364
365 if (element) {
366 element.removeAttribute('disabled');
367 }
368 }
369 },
370
371 /**
372 * Add a content update handler to be called whenever the content is updated.
adca7326
DW
373 *
374 * @param string elementid - the id of the textarea we created this editor from.
375 * @handler function callback - The function to do the cleaning.
376 * @param object context - the context to set for the callback.
377 * @handler function handler - A function to call when the button is clicked.
378 */
379 add_text_updated_handler : function(elementid, callback) {
380 if (!(elementid in M.editor_atto.textupdatedhandlers)) {
381 M.editor_atto.textupdatedhandlers[elementid] = [];
382 }
383 M.editor_atto.textupdatedhandlers[elementid].push(callback);
384 },
385
386 /**
387 * Add a button to the toolbar belonging to the editor for element with id "elementid".
388 * @param string elementid - the id of the textarea we created this editor from.
389 * @param string plugin - the plugin defining the button
390 * @param string icon - the html used for the content of the button
391 * @param string groupname - the group the button should be appended to.
392 * @param array entries - List of menu entries with the string (entry.text) and the handlers (entry.handler).
5ec54dd1
DW
393 * @param string buttonname - (optional) a name for the button. Required if a plugin creates more than one button.
394 * @param string buttontitle - (optional) a title for the button. Required if a plugin creates more than one button.
0fa78b80 395 * @param int overlaywidth - the overlay width size in 'ems'.
534cf7b7 396 * @param string menucolor - menu icon background color
adca7326 397 */
5ec54dd1 398 add_toolbar_menu : function(elementid, plugin, iconurl, groupname, entries, buttonname, buttontitle, overlaywidth, menucolor) {
48bdf86f
DW
399 var toolbar = M.editor_atto.get_toolbar_node(elementid),
400 group = toolbar.one('.atto_group.' + groupname + '_group'),
adca7326
DW
401 currentfocus,
402 button,
5ec54dd1 403 buttonpath,
adca7326
DW
404 expimgurl;
405
5ec54dd1
DW
406 if (buttonname) {
407 buttonpath = plugin + '_' + buttonname;
408 } else {
409 buttonname = '';
410 buttonpath = plugin;
411 }
412
413 if (!buttontitle) {
414 buttontitle = M.util.get_string('pluginname', 'atto_' + plugin);
415 }
416
534cf7b7
RW
417 if ((typeof overlaywidth) === 'undefined') {
418 overlaywidth = '14';
419 }
420 if ((typeof menucolor) === 'undefined') {
421 menucolor = 'transparent';
422 }
423
adca7326
DW
424 if (!group) {
425 group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
426 toolbar.append(group);
427 }
adca7326 428 expimgurl = M.util.image_url('t/expanded', 'moodle');
5ec54dd1 429 button = Y.Node.create('<button class="atto_' + buttonpath + '_button atto_hasmenu" ' +
adca7326
DW
430 'data-editor="' + Y.Escape.html(elementid) + '" ' +
431 'tabindex="-1" ' +
0012a946 432 'type="button" ' +
5ec54dd1
DW
433 'data-menu="' + buttonpath + '_' + elementid + '" ' +
434 'title="' + Y.Escape.html(buttontitle) + '">' +
534cf7b7
RW
435 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" '+
436 'style="background-color:' + menucolor + ';" src="' + iconurl + '"/>' +
adca7326
DW
437 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + expimgurl + '"/>' +
438 '</button>');
439
440 group.append(button);
441
442 currentfocus = toolbar.getAttribute('aria-activedescendant');
443 if (!currentfocus) {
fcb5b5c4 444 // Initially set the first button in the toolbar to be the default on keyboard focus.
adca7326
DW
445 button.setAttribute('tabindex', '0');
446 toolbar.setAttribute('aria-activedescendant', button.generateID());
447 }
448
449 // Save the name of the plugin.
5ec54dd1 450 M.editor_atto.widgets[buttonpath] = buttonpath;
adca7326 451
5ec54dd1 452 var menu = Y.Node.create('<div class="atto_' + buttonpath + '_menu' +
534cf7b7
RW
453 ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"' +
454 ' style="min-width:' + (overlaywidth-2) + 'em"' +
455 '"></div>');
adca7326
DW
456 var i = 0, entry = {};
457
458 for (i = 0; i < entries.length; i++) {
459 entry = entries[i];
8951d614
JM
460 var insidemenu = Y.Node.create('<div tabindex="-1" class="atto_menuentry">' +
461 '<a href="#" class="atto_' + buttonpath + '_action_' + i + '" ' +
462 'data-editor="' + Y.Escape.html(elementid) + '" ' +
463 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
464 'data-button="' + Y.Escape.html(buttonname) + '" ' +
465 'data-handler="' + Y.Escape.html(buttonpath + '_action_' + i) + '">' +
466 entry.text +
467 '</a>' +
468 '</div>');
469 insidemenu.on("keydown", M.editor_atto.menu_item_arrow_key_handler, null, button);
470 menu.append(insidemenu);
adca7326 471 if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
5ec54dd1
DW
472 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + buttonpath + '_action_' + i);
473 // Activate the link on space or enter.
474 Y.one('body').delegate('key', M.editor_atto.buttonclicked_handler, '32,enter', '.atto_' + buttonpath + '_action_' + i);
475 M.editor_atto.buttonhandlers[buttonpath + '_action_' + i] = entry.handler;
adca7326
DW
476 }
477 }
478
5ec54dd1
DW
479 if (!M.editor_atto.buttonhandlers[buttonpath]) {
480 Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + buttonpath + '_button');
481 M.editor_atto.buttonhandlers[buttonpath] = true;
adca7326
DW
482 }
483
484 var overlay = new M.core.dialogue({
485 bodyContent : menu,
486 visible : false,
534cf7b7 487 width: overlaywidth + 'em',
adca7326 488 closeButton: false,
117f9f04
AN
489 center : false,
490 render: true
adca7326
DW
491 });
492
5ec54dd1 493 M.editor_atto.menus[buttonpath + '_' + elementid] = overlay;
55c0403c 494 overlay.align(button, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
adca7326
DW
495 overlay.headerNode.hide();
496 },
497
8951d614
JM
498 /**
499 * Handle arrow key on the menu items.
500 *
501 * @param event e the key event.
502 * @param Y.Node the button calling the menu..
503 */
504 menu_item_arrow_key_handler : function(e, button) {
505 var siblings;
506
507 // Intercept the down and left keys.
508 if (e.keyCode === 40 || e.keyCode === 37) {
509 e.preventDefault();
510 var nextsibling = e.currentTarget.get('nextSibling');
511
512 // If no next sibling the we are the last sibling, go to the first one.
513 if(nextsibling === null) {
514 siblings = e.currentTarget.siblings();
515 nextsibling = siblings.item(0);
516 }
517
518 nextsibling.get('firstChild').focus();
519 } else if (e.keyCode === 38 || e.keyCode === 39) {
520 // Intercept the up and right keys.
521 e.preventDefault();
522 var previoussibling = e.currentTarget.get('previousSibling');
523
524 // If no preious sibling the we are the first sibling, go to the last one.
525 if(previoussibling === null) {
526 siblings = e.currentTarget.siblings();
527 previoussibling = siblings.item(siblings.size() - 1);
528 }
529
530 previoussibling.get('firstChild').focus();
531 } else if (e.keyCode === 9 || e.keyCode === 27) {
532 // On the tab or esc key we close the menu.
533 e.preventDefault();
534 var overlay = M.editor_atto.menus[button.getAttribute('data-menu')];
535 var menu = overlay.get('bodyContent');
536 overlay.hide();
537 menu.detach('clickoutside');
538 // Put back the focus on the button.
539 button.focus();
540
541 }
542 },
543
adca7326
DW
544 /**
545 * Add a button to the toolbar belonging to the editor for element with id "elementid".
546 * @param string elementid - the id of the textarea we created this editor from.
547 * @param string plugin - the plugin defining the button.
55c0403c 548 * @param string icon - the url to the image for the icon
adca7326
DW
549 * @param string groupname - the group the button should be appended to.
550 * @handler function handler- A function to call when the button is clicked.
5ec54dd1
DW
551 * @param string buttonname - (optional) a name for the button. Required if a plugin creates more than one button.
552 * @param string buttontitle - (optional) a title for the button. Required if a plugin creates more than one button.
adca7326 553 */
5ec54dd1 554 add_toolbar_button : function(elementid, plugin, iconurl, groupname, handler, buttonname, buttontitle) {
48bdf86f
DW
555 var toolbar = M.editor_atto.get_toolbar_node(elementid),
556 group = toolbar.one('.atto_group.' + groupname + '_group'),
adca7326 557 button,
5ec54dd1 558 buttonpath,
55c0403c 559 currentfocus;
adca7326 560
5ec54dd1
DW
561 if (buttonname) {
562 buttonpath = plugin + '_' + buttonname;
563 } else {
564 buttonname = '';
565 buttonpath = plugin;
566 }
567
568 if (!buttontitle) {
569 buttontitle = M.util.get_string('pluginname', 'atto_' + plugin);
fe0d2477
JM
570 }
571
adca7326
DW
572 if (!group) {
573 group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
574 toolbar.append(group);
575 }
5ec54dd1 576 button = Y.Node.create('<button class="atto_' + buttonpath + '_button" ' +
adca7326
DW
577 'data-editor="' + Y.Escape.html(elementid) + '" ' +
578 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
5ec54dd1 579 'data-button="' + Y.Escape.html(buttonname) + '" ' +
adca7326 580 'tabindex="-1" ' +
5ec54dd1
DW
581 'data-handler="' + Y.Escape.html(buttonpath) + '" ' +
582 'title="' + Y.Escape.html(buttontitle) + '">' +
55c0403c 583 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + iconurl + '"/>' +
adca7326
DW
584 '</button>');
585
586 group.append(button);
587
588 currentfocus = toolbar.getAttribute('aria-activedescendant');
589 if (!currentfocus) {
fcb5b5c4 590 // Initially set the first button in the toolbar to be the default on keyboard focus.
adca7326
DW
591 button.setAttribute('tabindex', '0');
592 toolbar.setAttribute('aria-activedescendant', button.generateID());
593 }
594
595 // We only need to attach this once.
5ec54dd1
DW
596 if (!M.editor_atto.buttonhandlers[buttonpath]) {
597 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + buttonpath + '_button');
598 M.editor_atto.buttonhandlers[buttonpath] = handler;
adca7326
DW
599 }
600
601 // Save the name of the plugin.
5ec54dd1 602 M.editor_atto.widgets[buttonpath] = buttonpath;
adca7326
DW
603
604 },
605
606 /**
607 * Work out if the cursor is in the editable area for this editor instance.
608 * @param string elementid of this editor
609 * @return bool
610 */
611 is_active : function(elementid) {
d321f68b
DW
612 var range = rangy.createRange(),
613 editable = M.editor_atto.get_editable_node(elementid),
614 selection = rangy.getSelection();
adca7326 615
d321f68b
DW
616 if (!selection.rangeCount) {
617 return false;
adca7326
DW
618 }
619
d321f68b 620 range.selectNode(editable.getDOMNode());
34f5867a 621
d321f68b 622 return range.intersectsRange(selection.getRangeAt(0));
adca7326
DW
623 },
624
625 /**
626 * Focus on the editable area for this editor.
627 * @param string elementid of this editor
628 */
629 focus : function(elementid) {
48bdf86f 630 M.editor_atto.get_editable_node(elementid).focus();
adca7326
DW
631 },
632
86a83e3a
DW
633 /**
634 * Copy and clean the text from the textarea into the contenteditable div.
635 * If the text is empty, provide a default paragraph tag to hold the content.
636 *
637 * @param string elementid of this editor
638 */
639 update_from_textarea : function(elementid) {
640 var editable = M.editor_atto.get_editable_node(elementid);
641 var textarea = M.editor_atto.get_textarea_node(elementid);
642
643 // Clear it first.
644 editable.setHTML('');
645
646 // Copy text to editable div.
647 editable.append(textarea.get('value'));
648
649 // Clean it.
650 editable.cleanHTML();
651
652 // Insert a paragraph in the empty contenteditable div.
653 if (editable.getHTML() === '') {
654 if (Y.UA.ie && Y.UA.ie < 10) {
655 editable.setHTML('<p></p>');
656 } else {
657 editable.setHTML('<p><br></p>');
658 }
659 }
660 },
661
adca7326
DW
662 /**
663 * Initialise the editor
664 * @param object params for this editor instance.
665 */
666 init : function(params) {
67d3fe45
SH
667 // Some things we only want to do on the first init.
668 var firstinit = this.firstinit;
669 this.firstinit = false;
adca7326
DW
670 var wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
671 var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
672 'contenteditable="true" ' +
26f8822d 673 'role="textbox" ' +
adca7326 674 'spellcheck="true" ' +
26f8822d 675 'aria-live="off" ' +
bdfbdeeb
SH
676 'class="' + CSS.CONTENT + '" '+
677 'data-editor="' + params.elementid + '" />');
adca7326 678
26f8822d 679 var toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" id="' + params.elementid + '_toolbar" role="toolbar" aria-live="off"/>');
adca7326
DW
680
681 // Editable content wrapper.
682 var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
48bdf86f 683 var textarea = M.editor_atto.get_textarea_node(params.elementid);
26f8822d
DW
684 var label = Y.one('[for="' + params.elementid + '"]');
685
686 // Add a labelled-by attribute to the contenteditable.
687 if (label) {
688 label.generateID();
689 atto.setAttribute('aria-labelledby', label.get("id"));
690 toolbar.setAttribute('aria-labelledby', label.get("id"));
691 }
b269f635 692
adca7326
DW
693 content.appendChild(atto);
694
695 // Add everything to the wrapper.
696 wrapper.appendChild(toolbar);
697 wrapper.appendChild(content);
698
4c37c1f4 699 // Style the editor.
adca7326
DW
700 atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows'))) + 'em');
701
d088a835 702
adca7326
DW
703 // Add the toolbar and editable zone to the page.
704 textarea.get('parentNode').insert(wrapper, textarea);
26f8822d
DW
705
706 // Disable odd inline CSS styles.
f6bef145 707 M.editor_atto.disable_css_styling();
4c37c1f4 708
adca7326
DW
709 // Hide the old textarea.
710 textarea.hide();
3ee53a42 711
86a83e3a
DW
712 // Copy the text to the contenteditable div.
713 this.update_from_textarea(params.elementid);
714
3ee53a42
DW
715 this.publish_events();
716 atto.on('atto:selectionchanged', this.save_selection, this, params.elementid);
67d3fe45
SH
717 if (firstinit) {
718 // Aha this is the first initialisation.
719 // Publish our custom events.
720 this.publish_events();
721 // Start tracking selections.
722 this.on('atto:selectionchanged', this.save_selection, this);
723 }
724
26f8822d
DW
725 atto.on('focus', this.restore_selection, this, params.elementid);
726 // Do not restore selection when focus is from a click event.
727 atto.on('mousedown', function() { this.focusfromclick = true; }, this);
adca7326 728
26f8822d 729 // Copy the current value back to the textarea when focus leaves us and save the current selection.
adca7326 730 atto.on('blur', function() {
26f8822d 731 this.focusfromclick = false;
48bdf86f 732 this.text_updated(params.elementid);
adca7326
DW
733 }, this);
734
86a83e3a
DW
735 // Use paragraphs not divs.
736 if (document.queryCommandSupported('DefaultParagraphSeparator')) {
737 document.execCommand('DefaultParagraphSeparator', false, 'p');
738 }
adca7326
DW
739 // Listen for Arrow left and Arrow right keys.
740 Y.one(Y.config.doc.body).delegate('key',
741 this.keyboard_navigation,
742 'down:37,39',
48bdf86f 743 '#' + params.elementid + '_toolbar',
adca7326 744 this,
48bdf86f 745 params.elementid);
adca7326
DW
746
747 // Save the file picker options for later.
48bdf86f 748 M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
55c0403c
DW
749
750 // Init each of the plugins
fcb5b5c4 751 var i, j, group, plugin;
55c0403c 752 for (i = 0; i < params.plugins.length; i++) {
fcb5b5c4 753 group = params.plugins[i].group;
55c0403c 754 for (j = 0; j < params.plugins[i].plugins.length; j++) {
fcb5b5c4 755 plugin = params.plugins[i].plugins[j];
48bdf86f 756 plugin.params.elementid = params.elementid;
55c0403c
DW
757 plugin.params.group = group;
758
759 M['atto_' + plugin.name].init(plugin.params);
760 }
761 }
fcb5b5c4
DW
762
763 // Let the plugins run some init code once all plugins are in the page.
764 for (i = 0; i < params.plugins.length; i++) {
765 group = params.plugins[i].group;
766 for (j = 0; j < params.plugins[i].plugins.length; j++) {
767 plugin = params.plugins[i].plugins[j];
768 plugin.params.elementid = params.elementid;
769 plugin.params.group = group;
770
771 if (typeof M['atto_' + plugin.name].after_init !== 'undefined') {
772 M['atto_' + plugin.name].after_init(plugin.params);
773 }
774 }
775 }
3ee53a42
DW
776
777 },
778
779 /**
780 * Add code to trigger the a set of custom events on either the toolbar, or the contenteditable node
781 * that can be listened to by plugins (or even this class).
782 * @param string elementid - the id of the textarea we created this editor from.
783 */
784 publish_events : function() {
67d3fe45
SH
785 if (!this.eventspublished) {
786 Y.publish('atto:selectionchanged', {prefix: 'atto'});
787 Y.delegate(['mouseup', 'keyup', 'focus'], this._has_selection_changed, document.body, '.' + CSS.CONTENT, this);
788 this.eventspublished = true;
789 }
adca7326
DW
790 },
791
792 /**
793 * The text in the contenteditable region has been updated,
794 * clean and copy the buffer to the text area.
795 * @param string elementid - the id of the textarea we created this editor from.
796 */
797 text_updated : function(elementid) {
48bdf86f 798 var textarea = M.editor_atto.get_textarea_node(elementid),
adca7326
DW
799 cleancontent = this.get_clean_html(elementid);
800 textarea.set('value', cleancontent);
dad09216
FM
801 // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
802 textarea.simulate('change');
adca7326
DW
803 // Trigger handlers for this action.
804 var i = 0;
805 if (elementid in M.editor_atto.textupdatedhandlers) {
806 for (i = 0; i < M.editor_atto.textupdatedhandlers[elementid].length; i++) {
807 var callback = M.editor_atto.textupdatedhandlers[elementid][i];
808 callback(elementid);
809 }
810 }
811 },
812
813 /**
814 * Remove all YUI ids from the generated HTML.
815 * @param string elementid - the id of the textarea we created this editor from.
816 * @return string HTML stripped of YUI ids
817 */
818 get_clean_html : function(elementid) {
48bdf86f 819 var atto = M.editor_atto.get_editable_node(elementid).cloneNode(true);
adca7326
DW
820
821 Y.each(atto.all('[id]'), function(node) {
822 var id = node.get('id');
823 if (id.indexOf('yui') === 0) {
824 node.removeAttribute('id');
825 }
826 });
827
d088a835
DW
828 // Remove any and all nasties from source.
829 atto.cleanHTML();
830
86a83e3a
DW
831 // Revert untouched editor contents to an empty string.
832 if (atto.getHTML() === '<p></p>' || atto.getHTML() === '<p><br></p>') {
833 atto.setHTML('');
834 }
835
adca7326
DW
836 return atto.getHTML();
837 },
838
839 /**
840 * Implement arrow key navigation for the buttons in the toolbar.
841 * @param Event e - the keyboard event.
842 * @param string elementid - the id of the textarea we created this editor from.
843 */
844 keyboard_navigation : function(e, elementid) {
845 var buttons,
846 current,
847 currentid,
48bdf86f
DW
848 currentindex,
849 toolbar = M.editor_atto.get_toolbar_node(elementid);
adca7326
DW
850
851 e.preventDefault();
852
fcb5b5c4
DW
853 // This workaround is because we cannot do ".atto_group:not([hidden]) button" in ie8 (even with selector-css3).
854 // Create an empty NodeList.
855 buttons = toolbar.all('empty');
856 toolbar.all('.atto_group').each(function(group) {
857 if (!group.hasAttribute('hidden')) {
858 // Append the visible buttons to the buttons list.
859 buttons = buttons.concat(group.all('button'));
860 }
861 });
862 // The currentid is the id of the previously selected button.
48bdf86f 863 currentid = toolbar.getAttribute('aria-activedescendant');
adca7326
DW
864 if (!currentid) {
865 return;
866 }
fcb5b5c4 867 // We only ever want one button with a tabindex of 0.
adca7326
DW
868 current = Y.one('#' + currentid);
869 current.setAttribute('tabindex', '-1');
870
871 currentindex = buttons.indexOf(current);
872
873 if (e.keyCode === 37) {
874 // Left
875 currentindex--;
876 if (currentindex < 0) {
877 currentindex = buttons.size()-1;
878 }
879 } else {
880 // Right
881 currentindex++;
882 if (currentindex >= buttons.size()) {
883 currentindex = 0;
884 }
885 }
fcb5b5c4 886 // Currentindex has been updated to point to the new button.
adca7326
DW
887 current = buttons.item(currentindex);
888 current.setAttribute('tabindex', '0');
889 current.focus();
48bdf86f 890 toolbar.setAttribute('aria-activedescendant', current.generateID());
adca7326
DW
891 },
892
b269f635
DW
893 /**
894 * Should we show the filepicker for this filetype?
895 *
896 * @param string elementid for this editor instance.
897 * @param string type The media type for the file picker
898 * @return boolean
899 */
900 can_show_filepicker : function(elementid, type) {
901 var options = M.editor_atto.filepickeroptions[elementid];
902 return ((typeof options[type]) !== "undefined");
903 },
904
adca7326
DW
905 /**
906 * Show the filepicker.
907 * @param string elementid for this editor instance.
908 * @param string type The media type for the file picker
909 * @param function callback
910 */
911 show_filepicker : function(elementid, type, callback) {
912 Y.use('core_filepicker', function (Y) {
913 var options = M.editor_atto.filepickeroptions[elementid][type];
914
915 options.formcallback = callback;
adca7326
DW
916
917 M.core_filepicker.show(Y, options);
918 });
919 },
920
921 /**
922 * Create a cross browser selection object that represents a yui node.
923 * @param Node yui node for the selection
d321f68b 924 * @return rangy.Range[]
adca7326
DW
925 */
926 get_selection_from_node: function(node) {
d321f68b
DW
927 var range = rangy.createRange();
928 range.selectNode(node.getDOMNode());
929 return [range];
adca7326
DW
930 },
931
26f8822d
DW
932 /**
933 * Save the current selection on blur, allows more reliable keyboard navigation.
934 * @param Y.Event event
935 * @param string elementid
936 */
67d3fe45
SH
937 save_selection : function(event) {
938 var elementid = event.elementid;
939 if (elementid && this.is_active(elementid)) {
d321f68b 940 this.selections[elementid] = this.get_selection();
26f8822d
DW
941 }
942 },
943
944 /**
945 * Restore any current selection when the editor gets focus again.
946 * @param Y.Event event
947 * @param string elementid
948 */
949 restore_selection : function(event, elementid) {
26f8822d
DW
950 if (!this.focusfromclick) {
951 if (typeof this.selections[elementid] !== "undefined") {
952 this.set_selection(this.selections[elementid]);
953 }
954 }
955 this.focusfromclick = false;
956 },
957
adca7326
DW
958 /**
959 * Get the selection object that can be passed back to set_selection.
d321f68b 960 * @return rangy.Range[]
adca7326
DW
961 */
962 get_selection : function() {
d321f68b 963 return rangy.getSelection().getAllRanges();
adca7326
DW
964 },
965
966 /**
967 * Check that a YUI node it at least partly contained by the selection.
adca7326
DW
968 * @param Y.Node node
969 * @return boolean
970 */
971 selection_contains_node : function(node) {
d321f68b 972 return rangy.getSelection().containsNode(node.getDOMNode(), true);
adca7326
DW
973 },
974
3ee53a42
DW
975 /**
976 * Runs a selector on each node in the selection and will only return true if all nodes
977 * in the selection match the filter.
978 *
979 * @param {String} elementid
980 * @param {String} selector
981 * @param {NodeList} selectednodes (Optional) - for performance we can pass this instead of looking it up.
d321f68b 982 * @param {Boolean} requireall (Optional) - used to specify that any "any" match is good enough. Defaults to true.
3ee53a42
DW
983 * @return {Boolean}
984 */
d321f68b
DW
985 selection_filter_matches : function(elementid, selector, selectednodes, requireall) {
986 if ((typeof requireall) === 'undefined') {
987 requireall = true;
988 }
3ee53a42
DW
989 if (!selectednodes) {
990 // Find this because it was not passed as a param.
991 selectednodes = M.editor_atto.get_selected_nodes(elementid);
992 }
d321f68b 993 var allmatch = selectednodes.size() > 0, anymatch = false;
67d3fe45
SH
994 selectednodes.each(function(node){
995 // Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
d321f68b
DW
996 if (requireall) {
997 // Check for at least one failure.
998 if (!allmatch || !node.ancestor(selector, true, '#' + elementid + "editable")) {
999 allmatch = false;
1000 }
1001 } else {
1002 // Check for at least one match.
1003 if (!anymatch && node.ancestor(selector, true, '#' + elementid + "editable")) {
1004 anymatch = true;
1005 }
3ee53a42 1006 }
67d3fe45 1007 }, this);
d321f68b
DW
1008 if (requireall) {
1009 return allmatch;
1010 } else {
1011 return anymatch;
1012 }
3ee53a42
DW
1013 },
1014
1015 /**
67d3fe45
SH
1016 * Get the deepest possible list of nodes in the current selection.
1017 *
3ee53a42 1018 * @param {String} elementid
3ee53a42
DW
1019 * @return Y.NodeList
1020 */
67d3fe45 1021 get_selected_nodes : function(elementid) {
d321f68b
DW
1022 var results = new Y.NodeList(),
1023 nodes,
1024 selection,
67d3fe45 1025 range,
d321f68b
DW
1026 node,
1027 i;
1028
1029 selection = rangy.getSelection();
1030
1031 if (selection.rangeCount) {
1032 range = selection.getRangeAt(0);
1033 } else {
1034 // Empty range.
1035 range = rangy.createRange();
67d3fe45 1036 }
d321f68b
DW
1037
1038 if (range.collapsed) {
1039 range.selectNode(range.commonAncestorContainer);
1040 }
1041
1042 nodes = range.getNodes();
1043
1044 for (i = 0; i < nodes.length; i++) {
1045 node = Y.one(nodes[i]);
1046 if (node.ancestor('#' + elementid + "editable")) {
1047 results.push(node);
67d3fe45
SH
1048 }
1049 }
1050 return results;
3ee53a42
DW
1051 },
1052
1053 /**
67d3fe45
SH
1054 * Returns true if the selection has changed since this method was last called.
1055 *
1056 * If the selection has changed we also fire the custom event atto:selectionchanged
1057 *
1058 * @method _has_selection_changed
1059 * @private
1060 * @param {EventFacade} e
1061 * @returns {Boolean}
3ee53a42 1062 */
67d3fe45 1063 _has_selection_changed : function(e) {
d321f68b 1064 var selection = rangy.getSelection(),
67d3fe45 1065 range,
d321f68b
DW
1066 changed = false;
1067
1068 if (selection.rangeCount) {
1069 range = selection.getRangeAt(0);
1070 } else {
1071 // Empty range.
1072 range = rangy.createRange();
67d3fe45 1073 }
d321f68b
DW
1074
1075 if (this.lastselection) {
1076 if (!this.lastselection.equals(range)) {
1077 changed = true;
1078 return this._fire_selectionchanged(e);
1079 }
67d3fe45 1080 }
d321f68b
DW
1081 this.lastselection = range;
1082 return changed;
67d3fe45 1083 },
3ee53a42 1084
67d3fe45
SH
1085 /**
1086 * Fires the atto:selectionchanged event.
1087 *
1088 * When the selectionchanged event gets fired three arguments are given:
1089 * - event : the original event that lead to this event being fired.
1090 * - elementid : the editor elementid for which this event is being fired.
1091 * - selectednodes : an array containing nodes that are entirely selected of contain partially selected content.
1092 *
1093 * @method _fire_selectionchanged
1094 * @private
1095 * @param {EventFacade} e
1096 */
1097 _fire_selectionchanged : function(e) {
1098 var editableid = e.currentTarget.get('id'),
1099 elementid = editableid.substring(0, editableid.length - 8),
1100 selectednodes = this.get_selected_nodes(elementid);
1101 this.fire('atto:selectionchanged', {
1102 event : e,
1103 elementid : elementid,
1104 selectedNodes : selectednodes
1105 });
1106 },
1107
adca7326
DW
1108 /**
1109 * Get the dom node representing the common anscestor of the selection nodes.
34f5867a 1110 * @return DOMNode or false
adca7326
DW
1111 */
1112 get_selection_parent_node : function() {
d321f68b
DW
1113 var selection = rangy.getSelection();
1114 if (selection.rangeCount) {
1115 return selection.getRangeAt(0).commonAncestorContainer;
34f5867a 1116 }
34f5867a 1117 return false;
adca7326
DW
1118 },
1119
adca7326
DW
1120 /**
1121 * Set the current selection. Used to restore a selection.
d321f68b 1122 * @param rangy.Range[] ranges - List of ranges in selection.
adca7326 1123 */
d321f68b
DW
1124 set_selection : function(ranges) {
1125 var selection = rangy.getSelection();
1126 selection.setRanges(ranges);
34f5867a
DW
1127 },
1128
1129 /**
1130 * Change the formatting for the current selection.
1131 * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
1132 *
1133 * @param {String} elementid - The editor elementid.
1134 * @param {String} blocktag - Change the block level tag to this. Empty string, means do not change the tag.
1135 * @param {Object} attributes - The keys and values for attributes to be added/changed in the block tag.
1136 * @return Y.Node - if there was a selection.
1137 */
1138 format_selection_block : function(elementid, blocktag, attributes) {
1139 // First find the nearest ancestor of the selection that is a block level element.
1140 var selectionparentnode = M.editor_atto.get_selection_parent_node(),
1141 boundary,
1142 cell,
1143 nearestblock,
1144 newcontent,
1145 match,
1146 replacement;
1147
1148 if (!selectionparentnode) {
1149 // No selection, nothing to format.
1150 return;
1151 }
1152
1153 boundary = M.editor_atto.get_editable_node(elementid);
1154
1155 selectionparentnode = Y.one(selectionparentnode);
1156
1157 // If there is a table cell in between the selectionparentnode and the boundary,
1158 // move the boundary to the table cell.
1159 // This is because we might have a table in a div, and we select some text in a cell,
1160 // want to limit the change in style to the table cell, not the entire table (via the outer div).
1161 cell = selectionparentnode.ancestor(function (node) {
1162 var tagname = node.get('tagName');
1163 if (tagname) {
1164 tagname = tagname.toLowerCase();
1165 }
1166 return (node === boundary) ||
1167 (tagname === 'td') ||
1168 (tagname === 'th');
1169 }, true);
1170
1171 if (cell) {
1172 // Limit the scope to the table cell.
1173 boundary = cell;
1174 }
1175
1176 nearestblock = selectionparentnode.ancestor(M.editor_atto.BLOCK_TAGS.join(', '), true);
1177 if (nearestblock) {
1178 // Check that the block is contained by the boundary.
1179 match = nearestblock.ancestor(function (node) {
1180 return node === boundary;
1181 }, false);
1182
1183 if (!match) {
1184 nearestblock = false;
1185 }
1186 }
1187
1188 // No valid block element - make one.
1189 if (!nearestblock) {
1190 // There is no block node in the content, wrap the content in a p and use that.
1191 newcontent = Y.Node.create('<p></p>');
1192 boundary.get('childNodes').each(function (child) {
1193 newcontent.append(child.remove());
1194 });
1195 boundary.append(newcontent);
1196 nearestblock = newcontent;
1197 }
1198
1199 // Guaranteed to have a valid block level element contained in the contenteditable region.
1200 // Change the tag to the new block level tag.
1201 if (blocktag && blocktag !== '') {
1202 // Change the block level node for a new one.
1203 replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
1204 // Copy all attributes.
1205 replacement.setAttrs(nearestblock.getAttrs());
1206 // Copy all children.
1207 nearestblock.get('childNodes').each(function (child) {
1208 child.remove();
1209 replacement.append(child);
1210 });
1211
1212 nearestblock.replace(replacement);
1213 nearestblock = replacement;
1214 }
1215
1216 // Set the attributes on the block level tag.
1217 if (attributes) {
1218 nearestblock.setAttrs(attributes);
1219 }
1220
1221 // Change the selection to the modified block. This makes sense when we might apply multiple styles
1222 // to the block.
1223 var selection = M.editor_atto.get_selection_from_node(nearestblock);
1224 M.editor_atto.set_selection(selection);
1225
1226 return nearestblock;
f6bef145
FM
1227 },
1228
1229 /**
1230 * Disable CSS styling.
1231 *
1232 * @return {Void}
1233 */
1234 disable_css_styling: function() {
1235 try {
1236 document.execCommand("styleWithCSS", 0, false);
1237 } catch (e1) {
1238 try {
1239 document.execCommand("useCSS", 0, true);
1240 } catch (e2) {
1241 try {
1242 document.execCommand('styleWithCSS', false, false);
1243 } catch (e3) {
1244 // We did our best.
1245 }
1246 }
1247 }
1248 },
1249
1250 /**
1251 * Enable CSS styling.
1252 *
1253 * @return {Void}
1254 */
1255 enable_css_styling: function() {
1256 try {
1257 document.execCommand("styleWithCSS", 0, true);
1258 } catch (e1) {
1259 try {
1260 document.execCommand("useCSS", 0, false);
1261 } catch (e2) {
1262 try {
1263 document.execCommand('styleWithCSS', false, true);
1264 } catch (e3) {
1265 // We did our best.
1266 }
1267 }
1268 }
2faf4c45
SH
1269 },
1270
1271 /**
1272 * Inserts the given HTML into the editable content at the currently focused point.
d321f68b 1273 * @param String html
2faf4c45
SH
1274 */
1275 insert_html_at_focus_point: function(html) {
1276 // We check document.selection here as the insertHTML exec command wan't available in IE until version 11.
1277 // In IE document.selection was removed at from version 11. Making it an easy affordable check.
d321f68b
DW
1278 var selection = rangy.getSelection(),
1279 range,
1280 node = Y.Node.create(html);
1281 if (selection.rangeCount) {
1282 range = selection.getRangeAt(0);
1283 }
1284 if (range) {
1285 range.deleteContents();
1286 range.insertNode(node.getDOMNode());
2faf4c45 1287 }
bed1abbc
AD
1288 },
1289
1290 /**
1291 * Change the formatting for the current selection.
1292 * Wraps the selection in spans and adds the provides classes.
1293 *
1294 * If the selection covers multiple block elements, multiple spans will be inserted to preserve the original structure.
1295 *
1296 * @method toggle_inline_selection_class
1297 * @param {String} elementid - The editor elementid.
1298 * @param {Array} toggleclasses - Class names to be toggled on or off.
1299 */
1300 toggle_inline_selection_class: function(elementid, toggleclasses) {
1301 var selectionparentnode = M.editor_atto.get_selection_parent_node(),
1302 root = Y.one('body'),
1303 nodes,
1304 items = [],
1305 parentspan,
1306 currentnode,
1307 newnode,
1308 i = 0;
1309
1310 if (!selectionparentnode) {
1311 // No selection, nothing to format.
1312 return;
1313 }
1314
1315 // Add a bogus fontname as the browsers handle inserting fonts into multiple blocks correctly.
1316 document.execCommand('fontname', false, M.editor_atto.PLACEHOLDER_FONTNAME);
1317 nodes = root.all(M.editor_atto.ALL_NODES_SELECTOR);
1318
1319 // Create a list of all nodes that have our bogus fontname.
1320 nodes.each(function(node, index) {
1321 if (node.getStyle(M.editor_atto.FONT_FAMILY) === M.editor_atto.PLACEHOLDER_FONTNAME) {
1322 node.setStyle(M.editor_atto.FONT_FAMILY, '');
1323 if (!node.compareTo(root)) {
1324 items.push(Y.Node.getDOMNode(nodes.item(index)));
1325 }
1326 }
1327 });
1328
1329 // Replace the fontname tags with spans
1330 for (i = 0; i < items.length; i++) {
1331 currentnode = Y.one(items[i]);
adca7326 1332
bed1abbc
AD
1333 // Check for an existing span to add the nolink class to.
1334 parentspan = currentnode.ancestor('.editor_atto_content span');
1335 if (!parentspan) {
1336 newnode = Y.Node.create('<span></span>');
1337 newnode.append(items[i].innerHTML);
1338 currentnode.replace(newnode);
1339
1340 currentnode = newnode;
1341 } else {
1342 currentnode = parentspan;
1343 }
1344
1345 // Toggle the classes on the spans.
1346 for (var j = 0; j < toggleclasses.length; j++) {
1347 currentnode.toggleClass(toggleclasses[j]);
1348 }
1349 }
1350 }
adca7326 1351};
67d3fe45
SH
1352
1353// The editor_atto is publishing custom events that can be subscribed to.
1354Y.augment(M.editor_atto, Y.EventTarget);
adca7326
DW
1355var CONTROLMENU_NAME = "Controlmenu",
1356 CONTROLMENU;
1357
1358/**
1359 * CONTROLMENU
1360 * This is a drop down list of buttons triggered (and aligned to) a button.
1361 *
1362 * @namespace M.editor_atto.controlmenu
1363 * @class controlmenu
1364 * @constructor
1365 * @extends M.core.dialogue
1366 */
1367CONTROLMENU = function(config) {
1368 config.draggable = false;
55c0403c 1369 config.center = false;
adca7326
DW
1370 config.width = 'auto';
1371 config.lightbox = false;
adca7326 1372 config.footerContent = '';
05843fd3
DW
1373 config.hideOn = [ { eventName: 'clickoutside' } ];
1374
adca7326
DW
1375 CONTROLMENU.superclass.constructor.apply(this, [config]);
1376};
1377
1378Y.extend(CONTROLMENU, M.core.dialogue, {
1379
1380 /**
1381 * Initialise the menu.
1382 *
1383 * @method initializer
1384 * @return void
1385 */
1386 initializer : function(config) {
1387 var body, headertext, bb;
1388 CONTROLMENU.superclass.initializer.call(this, config);
1389
1390 bb = this.get('boundingBox');
1391 bb.addClass('editor_atto_controlmenu');
1392
1393 // Close the menu when clicked outside (excluding the button that opened the menu).
1394 body = this.bodyNode;
1395
1396 headertext = Y.Node.create('<h3/>');
1397 headertext.addClass('accesshide');
1398 headertext.setHTML(this.get('headerText'));
1399 body.prepend(headertext);
adca7326
DW
1400 }
1401
1402}, {
1403 NAME : CONTROLMENU_NAME,
1404 ATTRS : {
1405 /**
1406 * The header for the drop down (only accessible to screen readers).
1407 *
1408 * @attribute headerText
1409 * @type String
1410 * @default ''
1411 */
1412 headerText : {
1413 value : ''
1414 }
1415
1416 }
1417});
1418
1419M.editor_atto = M.editor_atto || {};
1420M.editor_atto.controlmenu = CONTROLMENU;
d088a835
DW
1421/**
1422 * Class for cleaning ugly HTML.
1423 * Rewritten JS from jquery-clean plugin.
1424 *
1425 * @module editor_atto
1426 * @chainable
1427 */
1428function cleanHTML() {
1429 var cleaned = this.getHTML();
1430
1431 // What are we doing ?
1432 // We are cleaning random HTML from all over the shop into a set of useful html suitable for content.
1433 // We are allowing styles etc, but not e.g. font tags, class="MsoNormal" etc.
1434
1435 var rules = [
1436 // Source: "http://stackoverflow.com/questions/2875027/clean-microsoft-word-pasted-text-using-javascript"
1437 // Source: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
1438
1439 // Remove all HTML comments.
1440 {regex: /<!--[\s\S]*?-->/gi, replace: ""},
1441 // Source: "http://www.1stclassmedia.co.uk/developers/clean-ms-word-formatting.php"
1442 // Remove <?xml>, <\?xml>.
1443 {regex: /<\\?\?xml[^>]*>/gi, replace: ""},
1444 // Remove <o:blah>, <\o:blah>.
1445 {regex: /<\/?\w+:[^>]*>/gi, replace: ""}, // e.g. <o:p...
1446 // Remove MSO-blah, MSO:blah (e.g. in style attributes)
1447 {regex: /\s*MSO[-:][^;"']*;?/gi, replace: ""},
1448 // Remove empty spans
1449 {regex: /<span[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""},
1450 // Remove class="Msoblah"
1451 {regex: /class="Mso[^"]*"/gi, replace: ""},
1452
1453 // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
1454 // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body.
1455 {regex: /<(\/?title|\/?meta|\/?style|\/?st\d|\/?head|\/?font|\/?html|\/?body|!\[)[^>]*?>/gi, replace: ""},
1456
1457 // Source: "http://www.tim-jarrett.com/labs_javascript_scrub_word.php"
1458 // Replace extended chars with simple text.
1459 {regex: new RegExp(String.fromCharCode(8220), 'gi'), replace: '"'},
1460 {regex: new RegExp(String.fromCharCode(8216), 'gi'), replace: "'"},
1461 {regex: new RegExp(String.fromCharCode(8217), 'gi'), replace: "'"},
1462 {regex: new RegExp(String.fromCharCode(8211), 'gi'), replace: '-'},
1463 {regex: new RegExp(String.fromCharCode(8212), 'gi'), replace: '--'},
1464 {regex: new RegExp(String.fromCharCode(189), 'gi'), replace: '1/2'},
1465 {regex: new RegExp(String.fromCharCode(188), 'gi'), replace: '1/4'},
1466 {regex: new RegExp(String.fromCharCode(190), 'gi'), replace: '3/4'},
1467 {regex: new RegExp(String.fromCharCode(169), 'gi'), replace: '(c)'},
1468 {regex: new RegExp(String.fromCharCode(174), 'gi'), replace: '(r)'},
1469 {regex: new RegExp(String.fromCharCode(8230), 'gi'), replace: '...'}
1470 ];
1471
1472 var i = 0, rule;
1473
1474 for (i = 0; i < rules.length; i++) {
1475 rule = rules[i];
1476 cleaned = cleaned.replace(rule.regex, rule.replace);
1477 }
1478
1479 this.setHTML(cleaned);
1480 return this;
1481}
1482
1483Y.Node.addMethod("cleanHTML", cleanHTML);
1484Y.NodeList.importMethod(Y.Node.prototype, "cleanHTML");
adca7326
DW
1485
1486
3ee53a42
DW
1487}, '@VERSION@', {
1488 "requires": [
1489 "node",
1490 "io",
1491 "overlay",
1492 "escape",
1493 "event",
1494 "event-simulate",
1495 "event-custom",
1496 "yui-throttle",
d321f68b
DW
1497 "moodle-core-notification-dialogue",
1498 "moodle-editor_atto-rangy"
3ee53a42
DW
1499 ]
1500});