MDL-42026 Atto: Remove the styles from the headings menu + focus when opened
[moodle.git] / lib / editor / atto / yui / src / editor / js / editor.js
CommitLineData
c90641fa
DW
1// This file is part of Moodle - http://moodle.org/
2//
3// Moodle is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version.
7//
8// Moodle is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
15
16/**
17 * Atto editor main class.
18 * Common functions required by editor plugins.
19 *
20 * @package editor-atto
21 * @copyright 2013 Damyon Wiese <damyon@moodle.com>
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24M.editor_atto = M.editor_atto || {
25 /**
26 * List of attached button handlers to prevent duplicates.
27 */
28 buttonhandlers : {},
29
30 /**
31 * List of YUI overlays for custom menus.
32 */
33 menus : {},
34
35 /**
36 * List of attached menu handlers to prevent duplicates.
37 */
38 menuhandlers : {},
39
40 /**
41 * List of file picker options for specific editor instances.
42 */
43 filepickeroptions : {},
44
45 /**
46 * List of buttons and menus that have been added to the toolbar.
47 */
48 widgets : {},
49
50 /**
51 * Toggle a menu.
52 * @param event e
53 */
54 showhide_menu_handler : function(e) {
55 e.preventDefault();
56 var disabled = this.getAttribute('disabled');
57 var overlayid = this.getAttribute('data-menu');
58 var overlay = M.editor_atto.menus[overlayid];
21f6c529 59 var menu = overlay.get('bodyContent');
c90641fa
DW
60 if (overlay.get('visible') || disabled) {
61 overlay.hide();
21f6c529 62 menu.detach('clickoutside');
c90641fa 63 } else {
21f6c529 64 menu.on('clickoutside', function(ev) {
c1f10ffb 65 if ((ev.target.ancestor() !== this) && (ev.target !== this)) {
21f6c529
JF
66 if (overlay.get('visible')) {
67 menu.detach('clickoutside');
68 overlay.hide();
69 }
70 }
71 }, this);
c90641fa 72 overlay.show();
bb76233d 73 overlay.bodyNode.one('a').focus();
c90641fa 74 }
c90641fa
DW
75 },
76
77 /**
78 * Handle clicks on editor buttons.
79 * @param event e
80 */
81 buttonclicked_handler : function(e) {
82 var elementid = this.getAttribute('data-editor');
83 var plugin = this.getAttribute('data-plugin');
84 var handler = this.getAttribute('data-handler');
85 var overlay = M.editor_atto.menus[plugin + '_' + elementid];
86
87 if (overlay) {
88 overlay.hide();
89 }
90
91 if (M.editor_atto.is_enabled(elementid, plugin)) {
92 // Pass it on.
93 handler = M.editor_atto.buttonhandlers[handler];
94 return handler(e, elementid);
95 }
96 },
97
98 /**
99 * Determine if the specified toolbar button/menu is enabled.
100 * @param string elementid, the element id of this editor.
101 * @param string plugin, the plugin that created the button/menu.
102 */
103 is_enabled : function(elementid, plugin) {
104 var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
105
106 return !element.hasAttribute('disabled');
107 },
108 /**
109 * Disable all buttons and menus in the toolbar.
110 * @param string elementid, the element id of this editor.
111 */
112 disable_all_widgets : function(elementid) {
113 var plugin, element;
114 for (plugin in M.editor_atto.widgets) {
115 element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
116
117 if (element) {
118 element.setAttribute('disabled', 'true');
119 }
120 }
121 },
122
123 /**
124 * Enable a single widget in the toolbar.
125 * @param string elementid, the element id of this editor.
126 * @param string plugin, the name of the plugin that created the widget.
127 */
128 enable_widget : function(elementid, plugin) {
129 var element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
130
131 if (element) {
132 element.removeAttribute('disabled');
133 }
134 },
135
136 /**
137 * Enable all buttons and menus in the toolbar.
138 * @param string elementid, the element id of this editor.
139 */
140 enable_all_widgets : function(elementid) {
141 var plugin, element;
142 for (plugin in M.editor_atto.widgets) {
143 element = Y.one('#' + elementid + '_toolbar .atto_' + plugin + '_button');
144
145 if (element) {
146 element.removeAttribute('disabled');
147 }
148 }
149 },
150
151 /**
152 * Add a button to the toolbar belonging to the editor for element with id "elementid".
153 * @param string elementid - the id of the textarea we created this editor from.
154 * @param string plugin - the plugin defining the button
155 * @param string icon - the html used for the content of the button
36973d70 156 * @param string groupname - the group the button should be appended to.
c90641fa
DW
157 * @handler function handler- A function to call when the button is clicked.
158 */
36973d70
JF
159 add_toolbar_menu : function(elementid, plugin, icon, groupname, entries) {
160 var toolbar = Y.one('#' + elementid + '_toolbar'),
6f9cf841
DW
161 group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
162 currentfocus,
163 button;
164
36973d70
JF
165 if (!group) {
166 group = Y.Node.create('<div class="atto_group ' + groupname + '_group"></div>');
167 toolbar.append(group);
168 }
6f9cf841 169 button = Y.Node.create('<button class="atto_' + plugin + '_button atto_hasmenu" ' +
c90641fa 170 'data-editor="' + Y.Escape.html(elementid) + '" ' +
6f9cf841 171 'tabindex="-1" ' +
c90641fa
DW
172 'data-menu="' + plugin + '_' + elementid + '" >' +
173 icon +
174 '</button>');
175
36973d70 176 group.append(button);
c90641fa 177
6f9cf841
DW
178 currentfocus = toolbar.getAttribute('aria-activedescendant');
179 if (!currentfocus) {
180 button.setAttribute('tabindex', '0');
181 toolbar.setAttribute('aria-activedescendant', button.generateID());
182 }
183
c90641fa
DW
184 // Save the name of the plugin.
185 M.editor_atto.widgets[plugin] = plugin;
186
187 var menu = Y.Node.create('<div class="atto_' + plugin + '_menu' +
188 ' atto_menu" data-editor="' + Y.Escape.html(elementid) + '"></div>');
c90641fa
DW
189 var i = 0, entry = {};
190
191 for (i = 0; i < entries.length; i++) {
192 entry = entries[i];
193
194 menu.append(Y.Node.create('<div class="atto_menuentry">' +
195 '<a href="#" class="atto_' + plugin + '_action_' + i + '" ' +
196 'data-editor="' + Y.Escape.html(elementid) + '" ' +
197 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
198 'data-handler="' + Y.Escape.html(plugin + '_action_' + i) + '">' +
199 entry.text +
200 '</a>' +
201 '</div>'));
202 if (!M.editor_atto.buttonhandlers[plugin + '_action_' + i]) {
203 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_action_' + i);
bb76233d 204 Y.one('body').delegate('key', M.editor_atto.buttonclicked_handler, 'space,enter', '.atto_' + plugin + '_action_' + i);
c90641fa
DW
205 M.editor_atto.buttonhandlers[plugin + '_action_' + i] = entry.handler;
206 }
207 }
208
209 if (!M.editor_atto.buttonhandlers[plugin]) {
210 Y.one('body').delegate('click', M.editor_atto.showhide_menu_handler, '.atto_' + plugin + '_button');
211 M.editor_atto.buttonhandlers[plugin] = true;
212 }
213
214 var overlay = new M.core.dialogue({
215 bodyContent : menu,
216 visible : false,
217 width: '14em',
218 zindex: 100,
219 lightbox: false,
220 closeButton: false,
4fd8adab 221 centered : false,
c90641fa
DW
222 align: {node: button, points: [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]}
223 });
224
225 M.editor_atto.menus[plugin + '_' + elementid] = overlay;
226 overlay.render();
227 overlay.hide();
228 overlay.headerNode.hide();
229 },
230
231 /**
232 * Add a button to the toolbar belonging to the editor for element with id "elementid".
233 * @param string elementid - the id of the textarea we created this editor from.
36973d70
JF
234 * @param string plugin - the plugin defining the button.
235 * @param string icon - the html used for the content of the button.
236 * @param string groupname - the group the button should be appended to.
c90641fa
DW
237 * @handler function handler- A function to call when the button is clicked.
238 */
36973d70
JF
239 add_toolbar_button : function(elementid, plugin, icon, groupname, handler) {
240 var toolbar = Y.one('#' + elementid + '_toolbar'),
6f9cf841
DW
241 group = Y.one('#' + elementid + '_toolbar .atto_group.' + groupname + '_group'),
242 button,
243 currentfocus;
244
36973d70
JF
245 if (!group) {
246 group = Y.Node.create('<div class="atto_group ' + groupname +'_group"></div>');
247 toolbar.append(group);
248 }
6f9cf841
DW
249 button = Y.Node.create('<button class="atto_' + plugin + '_button" ' +
250 'data-editor="' + Y.Escape.html(elementid) + '" ' +
251 'data-plugin="' + Y.Escape.html(plugin) + '" ' +
252 'tabindex="-1" ' +
253 'data-handler="' + Y.Escape.html(plugin) + '">' +
254 icon +
255 '</button>');
c90641fa 256
36973d70 257 group.append(button);
c90641fa 258
6f9cf841
DW
259 currentfocus = toolbar.getAttribute('aria-activedescendant');
260 if (!currentfocus) {
261 button.setAttribute('tabindex', '0');
262 toolbar.setAttribute('aria-activedescendant', button.generateID());
263 }
264
c90641fa
DW
265 // We only need to attach this once.
266 if (!M.editor_atto.buttonhandlers[plugin]) {
267 Y.one('body').delegate('click', M.editor_atto.buttonclicked_handler, '.atto_' + plugin + '_button');
268 M.editor_atto.buttonhandlers[plugin] = handler;
269 }
270
271 // Save the name of the plugin.
272 M.editor_atto.widgets[plugin] = plugin;
273
274 },
275
276 /**
277 * Work out if the cursor is in the editable area for this editor instance.
278 * @param string elementid of this editor
279 * @return bool
280 */
281 is_active : function(elementid) {
282 var selection = M.editor_atto.get_selection();
283
284 if (selection.length) {
285 selection = selection.pop();
286 }
287
288 var node = null;
289 if (selection.parentElement) {
290 node = Y.one(selection.parentElement());
291 } else {
292 node = Y.one(selection.startContainer);
293 }
294
295 return node && node.ancestor('#' + elementid + 'editable') !== null;
296 },
297
298 /**
299 * Focus on the editable area for this editor.
300 * @param string elementid of this editor
301 */
302 focus : function(elementid) {
303 Y.one('#' + elementid + 'editable').focus();
304 },
305
306 /**
307 * Initialise the editor
308 * @param object params for this editor instance.
309 */
310 init : function(params) {
311 var textarea = Y.one('#' +params.elementid);
312 var atto = Y.Node.create('<div id="' + params.elementid + 'editable" ' +
313 'contenteditable="true" ' +
314 'spellcheck="true" ' +
315 'class="editor_atto"/>');
316 var cssfont = '';
6f9cf841 317 var toolbar = Y.Node.create('<div class="editor_atto_toolbar" id="' + params.elementid + '_toolbar" role="toolbar"/>');
c90641fa
DW
318
319 // Bleh - why are we sent a url and not the css to apply directly?
320 var css = Y.io(params.content_css, { sync: true });
321 var pos = css.responseText.indexOf('font:');
322 if (pos) {
323 cssfont = css.responseText.substring(pos + 'font:'.length, css.responseText.length - 1);
324 atto.setStyle('font', cssfont);
325 }
ceaef9a9 326 atto.setStyle('minHeight', (1.2 * (textarea.getAttribute('rows') - 1)) + 'em');
c90641fa
DW
327
328 // Copy text to editable div.
329 atto.append(textarea.get('value'));
330
331 // Add the toolbar to the page.
332 textarea.get('parentNode').insert(toolbar, textarea);
333 // Add the editable div to the page.
334 textarea.get('parentNode').insert(atto, textarea);
08a95d50
DW
335 atto.setStyle('color', textarea.getStyle('color'));
336 atto.setStyle('lineHeight', textarea.getStyle('lineHeight'));
337 atto.setStyle('fontSize', textarea.getStyle('fontSize'));
c90641fa
DW
338 // Hide the old textarea.
339 textarea.hide();
340
341 // Copy the current value back to the textarea when focus leaves us.
342 atto.on('blur', function() {
343 textarea.set('value', atto.getHTML());
344 });
345
6f9cf841
DW
346 // Listen for Arrow left and Arrow right keys.
347 Y.one(Y.config.doc.body).delegate('key',
348 this.keyboard_navigation,
349 'down:37,39',
350 '#' + params.elementid + '_toolbar',
351 this,
352 params.elementid);
353
c90641fa
DW
354 // Save the file picker options for later.
355 M.editor_atto.filepickeroptions[params.elementid] = params.filepickeroptions;
356 },
357
6f9cf841
DW
358 /**
359 * Implement arrow key navigation for the buttons in the toolbar.
360 * @param Event e - the keyboard event.
361 * @param string elementid - the id of the textarea we created this editor from.
362 */
363 keyboard_navigation : function(e, elementid) {
364 var buttons,
365 current,
366 currentid,
367 currentindex;
368
369 e.preventDefault();
370
371 buttons = Y.all('#' + elementid + '_toolbar button');
372 currentid = Y.one('#' + elementid + '_toolbar').getAttribute('aria-activedescendant');
373 if (!currentid) {
374 return;
375 }
376 current = Y.one('#' + currentid);
377 current.setAttribute('tabindex', '-1');
378
379 currentindex = buttons.indexOf(current);
380
381 if (e.keyCode === 37) {
382 // Left
383 currentindex--;
384 if (currentindex < 0) {
385 currentindex = buttons.size()-1;
386 }
387 } else {
388 // Right
389 currentindex++;
390 if (currentindex >= buttons.size()) {
391 currentindex = 0;
392 }
393 }
394
395 current = buttons.item(currentindex);
396 current.setAttribute('tabindex', '0');
397 current.focus();
398 Y.one('#' + elementid + '_toolbar').setAttribute('aria-activedescendant', current.generateID());
399 },
400
c90641fa
DW
401 /**
402 * Show the filepicker.
403 * @param string elementid for this editor instance.
404 * @param string type The media type for the file picker
405 * @param function callback
406 */
407 show_filepicker : function(elementid, type, callback) {
408 Y.use('core_filepicker', function (Y) {
409 var options = M.editor_atto.filepickeroptions[elementid][type];
410
411 options.formcallback = callback;
412 options.editor_target = Y.one(elementid);
413
414 M.core_filepicker.show(Y, options);
415 });
416 },
417
418 /**
419 * Create a cross browser selection object that represents a yui node.
420 * @param Node yui node for the selection
421 * @return range (browser dependent)
422 */
423 get_selection_from_node: function(node) {
424 var range;
425
426 if (window.getSelection) {
427 range = document.createRange();
428
429 range.setStartBefore(node.getDOMNode());
430 range.setEndAfter(node.getDOMNode());
431 return [range];
432 } else if (document.selection) {
433 range = document.body.createTextRange();
434 range.moveToElementText(node.getDOMNode());
435 return range;
436 }
437 return false;
438 },
439
440 /**
441 * Get the selection object that can be passed back to set_selection.
442 * @return range (browser dependent)
443 */
444 get_selection : function() {
445 if (window.getSelection) {
446 var sel = window.getSelection();
447 var ranges = [], i = 0;
448 for (i = 0; i < sel.rangeCount; i++) {
449 ranges.push(sel.getRangeAt(i));
450 }
451 return ranges;
452 } else if (document.selection) {
453 // IE < 9
454 if (document.selection.createRange) {
455 return document.selection.createRange();
456 }
457 }
458 return false;
459 },
460
461 /**
462 * Get the dom node representing the common anscestor of the selection nodes.
463 * @return DOMNode
464 */
465 get_selection_parent_node : function() {
466 var selection = M.editor_atto.get_selection();
467 if (selection.length > 0) {
468 return selection[0].commonAncestorContainer;
469 }
470 },
471
472 /**
473 * Get the list of child nodes of the selection.
474 * @return DOMNode[]
475 */
476 get_selection_text : function() {
477 var selection = M.editor_atto.get_selection();
478 if (selection.length > 0 && selection[0].cloneContents) {
479 return selection[0].cloneContents();
480 }
481 },
482
483 /**
484 * Set the current selection. Used to restore a selection.
485 */
486 set_selection : function(selection) {
487 var sel, i;
488
489 if (window.getSelection) {
490 sel = window.getSelection();
491 sel.removeAllRanges();
492 for (i = 0; i < selection.length; i++) {
493 sel.addRange(selection[i]);
494 }
495 } else if (document.selection) {
496 // IE < 9
497 if (selection.select) {
498 selection.select();
499 }
500 }
501 }
502
503};