MDL-43863 Add Undo/Redo plugins to Atto
[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.
f6bef145 557 M.editor_atto.disable_css_styling();
4c37c1f4 558
adca7326
DW
559 // Hide the old textarea.
560 textarea.hide();
26f8822d
DW
561 atto.on('keydown', this.save_selection, this, params.elementid);
562 atto.on('mouseup', this.save_selection, this, params.elementid);
563 atto.on('focus', this.restore_selection, this, params.elementid);
564 // Do not restore selection when focus is from a click event.
565 atto.on('mousedown', function() { this.focusfromclick = true; }, this);
adca7326 566
26f8822d 567 // Copy the current value back to the textarea when focus leaves us and save the current selection.
adca7326 568 atto.on('blur', function() {
26f8822d 569 this.focusfromclick = false;
48bdf86f 570 this.text_updated(params.elementid);
adca7326
DW
571 }, this);
572
573 // Listen for Arrow left and Arrow right keys.
574 Y.one(Y.config.doc.body).delegate('key',
575 this.keyboard_navigation,
576 'down:37,39',
48bdf86f 577 '#' + params.elementid + '_toolbar',
adca7326 578 this,
48bdf86f 579 params.elementid);
adca7326
DW
580
581 // Save the file picker options for later.
48bdf86f 582 M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
55c0403c
DW
583
584 // Init each of the plugins
fcb5b5c4 585 var i, j, group, plugin;
55c0403c 586 for (i = 0; i < params.plugins.length; i++) {
fcb5b5c4 587 group = params.plugins[i].group;
55c0403c 588 for (j = 0; j < params.plugins[i].plugins.length; j++) {
fcb5b5c4 589 plugin = params.plugins[i].plugins[j];
48bdf86f 590 plugin.params.elementid = params.elementid;
55c0403c
DW
591 plugin.params.group = group;
592
593 M['atto_' + plugin.name].init(plugin.params);
594 }
595 }
fcb5b5c4
DW
596
597 // Let the plugins run some init code once all plugins are in the page.
598 for (i = 0; i < params.plugins.length; i++) {
599 group = params.plugins[i].group;
600 for (j = 0; j < params.plugins[i].plugins.length; j++) {
601 plugin = params.plugins[i].plugins[j];
602 plugin.params.elementid = params.elementid;
603 plugin.params.group = group;
604
605 if (typeof M['atto_' + plugin.name].after_init !== 'undefined') {
606 M['atto_' + plugin.name].after_init(plugin.params);
607 }
608 }
609 }
adca7326
DW
610 },
611
612 /**
613 * The text in the contenteditable region has been updated,
614 * clean and copy the buffer to the text area.
615 * @param string elementid - the id of the textarea we created this editor from.
616 */
617 text_updated : function(elementid) {
48bdf86f 618 var textarea = M.editor_atto.get_textarea_node(elementid),
adca7326
DW
619 cleancontent = this.get_clean_html(elementid);
620 textarea.set('value', cleancontent);
dad09216
FM
621 // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
622 textarea.simulate('change');
adca7326
DW
623 // Trigger handlers for this action.
624 var i = 0;
625 if (elementid in M.editor_atto.textupdatedhandlers) {
626 for (i = 0; i < M.editor_atto.textupdatedhandlers[elementid].length; i++) {
627 var callback = M.editor_atto.textupdatedhandlers[elementid][i];
628 callback(elementid);
629 }
630 }
631 },
632
633 /**
634 * Remove all YUI ids from the generated HTML.
635 * @param string elementid - the id of the textarea we created this editor from.
636 * @return string HTML stripped of YUI ids
637 */
638 get_clean_html : function(elementid) {
48bdf86f 639 var atto = M.editor_atto.get_editable_node(elementid).cloneNode(true);
adca7326
DW
640
641 Y.each(atto.all('[id]'), function(node) {
642 var id = node.get('id');
643 if (id.indexOf('yui') === 0) {
644 node.removeAttribute('id');
645 }
646 });
647
d088a835
DW
648 // Remove any and all nasties from source.
649 atto.cleanHTML();
650
adca7326
DW
651 return atto.getHTML();
652 },
653
654 /**
655 * Implement arrow key navigation for the buttons in the toolbar.
656 * @param Event e - the keyboard event.
657 * @param string elementid - the id of the textarea we created this editor from.
658 */
659 keyboard_navigation : function(e, elementid) {
660 var buttons,
661 current,
662 currentid,
48bdf86f
DW
663 currentindex,
664 toolbar = M.editor_atto.get_toolbar_node(elementid);
adca7326
DW
665
666 e.preventDefault();
667
fcb5b5c4
DW
668 // This workaround is because we cannot do ".atto_group:not([hidden]) button" in ie8 (even with selector-css3).
669 // Create an empty NodeList.
670 buttons = toolbar.all('empty');
671 toolbar.all('.atto_group').each(function(group) {
672 if (!group.hasAttribute('hidden')) {
673 // Append the visible buttons to the buttons list.
674 buttons = buttons.concat(group.all('button'));
675 }
676 });
677 // The currentid is the id of the previously selected button.
48bdf86f 678 currentid = toolbar.getAttribute('aria-activedescendant');
adca7326
DW
679 if (!currentid) {
680 return;
681 }
fcb5b5c4 682 // We only ever want one button with a tabindex of 0.
adca7326
DW
683 current = Y.one('#' + currentid);
684 current.setAttribute('tabindex', '-1');
685
686 currentindex = buttons.indexOf(current);
687
688 if (e.keyCode === 37) {
689 // Left
690 currentindex--;
691 if (currentindex < 0) {
692 currentindex = buttons.size()-1;
693 }
694 } else {
695 // Right
696 currentindex++;
697 if (currentindex >= buttons.size()) {
698 currentindex = 0;
699 }
700 }
fcb5b5c4 701 // Currentindex has been updated to point to the new button.
adca7326
DW
702 current = buttons.item(currentindex);
703 current.setAttribute('tabindex', '0');
704 current.focus();
48bdf86f 705 toolbar.setAttribute('aria-activedescendant', current.generateID());
adca7326
DW
706 },
707
b269f635
DW
708 /**
709 * Should we show the filepicker for this filetype?
710 *
711 * @param string elementid for this editor instance.
712 * @param string type The media type for the file picker
713 * @return boolean
714 */
715 can_show_filepicker : function(elementid, type) {
716 var options = M.editor_atto.filepickeroptions[elementid];
717 return ((typeof options[type]) !== "undefined");
718 },
719
adca7326
DW
720 /**
721 * Show the filepicker.
722 * @param string elementid for this editor instance.
723 * @param string type The media type for the file picker
724 * @param function callback
725 */
726 show_filepicker : function(elementid, type, callback) {
727 Y.use('core_filepicker', function (Y) {
728 var options = M.editor_atto.filepickeroptions[elementid][type];
729
730 options.formcallback = callback;
adca7326
DW
731
732 M.core_filepicker.show(Y, options);
733 });
734 },
735
736 /**
737 * Create a cross browser selection object that represents a yui node.
738 * @param Node yui node for the selection
739 * @return range (browser dependent)
740 */
741 get_selection_from_node: function(node) {
742 var range;
743
744 if (window.getSelection) {
745 range = document.createRange();
746
747 range.setStartBefore(node.getDOMNode());
748 range.setEndAfter(node.getDOMNode());
749 return [range];
750 } else if (document.selection) {
751 range = document.body.createTextRange();
752 range.moveToElementText(node.getDOMNode());
753 return range;
754 }
755 return false;
756 },
757
26f8822d
DW
758 /**
759 * Save the current selection on blur, allows more reliable keyboard navigation.
760 * @param Y.Event event
761 * @param string elementid
762 */
763 save_selection : function(event, elementid) {
764 if (this.is_active(elementid)) {
765 var sel = this.get_selection();
766 if (sel.length > 0) {
767 this.selections[elementid] = sel;
768 }
769 }
770 },
771
772 /**
773 * Restore any current selection when the editor gets focus again.
774 * @param Y.Event event
775 * @param string elementid
776 */
777 restore_selection : function(event, elementid) {
778 event.preventDefault();
779 if (!this.focusfromclick) {
780 if (typeof this.selections[elementid] !== "undefined") {
781 this.set_selection(this.selections[elementid]);
782 }
783 }
784 this.focusfromclick = false;
785 },
786
adca7326
DW
787 /**
788 * Get the selection object that can be passed back to set_selection.
789 * @return range (browser dependent)
790 */
791 get_selection : function() {
792 if (window.getSelection) {
793 var sel = window.getSelection();
794 var ranges = [], i = 0;
795 for (i = 0; i < sel.rangeCount; i++) {
796 ranges.push(sel.getRangeAt(i));
797 }
798 return ranges;
799 } else if (document.selection) {
800 // IE < 9
801 if (document.selection.createRange) {
802 return document.selection.createRange();
803 }
804 }
805 return false;
806 },
807
808 /**
809 * Check that a YUI node it at least partly contained by the selection.
adca7326
DW
810 * @param Y.Node node
811 * @return boolean
812 */
813 selection_contains_node : function(node) {
814 var range, sel;
815 if (window.getSelection) {
816 sel = window.getSelection();
817
818 if (sel.containsNode) {
819 return sel.containsNode(node.getDOMNode(), true);
820 }
821 }
822 sel = document.selection.createRange();
823 range = sel.duplicate();
824 range.moveToElementText(node.getDOMNode());
825 return sel.inRange(range);
826 },
827
828 /**
829 * Get the dom node representing the common anscestor of the selection nodes.
34f5867a 830 * @return DOMNode or false
adca7326
DW
831 */
832 get_selection_parent_node : function() {
833 var selection = M.editor_atto.get_selection();
34f5867a
DW
834 if (selection.length) {
835 selection = selection.pop();
836 }
837
838 if (selection.commonAncestorContainer) {
839 return selection.commonAncestorContainer;
840 } else if (selection.parentElement) {
841 return selection.parentElement();
adca7326 842 }
34f5867a
DW
843 // No selection
844 return false;
adca7326
DW
845 },
846
847 /**
848 * Get the list of child nodes of the selection.
849 * @return DOMNode[]
850 */
851 get_selection_text : function() {
852 var selection = M.editor_atto.get_selection();
853 if (selection.length > 0 && selection[0].cloneContents) {
854 return selection[0].cloneContents();
855 }
856 },
857
858 /**
859 * Set the current selection. Used to restore a selection.
860 */
861 set_selection : function(selection) {
862 var sel, i;
863
864 if (window.getSelection) {
865 sel = window.getSelection();
866 sel.removeAllRanges();
867 for (i = 0; i < selection.length; i++) {
868 sel.addRange(selection[i]);
869 }
870 } else if (document.selection) {
871 // IE < 9
872 if (selection.select) {
873 selection.select();
874 }
875 }
34f5867a
DW
876 },
877
878 /**
879 * Change the formatting for the current selection.
880 * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
881 *
882 * @param {String} elementid - The editor elementid.
883 * @param {String} blocktag - Change the block level tag to this. Empty string, means do not change the tag.
884 * @param {Object} attributes - The keys and values for attributes to be added/changed in the block tag.
885 * @return Y.Node - if there was a selection.
886 */
887 format_selection_block : function(elementid, blocktag, attributes) {
888 // First find the nearest ancestor of the selection that is a block level element.
889 var selectionparentnode = M.editor_atto.get_selection_parent_node(),
890 boundary,
891 cell,
892 nearestblock,
893 newcontent,
894 match,
895 replacement;
896
897 if (!selectionparentnode) {
898 // No selection, nothing to format.
899 return;
900 }
901
902 boundary = M.editor_atto.get_editable_node(elementid);
903
904 selectionparentnode = Y.one(selectionparentnode);
905
906 // If there is a table cell in between the selectionparentnode and the boundary,
907 // move the boundary to the table cell.
908 // This is because we might have a table in a div, and we select some text in a cell,
909 // want to limit the change in style to the table cell, not the entire table (via the outer div).
910 cell = selectionparentnode.ancestor(function (node) {
911 var tagname = node.get('tagName');
912 if (tagname) {
913 tagname = tagname.toLowerCase();
914 }
915 return (node === boundary) ||
916 (tagname === 'td') ||
917 (tagname === 'th');
918 }, true);
919
920 if (cell) {
921 // Limit the scope to the table cell.
922 boundary = cell;
923 }
924
925 nearestblock = selectionparentnode.ancestor(M.editor_atto.BLOCK_TAGS.join(', '), true);
926 if (nearestblock) {
927 // Check that the block is contained by the boundary.
928 match = nearestblock.ancestor(function (node) {
929 return node === boundary;
930 }, false);
931
932 if (!match) {
933 nearestblock = false;
934 }
935 }
936
937 // No valid block element - make one.
938 if (!nearestblock) {
939 // There is no block node in the content, wrap the content in a p and use that.
940 newcontent = Y.Node.create('<p></p>');
941 boundary.get('childNodes').each(function (child) {
942 newcontent.append(child.remove());
943 });
944 boundary.append(newcontent);
945 nearestblock = newcontent;
946 }
947
948 // Guaranteed to have a valid block level element contained in the contenteditable region.
949 // Change the tag to the new block level tag.
950 if (blocktag && blocktag !== '') {
951 // Change the block level node for a new one.
952 replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
953 // Copy all attributes.
954 replacement.setAttrs(nearestblock.getAttrs());
955 // Copy all children.
956 nearestblock.get('childNodes').each(function (child) {
957 child.remove();
958 replacement.append(child);
959 });
960
961 nearestblock.replace(replacement);
962 nearestblock = replacement;
963 }
964
965 // Set the attributes on the block level tag.
966 if (attributes) {
967 nearestblock.setAttrs(attributes);
968 }
969
970 // Change the selection to the modified block. This makes sense when we might apply multiple styles
971 // to the block.
972 var selection = M.editor_atto.get_selection_from_node(nearestblock);
973 M.editor_atto.set_selection(selection);
974
975 return nearestblock;
f6bef145
FM
976 },
977
978 /**
979 * Disable CSS styling.
980 *
981 * @return {Void}
982 */
983 disable_css_styling: function() {
984 try {
985 document.execCommand("styleWithCSS", 0, false);
986 } catch (e1) {
987 try {
988 document.execCommand("useCSS", 0, true);
989 } catch (e2) {
990 try {
991 document.execCommand('styleWithCSS', false, false);
992 } catch (e3) {
993 // We did our best.
994 }
995 }
996 }
997 },
998
999 /**
1000 * Enable CSS styling.
1001 *
1002 * @return {Void}
1003 */
1004 enable_css_styling: function() {
1005 try {
1006 document.execCommand("styleWithCSS", 0, true);
1007 } catch (e1) {
1008 try {
1009 document.execCommand("useCSS", 0, false);
1010 } catch (e2) {
1011 try {
1012 document.execCommand('styleWithCSS', false, true);
1013 } catch (e3) {
1014 // We did our best.
1015 }
1016 }
1017 }
adca7326
DW
1018 }
1019
1020};
1021var CONTROLMENU_NAME = "Controlmenu",
1022 CONTROLMENU;
1023
1024/**
1025 * CONTROLMENU
1026 * This is a drop down list of buttons triggered (and aligned to) a button.
1027 *
1028 * @namespace M.editor_atto.controlmenu
1029 * @class controlmenu
1030 * @constructor
1031 * @extends M.core.dialogue
1032 */
1033CONTROLMENU = function(config) {
1034 config.draggable = false;
55c0403c 1035 config.center = false;
adca7326
DW
1036 config.width = 'auto';
1037 config.lightbox = false;
adca7326 1038 config.footerContent = '';
05843fd3
DW
1039 config.hideOn = [ { eventName: 'clickoutside' } ];
1040
adca7326
DW
1041 CONTROLMENU.superclass.constructor.apply(this, [config]);
1042};
1043
1044Y.extend(CONTROLMENU, M.core.dialogue, {
1045
1046 /**
1047 * Initialise the menu.
1048 *
1049 * @method initializer
1050 * @return void
1051 */
1052 initializer : function(config) {
1053 var body, headertext, bb;
1054 CONTROLMENU.superclass.initializer.call(this, config);
1055
1056 bb = this.get('boundingBox');
1057 bb.addClass('editor_atto_controlmenu');
1058
1059 // Close the menu when clicked outside (excluding the button that opened the menu).
1060 body = this.bodyNode;
1061
1062 headertext = Y.Node.create('<h3/>');
1063 headertext.addClass('accesshide');
1064 headertext.setHTML(this.get('headerText'));
1065 body.prepend(headertext);
adca7326
DW
1066 }
1067
1068}, {
1069 NAME : CONTROLMENU_NAME,
1070 ATTRS : {
1071 /**
1072 * The header for the drop down (only accessible to screen readers).
1073 *
1074 * @attribute headerText
1075 * @type String
1076 * @default ''
1077 */
1078 headerText : {
1079 value : ''
1080 }
1081
1082 }
1083});
1084
1085M.editor_atto = M.editor_atto || {};
1086M.editor_atto.controlmenu = CONTROLMENU;
d088a835
DW
1087/**
1088 * Class for cleaning ugly HTML.
1089 * Rewritten JS from jquery-clean plugin.
1090 *
1091 * @module editor_atto
1092 * @chainable
1093 */
1094function cleanHTML() {
1095 var cleaned = this.getHTML();
1096
1097 // What are we doing ?
1098 // We are cleaning random HTML from all over the shop into a set of useful html suitable for content.
1099 // We are allowing styles etc, but not e.g. font tags, class="MsoNormal" etc.
1100
1101 var rules = [
1102 // Source: "http://stackoverflow.com/questions/2875027/clean-microsoft-word-pasted-text-using-javascript"
1103 // Source: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
1104
1105 // Remove all HTML comments.
1106 {regex: /<!--[\s\S]*?-->/gi, replace: ""},
1107 // Source: "http://www.1stclassmedia.co.uk/developers/clean-ms-word-formatting.php"
1108 // Remove <?xml>, <\?xml>.
1109 {regex: /<\\?\?xml[^>]*>/gi, replace: ""},
1110 // Remove <o:blah>, <\o:blah>.
1111 {regex: /<\/?\w+:[^>]*>/gi, replace: ""}, // e.g. <o:p...
1112 // Remove MSO-blah, MSO:blah (e.g. in style attributes)
1113 {regex: /\s*MSO[-:][^;"']*;?/gi, replace: ""},
1114 // Remove empty spans
1115 {regex: /<span[^>]*>(&nbsp;|\s)*<\/span>/gi, replace: ""},
1116 // Remove class="Msoblah"
1117 {regex: /class="Mso[^"]*"/gi, replace: ""},
1118
1119 // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
1120 // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body.
1121 {regex: /<(\/?title|\/?meta|\/?style|\/?st\d|\/?head|\/?font|\/?html|\/?body|!\[)[^>]*?>/gi, replace: ""},
1122
1123 // Source: "http://www.tim-jarrett.com/labs_javascript_scrub_word.php"
1124 // Replace extended chars with simple text.
1125 {regex: new RegExp(String.fromCharCode(8220), 'gi'), replace: '"'},
1126 {regex: new RegExp(String.fromCharCode(8216), 'gi'), replace: "'"},
1127 {regex: new RegExp(String.fromCharCode(8217), 'gi'), replace: "'"},
1128 {regex: new RegExp(String.fromCharCode(8211), 'gi'), replace: '-'},
1129 {regex: new RegExp(String.fromCharCode(8212), 'gi'), replace: '--'},
1130 {regex: new RegExp(String.fromCharCode(189), 'gi'), replace: '1/2'},
1131 {regex: new RegExp(String.fromCharCode(188), 'gi'), replace: '1/4'},
1132 {regex: new RegExp(String.fromCharCode(190), 'gi'), replace: '3/4'},
1133 {regex: new RegExp(String.fromCharCode(169), 'gi'), replace: '(c)'},
1134 {regex: new RegExp(String.fromCharCode(174), 'gi'), replace: '(r)'},
1135 {regex: new RegExp(String.fromCharCode(8230), 'gi'), replace: '...'}
1136 ];
1137
1138 var i = 0, rule;
1139
1140 for (i = 0; i < rules.length; i++) {
1141 rule = rules[i];
1142 cleaned = cleaned.replace(rule.regex, rule.replace);
1143 }
1144
1145 this.setHTML(cleaned);
1146 return this;
1147}
1148
1149Y.Node.addMethod("cleanHTML", cleanHTML);
1150Y.NodeList.importMethod(Y.Node.prototype, "cleanHTML");
adca7326
DW
1151
1152
dad09216 1153}, '@VERSION@', {"requires": ["node", "io", "overlay", "escape", "event", "event-simulate", "moodle-core-notification"]});