MDL-44219 editor_atto: reinvent the event wheel for the atto editor
[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'],
bed1abbc
AD
87
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
143 /**
144 * The number of selection ranges when the selection was last recorded.
145 * @param lastselectioncount
146 * @type integer
147 */
148 lastselectioncount : 0,
149
150 /**
151 * Set to true after the first editor has been initialised.
152 *
153 * "The first transport is away. The first transport is away."
154 * ―Lieutenant Romas Navander, during the Battle of Hoth
155 *
156 * @param firstinit
157 * @type boolean
158 */
159 firstinit : true,
160
adca7326
DW
161 /**
162 * Toggle a menu.
163 * @param event e
164 */
165 showhide_menu_handler : function(e) {
166 e.preventDefault();
167 var disabled = this.getAttribute('disabled');
168 var overlayid = this.getAttribute('data-menu');
169 var overlay = M.editor_atto.menus[overlayid];
170 var menu = overlay.get('bodyContent');
171 if (overlay.get('visible') || disabled) {
172 overlay.hide();
173 menu.detach('clickoutside');
174 } else {
175 menu.on('clickoutside', function(ev) {
176 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
177 if (overlay.get('visible')) {
178 menu.detach('clickoutside');
179 overlay.hide();
180 }
181 }
182 }, this);
0fa78b80
DW
183
184 overlay.align(Y.one(Y.config.doc.body), [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
adca7326 185 overlay.show();
0fa78b80
DW
186 var icon = e.target.ancestor('button', true).one('img');
187 overlay.align(icon, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
26f8822d 188 overlay.get('boundingBox').one('a').focus();
adca7326
DW
189 }
190 },
191
192 /**
193 * Handle clicks on editor buttons.
194 * @param event e
195 */
196 buttonclicked_handler : function(e) {
197 var elementid = this.getAttribute('data-editor');
198 var plugin = this.getAttribute('data-plugin');
5ec54dd1 199 var button = this.getAttribute('data-button');
adca7326
DW
200 var handler = this.getAttribute('data-handler');
201 var overlay = M.editor_atto.menus[plugin + '_' + elementid];
fcb5b5c4
DW
202 var toolbar = M.editor_atto.get_toolbar_node(elementid);
203 var currentid = toolbar.getAttribute('aria-activedescendant');
204
205 // Right now, currentid is the id of the previously selected button.
206 if (currentid) {
207 current = Y.one('#' + currentid);
208 // We only ever want one button with a tabindex of 0 at any one time.
209 current.setAttribute('tabindex', '-1');
210 }
211 this.setAttribute('tabindex', 0);
212 // And update the activedescendant to point at the currently selected button.
213 toolbar.setAttribute('aria-activedescendant', this.generateID());
adca7326
DW
214
215 if (overlay) {
216 overlay.hide();
217 }
218
5ec54dd1 219 if (M.editor_atto.is_enabled(elementid, plugin, button)) {
adca7326
DW
220 // Pass it on.
221 handler = M.editor_atto.buttonhandlers[handler];
222 return handler(e, elementid);
223 }
224 },
225
adca7326
DW
226 /**
227 * Disable all buttons and menus in the toolbar.
228 * @param string elementid, the element id of this editor.
229 */
230 disable_all_widgets : function(elementid) {
48bdf86f 231 var plugin, element, toolbar = M.editor_atto.get_toolbar_node(elementid);
adca7326 232 for (plugin in M.editor_atto.widgets) {
48bdf86f 233 element = toolbar.one('.atto_' + plugin + '_button');
adca7326
DW
234
235 if (element) {
236 element.setAttribute('disabled', 'true');
237 }
238 }
239 },
240
48bdf86f
DW
241 /**
242 * Get the node of the original textarea element that this editor replaced.
243 *
244 * @param string elementid, the element id of this editor.
245 * @return Y.Node
246 */
247 get_textarea_node : function(elementid) {
248 // Note - it is not safe to use a CSS selector like '#' + elementid
249 // because the id may have colons in it - e.g. quiz.
250 return Y.one(document.getElementById(elementid));
251 },
252
253 /**
254 * Get the node of the toolbar container for this editor.
255 *
256 * @param string elementid, the element id of this editor.
257 * @return Y.Node
258 */
259 get_toolbar_node : function(elementid) {
260 // Note - it is not safe to use a CSS selector like '#' + elementid
261 // because the id may have colons in it - e.g. quiz.
262 return Y.one(document.getElementById(elementid + '_toolbar'));
263 },
264
265 /**
266 * Get the node of the contenteditable container for this editor.
267 *
268 * @param string elementid, the element id of this editor.
269 * @return Y.Node
270 */
271 get_editable_node : function(elementid) {
272 // Note - it is not safe to use a CSS selector like '#' + elementid
273 // because the id may have colons in it - e.g. quiz.
274 return Y.one(document.getElementById(elementid + 'editable'));
275 },
276
277 /**
278 * Determine if the specified toolbar button/menu is enabled.
279 * @param string elementid, the element id of this editor.
280 * @param string plugin, the plugin that created the button/menu.
5ec54dd1 281 * @param string buttonname, optional - used when a plugin has multiple buttons.
48bdf86f 282 */
5ec54dd1
DW
283 is_enabled : function(elementid, plugin, button) {
284 var buttonpath = plugin;
285 if (button) {
286 buttonpath += '_' + button;
287 }
288 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
48bdf86f
DW
289
290 return !element.hasAttribute('disabled');
291 },
292
3ee53a42
DW
293 /**
294 * Determine if the specified toolbar button/menu is highlighted.
295 * @param string elementid, the element id of this editor.
296 * @param string plugin, the plugin that created the button/menu.
297 * @param string buttonname, optional - used when a plugin has multiple buttons.
298 */
299 is_highlighted : function(elementid, plugin, button) {
300 var buttonpath = plugin;
301 if (button) {
302 buttonpath += '_' + button;
303 }
304 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
305
306 return !element.hasClass(CSS.HIGHLIGHT);
307 },
308
adca7326
DW
309 /**
310 * Enable a single widget in the toolbar.
311 * @param string elementid, the element id of this editor.
312 * @param string plugin, the name of the plugin that created the widget.
5ec54dd1 313 * @param string buttonname, optional - used when a plugin has multiple buttons.
adca7326 314 */
5ec54dd1
DW
315 enable_widget : function(elementid, plugin, button) {
316 var buttonpath = plugin;
317 if (button) {
318 buttonpath += '_' + button;
319 }
320 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
adca7326
DW
321
322 if (element) {
323 element.removeAttribute('disabled');
324 }
325 },
326
3ee53a42
DW
327 /**
328 * Highlight a single widget in the toolbar.
329 * @param string elementid, the element id of this editor.
330 * @param string plugin, the name of the plugin that created the widget.
331 * @param string buttonname, optional - used when a plugin has multiple buttons.
332 */
333 add_widget_highlight : function(elementid, plugin, button) {
334 var buttonpath = plugin;
335 if (button) {
336 buttonpath += '_' + button;
337 }
338 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
339
340 if (element) {
341 element.addClass(CSS.HIGHLIGHT);
342 }
343 },
344
345 /**
346 * Unhighlight a single widget in the toolbar.
347 * @param string elementid, the element id of this editor.
348 * @param string plugin, the name of the plugin that created the widget.
349 * @param string buttonname, optional - used when a plugin has multiple buttons.
350 */
351 remove_widget_highlight : function(elementid, plugin, button) {
352 var buttonpath = plugin;
353 if (button) {
354 buttonpath += '_' + button;
355 }
356 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
357
358 if (element) {
359 element.removeClass(CSS.HIGHLIGHT);
360 }
361 },
362
adca7326
DW
363 /**
364 * Enable all buttons and menus in the toolbar.
365 * @param string elementid, the element id of this editor.
366 */
367 enable_all_widgets : function(elementid) {
5ec54dd1
DW
368 var path, element;
369 for (path in M.editor_atto.widgets) {
370 element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + path + '_button');
adca7326
DW
371
372 if (element) {
373 element.removeAttribute('disabled');
374 }
375 }
376 },
377
378 /**
379 * Add a content update handler to be called whenever the content is updated.
adca7326
DW
380 *
381 * @param string elementid - the id of the textarea we created this editor from.
382 * @handler function callback - The function to do the cleaning.
383 * @param object context - the context to set for the callback.
384 * @handler function handler - A function to call when the button is clicked.
385 */
386 add_text_updated_handler : function(elementid, callback) {
387 if (!(elementid in M.editor_atto.textupdatedhandlers)) {
388 M.editor_atto.textupdatedhandlers[elementid] = [];
389 }
390 M.editor_atto.textupdatedhandlers[elementid].push(callback);
391 },
392
393 /**
394 * Add a button to the toolbar belonging to the editor for element with id "elementid".
395 * @param string elementid - the id of the textarea we created this editor from.
396 * @param string plugin - the plugin defining the button
397 * @param string icon - the html used for the content of the button
398 * @param string groupname - the group the button should be appended to.
399 * @param array entries - List of menu entries with the string (entry.text) and the handlers (entry.handler).
5ec54dd1
DW
400 * @param string buttonname - (optional) a name for the button. Required if a plugin creates more than one button.
401 * @param string buttontitle - (optional) a title for the button. Required if a plugin creates more than one button.
0fa78b80 402 * @param int overlaywidth - the overlay width size in 'ems'.
534cf7b7 403 * @param string menucolor - menu icon background color
adca7326 404 */
5ec54dd1 405 add_toolbar_menu : function(elementid, plugin, iconurl, groupname, entries, buttonname, buttontitle, overlaywidth, menucolor) {
48bdf86f
DW
406 var toolbar = M.editor_atto.get_toolbar_node(elementid),
407 group = toolbar.one('.atto_group.' + groupname + '_group'),
adca7326
DW
408 currentfocus,
409 button,
5ec54dd1 410 buttonpath,
adca7326
DW
411 expimgurl;
412
5ec54dd1
DW
413 if (buttonname) {
414 buttonpath = plugin + '_' + buttonname;
415 } else {
416 buttonname = '';
417 buttonpath = plugin;
418 }
419
420 if (!buttontitle) {
421 buttontitle = M.util.get_string('pluginname', 'atto_' + plugin);
422 }
423
534cf7b7
RW
424 if ((typeof overlaywidth) === 'undefined') {
425 overlaywidth = '14';
426 }
427 if ((typeof menucolor) === 'undefined') {
428 menucolor = 'transparent';
429 }
430
adca7326
DW
431 if (!group) {
432 group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
433 toolbar.append(group);
434 }
adca7326 435 expimgurl = M.util.image_url('t/expanded', 'moodle');
5ec54dd1 436 button = Y.Node.create('<button class="atto_' + buttonpath + '_button atto_hasmenu" ' +
adca7326
DW
437 'data-editor="' + Y.Escape.html(elementid) + '" ' +
438 'tabindex="-1" ' +
0012a946 439 'type="button" ' +
5ec54dd1
DW
440 'data-menu="' + buttonpath + '_' + elementid + '" ' +
441 'title="' + Y.Escape.html(buttontitle) + '">' +
534cf7b7
RW
442 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" '+
443 'style="background-color:' + menucolor + ';" src="' + iconurl + '"/>' +
adca7326
DW
444 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + expimgurl + '"/>' +
445 '</button>');
446
447 group.append(button);
448
449 currentfocus = toolbar.getAttribute('aria-activedescendant');
450 if (!currentfocus) {
fcb5b5c4 451 // Initially set the first button in the toolbar to be the default on keyboard focus.
adca7326
DW
452 button.setAttribute('tabindex', '0');
453 toolbar.setAttribute('aria-activedescendant', button.generateID());
454 }
455
456 // Save the name of the plugin.
5ec54dd1 457 M.editor_atto.widgets[buttonpath] = buttonpath;
adca7326 458
5ec54dd1 459 var menu = Y.Node.create('<div class="atto_' + buttonpath + '_menu' +
534cf7b7
RW
460 ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"' +
461 ' style="min-width:' + (overlaywidth-2) + 'em"' +
462 '"></div>');
adca7326
DW
463 var i = 0, entry = {};
464
465 for (i = 0; i < entries.length; i++) {
466 entry = entries[i];
467
468 menu.append(Y.Node.create('<div class="atto_menuentry">' +
5ec54dd1 469 '<a href="#" class="atto_' + buttonpath + '_action_' + i + '" ' +
adca7326
DW
470 'data-editor="' + Y.Escape.html(elementid) + '" ' +
471 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
5ec54dd1
DW
472 'data-button="' + Y.Escape.html(buttonname) + '" ' +
473 'data-handler="' + Y.Escape.html(buttonpath + '_action_' + i) + '">' +
adca7326
DW
474 entry.text +
475 '</a>' +
476 '</div>'));
477 if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
5ec54dd1
DW
478 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + buttonpath + '_action_' + i);
479 // Activate the link on space or enter.
480 Y.one('body').delegate('key', M.editor_atto.buttonclicked_handler, '32,enter', '.atto_' + buttonpath + '_action_' + i);
481 M.editor_atto.buttonhandlers[buttonpath + '_action_' + i] = entry.handler;
adca7326
DW
482 }
483 }
484
5ec54dd1
DW
485 if (!M.editor_atto.buttonhandlers[buttonpath]) {
486 Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + buttonpath + '_button');
487 M.editor_atto.buttonhandlers[buttonpath] = true;
adca7326
DW
488 }
489
490 var overlay = new M.core.dialogue({
491 bodyContent : menu,
492 visible : false,
534cf7b7 493 width: overlaywidth + 'em',
adca7326 494 closeButton: false,
117f9f04
AN
495 center : false,
496 render: true
adca7326
DW
497 });
498
5ec54dd1 499 M.editor_atto.menus[buttonpath + '_' + elementid] = overlay;
55c0403c 500 overlay.align(button, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
adca7326
DW
501 overlay.headerNode.hide();
502 },
503
504 /**
505 * Add a button to the toolbar belonging to the editor for element with id "elementid".
506 * @param string elementid - the id of the textarea we created this editor from.
507 * @param string plugin - the plugin defining the button.
55c0403c 508 * @param string icon - the url to the image for the icon
adca7326
DW
509 * @param string groupname - the group the button should be appended to.
510 * @handler function handler- A function to call when the button is clicked.
5ec54dd1
DW
511 * @param string buttonname - (optional) a name for the button. Required if a plugin creates more than one button.
512 * @param string buttontitle - (optional) a title for the button. Required if a plugin creates more than one button.
adca7326 513 */
5ec54dd1 514 add_toolbar_button : function(elementid, plugin, iconurl, groupname, handler, buttonname, buttontitle) {
48bdf86f
DW
515 var toolbar = M.editor_atto.get_toolbar_node(elementid),
516 group = toolbar.one('.atto_group.' + groupname + '_group'),
adca7326 517 button,
5ec54dd1 518 buttonpath,
55c0403c 519 currentfocus;
adca7326 520
5ec54dd1
DW
521 if (buttonname) {
522 buttonpath = plugin + '_' + buttonname;
523 } else {
524 buttonname = '';
525 buttonpath = plugin;
526 }
527
528 if (!buttontitle) {
529 buttontitle = M.util.get_string('pluginname', 'atto_' + plugin);
fe0d2477
JM
530 }
531
adca7326
DW
532 if (!group) {
533 group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
534 toolbar.append(group);
535 }
5ec54dd1 536 button = Y.Node.create('<button class="atto_' + buttonpath + '_button" ' +
adca7326
DW
537 'data-editor="' + Y.Escape.html(elementid) + '" ' +
538 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
5ec54dd1 539 'data-button="' + Y.Escape.html(buttonname) + '" ' +
adca7326 540 'tabindex="-1" ' +
5ec54dd1
DW
541 'data-handler="' + Y.Escape.html(buttonpath) + '" ' +
542 'title="' + Y.Escape.html(buttontitle) + '">' +
55c0403c 543 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + iconurl + '"/>' +
adca7326
DW
544 '</button>');
545
546 group.append(button);
547
548 currentfocus = toolbar.getAttribute('aria-activedescendant');
549 if (!currentfocus) {
fcb5b5c4 550 // Initially set the first button in the toolbar to be the default on keyboard focus.
adca7326
DW
551 button.setAttribute('tabindex', '0');
552 toolbar.setAttribute('aria-activedescendant', button.generateID());
553 }
554
555 // We only need to attach this once.
5ec54dd1
DW
556 if (!M.editor_atto.buttonhandlers[buttonpath]) {
557 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + buttonpath + '_button');
558 M.editor_atto.buttonhandlers[buttonpath] = handler;
adca7326
DW
559 }
560
561 // Save the name of the plugin.
5ec54dd1 562 M.editor_atto.widgets[buttonpath] = buttonpath;
adca7326
DW
563
564 },
565
566 /**
567 * Work out if the cursor is in the editable area for this editor instance.
568 * @param string elementid of this editor
569 * @return bool
570 */
571 is_active : function(elementid) {
572 var selection = M.editor_atto.get_selection();
573
c9292b18 574 if (selection.length && selection.pop) {
adca7326
DW
575 selection = selection.pop();
576 }
577
578 var node = null;
579 if (selection.parentElement) {
580 node = Y.one(selection.parentElement());
581 } else {
582 node = Y.one(selection.startContainer);
583 }
584
34f5867a
DW
585 var editable = M.editor_atto.get_editable_node(elementid);
586
587 return node && editable.contains(node);
adca7326
DW
588 },
589
590 /**
591 * Focus on the editable area for this editor.
592 * @param string elementid of this editor
593 */
594 focus : function(elementid) {
48bdf86f 595 M.editor_atto.get_editable_node(elementid).focus();
adca7326
DW
596 },
597
86a83e3a
DW
598 /**
599 * Copy and clean the text from the textarea into the contenteditable div.
600 * If the text is empty, provide a default paragraph tag to hold the content.
601 *
602 * @param string elementid of this editor
603 */
604 update_from_textarea : function(elementid) {
605 var editable = M.editor_atto.get_editable_node(elementid);
606 var textarea = M.editor_atto.get_textarea_node(elementid);
607
608 // Clear it first.
609 editable.setHTML('');
610
611 // Copy text to editable div.
612 editable.append(textarea.get('value'));
613
614 // Clean it.
615 editable.cleanHTML();
616
617 // Insert a paragraph in the empty contenteditable div.
618 if (editable.getHTML() === '') {
619 if (Y.UA.ie && Y.UA.ie < 10) {
620 editable.setHTML('<p></p>');
621 } else {
622 editable.setHTML('<p><br></p>');
623 }
624 }
625 },
626
adca7326
DW
627 /**
628 * Initialise the editor
629 * @param object params for this editor instance.
630 */
631 init : function(params) {
67d3fe45
SH
632 // Some things we only want to do on the first init.
633 var firstinit = this.firstinit;
634 this.firstinit = false;
adca7326
DW
635 var wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
636 var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
637 'contenteditable="true" ' +
26f8822d 638 'role="textbox" ' +
adca7326 639 'spellcheck="true" ' +
26f8822d 640 'aria-live="off" ' +
bdfbdeeb
SH
641 'class="' + CSS.CONTENT + '" '+
642 'data-editor="' + params.elementid + '" />');
adca7326 643
26f8822d 644 var toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" id="' + params.elementid + '_toolbar" role="toolbar" aria-live="off"/>');
adca7326
DW
645
646 // Editable content wrapper.
647 var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
48bdf86f 648 var textarea = M.editor_atto.get_textarea_node(params.elementid);
26f8822d
DW
649 var label = Y.one('[for="' + params.elementid + '"]');
650
651 // Add a labelled-by attribute to the contenteditable.
652 if (label) {
653 label.generateID();
654 atto.setAttribute('aria-labelledby', label.get("id"));
655 toolbar.setAttribute('aria-labelledby', label.get("id"));
656 }
b269f635 657
adca7326
DW
658 content.appendChild(atto);
659
660 // Add everything to the wrapper.
661 wrapper.appendChild(toolbar);
662 wrapper.appendChild(content);
663
4c37c1f4 664 // Style the editor.
adca7326
DW
665 atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows'))) + 'em');
666
d088a835 667
adca7326
DW
668 // Add the toolbar and editable zone to the page.
669 textarea.get('parentNode').insert(wrapper, textarea);
26f8822d
DW
670
671 // Disable odd inline CSS styles.
f6bef145 672 M.editor_atto.disable_css_styling();
4c37c1f4 673
adca7326
DW
674 // Hide the old textarea.
675 textarea.hide();
3ee53a42 676
86a83e3a
DW
677 // Copy the text to the contenteditable div.
678 this.update_from_textarea(params.elementid);
679
3ee53a42
DW
680 this.publish_events();
681 atto.on('atto:selectionchanged', this.save_selection, this, params.elementid);
67d3fe45
SH
682 if (firstinit) {
683 // Aha this is the first initialisation.
684 // Publish our custom events.
685 this.publish_events();
686 // Start tracking selections.
687 this.on('atto:selectionchanged', this.save_selection, this);
688 }
689
26f8822d
DW
690 atto.on('focus', this.restore_selection, this, params.elementid);
691 // Do not restore selection when focus is from a click event.
692 atto.on('mousedown', function() { this.focusfromclick = true; }, this);
adca7326 693
26f8822d 694 // Copy the current value back to the textarea when focus leaves us and save the current selection.
adca7326 695 atto.on('blur', function() {
26f8822d 696 this.focusfromclick = false;
48bdf86f 697 this.text_updated(params.elementid);
adca7326
DW
698 }, this);
699
86a83e3a
DW
700 // Use paragraphs not divs.
701 if (document.queryCommandSupported('DefaultParagraphSeparator')) {
702 document.execCommand('DefaultParagraphSeparator', false, 'p');
703 }
adca7326
DW
704 // Listen for Arrow left and Arrow right keys.
705 Y.one(Y.config.doc.body).delegate('key',
706 this.keyboard_navigation,
707 'down:37,39',
48bdf86f 708 '#' + params.elementid + '_toolbar',
adca7326 709 this,
48bdf86f 710 params.elementid);
adca7326
DW
711
712 // Save the file picker options for later.
48bdf86f 713 M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
55c0403c
DW
714
715 // Init each of the plugins
fcb5b5c4 716 var i, j, group, plugin;
55c0403c 717 for (i = 0; i < params.plugins.length; i++) {
fcb5b5c4 718 group = params.plugins[i].group;
55c0403c 719 for (j = 0; j < params.plugins[i].plugins.length; j++) {
fcb5b5c4 720 plugin = params.plugins[i].plugins[j];
48bdf86f 721 plugin.params.elementid = params.elementid;
55c0403c
DW
722 plugin.params.group = group;
723
724 M['atto_' + plugin.name].init(plugin.params);
725 }
726 }
fcb5b5c4
DW
727
728 // Let the plugins run some init code once all plugins are in the page.
729 for (i = 0; i < params.plugins.length; i++) {
730 group = params.plugins[i].group;
731 for (j = 0; j < params.plugins[i].plugins.length; j++) {
732 plugin = params.plugins[i].plugins[j];
733 plugin.params.elementid = params.elementid;
734 plugin.params.group = group;
735
736 if (typeof M['atto_' + plugin.name].after_init !== 'undefined') {
737 M['atto_' + plugin.name].after_init(plugin.params);
738 }
739 }
740 }
3ee53a42
DW
741
742 },
743
744 /**
745 * Add code to trigger the a set of custom events on either the toolbar, or the contenteditable node
746 * that can be listened to by plugins (or even this class).
747 * @param string elementid - the id of the textarea we created this editor from.
748 */
749 publish_events : function() {
67d3fe45
SH
750 if (!this.eventspublished) {
751 Y.publish('atto:selectionchanged', {prefix: 'atto'});
752 Y.delegate(['mouseup', 'keyup', 'focus'], this._has_selection_changed, document.body, '.' + CSS.CONTENT, this);
753 this.eventspublished = true;
754 }
adca7326
DW
755 },
756
757 /**
758 * The text in the contenteditable region has been updated,
759 * clean and copy the buffer to the text area.
760 * @param string elementid - the id of the textarea we created this editor from.
761 */
762 text_updated : function(elementid) {
48bdf86f 763 var textarea = M.editor_atto.get_textarea_node(elementid),
adca7326
DW
764 cleancontent = this.get_clean_html(elementid);
765 textarea.set('value', cleancontent);
dad09216
FM
766 // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
767 textarea.simulate('change');
adca7326
DW
768 // Trigger handlers for this action.
769 var i = 0;
770 if (elementid in M.editor_atto.textupdatedhandlers) {
771 for (i = 0; i < M.editor_atto.textupdatedhandlers[elementid].length; i++) {
772 var callback = M.editor_atto.textupdatedhandlers[elementid][i];
773 callback(elementid);
774 }
775 }
776 },
777
778 /**
779 * Remove all YUI ids from the generated HTML.
780 * @param string elementid - the id of the textarea we created this editor from.
781 * @return string HTML stripped of YUI ids
782 */
783 get_clean_html : function(elementid) {
48bdf86f 784 var atto = M.editor_atto.get_editable_node(elementid).cloneNode(true);
adca7326
DW
785
786 Y.each(atto.all('[id]'), function(node) {
787 var id = node.get('id');
788 if (id.indexOf('yui') === 0) {
789 node.removeAttribute('id');
790 }
791 });
792
d088a835
DW
793 // Remove any and all nasties from source.
794 atto.cleanHTML();
795
86a83e3a
DW
796 // Revert untouched editor contents to an empty string.
797 if (atto.getHTML() === '<p></p>' || atto.getHTML() === '<p><br></p>') {
798 atto.setHTML('');
799 }
800
adca7326
DW
801 return atto.getHTML();
802 },
803
804 /**
805 * Implement arrow key navigation for the buttons in the toolbar.
806 * @param Event e - the keyboard event.
807 * @param string elementid - the id of the textarea we created this editor from.
808 */
809 keyboard_navigation : function(e, elementid) {
810 var buttons,
811 current,
812 currentid,
48bdf86f
DW
813 currentindex,
814 toolbar = M.editor_atto.get_toolbar_node(elementid);
adca7326
DW
815
816 e.preventDefault();
817
fcb5b5c4
DW
818 // This workaround is because we cannot do ".atto_group:not([hidden]) button" in ie8 (even with selector-css3).
819 // Create an empty NodeList.
820 buttons = toolbar.all('empty');
821 toolbar.all('.atto_group').each(function(group) {
822 if (!group.hasAttribute('hidden')) {
823 // Append the visible buttons to the buttons list.
824 buttons = buttons.concat(group.all('button'));
825 }
826 });
827 // The currentid is the id of the previously selected button.
48bdf86f 828 currentid = toolbar.getAttribute('aria-activedescendant');
adca7326
DW
829 if (!currentid) {
830 return;
831 }
fcb5b5c4 832 // We only ever want one button with a tabindex of 0.
adca7326
DW
833 current = Y.one('#' + currentid);
834 current.setAttribute('tabindex', '-1');
835
836 currentindex = buttons.indexOf(current);
837
838 if (e.keyCode === 37) {
839 // Left
840 currentindex--;
841 if (currentindex < 0) {
842 currentindex = buttons.size()-1;
843 }
844 } else {
845 // Right
846 currentindex++;
847 if (currentindex >= buttons.size()) {
848 currentindex = 0;
849 }
850 }
fcb5b5c4 851 // Currentindex has been updated to point to the new button.
adca7326
DW
852 current = buttons.item(currentindex);
853 current.setAttribute('tabindex', '0');
854 current.focus();
48bdf86f 855 toolbar.setAttribute('aria-activedescendant', current.generateID());
adca7326
DW
856 },
857
b269f635
DW
858 /**
859 * Should we show the filepicker for this filetype?
860 *
861 * @param string elementid for this editor instance.
862 * @param string type The media type for the file picker
863 * @return boolean
864 */
865 can_show_filepicker : function(elementid, type) {
866 var options = M.editor_atto.filepickeroptions[elementid];
867 return ((typeof options[type]) !== "undefined");
868 },
869
adca7326
DW
870 /**
871 * Show the filepicker.
872 * @param string elementid for this editor instance.
873 * @param string type The media type for the file picker
874 * @param function callback
875 */
876 show_filepicker : function(elementid, type, callback) {
877 Y.use('core_filepicker', function (Y) {
878 var options = M.editor_atto.filepickeroptions[elementid][type];
879
880 options.formcallback = callback;
adca7326
DW
881
882 M.core_filepicker.show(Y, options);
883 });
884 },
885
886 /**
887 * Create a cross browser selection object that represents a yui node.
888 * @param Node yui node for the selection
889 * @return range (browser dependent)
890 */
891 get_selection_from_node: function(node) {
892 var range;
893
894 if (window.getSelection) {
895 range = document.createRange();
896
897 range.setStartBefore(node.getDOMNode());
898 range.setEndAfter(node.getDOMNode());
899 return [range];
900 } else if (document.selection) {
901 range = document.body.createTextRange();
902 range.moveToElementText(node.getDOMNode());
903 return range;
904 }
905 return false;
906 },
907
26f8822d
DW
908 /**
909 * Save the current selection on blur, allows more reliable keyboard navigation.
910 * @param Y.Event event
911 * @param string elementid
912 */
67d3fe45
SH
913 save_selection : function(event) {
914 var elementid = event.elementid;
915 if (elementid && this.is_active(elementid)) {
26f8822d
DW
916 var sel = this.get_selection();
917 if (sel.length > 0) {
918 this.selections[elementid] = sel;
919 }
920 }
921 },
922
923 /**
924 * Restore any current selection when the editor gets focus again.
925 * @param Y.Event event
926 * @param string elementid
927 */
928 restore_selection : function(event, elementid) {
26f8822d
DW
929 if (!this.focusfromclick) {
930 if (typeof this.selections[elementid] !== "undefined") {
931 this.set_selection(this.selections[elementid]);
932 }
933 }
934 this.focusfromclick = false;
935 },
936
adca7326
DW
937 /**
938 * Get the selection object that can be passed back to set_selection.
67d3fe45 939 * @return Range[]|TextRange[] All browsers except IE8 and less will get Range[], IE8 and less will get TextRange[]
adca7326
DW
940 */
941 get_selection : function() {
942 if (window.getSelection) {
943 var sel = window.getSelection();
944 var ranges = [], i = 0;
945 for (i = 0; i < sel.rangeCount; i++) {
946 ranges.push(sel.getRangeAt(i));
947 }
948 return ranges;
949 } else if (document.selection) {
950 // IE < 9
951 if (document.selection.createRange) {
67d3fe45 952 return [document.selection.createRange()];
adca7326
DW
953 }
954 }
955 return false;
956 },
957
958 /**
959 * Check that a YUI node it at least partly contained by the selection.
adca7326
DW
960 * @param Y.Node node
961 * @return boolean
962 */
963 selection_contains_node : function(node) {
67d3fe45 964 // TODO: This will break in IE11 . IE11 doesn't provide containsNode and document.selection has been removed.
adca7326
DW
965 var range, sel;
966 if (window.getSelection) {
967 sel = window.getSelection();
adca7326
DW
968 if (sel.containsNode) {
969 return sel.containsNode(node.getDOMNode(), true);
970 }
971 }
972 sel = document.selection.createRange();
973 range = sel.duplicate();
974 range.moveToElementText(node.getDOMNode());
975 return sel.inRange(range);
976 },
977
3ee53a42
DW
978 /**
979 * Runs a selector on each node in the selection and will only return true if all nodes
980 * in the selection match the filter.
981 *
982 * @param {String} elementid
983 * @param {String} selector
984 * @param {NodeList} selectednodes (Optional) - for performance we can pass this instead of looking it up.
985 * @return {Boolean}
986 */
987 selection_filter_matches : function(elementid, selector, selectednodes) {
3ee53a42
DW
988 if (!selectednodes) {
989 // Find this because it was not passed as a param.
990 selectednodes = M.editor_atto.get_selected_nodes(elementid);
991 }
67d3fe45
SH
992 var allmatch = selectednodes.size() > 0;
993 selectednodes.each(function(node){
994 // Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
995 if (!allmatch || !node.ancestor(selector, true, '#' + elementid + "editable")) {
996 allmatch = false;
3ee53a42 997 }
67d3fe45
SH
998 }, this);
999 return allmatch;
3ee53a42
DW
1000 },
1001
1002 /**
67d3fe45
SH
1003 * Get the deepest possible list of nodes in the current selection.
1004 *
1005 * Presently this method contains logic to work out what nodes are "selected" by the user.
1006 * The user selection may take the form of:
1007 * - text : selected text within a single node, such as double clicking a word.
1008 * - A node : the user has clicked an image or media object that is selectable by clicking upon an element.
1009 * - text + nodes: the user has dragged or used shift + arrows to select a body of content that may include
1010 * both text and/or nodes.
1011 * To work out the selected nodes we look at the start and end node. If they are the same then there is only one node
1012 * containing selected content.
1013 * If they don't match we get the common ancestor of the start and end nodes, and then iterate all child nodes recording
1014 * all nodes that appear after the start node and before the end node.
1015 *
1016 * YUI deal with this in a totally different way to us, they have a method getSelected that gets the selected nodes by
1017 * exec'ing a command to add fontname, and then find all nodes that have been affected. They remove the fontname property
1018 * before returning the nodes.
3ee53a42
DW
1019 *
1020 * @param {String} elementid
3ee53a42
DW
1021 * @return Y.NodeList
1022 */
67d3fe45
SH
1023 get_selected_nodes : function(elementid) {
1024 var nodes = [],
1025 results = new Y.NodeList(),
1026 points = [],
1027 range,
1028 i,
1029 sel,
1030 start,
1031 end,
1032 parent,
1033 values;
1034
1035 // The window.getSelection method is available from IE9 onwards and for all other browsers.
1036 if (window.getSelection) {
1037 sel = window.getSelection();
1038 for (i = 0; i < sel.rangeCount; i++) {
1039 range = sel.getRangeAt(i);
1040 start = new Y.Node(range.startContainer);
1041 end = new Y.Node(range.endContainer);
1042 if (start.compareTo(end)) {
1043 // The start and end node are the same node. Only one node is selected. Easy!
1044 nodes.push(end);
1045 } else {
1046 // The start and end node are not the same, we have 2..n nodes selected, we'll need to work them out.
1047 // For the time being record them as a pair of points along with the common ancestor.
1048 // First up we need to check the order of the nodes, we need them in the order they appear in the DOM
1049 // however the selection may have been rtl or ltr and in the case of rtl we need to switch the order.
1050 values = this._fix_selected_nodes_from_range(
1051 start,
1052 range.startOffset,
1053 end,
1054 range.endOffset
1055 );
1056 // Ok start and end are in order. Yay!
1057 start = values[0];
1058 end = values[2];
1059 // We calculate the common ancestor here as we should be able to get it from the range object.
1060 if (range.commonAncestorContainer) {
1061 parent = range.commonAncestorContainer;
1062 } else if (range.parentElement) {
1063 parent = range.parentElement();
1064 } else {
1065 parent = this._get_common_parent_from_nodes(start, end);
1066 }
1067 points.push([
1068 start,
1069 end,
1070 parent
1071 ]);
3ee53a42 1072 }
67d3fe45
SH
1073 }
1074 } else if (document.selection && document.selection.createRange) {
1075 // The document.selection.createRange is available in IE only, versions 5.5 through 10 (not IE11).
1076 // However s window.getSelection exists in IE9+ this code will only execute in IE 5.5 -> IE 8.
1077 // http://msdn.microsoft.com/en-us/library/ie/ms535869(v=vs.85).aspx
1078 range = document.selection.createRange();
1079 // Create range will return either TextRange object from the current text selection, or a controlRange
1080 // collection from the control selection.
1081 if (document.selection.type === "Control") {
1082 // Its a control collection, well this is easy, its an array of Nodes. All the work has been done for us.
1083 for (i = 0; i < range.length; i++) {
1084 nodes.push(new Y.Node(range(i)));
1085 }
1086 } else {
1087 // Its a TextRange, in which case its a text element (nodeType 3) and its parent will be the node containing the
1088 // selection.
1089 nodes.push(new Y.Node(range.parentElement()));
1090 }
1091 }
1092 // Ok we have two array as this point.
1093 // - Nodes is an array containing nodes that are currently highlighted by selection.
1094 // - Points is an array containing start, end, and parent nodes.
1095 // For each item in the points array we need to find all nodes containing selected content.
1096 for (i in points) {
1097 nodes = nodes.concat(this._get_all_nodes_between.apply(this, points[i]));
1098 }
1099 // Finally for each node we've determined is highlighted we need to check it belongs to the current editable area.
1100 for (i in nodes) {
1101 if (results.indexOf(nodes[i]) === -1 && nodes[i].ancestor('#' + elementid + "editable")) {
1102 results.push(nodes[i]);
1103 }
1104 }
1105 return results;
3ee53a42
DW
1106 },
1107
1108 /**
67d3fe45
SH
1109 * Returns true if the selection has changed since this method was last called.
1110 *
1111 * If the selection has changed we also fire the custom event atto:selectionchanged
1112 *
1113 * @method _has_selection_changed
1114 * @private
1115 * @param {EventFacade} e
1116 * @returns {Boolean}
3ee53a42 1117 */
67d3fe45
SH
1118 _has_selection_changed : function(e) {
1119 var sel,
1120 i,
1121 range,
1122 bits = [],
1123 selectedstr = '',
1124 selectioncount = 0;
1125 if (window.getSelection) {
1126 // Hoorah - working with the alternative is horrid..
1127 sel = window.getSelection();
1128 selectioncount = sel.rangeCount;
1129 for (i = 0; i < selectioncount; i++) {
1130 range = sel.getRangeAt(i);
1131 bits.push(range.startOffset);
1132 bits.push(range.endOffset);
1133 bits.push(this._get_node_identifier(range.startContainer));
1134 bits.push(this._get_node_identifier(range.endContainer));
1135 bits.push('&');
1136 }
1137 } else if (document.selection && document.selection.createRange) {
1138 range = document.selection.createRange();
1139 if (range !== null) {
1140 if (document.selection.type === "Control") {
1141 // Is a controlRange: http://msdn.microsoft.com/en-us/library/ie/hh826021(v=vs.85).aspx
1142 selectioncount = range.length;
1143 for (i = 0; i < selectioncount; i++) {
1144 bits.push(this._get_node_identifier(range(i)));
3ee53a42 1145 }
67d3fe45
SH
1146 } else {
1147 // Is a TextRange: http://msdn.microsoft.com/en-us/library/ie/ms535872(v=vs.85).aspx
1148 selectioncount = 1;
1149 bits.push(this._get_node_identifier(range.parentElement()));
3ee53a42
DW
1150 }
1151 }
67d3fe45
SH
1152 }
1153 selectedstr = bits.join('|');
1154 if (this.lastselection !== selectedstr) {
1155 this.lastselection = selectedstr;
1156 this.lastselectioncount = selectioncount;
1157 return this._fire_selectionchanged(e);
1158 }
1159 return false;
1160 },
3ee53a42 1161
67d3fe45
SH
1162 /**
1163 * Fires the atto:selectionchanged event.
1164 *
1165 * When the selectionchanged event gets fired three arguments are given:
1166 * - event : the original event that lead to this event being fired.
1167 * - elementid : the editor elementid for which this event is being fired.
1168 * - selectednodes : an array containing nodes that are entirely selected of contain partially selected content.
1169 *
1170 * @method _fire_selectionchanged
1171 * @private
1172 * @param {EventFacade} e
1173 */
1174 _fire_selectionchanged : function(e) {
1175 var editableid = e.currentTarget.get('id'),
1176 elementid = editableid.substring(0, editableid.length - 8),
1177 selectednodes = this.get_selected_nodes(elementid);
1178 this.fire('atto:selectionchanged', {
1179 event : e,
1180 elementid : elementid,
1181 selectedNodes : selectednodes
1182 });
1183 },
1184
1185 /**
1186 * Returns the best identifier for the given node that can be found. May not be unique.
1187 *
1188 * @method _get_node_identifier
1189 * @private
1190 * @param {Node} node
1191 * @returns {string}
1192 */
1193 _get_node_identifier : function(node) {
1194 var uid = (node.nodeType === 3) ? node.length : node.id;
1195 return uid || node.className || node.nodeName;
1196 },
1197
1198 /**
1199 * Fixes the selection range, normalising it, ordering the start and end by dom position, and correcting for noteType and offset
1200 *
1201 * Note about offsets:
1202 * If the container is a Node of type Text, Comment, or CDATASection, then the offset is the number of characters from the
1203 * start of the container to the boundary point of the Range.
1204 * For other Node types, the offset is the number of child nodes between the start of the container and the boundary
1205 * point of the Range.
1206 *
1207 * @method _fix_selected_nodes_from_range
1208 * @private
1209 * @param {Node} a
1210 * @param {integer} aoffset
1211 * @param {Node} b
1212 * @param {integer} boffset
1213 * @returns {[Node, integer, Node, integer]}
1214 */
1215 _fix_selected_nodes_from_range : function(a, aoffset, b, boffset) {
1216 var first,
1217 firstoffset,
1218 second,
1219 secondoffset,
1220 searchfunc,
1221 acheckend = true,
1222 bcheckend = true,
1223 checkend = true;
1224
1225 if (a.get('nodeType') === 1) {
1226 a = a.get('childNodes').item(aoffset);
1227 acheckend = false;
1228 }
1229 if (b.get('nodeType') === 1 && boffset > 0) {
1230 b = b.get('childNodes').item(boffset - 1);
1231 bcheckend = false;
1232 }
1233 if (a._yuid === b._yuid) {
1234 if (aoffset < boffset) {
1235 first = a;
1236 firstoffset = aoffset;
1237 second = b;
1238 secondoffset = boffset;
1239 checkend = bcheckend;
3ee53a42 1240 } else {
67d3fe45
SH
1241 first = b;
1242 firstoffset = boffset;
1243 second = a;
1244 secondoffset = aoffset;
1245 checkend = acheckend;
3ee53a42 1246 }
67d3fe45
SH
1247 } else {
1248 if (a.contains(b)) {
1249 first = a;
1250 firstoffset = aoffset;
1251 second = b;
1252 secondoffset = boffset;
1253 checkend = bcheckend;
1254 } else if (b.contains(a)) {
1255 first = b;
1256 firstoffset = boffset;
1257 second = a;
1258 secondoffset = aoffset;
1259 checkend = acheckend;
1260 } else {
1261 searchfunc = function find_first(nodes, x, y) {
1262 var i, result;
1263 for (i in nodes) {
1264 if (nodes[i] === x.getDOMNode()) {
1265 return x;
1266 } else if (nodes[i] === y.getDOMNode()) {
1267 return y;
1268 } else if (nodes[i].nodeType === 1) {
1269 result = find_first(nodes[i].childNodes, x, y);
1270 if (result !== false) {
1271 return result;
1272 }
1273 }
1274 }
1275 return false;
1276 };
1277 first = searchfunc(this._get_common_parent_from_nodes(a, b).get('childNodes'), a, b) || a;
1278 if (first === a) {
1279 firstoffset = aoffset;
1280 second = b;
1281 secondoffset = boffset;
1282 checkend = bcheckend;
1283 } else {
1284 firstoffset = boffset;
1285 second = a;
1286 secondoffset = aoffset;
1287 checkend = acheckend;
1288 }
3ee53a42 1289 }
67d3fe45
SH
1290 if (checkend && second.get('nodeType') === 3 && secondoffset === 0 && second.previous('*', true)) {
1291 second = second.previous();
3ee53a42 1292 }
67d3fe45
SH
1293 }
1294 return [first, firstoffset, second, secondoffset];
3ee53a42
DW
1295 },
1296
1297 /**
67d3fe45
SH
1298 * Returns an array containing all of the nodes in the Dom between the two reference nodes.
1299 *
1300 * @method _get_all_nodes_between
1301 * @private
1302 * @param {Node} a
1303 * @param {Node} b
1304 * @param {Node} parent The common parent of a and b
1305 * @returns {Node[]}
3ee53a42 1306 */
67d3fe45
SH
1307 _get_all_nodes_between : function(a, b, parent) {
1308 var startfound = false,
1309 endfound = false,
1310 nodes = [],
1311 parenthasselectedtext = false;
1312 if (a === b || a.contains(b)) {
1313 return [a];
1314 }
1315 if (b.contains(a)) {
1316 return [b];
1317 }
1318 if (!parent) {
1319 return [a, b];
3ee53a42
DW
1320 }
1321
67d3fe45
SH
1322 nodes.push(a);
1323 parent.get('childNodes').each(function(node) {
1324 if (endfound) {
1325 return;
1326 }
1327 if (!startfound && node === a) {
1328 startfound = true;
1329 } else if (startfound && !endfound) {
1330 if (node === b) {
1331 endfound = true;
1332 } else {
1333 if (node.get('nodeType') === 3) {
1334 parenthasselectedtext = true;
1335 } else {
1336 nodes.push(node);
1337 nodes = nodes.compact(node.all('*'));
1338 }
1339 }
1340 }
1341 });
1342 nodes.push(b);
3ee53a42 1343
67d3fe45
SH
1344 if (parenthasselectedtext) {
1345 nodes.unshift(parent);
3ee53a42 1346 }
67d3fe45
SH
1347
1348 return nodes;
3ee53a42
DW
1349 },
1350
1351 /**
67d3fe45
SH
1352 * Returns the common parent of the two given nodes.
1353 *
1354 * @method _get_common_parent_from_nodes
1355 * @private
1356 * @param {Node} a
1357 * @param {Node} b
1358 * @returns {Node}
3ee53a42 1359 */
67d3fe45
SH
1360 _get_common_parent_from_nodes : function(a, b) {
1361 var aparents = a.ancestors('*', false, '.' + CSS.CONTENT),
1362 bparents = b.ancestors('*', false, '.' + CSS.CONTENT),
1363 node;
1364 while ((node = aparents.pop())) {
1365 if (bparents.indexOf(node) !== -1) {
1366 return node;
1367 }
3ee53a42 1368 }
67d3fe45 1369 return a.ancestor('.' + CSS.CONTENT);
3ee53a42
DW
1370 },
1371
adca7326
DW
1372 /**
1373 * Get the dom node representing the common anscestor of the selection nodes.
34f5867a 1374 * @return DOMNode or false
adca7326
DW
1375 */
1376 get_selection_parent_node : function() {
1377 var selection = M.editor_atto.get_selection();
34f5867a
DW
1378 if (selection.length) {
1379 selection = selection.pop();
1380 }
1381
1382 if (selection.commonAncestorContainer) {
1383 return selection.commonAncestorContainer;
1384 } else if (selection.parentElement) {
1385 return selection.parentElement();
adca7326 1386 }
34f5867a
DW
1387 // No selection
1388 return false;
adca7326
DW
1389 },
1390
adca7326
DW
1391 /**
1392 * Set the current selection. Used to restore a selection.
1393 */
1394 set_selection : function(selection) {
1395 var sel, i;
1396
1397 if (window.getSelection) {
1398 sel = window.getSelection();
1399 sel.removeAllRanges();
1400 for (i = 0; i < selection.length; i++) {
1401 sel.addRange(selection[i]);
1402 }
1403 } else if (document.selection) {
1404 // IE < 9
1405 if (selection.select) {
1406 selection.select();
1407 }
1408 }
34f5867a
DW
1409 },
1410
1411 /**
1412 * Change the formatting for the current selection.
1413 * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
1414 *
1415 * @param {String} elementid - The editor elementid.
1416 * @param {String} blocktag - Change the block level tag to this. Empty string, means do not change the tag.
1417 * @param {Object} attributes - The keys and values for attributes to be added/changed in the block tag.
1418 * @return Y.Node - if there was a selection.
1419 */
1420 format_selection_block : function(elementid, blocktag, attributes) {
1421 // First find the nearest ancestor of the selection that is a block level element.
1422 var selectionparentnode = M.editor_atto.get_selection_parent_node(),
1423 boundary,
1424 cell,
1425 nearestblock,
1426 newcontent,
1427 match,
1428 replacement;
1429
1430 if (!selectionparentnode) {
1431 // No selection, nothing to format.
1432 return;
1433 }
1434
1435 boundary = M.editor_atto.get_editable_node(elementid);
1436
1437 selectionparentnode = Y.one(selectionparentnode);
1438
1439 // If there is a table cell in between the selectionparentnode and the boundary,
1440 // move the boundary to the table cell.
1441 // This is because we might have a table in a div, and we select some text in a cell,
1442 // want to limit the change in style to the table cell, not the entire table (via the outer div).
1443 cell = selectionparentnode.ancestor(function (node) {
1444 var tagname = node.get('tagName');
1445 if (tagname) {
1446 tagname = tagname.toLowerCase();
1447 }
1448 return (node === boundary) ||
1449 (tagname === 'td') ||
1450 (tagname === 'th');
1451 }, true);
1452
1453 if (cell) {
1454 // Limit the scope to the table cell.
1455 boundary = cell;
1456 }
1457
1458 nearestblock = selectionparentnode.ancestor(M.editor_atto.BLOCK_TAGS.join(', '), true);
1459 if (nearestblock) {
1460 // Check that the block is contained by the boundary.
1461 match = nearestblock.ancestor(function (node) {
1462 return node === boundary;
1463 }, false);
1464
1465 if (!match) {
1466 nearestblock = false;
1467 }
1468 }
1469
1470 // No valid block element - make one.
1471 if (!nearestblock) {
1472 // There is no block node in the content, wrap the content in a p and use that.
1473 newcontent = Y.Node.create('<p></p>');
1474 boundary.get('childNodes').each(function (child) {
1475 newcontent.append(child.remove());
1476 });
1477 boundary.append(newcontent);
1478 nearestblock = newcontent;
1479 }
1480
1481 // Guaranteed to have a valid block level element contained in the contenteditable region.
1482 // Change the tag to the new block level tag.
1483 if (blocktag && blocktag !== '') {
1484 // Change the block level node for a new one.
1485 replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
1486 // Copy all attributes.
1487 replacement.setAttrs(nearestblock.getAttrs());
1488 // Copy all children.
1489 nearestblock.get('childNodes').each(function (child) {
1490 child.remove();
1491 replacement.append(child);
1492 });
1493
1494 nearestblock.replace(replacement);
1495 nearestblock = replacement;
1496 }
1497
1498 // Set the attributes on the block level tag.
1499 if (attributes) {
1500 nearestblock.setAttrs(attributes);
1501 }
1502
1503 // Change the selection to the modified block. This makes sense when we might apply multiple styles
1504 // to the block.
1505 var selection = M.editor_atto.get_selection_from_node(nearestblock);
1506 M.editor_atto.set_selection(selection);
1507
1508 return nearestblock;
f6bef145
FM
1509 },
1510
1511 /**
1512 * Disable CSS styling.
1513 *
1514 * @return {Void}
1515 */
1516 disable_css_styling: function() {
1517 try {
1518 document.execCommand("styleWithCSS", 0, false);
1519 } catch (e1) {
1520 try {
1521 document.execCommand("useCSS", 0, true);
1522 } catch (e2) {
1523 try {
1524 document.execCommand('styleWithCSS', false, false);
1525 } catch (e3) {
1526 // We did our best.
1527 }
1528 }
1529 }
1530 },
1531
1532 /**
1533 * Enable CSS styling.
1534 *
1535 * @return {Void}
1536 */
1537 enable_css_styling: function() {
1538 try {
1539 document.execCommand("styleWithCSS", 0, true);
1540 } catch (e1) {
1541 try {
1542 document.execCommand("useCSS", 0, false);
1543 } catch (e2) {
1544 try {
1545 document.execCommand('styleWithCSS', false, true);
1546 } catch (e3) {
1547 // We did our best.
1548 }
1549 }
1550 }
2faf4c45
SH
1551 },
1552
1553 /**
1554 * Inserts the given HTML into the editable content at the currently focused point.
1555 * @param {String} html
1556 */
1557 insert_html_at_focus_point: function(html) {
1558 // We check document.selection here as the insertHTML exec command wan't available in IE until version 11.
1559 // In IE document.selection was removed at from version 11. Making it an easy affordable check.
1560 if (document.selection && document.selection.createRange) {
1561 var range = document.selection.createRange();
1562 if (range.pasteHTML) {
1563 range.pasteHTML(html);
1564 }
1565 } else {
1566 // All other browsers are great!
1567 document.execCommand('insertHTML', false, html);
1568 }
bed1abbc
AD
1569 },
1570
1571 /**
1572 * Change the formatting for the current selection.
1573 * Wraps the selection in spans and adds the provides classes.
1574 *
1575 * If the selection covers multiple block elements, multiple spans will be inserted to preserve the original structure.
1576 *
1577 * @method toggle_inline_selection_class
1578 * @param {String} elementid - The editor elementid.
1579 * @param {Array} toggleclasses - Class names to be toggled on or off.
1580 */
1581 toggle_inline_selection_class: function(elementid, toggleclasses) {
1582 var selectionparentnode = M.editor_atto.get_selection_parent_node(),
1583 root = Y.one('body'),
1584 nodes,
1585 items = [],
1586 parentspan,
1587 currentnode,
1588 newnode,
1589 i = 0;
1590
1591 if (!selectionparentnode) {
1592 // No selection, nothing to format.
1593 return;
1594 }
1595
1596 // Add a bogus fontname as the browsers handle inserting fonts into multiple blocks correctly.
1597 document.execCommand('fontname', false, M.editor_atto.PLACEHOLDER_FONTNAME);
1598 nodes = root.all(M.editor_atto.ALL_NODES_SELECTOR);
1599
1600 // Create a list of all nodes that have our bogus fontname.
1601 nodes.each(function(node, index) {
1602 if (node.getStyle(M.editor_atto.FONT_FAMILY) === M.editor_atto.PLACEHOLDER_FONTNAME) {
1603 node.setStyle(M.editor_atto.FONT_FAMILY, '');
1604 if (!node.compareTo(root)) {
1605 items.push(Y.Node.getDOMNode(nodes.item(index)));
1606 }
1607 }
1608 });
1609
1610 // Replace the fontname tags with spans
1611 for (i = 0; i < items.length; i++) {
1612 currentnode = Y.one(items[i]);
adca7326 1613
bed1abbc
AD
1614 // Check for an existing span to add the nolink class to.
1615 parentspan = currentnode.ancestor('.editor_atto_content span');
1616 if (!parentspan) {
1617 newnode = Y.Node.create('<span></span>');
1618 newnode.append(items[i].innerHTML);
1619 currentnode.replace(newnode);
1620
1621 currentnode = newnode;
1622 } else {
1623 currentnode = parentspan;
1624 }
1625
1626 // Toggle the classes on the spans.
1627 for (var j = 0; j < toggleclasses.length; j++) {
1628 currentnode.toggleClass(toggleclasses[j]);
1629 }
1630 }
1631 }
adca7326 1632};
67d3fe45
SH
1633
1634// The editor_atto is publishing custom events that can be subscribed to.
1635Y.augment(M.editor_atto, Y.EventTarget);
adca7326
DW
1636var CONTROLMENU_NAME = "Controlmenu",
1637 CONTROLMENU;
1638
1639/**
1640 * CONTROLMENU
1641 * This is a drop down list of buttons triggered (and aligned to) a button.
1642 *
1643 * @namespace M.editor_atto.controlmenu
1644 * @class controlmenu
1645 * @constructor
1646 * @extends M.core.dialogue
1647 */
1648CONTROLMENU = function(config) {
1649 config.draggable = false;
55c0403c 1650 config.center = false;
adca7326
DW
1651 config.width = 'auto';
1652 config.lightbox = false;
adca7326 1653 config.footerContent = '';
05843fd3
DW
1654 config.hideOn = [ { eventName: 'clickoutside' } ];
1655
adca7326
DW
1656 CONTROLMENU.superclass.constructor.apply(this, [config]);
1657};
1658
1659Y.extend(CONTROLMENU, M.core.dialogue, {
1660
1661 /**
1662 * Initialise the menu.
1663 *
1664 * @method initializer
1665 * @return void
1666 */
1667 initializer : function(config) {
1668 var body, headertext, bb;
1669 CONTROLMENU.superclass.initializer.call(this, config);
1670
1671 bb = this.get('boundingBox');
1672 bb.addClass('editor_atto_controlmenu');
1673
1674 // Close the menu when clicked outside (excluding the button that opened the menu).
1675 body = this.bodyNode;
1676
1677 headertext = Y.Node.create('<h3/>');
1678 headertext.addClass('accesshide');
1679 headertext.setHTML(this.get('headerText'));
1680 body.prepend(headertext);
adca7326
DW
1681 }
1682
1683}, {
1684 NAME : CONTROLMENU_NAME,
1685 ATTRS : {
1686 /**
1687 * The header for the drop down (only accessible to screen readers).
1688 *
1689 * @attribute headerText
1690 * @type String
1691 * @default ''
1692 */
1693 headerText : {
1694 value : ''
1695 }
1696
1697 }
1698});
1699
1700M.editor_atto = M.editor_atto || {};
1701M.editor_atto.controlmenu = CONTROLMENU;
d088a835
DW
1702/**
1703 * Class for cleaning ugly HTML.
1704 * Rewritten JS from jquery-clean plugin.
1705 *
1706 * @module editor_atto
1707 * @chainable
1708 */
1709function cleanHTML() {
1710 var cleaned = this.getHTML();
1711
1712 // What are we doing ?
1713 // We are cleaning random HTML from all over the shop into a set of useful html suitable for content.
1714 // We are allowing styles etc, but not e.g. font tags, class="MsoNormal" etc.
1715
1716 var rules = [
1717 // Source: "http://stackoverflow.com/questions/2875027/clean-microsoft-word-pasted-text-using-javascript"
1718 // Source: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
1719
1720 // Remove all HTML comments.
1721 {regex: /<!--[\s\S]*?-->/gi, replace: ""},
1722 // Source: "http://www.1stclassmedia.co.uk/developers/clean-ms-word-formatting.php"
1723 // Remove <?xml>, <\?xml>.
1724 {regex: /<\\?\?xml[^>]*>/gi, replace: ""},
1725 // Remove <o:blah>, <\o:blah>.
1726 {regex: /<\/?\w+:[^>]*>/gi, replace: ""}, // e.g. <o:p...
1727 // Remove MSO-blah, MSO:blah (e.g. in style attributes)
1728 {regex: /\s*MSO[-:][^;"']*;?/gi, replace: ""},
1729 // Remove empty spans
1730 {regex: /<span[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""},
1731 // Remove class="Msoblah"
1732 {regex: /class="Mso[^"]*"/gi, replace: ""},
1733
1734 // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
1735 // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body.
1736 {regex: /<(\/?title|\/?meta|\/?style|\/?st\d|\/?head|\/?font|\/?html|\/?body|!\[)[^>]*?>/gi, replace: ""},
1737
1738 // Source: "http://www.tim-jarrett.com/labs_javascript_scrub_word.php"
1739 // Replace extended chars with simple text.
1740 {regex: new RegExp(String.fromCharCode(8220), 'gi'), replace: '"'},
1741 {regex: new RegExp(String.fromCharCode(8216), 'gi'), replace: "'"},
1742 {regex: new RegExp(String.fromCharCode(8217), 'gi'), replace: "'"},
1743 {regex: new RegExp(String.fromCharCode(8211), 'gi'), replace: '-'},
1744 {regex: new RegExp(String.fromCharCode(8212), 'gi'), replace: '--'},
1745 {regex: new RegExp(String.fromCharCode(189), 'gi'), replace: '1/2'},
1746 {regex: new RegExp(String.fromCharCode(188), 'gi'), replace: '1/4'},
1747 {regex: new RegExp(String.fromCharCode(190), 'gi'), replace: '3/4'},
1748 {regex: new RegExp(String.fromCharCode(169), 'gi'), replace: '(c)'},
1749 {regex: new RegExp(String.fromCharCode(174), 'gi'), replace: '(r)'},
1750 {regex: new RegExp(String.fromCharCode(8230), 'gi'), replace: '...'}
1751 ];
1752
1753 var i = 0, rule;
1754
1755 for (i = 0; i < rules.length; i++) {
1756 rule = rules[i];
1757 cleaned = cleaned.replace(rule.regex, rule.replace);
1758 }
1759
1760 this.setHTML(cleaned);
1761 return this;
1762}
1763
1764Y.Node.addMethod("cleanHTML", cleanHTML);
1765Y.NodeList.importMethod(Y.Node.prototype, "cleanHTML");
adca7326
DW
1766
1767
3ee53a42
DW
1768}, '@VERSION@', {
1769 "requires": [
1770 "node",
1771 "io",
1772 "overlay",
1773 "escape",
1774 "event",
1775 "event-simulate",
1776 "event-custom",
1777 "yui-throttle",
117f9f04 1778 "moodle-core-notification-dialogue"
3ee53a42
DW
1779 ]
1780});