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