MDL-43869 Atto: id escaping so Atto works with when there is : in the id.
[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
46 /**
47 * List of attached button handlers to prevent duplicates.
48 */
49 buttonhandlers : {},
50
51 /**
52 * List of attached handlers to add inline editing controls to content.
53 */
54 textupdatedhandlers : {},
55
56 /**
57 * List of YUI overlays for custom menus.
58 */
59 menus : {},
60
61 /**
62 * List of attached menu handlers to prevent duplicates.
63 */
64 menuhandlers : {},
65
66 /**
67 * List of file picker options for specific editor instances.
68 */
69 filepickeroptions : {},
70
71 /**
72 * List of buttons and menus that have been added to the toolbar.
73 */
74 widgets : {},
75
76 /**
77 * Toggle a menu.
78 * @param event e
79 */
80 showhide_menu_handler : function(e) {
81 e.preventDefault();
82 var disabled = this.getAttribute('disabled');
83 var overlayid = this.getAttribute('data-menu');
84 var overlay = M.editor_atto.menus[overlayid];
85 var menu = overlay.get('bodyContent');
86 if (overlay.get('visible') || disabled) {
87 overlay.hide();
88 menu.detach('clickoutside');
89 } else {
90 menu.on('clickoutside', function(ev) {
91 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
92 if (overlay.get('visible')) {
93 menu.detach('clickoutside');
94 overlay.hide();
95 }
96 }
97 }, this);
98 overlay.show();
99 overlay.bodyNode.one('a').focus();
100 }
101 },
102
103 /**
104 * Handle clicks on editor buttons.
105 * @param event e
106 */
107 buttonclicked_handler : function(e) {
108 var elementid = this.getAttribute('data-editor');
109 var plugin = this.getAttribute('data-plugin');
110 var handler = this.getAttribute('data-handler');
111 var overlay = M.editor_atto.menus[plugin + '_' + elementid];
112
113 if (overlay) {
114 overlay.hide();
115 }
116
117 if (M.editor_atto.is_enabled(elementid, plugin)) {
118 // Pass it on.
119 handler = M.editor_atto.buttonhandlers[handler];
120 return handler(e, elementid);
121 }
122 },
123
124 /**
125 * Determine if the specified toolbar button/menu is enabled.
126 * @param string elementid, the element id of this editor.
127 * @param string plugin, the plugin that created the button/menu.
128 */
129 is_enabled : function(elementid, plugin) {
130 var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
131
132 return !element.hasAttribute('disabled');
133 },
134 /**
135 * Disable all buttons and menus in the toolbar.
136 * @param string elementid, the element id of this editor.
137 */
138 disable_all_widgets : function(elementid) {
139 var plugin, element;
140 for (plugin in M.editor_atto.widgets) {
141 element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
142
143 if (element) {
144 element.setAttribute('disabled', 'true');
145 }
146 }
147 },
148
149 /**
150 * Enable a single widget in the toolbar.
151 * @param string elementid, the element id of this editor.
152 * @param string plugin, the name of the plugin that created the widget.
153 */
154 enable_widget : function(elementid, plugin) {
155 var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
156
157 if (element) {
158 element.removeAttribute('disabled');
159 }
160 },
161
162 /**
163 * Enable all buttons and menus in the toolbar.
164 * @param string elementid, the element id of this editor.
165 */
166 enable_all_widgets : function(elementid) {
167 var plugin, element;
168 for (plugin in M.editor_atto.widgets) {
169 element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
170
171 if (element) {
172 element.removeAttribute('disabled');
173 }
174 }
175 },
176
177 /**
178 * Add a content update handler to be called whenever the content is updated.
179 * This is used to add inline editing controls to the content that are cleaned on submission.
180 *
181 * @param string elementid - the id of the textarea we created this editor from.
182 * @handler function callback - The function to do the cleaning.
183 * @param object context - the context to set for the callback.
184 * @handler function handler - A function to call when the button is clicked.
185 */
186 add_text_updated_handler : function(elementid, callback) {
187 if (!(elementid in M.editor_atto.textupdatedhandlers)) {
188 M.editor_atto.textupdatedhandlers[elementid] = [];
189 }
190 M.editor_atto.textupdatedhandlers[elementid].push(callback);
191 },
192
193 /**
194 * Add a button to the toolbar belonging to the editor for element with id "elementid".
195 * @param string elementid - the id of the textarea we created this editor from.
196 * @param string plugin - the plugin defining the button
197 * @param string icon - the html used for the content of the button
198 * @param string groupname - the group the button should be appended to.
199 * @param array entries - List of menu entries with the string (entry.text) and the handlers (entry.handler).
200 */
55c0403c 201 add_toolbar_menu : function(elementid, plugin, iconurl, groupname, entries) {
adca7326
DW
202 var toolbar = Y.one('#' + elementid + '_toolbar'),
203 group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
204 currentfocus,
205 button,
adca7326
DW
206 expimgurl;
207
208 if (!group) {
209 group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
210 toolbar.append(group);
211 }
adca7326
DW
212 expimgurl = M.util.image_url('t/expanded', 'moodle');
213 button = Y.Node.create('<button class="atto_' + plugin + '_button atto_hasmenu" ' +
214 'data-editor="' + Y.Escape.html(elementid) + '" ' +
215 'tabindex="-1" ' +
216 'data-menu="' + plugin + '_' + elementid + '" ' +
217 'title="' + Y.Escape.html(M.util.get_string('pluginname', 'atto_' + plugin)) + '">' +
55c0403c 218 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + iconurl + '"/>' +
adca7326
DW
219 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + expimgurl + '"/>' +
220 '</button>');
221
222 group.append(button);
223
224 currentfocus = toolbar.getAttribute('aria-activedescendant');
225 if (!currentfocus) {
226 button.setAttribute('tabindex', '0');
227 toolbar.setAttribute('aria-activedescendant', button.generateID());
228 }
229
230 // Save the name of the plugin.
231 M.editor_atto.widgets[plugin] = plugin;
232
233 var menu = Y.Node.create('<div class="atto_' + plugin + '_menu' +
234 ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"></div>');
235 var i = 0, entry = {};
236
237 for (i = 0; i < entries.length; i++) {
238 entry = entries[i];
239
240 menu.append(Y.Node.create('<div class="atto_menuentry">' +
241 '<a href="#" class="atto_' + plugin + '_action_' + i + '" ' +
242 'data-editor="' + Y.Escape.html(elementid) + '" ' +
243 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
244 'data-handler="' + Y.Escape.html(plugin + '_action_' + i) + '">' +
245 entry.text +
246 '</a>' +
247 '</div>'));
248 if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
249 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_action_' + i);
250 Y.one('body').delegate('key', M.editor_atto.buttonclicked_handler, 'space,enter', '.atto_' + plugin + '_action_' + i);
251 M.editor_atto.buttonhandlers[plugin + '_action_' + i] = entry.handler;
252 }
253 }
254
255 if (!M.editor_atto.buttonhandlers[plugin]) {
256 Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + plugin + '_button');
257 M.editor_atto.buttonhandlers[plugin] = true;
258 }
259
260 var overlay = new M.core.dialogue({
261 bodyContent : menu,
262 visible : false,
263 width: '14em',
264 zindex: 100,
265 lightbox: false,
266 closeButton: false,
55c0403c 267 center : false
adca7326
DW
268 });
269
270 M.editor_atto.menus[plugin + '_' + elementid] = overlay;
271 overlay.render();
55c0403c 272 overlay.align(button, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
adca7326
DW
273 overlay.hide();
274 overlay.headerNode.hide();
275 },
276
277 /**
278 * Add a button to the toolbar belonging to the editor for element with id "elementid".
279 * @param string elementid - the id of the textarea we created this editor from.
280 * @param string plugin - the plugin defining the button.
55c0403c 281 * @param string icon - the url to the image for the icon
adca7326
DW
282 * @param string groupname - the group the button should be appended to.
283 * @handler function handler- A function to call when the button is clicked.
284 */
55c0403c 285 add_toolbar_button : function(elementid, plugin, iconurl, groupname, handler) {
adca7326
DW
286 var toolbar = Y.one('#' + elementid + '_toolbar'),
287 group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
288 button,
55c0403c 289 currentfocus;
adca7326
DW
290
291 if (!group) {
292 group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
293 toolbar.append(group);
294 }
adca7326
DW
295 button = Y.Node.create('<button class="atto_' + plugin + '_button" ' +
296 'data-editor="' + Y.Escape.html(elementid) + '" ' +
297 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
298 'tabindex="-1" ' +
299 'data-handler="' + Y.Escape.html(plugin) + '" ' +
300 'title="' + Y.Escape.html(M.util.get_string('pluginname', 'atto_' + plugin)) + '">' +
55c0403c 301 '<img class="icon" aria-hidden="true" role="presentation" width="16" height="16" src="' + iconurl + '"/>' +
adca7326
DW
302 '</button>');
303
304 group.append(button);
305
306 currentfocus = toolbar.getAttribute('aria-activedescendant');
307 if (!currentfocus) {
308 button.setAttribute('tabindex', '0');
309 toolbar.setAttribute('aria-activedescendant', button.generateID());
310 }
311
312 // We only need to attach this once.
313 if (!M.editor_atto.buttonhandlers[plugin]) {
314 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_button');
315 M.editor_atto.buttonhandlers[plugin] = handler;
316 }
317
318 // Save the name of the plugin.
319 M.editor_atto.widgets[plugin] = plugin;
320
321 },
322
323 /**
324 * Work out if the cursor is in the editable area for this editor instance.
325 * @param string elementid of this editor
326 * @return bool
327 */
328 is_active : function(elementid) {
329 var selection = M.editor_atto.get_selection();
330
331 if (selection.length) {
332 selection = selection.pop();
333 }
334
335 var node = null;
336 if (selection.parentElement) {
337 node = Y.one(selection.parentElement());
338 } else {
339 node = Y.one(selection.startContainer);
340 }
341
342 return node && node.ancestor('#' + elementid + 'editable') !== null;
343 },
344
345 /**
346 * Focus on the editable area for this editor.
347 * @param string elementid of this editor
348 */
349 focus : function(elementid) {
350 Y.one('#' + elementid + 'editable').focus();
351 },
352
353 /**
354 * Initialise the editor
355 * @param object params for this editor instance.
356 */
357 init : function(params) {
adca7326
DW
358 var wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
359 var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
360 'contenteditable="true" ' +
361 'spellcheck="true" ' +
362 'class="' + CSS.CONTENT + '" />');
363
364 var cssfont = '';
365 var toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" id="' + params.elementid + '_toolbar" role="toolbar"/>');
366
367 // Editable content wrapper.
368 var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
b269f635
DW
369 var elementid = params.elementid.replace(':', '\\:');
370 var textarea = Y.one('#' +elementid);
371
adca7326
DW
372 content.appendChild(atto);
373
374 // Add everything to the wrapper.
375 wrapper.appendChild(toolbar);
376 wrapper.appendChild(content);
377
378 // Bleh - why are we sent a url and not the css to apply directly?
379 var css = Y.io(params.content_css, { sync: true });
380 var pos = css.responseText.indexOf('font:');
381 if (pos) {
382 cssfont = css.responseText.substring(pos + 'font:'.length, css.responseText.length - 1);
383 atto.setStyle('font', cssfont);
384 }
385 atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows'))) + 'em');
386
387 // Copy text to editable div.
388 atto.append(textarea.get('value'));
389
390 // Add the toolbar and editable zone to the page.
391 textarea.get('parentNode').insert(wrapper, textarea);
392 atto.setStyle('color', textarea.getStyle('color'));
393 atto.setStyle('lineHeight', textarea.getStyle('lineHeight'));
394 atto.setStyle('fontSize', textarea.getStyle('fontSize'));
395 // Hide the old textarea.
396 textarea.hide();
397
398 // Copy the current value back to the textarea when focus leaves us.
399 atto.on('blur', function() {
b269f635 400 this.text_updated(elementid);
adca7326
DW
401 }, this);
402
403 // Listen for Arrow left and Arrow right keys.
404 Y.one(Y.config.doc.body).delegate('key',
405 this.keyboard_navigation,
406 'down:37,39',
b269f635 407 '#' + elementid + '_toolbar',
adca7326 408 this,
b269f635 409 elementid);
adca7326
DW
410
411 // Save the file picker options for later.
b269f635 412 M.editor_atto.filepickeroptions[elementid] = params.filepickeroptions;
55c0403c
DW
413
414 // Init each of the plugins
415 var i, j;
416 for (i = 0; i < params.plugins.length; i++) {
417 var group = params.plugins[i].group;
418 for (j = 0; j < params.plugins[i].plugins.length; j++) {
419 var plugin = params.plugins[i].plugins[j];
b269f635 420 plugin.params.elementid = elementid;
55c0403c
DW
421 plugin.params.group = group;
422
423 M['atto_' + plugin.name].init(plugin.params);
424 }
425 }
adca7326
DW
426 },
427
428 /**
429 * The text in the contenteditable region has been updated,
430 * clean and copy the buffer to the text area.
431 * @param string elementid - the id of the textarea we created this editor from.
432 */
433 text_updated : function(elementid) {
434 var textarea = Y.one('#' + elementid),
435 cleancontent = this.get_clean_html(elementid);
436 textarea.set('value', cleancontent);
437 // Trigger handlers for this action.
438 var i = 0;
439 if (elementid in M.editor_atto.textupdatedhandlers) {
440 for (i = 0; i < M.editor_atto.textupdatedhandlers[elementid].length; i++) {
441 var callback = M.editor_atto.textupdatedhandlers[elementid][i];
442 callback(elementid);
443 }
444 }
445 },
446
447 /**
448 * Remove all YUI ids from the generated HTML.
449 * @param string elementid - the id of the textarea we created this editor from.
450 * @return string HTML stripped of YUI ids
451 */
452 get_clean_html : function(elementid) {
453 var atto = Y.one('#' + elementid + 'editable').cloneNode(true);
454
455 Y.each(atto.all('[id]'), function(node) {
456 var id = node.get('id');
457 if (id.indexOf('yui') === 0) {
458 node.removeAttribute('id');
459 }
460 });
461
462 Y.each(atto.all('.atto_control'), function(node) {
463 node.remove(true);
464 });
465
466 return atto.getHTML();
467 },
468
469 /**
470 * Implement arrow key navigation for the buttons in the toolbar.
471 * @param Event e - the keyboard event.
472 * @param string elementid - the id of the textarea we created this editor from.
473 */
474 keyboard_navigation : function(e, elementid) {
475 var buttons,
476 current,
477 currentid,
478 currentindex;
479
480 e.preventDefault();
481
482 buttons = Y.all('#' + elementid + '_toolbar button');
483 currentid = Y.one('#' + elementid + '_toolbar').getAttribute('aria-activedescendant');
484 if (!currentid) {
485 return;
486 }
487 current = Y.one('#' + currentid);
488 current.setAttribute('tabindex', '-1');
489
490 currentindex = buttons.indexOf(current);
491
492 if (e.keyCode === 37) {
493 // Left
494 currentindex--;
495 if (currentindex < 0) {
496 currentindex = buttons.size()-1;
497 }
498 } else {
499 // Right
500 currentindex++;
501 if (currentindex >= buttons.size()) {
502 currentindex = 0;
503 }
504 }
505
506 current = buttons.item(currentindex);
507 current.setAttribute('tabindex', '0');
508 current.focus();
509 Y.one('#' + elementid + '_toolbar').setAttribute('aria-activedescendant', current.generateID());
510 },
511
b269f635
DW
512 /**
513 * Should we show the filepicker for this filetype?
514 *
515 * @param string elementid for this editor instance.
516 * @param string type The media type for the file picker
517 * @return boolean
518 */
519 can_show_filepicker : function(elementid, type) {
520 var options = M.editor_atto.filepickeroptions[elementid];
521 return ((typeof options[type]) !== "undefined");
522 },
523
adca7326
DW
524 /**
525 * Show the filepicker.
526 * @param string elementid for this editor instance.
527 * @param string type The media type for the file picker
528 * @param function callback
529 */
530 show_filepicker : function(elementid, type, callback) {
531 Y.use('core_filepicker', function (Y) {
532 var options = M.editor_atto.filepickeroptions[elementid][type];
533
534 options.formcallback = callback;
535 options.editor_target = Y.one(elementid);
536
537 M.core_filepicker.show(Y, options);
538 });
539 },
540
541 /**
542 * Create a cross browser selection object that represents a yui node.
543 * @param Node yui node for the selection
544 * @return range (browser dependent)
545 */
546 get_selection_from_node: function(node) {
547 var range;
548
549 if (window.getSelection) {
550 range = document.createRange();
551
552 range.setStartBefore(node.getDOMNode());
553 range.setEndAfter(node.getDOMNode());
554 return [range];
555 } else if (document.selection) {
556 range = document.body.createTextRange();
557 range.moveToElementText(node.getDOMNode());
558 return range;
559 }
560 return false;
561 },
562
563 /**
564 * Get the selection object that can be passed back to set_selection.
565 * @return range (browser dependent)
566 */
567 get_selection : function() {
568 if (window.getSelection) {
569 var sel = window.getSelection();
570 var ranges = [], i = 0;
571 for (i = 0; i < sel.rangeCount; i++) {
572 ranges.push(sel.getRangeAt(i));
573 }
574 return ranges;
575 } else if (document.selection) {
576 // IE < 9
577 if (document.selection.createRange) {
578 return document.selection.createRange();
579 }
580 }
581 return false;
582 },
583
584 /**
585 * Check that a YUI node it at least partly contained by the selection.
586 * @param Range selection
587 * @param Y.Node node
588 * @return boolean
589 */
590 selection_contains_node : function(node) {
591 var range, sel;
592 if (window.getSelection) {
593 sel = window.getSelection();
594
595 if (sel.containsNode) {
596 return sel.containsNode(node.getDOMNode(), true);
597 }
598 }
599 sel = document.selection.createRange();
600 range = sel.duplicate();
601 range.moveToElementText(node.getDOMNode());
602 return sel.inRange(range);
603 },
604
605 /**
606 * Get the dom node representing the common anscestor of the selection nodes.
607 * @return DOMNode
608 */
609 get_selection_parent_node : function() {
610 var selection = M.editor_atto.get_selection();
611 if (selection.length > 0) {
612 return selection[0].commonAncestorContainer;
613 }
614 },
615
616 /**
617 * Get the list of child nodes of the selection.
618 * @return DOMNode[]
619 */
620 get_selection_text : function() {
621 var selection = M.editor_atto.get_selection();
622 if (selection.length > 0 && selection[0].cloneContents) {
623 return selection[0].cloneContents();
624 }
625 },
626
627 /**
628 * Set the current selection. Used to restore a selection.
629 */
630 set_selection : function(selection) {
631 var sel, i;
632
633 if (window.getSelection) {
634 sel = window.getSelection();
635 sel.removeAllRanges();
636 for (i = 0; i < selection.length; i++) {
637 sel.addRange(selection[i]);
638 }
639 } else if (document.selection) {
640 // IE < 9
641 if (selection.select) {
642 selection.select();
643 }
644 }
645 }
646
647};
648var CONTROLMENU_NAME = "Controlmenu",
649 CONTROLMENU;
650
651/**
652 * CONTROLMENU
653 * This is a drop down list of buttons triggered (and aligned to) a button.
654 *
655 * @namespace M.editor_atto.controlmenu
656 * @class controlmenu
657 * @constructor
658 * @extends M.core.dialogue
659 */
660CONTROLMENU = function(config) {
661 config.draggable = false;
55c0403c 662 config.center = false;
adca7326
DW
663 config.width = 'auto';
664 config.lightbox = false;
adca7326
DW
665 config.footerContent = '';
666 CONTROLMENU.superclass.constructor.apply(this, [config]);
667};
668
669Y.extend(CONTROLMENU, M.core.dialogue, {
670
671 /**
672 * Initialise the menu.
673 *
674 * @method initializer
675 * @return void
676 */
677 initializer : function(config) {
678 var body, headertext, bb;
679 CONTROLMENU.superclass.initializer.call(this, config);
680
681 bb = this.get('boundingBox');
682 bb.addClass('editor_atto_controlmenu');
683
684 // Close the menu when clicked outside (excluding the button that opened the menu).
685 body = this.bodyNode;
686
687 headertext = Y.Node.create('<h3/>');
688 headertext.addClass('accesshide');
689 headertext.setHTML(this.get('headerText'));
690 body.prepend(headertext);
691
692 body.on('clickoutside', function(e) {
693 if (this.get('visible')) {
694 // Note: we need to compare ids because for some reason - sometimes button is an Object, not a Y.Node.
695 if (!e.target.ancestor('.atto_control')) {
696 e.preventDefault();
697 this.hide();
698 }
699 }
700 }, this);
701 }
702
703}, {
704 NAME : CONTROLMENU_NAME,
705 ATTRS : {
706 /**
707 * The header for the drop down (only accessible to screen readers).
708 *
709 * @attribute headerText
710 * @type String
711 * @default ''
712 */
713 headerText : {
714 value : ''
715 }
716
717 }
718});
719
720M.editor_atto = M.editor_atto || {};
721M.editor_atto.controlmenu = CONTROLMENU;
722
723
724}, '@VERSION@', {"requires": ["node", "io", "overlay", "escape", "event", "moodle-core-notification"]});