MDL-44125 atto_align: New plugin for text alignment
[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',
33 WRAPPER: 'editor_atto'
34};
35
36/**
37 * Atto editor main class.
38 * Common functions required by editor plugins.
39 *
40 * @package editor_atto
41 * @copyright 2013 Damyon Wiese <damyon@moodle.com>
42 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
43 */
44M.editor_atto = M.editor_atto || {
45
34f5867a
DW
46 /**
47 * List of known block level tags.
48 * Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
49 *
50 * @type {Array}
51 */
52 BLOCK_TAGS : [
53 'address',
54 'article',
55 'aside',
56 'audio',
57 'blockquote',
58 'canvas',
59 'dd',
60 'div',
61 'dl',
62 'fieldset',
63 'figcaption',
64 'figure',
65 'footer',
66 'form',
67 'h1',
68 'h2',
69 'h3',
70 'h4',
71 'h5',
72 'h6',
73 'header',
74 'hgroup',
75 'hr',
76 'noscript',
77 'ol',
78 'output',
79 'p',
80 'pre',
81 'section',
82 'table',
83 'tfoot',
84 'ul',
85 'video'],
86
adca7326
DW
87 /**
88 * List of attached button handlers to prevent duplicates.
89 */
90 buttonhandlers : {},
91
92 /**
05843fd3 93 * List of attached handlers.
adca7326
DW
94 */
95 textupdatedhandlers : {},
96
97 /**
98 * List of YUI overlays for custom menus.
99 */
100 menus : {},
101
102 /**
103 * List of attached menu handlers to prevent duplicates.
104 */
105 menuhandlers : {},
106
107 /**
108 * List of file picker options for specific editor instances.
109 */
110 filepickeroptions : {},
111
112 /**
113 * List of buttons and menus that have been added to the toolbar.
114 */
115 widgets : {},
116
26f8822d
DW
117 /**
118 * List of saved selections per editor instance.
119 */
120 selections : {},
121
122 focusfromclick : false,
123
adca7326
DW
124 /**
125 * Toggle a menu.
126 * @param event e
127 */
128 showhide_menu_handler : function(e) {
129 e.preventDefault();
130 var disabled = this.getAttribute('disabled');
131 var overlayid = this.getAttribute('data-menu');
132 var overlay = M.editor_atto.menus[overlayid];
133 var menu = overlay.get('bodyContent');
134 if (overlay.get('visible') || disabled) {
135 overlay.hide();
136 menu.detach('clickoutside');
137 } else {
138 menu.on('clickoutside', function(ev) {
139 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
140 if (overlay.get('visible')) {
141 menu.detach('clickoutside');
142 overlay.hide();
143 }
144 }
145 }, this);
0fa78b80
DW
146
147 overlay.align(Y.one(Y.config.doc.body), [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
adca7326 148 overlay.show();
0fa78b80
DW
149 var icon = e.target.ancestor('button', true).one('img');
150 overlay.align(icon, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
26f8822d 151 overlay.get('boundingBox').one('a').focus();
adca7326
DW
152 }
153 },
154
155 /**
156 * Handle clicks on editor buttons.
157 * @param event e
158 */
159 buttonclicked_handler : function(e) {
160 var elementid = this.getAttribute('data-editor');
161 var plugin = this.getAttribute('data-plugin');
5ec54dd1 162 var button = this.getAttribute('data-button');
adca7326
DW
163 var handler = this.getAttribute('data-handler');
164 var overlay = M.editor_atto.menus[plugin + '_' + elementid];
fcb5b5c4
DW
165 var toolbar = M.editor_atto.get_toolbar_node(elementid);
166 var currentid = toolbar.getAttribute('aria-activedescendant');
167
168 // Right now, currentid is the id of the previously selected button.
169 if (currentid) {
170 current = Y.one('#' + currentid);
171 // We only ever want one button with a tabindex of 0 at any one time.
172 current.setAttribute('tabindex', '-1');
173 }
174 this.setAttribute('tabindex', 0);
175 // And update the activedescendant to point at the currently selected button.
176 toolbar.setAttribute('aria-activedescendant', this.generateID());
adca7326
DW
177
178 if (overlay) {
179 overlay.hide();
180 }
181
5ec54dd1 182 if (M.editor_atto.is_enabled(elementid, plugin, button)) {
adca7326
DW
183 // Pass it on.
184 handler = M.editor_atto.buttonhandlers[handler];
185 return handler(e, elementid);
186 }
187 },
188
adca7326
DW
189 /**
190 * Disable all buttons and menus in the toolbar.
191 * @param string elementid, the element id of this editor.
192 */
193 disable_all_widgets : function(elementid) {
48bdf86f 194 var plugin, element, toolbar = M.editor_atto.get_toolbar_node(elementid);
adca7326 195 for (plugin in M.editor_atto.widgets) {
48bdf86f 196 element = toolbar.one('.atto_' + plugin + '_button');
adca7326
DW
197
198 if (element) {
199 element.setAttribute('disabled', 'true');
200 }
201 }
202 },
203
48bdf86f
DW
204 /**
205 * Get the node of the original textarea element that this editor replaced.
206 *
207 * @param string elementid, the element id of this editor.
208 * @return Y.Node
209 */
210 get_textarea_node : function(elementid) {
211 // Note - it is not safe to use a CSS selector like '#' + elementid
212 // because the id may have colons in it - e.g. quiz.
213 return Y.one(document.getElementById(elementid));
214 },
215
216 /**
217 * Get the node of the toolbar container for this editor.
218 *
219 * @param string elementid, the element id of this editor.
220 * @return Y.Node
221 */
222 get_toolbar_node : function(elementid) {
223 // Note - it is not safe to use a CSS selector like '#' + elementid
224 // because the id may have colons in it - e.g. quiz.
225 return Y.one(document.getElementById(elementid + '_toolbar'));
226 },
227
228 /**
229 * Get the node of the contenteditable container for this editor.
230 *
231 * @param string elementid, the element id of this editor.
232 * @return Y.Node
233 */
234 get_editable_node : function(elementid) {
235 // Note - it is not safe to use a CSS selector like '#' + elementid
236 // because the id may have colons in it - e.g. quiz.
237 return Y.one(document.getElementById(elementid + 'editable'));
238 },
239
240 /**
241 * Determine if the specified toolbar button/menu is enabled.
242 * @param string elementid, the element id of this editor.
243 * @param string plugin, the plugin that created the button/menu.
5ec54dd1 244 * @param string buttonname, optional - used when a plugin has multiple buttons.
48bdf86f 245 */
5ec54dd1
DW
246 is_enabled : function(elementid, plugin, button) {
247 var buttonpath = plugin;
248 if (button) {
249 buttonpath += '_' + button;
250 }
251 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
48bdf86f
DW
252
253 return !element.hasAttribute('disabled');
254 },
255
adca7326
DW
256 /**
257 * Enable a single widget in the toolbar.
258 * @param string elementid, the element id of this editor.
259 * @param string plugin, the name of the plugin that created the widget.
5ec54dd1 260 * @param string buttonname, optional - used when a plugin has multiple buttons.
adca7326 261 */
5ec54dd1
DW
262 enable_widget : function(elementid, plugin, button) {
263 var buttonpath = plugin;
264 if (button) {
265 buttonpath += '_' + button;
266 }
267 var element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + buttonpath + '_button');
adca7326
DW
268
269 if (element) {
270 element.removeAttribute('disabled');
271 }
272 },
273
274 /**
275 * Enable all buttons and menus in the toolbar.
276 * @param string elementid, the element id of this editor.
277 */
278 enable_all_widgets : function(elementid) {
5ec54dd1
DW
279 var path, element;
280 for (path in M.editor_atto.widgets) {
281 element = M.editor_atto.get_toolbar_node(elementid).one('.atto_' + path + '_button');
adca7326
DW
282
283 if (element) {
284 element.removeAttribute('disabled');
285 }
286 }
287 },
288
289 /**
290 * Add a content update handler to be called whenever the content is updated.
adca7326
DW
291 *
292 * @param string elementid - the id of the textarea we created this editor from.
293 * @handler function callback - The function to do the cleaning.
294 * @param object context - the context to set for the callback.
295 * @handler function handler - A function to call when the button is clicked.
296 */
297 add_text_updated_handler : function(elementid, callback) {
298 if (!(elementid in M.editor_atto.textupdatedhandlers)) {
299 M.editor_atto.textupdatedhandlers[elementid] = [];
300 }
301 M.editor_atto.textupdatedhandlers[elementid].push(callback);
302 },
303
304 /**
305 * Add a button to the toolbar belonging to the editor for element with id "elementid".
306 * @param string elementid - the id of the textarea we created this editor from.
307 * @param string plugin - the plugin defining the button
308 * @param string icon - the html used for the content of the button
309 * @param string groupname - the group the button should be appended to.
310 * @param array entries - List of menu entries with the string (entry.text) and the handlers (entry.handler).
5ec54dd1
DW
311 * @param string buttonname - (optional) a name for the button. Required if a plugin creates more than one button.
312 * @param string buttontitle - (optional) a title for the button. Required if a plugin creates more than one button.
0fa78b80 313 * @param int overlaywidth - the overlay width size in 'ems'.
534cf7b7 314 * @param string menucolor - menu icon background color
adca7326 315 */
5ec54dd1 316 add_toolbar_menu : function(elementid, plugin, iconurl, groupname, entries, buttonname, buttontitle, overlaywidth, menucolor) {
48bdf86f
DW
317 var toolbar = M.editor_atto.get_toolbar_node(elementid),
318 group = toolbar.one('.atto_group.' + groupname + '_group'),
adca7326
DW
319 currentfocus,
320 button,
5ec54dd1 321 buttonpath,
adca7326
DW
322 expimgurl;
323
5ec54dd1
DW
324 if (buttonname) {
325 buttonpath = plugin + '_' + buttonname;
326 } else {
327 buttonname = '';
328 buttonpath = plugin;
329 }
330
331 if (!buttontitle) {
332 buttontitle = M.util.get_string('pluginname', 'atto_' + plugin);
333 }
334
534cf7b7
RW
335 if ((typeof overlaywidth) === 'undefined') {
336 overlaywidth = '14';
337 }
338 if ((typeof menucolor) === 'undefined') {
339 menucolor = 'transparent';
340 }
341
adca7326
DW
342 if (!group) {
343 group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
344 toolbar.append(group);
345 }
adca7326 346 expimgurl = M.util.image_url('t/expanded', 'moodle');
5ec54dd1 347 button = Y.Node.create('<button class="atto_' + buttonpath + '_button atto_hasmenu" ' +
adca7326
DW
348 'data-editor="' + Y.Escape.html(elementid) + '" ' +
349 'tabindex="-1" ' +
0012a946 350 'type="button" ' +
5ec54dd1
DW
351 'data-menu="' + buttonpath + '_' + elementid + '" ' +
352 'title="' + Y.Escape.html(buttontitle) + '">' +
534cf7b7
RW
353 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" '+
354 'style="background-color:' + menucolor + ';" src="' + iconurl + '"/>' +
adca7326
DW
355 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + expimgurl + '"/>' +
356 '</button>');
357
358 group.append(button);
359
360 currentfocus = toolbar.getAttribute('aria-activedescendant');
361 if (!currentfocus) {
fcb5b5c4 362 // Initially set the first button in the toolbar to be the default on keyboard focus.
adca7326
DW
363 button.setAttribute('tabindex', '0');
364 toolbar.setAttribute('aria-activedescendant', button.generateID());
365 }
366
367 // Save the name of the plugin.
5ec54dd1 368 M.editor_atto.widgets[buttonpath] = buttonpath;
adca7326 369
5ec54dd1 370 var menu = Y.Node.create('<div class="atto_' + buttonpath + '_menu' +
534cf7b7
RW
371 ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"' +
372 ' style="min-width:' + (overlaywidth-2) + 'em"' +
373 '"></div>');
adca7326
DW
374 var i = 0, entry = {};
375
376 for (i = 0; i < entries.length; i++) {
377 entry = entries[i];
378
379 menu.append(Y.Node.create('<div class="atto_menuentry">' +
5ec54dd1 380 '<a href="#" class="atto_' + buttonpath + '_action_' + i + '" ' +
adca7326
DW
381 'data-editor="' + Y.Escape.html(elementid) + '" ' +
382 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
5ec54dd1
DW
383 'data-button="' + Y.Escape.html(buttonname) + '" ' +
384 'data-handler="' + Y.Escape.html(buttonpath + '_action_' + i) + '">' +
adca7326
DW
385 entry.text +
386 '</a>' +
387 '</div>'));
388 if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
5ec54dd1
DW
389 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + buttonpath + '_action_' + i);
390 // Activate the link on space or enter.
391 Y.one('body').delegate('key', M.editor_atto.buttonclicked_handler, '32,enter', '.atto_' + buttonpath + '_action_' + i);
392 M.editor_atto.buttonhandlers[buttonpath + '_action_' + i] = entry.handler;
adca7326
DW
393 }
394 }
395
5ec54dd1
DW
396 if (!M.editor_atto.buttonhandlers[buttonpath]) {
397 Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + buttonpath + '_button');
398 M.editor_atto.buttonhandlers[buttonpath] = true;
adca7326
DW
399 }
400
401 var overlay = new M.core.dialogue({
402 bodyContent : menu,
403 visible : false,
534cf7b7 404 width: overlaywidth + 'em',
adca7326
DW
405 lightbox: false,
406 closeButton: false,
55c0403c 407 center : false
adca7326
DW
408 });
409
5ec54dd1 410 M.editor_atto.menus[buttonpath + '_' + elementid] = overlay;
55c0403c 411 overlay.align(button, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
adca7326
DW
412 overlay.hide();
413 overlay.headerNode.hide();
0fa78b80 414 overlay.render();
adca7326
DW
415 },
416
417 /**
418 * Add a button to the toolbar belonging to the editor for element with id "elementid".
419 * @param string elementid - the id of the textarea we created this editor from.
420 * @param string plugin - the plugin defining the button.
55c0403c 421 * @param string icon - the url to the image for the icon
adca7326
DW
422 * @param string groupname - the group the button should be appended to.
423 * @handler function handler- A function to call when the button is clicked.
5ec54dd1
DW
424 * @param string buttonname - (optional) a name for the button. Required if a plugin creates more than one button.
425 * @param string buttontitle - (optional) a title for the button. Required if a plugin creates more than one button.
adca7326 426 */
5ec54dd1 427 add_toolbar_button : function(elementid, plugin, iconurl, groupname, handler, buttonname, buttontitle) {
48bdf86f
DW
428 var toolbar = M.editor_atto.get_toolbar_node(elementid),
429 group = toolbar.one('.atto_group.' + groupname + '_group'),
adca7326 430 button,
5ec54dd1 431 buttonpath,
55c0403c 432 currentfocus;
adca7326 433
5ec54dd1
DW
434 if (buttonname) {
435 buttonpath = plugin + '_' + buttonname;
436 } else {
437 buttonname = '';
438 buttonpath = plugin;
439 }
440
441 if (!buttontitle) {
442 buttontitle = M.util.get_string('pluginname', 'atto_' + plugin);
fe0d2477
JM
443 }
444
adca7326
DW
445 if (!group) {
446 group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
447 toolbar.append(group);
448 }
5ec54dd1 449 button = Y.Node.create('<button class="atto_' + buttonpath + '_button" ' +
adca7326
DW
450 'data-editor="' + Y.Escape.html(elementid) + '" ' +
451 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
5ec54dd1 452 'data-button="' + Y.Escape.html(buttonname) + '" ' +
adca7326 453 'tabindex="-1" ' +
5ec54dd1
DW
454 'data-handler="' + Y.Escape.html(buttonpath) + '" ' +
455 'title="' + Y.Escape.html(buttontitle) + '">' +
55c0403c 456 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + iconurl + '"/>' +
adca7326
DW
457 '</button>');
458
459 group.append(button);
460
461 currentfocus = toolbar.getAttribute('aria-activedescendant');
462 if (!currentfocus) {
fcb5b5c4 463 // Initially set the first button in the toolbar to be the default on keyboard focus.
adca7326
DW
464 button.setAttribute('tabindex', '0');
465 toolbar.setAttribute('aria-activedescendant', button.generateID());
466 }
467
468 // We only need to attach this once.
5ec54dd1
DW
469 if (!M.editor_atto.buttonhandlers[buttonpath]) {
470 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + buttonpath + '_button');
471 M.editor_atto.buttonhandlers[buttonpath] = handler;
adca7326
DW
472 }
473
474 // Save the name of the plugin.
5ec54dd1 475 M.editor_atto.widgets[buttonpath] = buttonpath;
adca7326
DW
476
477 },
478
479 /**
480 * Work out if the cursor is in the editable area for this editor instance.
481 * @param string elementid of this editor
482 * @return bool
483 */
484 is_active : function(elementid) {
485 var selection = M.editor_atto.get_selection();
486
487 if (selection.length) {
488 selection = selection.pop();
489 }
490
491 var node = null;
492 if (selection.parentElement) {
493 node = Y.one(selection.parentElement());
494 } else {
495 node = Y.one(selection.startContainer);
496 }
497
34f5867a
DW
498 var editable = M.editor_atto.get_editable_node(elementid);
499
500 return node && editable.contains(node);
adca7326
DW
501 },
502
503 /**
504 * Focus on the editable area for this editor.
505 * @param string elementid of this editor
506 */
507 focus : function(elementid) {
48bdf86f 508 M.editor_atto.get_editable_node(elementid).focus();
adca7326
DW
509 },
510
511 /**
512 * Initialise the editor
513 * @param object params for this editor instance.
514 */
515 init : function(params) {
adca7326
DW
516 var wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
517 var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
518 'contenteditable="true" ' +
26f8822d 519 'role="textbox" ' +
adca7326 520 'spellcheck="true" ' +
26f8822d 521 'aria-live="off" ' +
adca7326
DW
522 'class="' + CSS.CONTENT + '" />');
523
26f8822d 524 var toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" id="' + params.elementid + '_toolbar" role="toolbar" aria-live="off"/>');
adca7326
DW
525
526 // Editable content wrapper.
527 var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
48bdf86f 528 var textarea = M.editor_atto.get_textarea_node(params.elementid);
26f8822d
DW
529 var label = Y.one('[for="' + params.elementid + '"]');
530
531 // Add a labelled-by attribute to the contenteditable.
532 if (label) {
533 label.generateID();
534 atto.setAttribute('aria-labelledby', label.get("id"));
535 toolbar.setAttribute('aria-labelledby', label.get("id"));
536 }
b269f635 537
adca7326
DW
538 content.appendChild(atto);
539
540 // Add everything to the wrapper.
541 wrapper.appendChild(toolbar);
542 wrapper.appendChild(content);
543
4c37c1f4 544 // Style the editor.
adca7326
DW
545 atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows'))) + 'em');
546
547 // Copy text to editable div.
548 atto.append(textarea.get('value'));
549
d088a835
DW
550 // Clean it.
551 atto.cleanHTML();
552
adca7326
DW
553 // Add the toolbar and editable zone to the page.
554 textarea.get('parentNode').insert(wrapper, textarea);
26f8822d
DW
555
556 // Disable odd inline CSS styles.
557 try {
558 document.execCommand("styleWithCSS", 0, false);
559 } catch (e1) {
560 try {
561 document.execCommand("useCSS", 0, true);
562 } catch (e2) {
563 try {
564 document.execCommand('styleWithCSS', false, false);
565 }
566 catch (e3) {
567 // We did our best.
568 }
569 }
570 }
4c37c1f4 571
adca7326
DW
572 // Hide the old textarea.
573 textarea.hide();
26f8822d
DW
574 atto.on('keydown', this.save_selection, this, params.elementid);
575 atto.on('mouseup', this.save_selection, this, params.elementid);
576 atto.on('focus', this.restore_selection, this, params.elementid);
577 // Do not restore selection when focus is from a click event.
578 atto.on('mousedown', function() { this.focusfromclick = true; }, this);
adca7326 579
26f8822d 580 // Copy the current value back to the textarea when focus leaves us and save the current selection.
adca7326 581 atto.on('blur', function() {
26f8822d 582 this.focusfromclick = false;
48bdf86f 583 this.text_updated(params.elementid);
adca7326
DW
584 }, this);
585
586 // Listen for Arrow left and Arrow right keys.
587 Y.one(Y.config.doc.body).delegate('key',
588 this.keyboard_navigation,
589 'down:37,39',
48bdf86f 590 '#' + params.elementid + '_toolbar',
adca7326 591 this,
48bdf86f 592 params.elementid);
adca7326
DW
593
594 // Save the file picker options for later.
48bdf86f 595 M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
55c0403c
DW
596
597 // Init each of the plugins
fcb5b5c4 598 var i, j, group, plugin;
55c0403c 599 for (i = 0; i < params.plugins.length; i++) {
fcb5b5c4 600 group = params.plugins[i].group;
55c0403c 601 for (j = 0; j < params.plugins[i].plugins.length; j++) {
fcb5b5c4 602 plugin = params.plugins[i].plugins[j];
48bdf86f 603 plugin.params.elementid = params.elementid;
55c0403c
DW
604 plugin.params.group = group;
605
606 M['atto_' + plugin.name].init(plugin.params);
607 }
608 }
fcb5b5c4
DW
609
610 // Let the plugins run some init code once all plugins are in the page.
611 for (i = 0; i < params.plugins.length; i++) {
612 group = params.plugins[i].group;
613 for (j = 0; j < params.plugins[i].plugins.length; j++) {
614 plugin = params.plugins[i].plugins[j];
615 plugin.params.elementid = params.elementid;
616 plugin.params.group = group;
617
618 if (typeof M['atto_' + plugin.name].after_init !== 'undefined') {
619 M['atto_' + plugin.name].after_init(plugin.params);
620 }
621 }
622 }
adca7326
DW
623 },
624
625 /**
626 * The text in the contenteditable region has been updated,
627 * clean and copy the buffer to the text area.
628 * @param string elementid - the id of the textarea we created this editor from.
629 */
630 text_updated : function(elementid) {
48bdf86f 631 var textarea = M.editor_atto.get_textarea_node(elementid),
adca7326
DW
632 cleancontent = this.get_clean_html(elementid);
633 textarea.set('value', cleancontent);
dad09216
FM
634 // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
635 textarea.simulate('change');
adca7326
DW
636 // Trigger handlers for this action.
637 var i = 0;
638 if (elementid in M.editor_atto.textupdatedhandlers) {
639 for (i = 0; i < M.editor_atto.textupdatedhandlers[elementid].length; i++) {
640 var callback = M.editor_atto.textupdatedhandlers[elementid][i];
641 callback(elementid);
642 }
643 }
644 },
645
646 /**
647 * Remove all YUI ids from the generated HTML.
648 * @param string elementid - the id of the textarea we created this editor from.
649 * @return string HTML stripped of YUI ids
650 */
651 get_clean_html : function(elementid) {
48bdf86f 652 var atto = M.editor_atto.get_editable_node(elementid).cloneNode(true);
adca7326
DW
653
654 Y.each(atto.all('[id]'), function(node) {
655 var id = node.get('id');
656 if (id.indexOf('yui') === 0) {
657 node.removeAttribute('id');
658 }
659 });
660
d088a835
DW
661 // Remove any and all nasties from source.
662 atto.cleanHTML();
663
adca7326
DW
664 return atto.getHTML();
665 },
666
667 /**
668 * Implement arrow key navigation for the buttons in the toolbar.
669 * @param Event e - the keyboard event.
670 * @param string elementid - the id of the textarea we created this editor from.
671 */
672 keyboard_navigation : function(e, elementid) {
673 var buttons,
674 current,
675 currentid,
48bdf86f
DW
676 currentindex,
677 toolbar = M.editor_atto.get_toolbar_node(elementid);
adca7326
DW
678
679 e.preventDefault();
680
fcb5b5c4
DW
681 // This workaround is because we cannot do ".atto_group:not([hidden]) button" in ie8 (even with selector-css3).
682 // Create an empty NodeList.
683 buttons = toolbar.all('empty');
684 toolbar.all('.atto_group').each(function(group) {
685 if (!group.hasAttribute('hidden')) {
686 // Append the visible buttons to the buttons list.
687 buttons = buttons.concat(group.all('button'));
688 }
689 });
690 // The currentid is the id of the previously selected button.
48bdf86f 691 currentid = toolbar.getAttribute('aria-activedescendant');
adca7326
DW
692 if (!currentid) {
693 return;
694 }
fcb5b5c4 695 // We only ever want one button with a tabindex of 0.
adca7326
DW
696 current = Y.one('#' + currentid);
697 current.setAttribute('tabindex', '-1');
698
699 currentindex = buttons.indexOf(current);
700
701 if (e.keyCode === 37) {
702 // Left
703 currentindex--;
704 if (currentindex < 0) {
705 currentindex = buttons.size()-1;
706 }
707 } else {
708 // Right
709 currentindex++;
710 if (currentindex >= buttons.size()) {
711 currentindex = 0;
712 }
713 }
fcb5b5c4 714 // Currentindex has been updated to point to the new button.
adca7326
DW
715 current = buttons.item(currentindex);
716 current.setAttribute('tabindex', '0');
717 current.focus();
48bdf86f 718 toolbar.setAttribute('aria-activedescendant', current.generateID());
adca7326
DW
719 },
720
b269f635
DW
721 /**
722 * Should we show the filepicker for this filetype?
723 *
724 * @param string elementid for this editor instance.
725 * @param string type The media type for the file picker
726 * @return boolean
727 */
728 can_show_filepicker : function(elementid, type) {
729 var options = M.editor_atto.filepickeroptions[elementid];
730 return ((typeof options[type]) !== "undefined");
731 },
732
adca7326
DW
733 /**
734 * Show the filepicker.
735 * @param string elementid for this editor instance.
736 * @param string type The media type for the file picker
737 * @param function callback
738 */
739 show_filepicker : function(elementid, type, callback) {
740 Y.use('core_filepicker', function (Y) {
741 var options = M.editor_atto.filepickeroptions[elementid][type];
742
743 options.formcallback = callback;
adca7326
DW
744
745 M.core_filepicker.show(Y, options);
746 });
747 },
748
749 /**
750 * Create a cross browser selection object that represents a yui node.
751 * @param Node yui node for the selection
752 * @return range (browser dependent)
753 */
754 get_selection_from_node: function(node) {
755 var range;
756
757 if (window.getSelection) {
758 range = document.createRange();
759
760 range.setStartBefore(node.getDOMNode());
761 range.setEndAfter(node.getDOMNode());
762 return [range];
763 } else if (document.selection) {
764 range = document.body.createTextRange();
765 range.moveToElementText(node.getDOMNode());
766 return range;
767 }
768 return false;
769 },
770
26f8822d
DW
771 /**
772 * Save the current selection on blur, allows more reliable keyboard navigation.
773 * @param Y.Event event
774 * @param string elementid
775 */
776 save_selection : function(event, elementid) {
777 if (this.is_active(elementid)) {
778 var sel = this.get_selection();
779 if (sel.length > 0) {
780 this.selections[elementid] = sel;
781 }
782 }
783 },
784
785 /**
786 * Restore any current selection when the editor gets focus again.
787 * @param Y.Event event
788 * @param string elementid
789 */
790 restore_selection : function(event, elementid) {
791 event.preventDefault();
792 if (!this.focusfromclick) {
793 if (typeof this.selections[elementid] !== "undefined") {
794 this.set_selection(this.selections[elementid]);
795 }
796 }
797 this.focusfromclick = false;
798 },
799
adca7326
DW
800 /**
801 * Get the selection object that can be passed back to set_selection.
802 * @return range (browser dependent)
803 */
804 get_selection : function() {
805 if (window.getSelection) {
806 var sel = window.getSelection();
807 var ranges = [], i = 0;
808 for (i = 0; i < sel.rangeCount; i++) {
809 ranges.push(sel.getRangeAt(i));
810 }
811 return ranges;
812 } else if (document.selection) {
813 // IE < 9
814 if (document.selection.createRange) {
815 return document.selection.createRange();
816 }
817 }
818 return false;
819 },
820
821 /**
822 * Check that a YUI node it at least partly contained by the selection.
adca7326
DW
823 * @param Y.Node node
824 * @return boolean
825 */
826 selection_contains_node : function(node) {
827 var range, sel;
828 if (window.getSelection) {
829 sel = window.getSelection();
830
831 if (sel.containsNode) {
832 return sel.containsNode(node.getDOMNode(), true);
833 }
834 }
835 sel = document.selection.createRange();
836 range = sel.duplicate();
837 range.moveToElementText(node.getDOMNode());
838 return sel.inRange(range);
839 },
840
841 /**
842 * Get the dom node representing the common anscestor of the selection nodes.
34f5867a 843 * @return DOMNode or false
adca7326
DW
844 */
845 get_selection_parent_node : function() {
846 var selection = M.editor_atto.get_selection();
34f5867a
DW
847 if (selection.length) {
848 selection = selection.pop();
849 }
850
851 if (selection.commonAncestorContainer) {
852 return selection.commonAncestorContainer;
853 } else if (selection.parentElement) {
854 return selection.parentElement();
adca7326 855 }
34f5867a
DW
856 // No selection
857 return false;
adca7326
DW
858 },
859
860 /**
861 * Get the list of child nodes of the selection.
862 * @return DOMNode[]
863 */
864 get_selection_text : function() {
865 var selection = M.editor_atto.get_selection();
866 if (selection.length > 0 && selection[0].cloneContents) {
867 return selection[0].cloneContents();
868 }
869 },
870
871 /**
872 * Set the current selection. Used to restore a selection.
873 */
874 set_selection : function(selection) {
875 var sel, i;
876
877 if (window.getSelection) {
878 sel = window.getSelection();
879 sel.removeAllRanges();
880 for (i = 0; i < selection.length; i++) {
881 sel.addRange(selection[i]);
882 }
883 } else if (document.selection) {
884 // IE < 9
885 if (selection.select) {
886 selection.select();
887 }
888 }
34f5867a
DW
889 },
890
891 /**
892 * Change the formatting for the current selection.
893 * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
894 *
895 * @param {String} elementid - The editor elementid.
896 * @param {String} blocktag - Change the block level tag to this. Empty string, means do not change the tag.
897 * @param {Object} attributes - The keys and values for attributes to be added/changed in the block tag.
898 * @return Y.Node - if there was a selection.
899 */
900 format_selection_block : function(elementid, blocktag, attributes) {
901 // First find the nearest ancestor of the selection that is a block level element.
902 var selectionparentnode = M.editor_atto.get_selection_parent_node(),
903 boundary,
904 cell,
905 nearestblock,
906 newcontent,
907 match,
908 replacement;
909
910 if (!selectionparentnode) {
911 // No selection, nothing to format.
912 return;
913 }
914
915 boundary = M.editor_atto.get_editable_node(elementid);
916
917 selectionparentnode = Y.one(selectionparentnode);
918
919 // If there is a table cell in between the selectionparentnode and the boundary,
920 // move the boundary to the table cell.
921 // This is because we might have a table in a div, and we select some text in a cell,
922 // want to limit the change in style to the table cell, not the entire table (via the outer div).
923 cell = selectionparentnode.ancestor(function (node) {
924 var tagname = node.get('tagName');
925 if (tagname) {
926 tagname = tagname.toLowerCase();
927 }
928 return (node === boundary) ||
929 (tagname === 'td') ||
930 (tagname === 'th');
931 }, true);
932
933 if (cell) {
934 // Limit the scope to the table cell.
935 boundary = cell;
936 }
937
938 nearestblock = selectionparentnode.ancestor(M.editor_atto.BLOCK_TAGS.join(', '), true);
939 if (nearestblock) {
940 // Check that the block is contained by the boundary.
941 match = nearestblock.ancestor(function (node) {
942 return node === boundary;
943 }, false);
944
945 if (!match) {
946 nearestblock = false;
947 }
948 }
949
950 // No valid block element - make one.
951 if (!nearestblock) {
952 // There is no block node in the content, wrap the content in a p and use that.
953 newcontent = Y.Node.create('<p></p>');
954 boundary.get('childNodes').each(function (child) {
955 newcontent.append(child.remove());
956 });
957 boundary.append(newcontent);
958 nearestblock = newcontent;
959 }
960
961 // Guaranteed to have a valid block level element contained in the contenteditable region.
962 // Change the tag to the new block level tag.
963 if (blocktag && blocktag !== '') {
964 // Change the block level node for a new one.
965 replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
966 // Copy all attributes.
967 replacement.setAttrs(nearestblock.getAttrs());
968 // Copy all children.
969 nearestblock.get('childNodes').each(function (child) {
970 child.remove();
971 replacement.append(child);
972 });
973
974 nearestblock.replace(replacement);
975 nearestblock = replacement;
976 }
977
978 // Set the attributes on the block level tag.
979 if (attributes) {
980 nearestblock.setAttrs(attributes);
981 }
982
983 // Change the selection to the modified block. This makes sense when we might apply multiple styles
984 // to the block.
985 var selection = M.editor_atto.get_selection_from_node(nearestblock);
986 M.editor_atto.set_selection(selection);
987
988 return nearestblock;
adca7326
DW
989 }
990
991};
992var CONTROLMENU_NAME = "Controlmenu",
993 CONTROLMENU;
994
995/**
996 * CONTROLMENU
997 * This is a drop down list of buttons triggered (and aligned to) a button.
998 *
999 * @namespace M.editor_atto.controlmenu
1000 * @class controlmenu
1001 * @constructor
1002 * @extends M.core.dialogue
1003 */
1004CONTROLMENU = function(config) {
1005 config.draggable = false;
55c0403c 1006 config.center = false;
adca7326
DW
1007 config.width = 'auto';
1008 config.lightbox = false;
adca7326 1009 config.footerContent = '';
05843fd3
DW
1010 config.hideOn = [ { eventName: 'clickoutside' } ];
1011
adca7326
DW
1012 CONTROLMENU.superclass.constructor.apply(this, [config]);
1013};
1014
1015Y.extend(CONTROLMENU, M.core.dialogue, {
1016
1017 /**
1018 * Initialise the menu.
1019 *
1020 * @method initializer
1021 * @return void
1022 */
1023 initializer : function(config) {
1024 var body, headertext, bb;
1025 CONTROLMENU.superclass.initializer.call(this, config);
1026
1027 bb = this.get('boundingBox');
1028 bb.addClass('editor_atto_controlmenu');
1029
1030 // Close the menu when clicked outside (excluding the button that opened the menu).
1031 body = this.bodyNode;
1032
1033 headertext = Y.Node.create('<h3/>');
1034 headertext.addClass('accesshide');
1035 headertext.setHTML(this.get('headerText'));
1036 body.prepend(headertext);
adca7326
DW
1037 }
1038
1039}, {
1040 NAME : CONTROLMENU_NAME,
1041 ATTRS : {
1042 /**
1043 * The header for the drop down (only accessible to screen readers).
1044 *
1045 * @attribute headerText
1046 * @type String
1047 * @default ''
1048 */
1049 headerText : {
1050 value : ''
1051 }
1052
1053 }
1054});
1055
1056M.editor_atto = M.editor_atto || {};
1057M.editor_atto.controlmenu = CONTROLMENU;
d088a835
DW
1058/**
1059 * Class for cleaning ugly HTML.
1060 * Rewritten JS from jquery-clean plugin.
1061 *
1062 * @module editor_atto
1063 * @chainable
1064 */
1065function cleanHTML() {
1066 var cleaned = this.getHTML();
1067
1068 // What are we doing ?
1069 // We are cleaning random HTML from all over the shop into a set of useful html suitable for content.
1070 // We are allowing styles etc, but not e.g. font tags, class="MsoNormal" etc.
1071
1072 var rules = [
1073 // Source: "http://stackoverflow.com/questions/2875027/clean-microsoft-word-pasted-text-using-javascript"
1074 // Source: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
1075
1076 // Remove all HTML comments.
1077 {regex: /<!--[\s\S]*?-->/gi, replace: ""},
1078 // Source: "http://www.1stclassmedia.co.uk/developers/clean-ms-word-formatting.php"
1079 // Remove <?xml>, <\?xml>.
1080 {regex: /<\\?\?xml[^>]*>/gi, replace: ""},
1081 // Remove <o:blah>, <\o:blah>.
1082 {regex: /<\/?\w+:[^>]*>/gi, replace: ""}, // e.g. <o:p...
1083 // Remove MSO-blah, MSO:blah (e.g. in style attributes)
1084 {regex: /\s*MSO[-:][^;"']*;?/gi, replace: ""},
1085 // Remove empty spans
1086 {regex: /<span[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""},
1087 // Remove class="Msoblah"
1088 {regex: /class="Mso[^"]*"/gi, replace: ""},
1089
1090 // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
1091 // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body.
1092 {regex: /<(\/?title|\/?meta|\/?style|\/?st\d|\/?head|\/?font|\/?html|\/?body|!\[)[^>]*?>/gi, replace: ""},
1093
1094 // Source: "http://www.tim-jarrett.com/labs_javascript_scrub_word.php"
1095 // Replace extended chars with simple text.
1096 {regex: new RegExp(String.fromCharCode(8220), 'gi'), replace: '"'},
1097 {regex: new RegExp(String.fromCharCode(8216), 'gi'), replace: "'"},
1098 {regex: new RegExp(String.fromCharCode(8217), 'gi'), replace: "'"},
1099 {regex: new RegExp(String.fromCharCode(8211), 'gi'), replace: '-'},
1100 {regex: new RegExp(String.fromCharCode(8212), 'gi'), replace: '--'},
1101 {regex: new RegExp(String.fromCharCode(189), 'gi'), replace: '1/2'},
1102 {regex: new RegExp(String.fromCharCode(188), 'gi'), replace: '1/4'},
1103 {regex: new RegExp(String.fromCharCode(190), 'gi'), replace: '3/4'},
1104 {regex: new RegExp(String.fromCharCode(169), 'gi'), replace: '(c)'},
1105 {regex: new RegExp(String.fromCharCode(174), 'gi'), replace: '(r)'},
1106 {regex: new RegExp(String.fromCharCode(8230), 'gi'), replace: '...'}
1107 ];
1108
1109 var i = 0, rule;
1110
1111 for (i = 0; i < rules.length; i++) {
1112 rule = rules[i];
1113 cleaned = cleaned.replace(rule.regex, rule.replace);
1114 }
1115
1116 this.setHTML(cleaned);
1117 return this;
1118}
1119
1120Y.Node.addMethod("cleanHTML", cleanHTML);
1121Y.NodeList.importMethod(Y.Node.prototype, "cleanHTML");
adca7326
DW
1122
1123
dad09216 1124}, '@VERSION@', {"requires": ["node", "io", "overlay", "escape", "event", "event-simulate", "moodle-core-notification"]});