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