MDL-50795 atto: Pasting into atto removes background colour style.
[moodle.git] / lib / editor / atto / yui / build / moodle-editor_atto-editor / moodle-editor_atto-editor-debug.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/**
62467795 19 * The Atto WYSIWG pluggable editor, written for Moodle.
adca7326 20 *
62467795 21 * @module moodle-editor_atto-editor
adca7326
DW
22 * @package editor_atto
23 * @copyright 2013 Damyon Wiese <damyon@moodle.com>
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
62467795 25 * @main moodle-editor_atto-editor
adca7326
DW
26 */
27
28/**
62467795
AN
29 * @module moodle-editor_atto-editor
30 * @submodule editor-base
adca7326 31 */
62467795
AN
32
33var LOGNAME = 'moodle-editor_atto-editor';
34var CSS = {
35 CONTENT: 'editor_atto_content',
36 CONTENTWRAPPER: 'editor_atto_content_wrap',
37 TOOLBAR: 'editor_atto_toolbar',
38 WRAPPER: 'editor_atto',
39 HIGHLIGHT: 'highlight'
557f44d9
AN
40 },
41 rangy = window.rangy;
adca7326
DW
42
43/**
62467795 44 * The Atto editor for Moodle.
adca7326 45 *
62467795
AN
46 * @namespace M.editor_atto
47 * @class Editor
48 * @constructor
49 * @uses M.editor_atto.EditorClean
50 * @uses M.editor_atto.EditorFilepicker
51 * @uses M.editor_atto.EditorSelection
52 * @uses M.editor_atto.EditorStyling
53 * @uses M.editor_atto.EditorTextArea
54 * @uses M.editor_atto.EditorToolbar
55 * @uses M.editor_atto.EditorToolbarNav
adca7326 56 */
62467795
AN
57
58function Editor() {
59 Editor.superclass.constructor.apply(this, arguments);
60}
61
62Y.extend(Editor, Y.Base, {
adca7326 63
34f5867a
DW
64 /**
65 * List of known block level tags.
66 * Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
67 *
62467795 68 * @property BLOCK_TAGS
34f5867a
DW
69 * @type {Array}
70 */
71 BLOCK_TAGS : [
72 'address',
73 'article',
74 'aside',
75 'audio',
76 'blockquote',
77 'canvas',
78 'dd',
79 'div',
80 'dl',
81 'fieldset',
82 'figcaption',
83 'figure',
84 'footer',
85 'form',
86 'h1',
87 'h2',
88 'h3',
89 'h4',
90 'h5',
91 'h6',
92 'header',
93 'hgroup',
94 'hr',
95 'noscript',
96 'ol',
97 'output',
98 'p',
99 'pre',
100 'section',
101 'table',
102 'tfoot',
103 'ul',
62467795
AN
104 'video'
105 ],
d321f68b 106
af6a2e94 107 PLACEHOLDER_CLASS: 'atto-tmp-class',
bed1abbc
AD
108 ALL_NODES_SELECTOR: '[style],font[face]',
109 FONT_FAMILY: 'fontFamily',
34f5867a 110
adca7326 111 /**
62467795
AN
112 * The wrapper containing the editor.
113 *
114 * @property _wrapper
115 * @type Node
116 * @private
adca7326 117 */
62467795 118 _wrapper: null,
adca7326
DW
119
120 /**
62467795
AN
121 * A reference to the content editable Node.
122 *
123 * @property editor
124 * @type Node
adca7326 125 */
62467795 126 editor: null,
adca7326
DW
127
128 /**
62467795
AN
129 * A reference to the original text area.
130 *
131 * @property textarea
132 * @type Node
adca7326 133 */
62467795 134 textarea: null,
adca7326
DW
135
136 /**
62467795
AN
137 * A reference to the label associated with the original text area.
138 *
139 * @property textareaLabel
140 * @type Node
adca7326 141 */
62467795 142 textareaLabel: null,
adca7326
DW
143
144 /**
62467795
AN
145 * A reference to the list of plugins.
146 *
147 * @property plugins
148 * @type object
adca7326 149 */
62467795 150 plugins: null,
adca7326 151
4dbc02f6
AN
152 /**
153 * Event Handles to clear on editor destruction.
154 *
155 * @property _eventHandles
156 * @private
157 */
158 _eventHandles: null,
159
62467795
AN
160 initializer: function() {
161 var template;
adca7326 162
62467795
AN
163 // Note - it is not safe to use a CSS selector like '#' + elementid because the id
164 // may have colons in it - e.g. quiz.
165 this.textarea = Y.one(document.getElementById(this.get('elementid')));
26f8822d 166
62467795
AN
167 if (!this.textarea) {
168 // No text area found.
169 Y.log('Text area not found - unable to setup editor for ' + this.get('elementid'),
170 'error', LOGNAME);
171 return;
172 }
26f8822d 173
4dbc02f6
AN
174 this._eventHandles = [];
175
62467795
AN
176 this._wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" />');
177 template = Y.Handlebars.compile('<div id="{{elementid}}editable" ' +
178 'contenteditable="true" ' +
179 'role="textbox" ' +
180 'spellcheck="true" ' +
181 'aria-live="off" ' +
182 'class="{{CSS.CONTENT}}" ' +
183 '/>');
184 this.editor = Y.Node.create(template({
185 elementid: this.get('elementid'),
186 CSS: CSS
187 }));
67d3fe45 188
6d9a83c2
DW
189 // Add a labelled-by attribute to the contenteditable.
190 this.textareaLabel = Y.one('[for="' + this.get('elementid') + '"]');
191 if (this.textareaLabel) {
192 this.textareaLabel.generateID();
193 this.editor.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
194 }
67d3fe45 195
62467795
AN
196 // Add everything to the wrapper.
197 this.setupToolbar();
67d3fe45 198
62467795
AN
199 // Editable content wrapper.
200 var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
201 content.appendChild(this.editor);
202 this._wrapper.appendChild(content);
0fa78b80 203
2f0a1236
FM
204 // Style the editor. According to the styles.css: 20 is the line-height, 8 is padding-top + padding-bottom.
205 this.editor.setStyle('minHeight', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
206
207 if (Y.UA.ie === 0) {
208 // We set a height here to force the overflow because decent browsers allow the CSS property resize.
209 this.editor.setStyle('height', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
210 }
211
62467795
AN
212 // Disable odd inline CSS styles.
213 this.disableCssStyling();
adca7326 214
9389ff57
FM
215 // Use paragraphs not divs.
216 if (document.queryCommandSupported('DefaultParagraphSeparator')) {
217 document.execCommand('DefaultParagraphSeparator', false, 'p');
218 }
219
62467795 220 // Add the toolbar and editable zone to the page.
369a63ac
DC
221 this.textarea.get('parentNode').insert(this._wrapper, this.textarea).
222 setAttribute('class', 'editor_atto_wrap');
adca7326 223
62467795
AN
224 // Hide the old textarea.
225 this.textarea.hide();
adca7326 226
62467795
AN
227 // Copy the text to the contenteditable div.
228 this.updateFromTextArea();
adca7326 229
62467795
AN
230 // Publish the events that are defined by this editor.
231 this.publishEvents();
adca7326 232
62467795
AN
233 // Add handling for saving and restoring selections on cursor/focus changes.
234 this.setupSelectionWatchers();
adca7326 235
4dbc02f6
AN
236 // Add polling to update the textarea periodically when typing long content.
237 this.setupAutomaticPolling();
238
62467795
AN
239 // Setup plugins.
240 this.setupPlugins();
2ba6706d
DW
241
242 // Initialize the auto-save timer.
243 this.setupAutosave();
8a5db547
DW
244 // Preload the icons for the notifications.
245 this.setupNotifications();
48bdf86f
DW
246 },
247
248 /**
62467795 249 * Focus on the editable area for this editor.
48bdf86f 250 *
62467795
AN
251 * @method focus
252 * @chainable
48bdf86f 253 */
62467795
AN
254 focus: function() {
255 this.editor.focus();
256
257 return this;
48bdf86f
DW
258 },
259
260 /**
62467795 261 * Publish events for this editor instance.
48bdf86f 262 *
62467795
AN
263 * @method publishEvents
264 * @private
265 * @chainable
48bdf86f 266 */
62467795
AN
267 publishEvents: function() {
268 /**
269 * Fired when changes are made within the editor.
270 *
271 * @event change
272 */
273 this.publish('change', {
274 broadcast: true,
275 preventable: true
276 });
48bdf86f 277
62467795
AN
278 /**
279 * Fired when all plugins have completed loading.
280 *
281 * @event pluginsloaded
282 */
283 this.publish('pluginsloaded', {
284 fireOnce: true
285 });
48bdf86f 286
62467795
AN
287 this.publish('atto:selectionchanged', {
288 prefix: 'atto'
289 });
48bdf86f 290
62467795 291 return this;
3ee53a42
DW
292 },
293
4dbc02f6
AN
294 /**
295 * Set up automated polling of the text area to update the textarea.
296 *
297 * @method setupAutomaticPolling
298 * @chainable
299 */
300 setupAutomaticPolling: function() {
a7fdadc9
EM
301 this._registerEventHandle(this.editor.on(['keyup', 'cut'], this.updateOriginal, this));
302 this._registerEventHandle(this.editor.on('paste', this.pasteCleanup, this));
4dbc02f6 303
10fae277
PN
304 // Call this.updateOriginal after dropped content has been processed.
305 this._registerEventHandle(this.editor.on('drop', this.updateOriginalDelayed, this));
306
307 return this;
308 },
309
310 /**
311 * Calls updateOriginal on a short timer to allow native event handlers to run first.
312 *
313 * @method updateOriginalDelayed
314 * @chainable
315 */
316 updateOriginalDelayed: function() {
317 Y.soon(Y.bind(this.updateOriginal, this));
318
4dbc02f6
AN
319 return this;
320 },
321
62467795
AN
322 setupPlugins: function() {
323 // Clear the list of plugins.
324 this.plugins = {};
325
326 var plugins = this.get('plugins');
adca7326 327
62467795
AN
328 var groupIndex,
329 group,
330 pluginIndex,
331 plugin,
332 pluginConfig;
333
334 for (groupIndex in plugins) {
335 group = plugins[groupIndex];
336 if (!group.plugins) {
337 // No plugins in this group - skip it.
338 continue;
339 }
340 for (pluginIndex in group.plugins) {
341 plugin = group.plugins[pluginIndex];
342
343 pluginConfig = Y.mix({
344 name: plugin.name,
345 group: group.group,
346 editor: this.editor,
347 toolbar: this.toolbar,
348 host: this
349 }, plugin);
350
351 // Add a reference to the current editor.
352 if (typeof Y.M['atto_' + plugin.name] === "undefined") {
353 Y.log("Plugin '" + plugin.name + "' could not be found - skipping initialisation", "warn", LOGNAME);
354 continue;
355 }
356 this.plugins[plugin.name] = new Y.M['atto_' + plugin.name].Button(pluginConfig);
357 }
adca7326 358 }
62467795
AN
359
360 // Some plugins need to perform actions once all plugins have loaded.
361 this.fire('pluginsloaded');
362
363 return this;
adca7326
DW
364 },
365
62467795
AN
366 enablePlugins: function(plugin) {
367 this._setPluginState(true, plugin);
368 },
3ee53a42 369
62467795
AN
370 disablePlugins: function(plugin) {
371 this._setPluginState(false, plugin);
3ee53a42
DW
372 },
373
62467795
AN
374 _setPluginState: function(enable, plugin) {
375 var target = 'disableButtons';
376 if (enable) {
377 target = 'enableButtons';
3ee53a42 378 }
3ee53a42 379
62467795
AN
380 if (plugin) {
381 this.plugins[plugin][target]();
382 } else {
383 Y.Object.each(this.plugins, function(currentPlugin) {
384 currentPlugin[target]();
385 }, this);
3ee53a42 386 }
4dbc02f6
AN
387 },
388
389 /**
390 * Register an event handle for disposal in the destructor.
391 *
392 * @method _registerEventHandle
393 * @param {EventHandle} The Event Handle as returned by Y.on, and Y.delegate.
394 * @private
395 */
396 _registerEventHandle: function(handle) {
397 this._eventHandles.push(handle);
62467795 398 }
3ee53a42 399
62467795
AN
400}, {
401 NS: 'editor_atto',
402 ATTRS: {
403 /**
404 * The unique identifier for the form element representing the editor.
405 *
406 * @attribute elementid
407 * @type String
408 * @writeOnce
409 */
410 elementid: {
411 value: null,
412 writeOnce: true
413 },
adca7326 414
2ba6706d
DW
415 /**
416 * The contextid of the form.
417 *
418 * @attribute contextid
419 * @type Integer
420 * @writeOnce
421 */
422 contextid: {
423 value: null,
424 writeOnce: true
425 },
426
62467795
AN
427 /**
428 * Plugins with their configuration.
429 *
430 * The plugins structure is:
431 *
432 * [
433 * {
434 * "group": "groupName",
435 * "plugins": [
436 * "pluginName": {
437 * "configKey": "configValue"
438 * },
439 * "pluginName": {
440 * "configKey": "configValue"
441 * }
442 * ]
443 * },
444 * {
445 * "group": "groupName",
446 * "plugins": [
447 * "pluginName": {
448 * "configKey": "configValue"
449 * }
450 * ]
451 * }
452 * ]
453 *
454 * @attribute plugins
455 * @type Object
456 * @writeOnce
457 */
458 plugins: {
459 value: {},
460 writeOnce: true
adca7326 461 }
62467795
AN
462 }
463});
464
465// The Editor publishes custom events that can be subscribed to.
466Y.augment(Editor, Y.EventTarget);
467
468Y.namespace('M.editor_atto').Editor = Editor;
469
470// Function for Moodle's initialisation.
471Y.namespace('M.editor_atto.Editor').init = function(config) {
472 return new Y.M.editor_atto.Editor(config);
473};
474// This file is part of Moodle - http://moodle.org/
475//
476// Moodle is free software: you can redistribute it and/or modify
477// it under the terms of the GNU General Public License as published by
478// the Free Software Foundation, either version 3 of the License, or
479// (at your option) any later version.
480//
481// Moodle is distributed in the hope that it will be useful,
482// but WITHOUT ANY WARRANTY; without even the implied warranty of
483// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
484// GNU General Public License for more details.
485//
486// You should have received a copy of the GNU General Public License
487// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
488
75c86d13
DW
489/**
490 * A notify function for the Atto editor.
491 *
492 * @module moodle-editor_atto-notify
19549f8b 493 * @submodule notify
75c86d13
DW
494 * @package editor_atto
495 * @copyright 2014 Damyon Wiese
496 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
497 */
498
19549f8b
DW
499var LOGNAME_NOTIFY = 'moodle-editor_atto-editor-notify',
500 NOTIFY_INFO = 'info',
501 NOTIFY_WARNING = 'warning';
502
75c86d13
DW
503function EditorNotify() {}
504
505EditorNotify.ATTRS= {
506};
507
75c86d13
DW
508EditorNotify.prototype = {
509
510 /**
19549f8b 511 * A single Y.Node for this editor. There is only ever one - it is replaced if a new message comes in.
75c86d13
DW
512 *
513 * @property messageOverlay
19549f8b 514 * @type {Node}
75c86d13
DW
515 */
516 messageOverlay: null,
517
8a5db547
DW
518 /**
519 * A single timer object that can be used to cancel the hiding behaviour.
520 *
521 * @property hideTimer
522 * @type {timer}
523 */
524 hideTimer: null,
525
526 /**
527 * Initialize the notifications.
528 *
529 * @method setupNotifications
530 * @chainable
531 */
532 setupNotifications: function() {
533 var preload1 = new Image(),
534 preload2 = new Image();
535
536 preload1.src = M.util.image_url('i/warning', 'moodle');
537 preload2.src = M.util.image_url('i/info', 'moodle');
538
539 return this;
540 },
541
75c86d13
DW
542 /**
543 * Show a notification in a floaty overlay somewhere in the atto editor text area.
544 *
545 * @method showMessage
19549f8b
DW
546 * @param {String} message The translated message (use get_string)
547 * @param {String} type Must be either "info" or "warning"
548 * @param {Number} timeout Time in milliseconds to show this message for.
75c86d13
DW
549 * @chainable
550 */
8a5db547 551 showMessage: function(message, type, timeout) {
19549f8b
DW
552 var messageTypeIcon = '',
553 intTimeout,
554 bodyContent;
75c86d13
DW
555
556 if (this.messageOverlay === null) {
557 this.messageOverlay = Y.Node.create('<div class="editor_atto_notification"></div>');
558
f1018cd6 559 this.messageOverlay.hide(true);
3a7887ba 560 this.textarea.get('parentNode').append(this.messageOverlay);
75c86d13
DW
561
562 this.messageOverlay.on('click', function() {
f1018cd6 563 this.messageOverlay.hide(true);
75c86d13 564 }, this);
8a5db547 565 }
75c86d13 566
8a5db547
DW
567 if (this.hideTimer !== null) {
568 this.hideTimer.cancel();
75c86d13
DW
569 }
570
19549f8b 571 if (type === NOTIFY_WARNING) {
6bfd450a 572 messageTypeIcon = '<img src="' +
75c86d13
DW
573 M.util.image_url('i/warning', 'moodle') +
574 '" alt="' + M.util.get_string('warning', 'moodle') + '"/>';
19549f8b 575 } else if (type === NOTIFY_INFO) {
6bfd450a 576 messageTypeIcon = '<img src="' +
75c86d13
DW
577 M.util.image_url('i/info', 'moodle') +
578 '" alt="' + M.util.get_string('info', 'moodle') + '"/>';
8a5db547 579 } else {
19549f8b 580 Y.log('Invalid message type specified: ' + type + '. Must be either "info" or "warning".', 'debug', LOGNAME_NOTIFY);
8a5db547
DW
581 }
582
583 // Parse the timeout value.
19549f8b
DW
584 intTimeout = parseInt(timeout, 10);
585 if (intTimeout <= 0) {
586 intTimeout = 60000;
75c86d13
DW
587 }
588
8a5db547
DW
589 // Convert class to atto_info (for example).
590 type = 'atto_' + type;
591
19549f8b 592 bodyContent = Y.Node.create('<div class="' + type + '" role="alert" aria-live="assertive">' +
75c86d13
DW
593 messageTypeIcon + ' ' +
594 Y.Escape.html(message) +
595 '</div>');
596 this.messageOverlay.empty();
597 this.messageOverlay.append(bodyContent);
f1018cd6 598 this.messageOverlay.show(true);
75c86d13 599
19549f8b
DW
600 this.hideTimer = Y.later(intTimeout, this, function() {
601 Y.log('Hide Atto notification.', 'debug', LOGNAME_NOTIFY);
8a5db547 602 this.hideTimer = null;
f1018cd6 603 this.messageOverlay.hide(true);
8a5db547
DW
604 });
605
75c86d13
DW
606 return this;
607 }
608
609};
610
611Y.Base.mix(Y.M.editor_atto.Editor, [EditorNotify]);
612// This file is part of Moodle - http://moodle.org/
613//
614// Moodle is free software: you can redistribute it and/or modify
615// it under the terms of the GNU General Public License as published by
616// the Free Software Foundation, either version 3 of the License, or
617// (at your option) any later version.
618//
619// Moodle is distributed in the hope that it will be useful,
620// but WITHOUT ANY WARRANTY; without even the implied warranty of
621// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
622// GNU General Public License for more details.
623//
624// You should have received a copy of the GNU General Public License
625// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
626
62467795
AN
627/**
628 * @module moodle-editor_atto-editor
629 * @submodule textarea
630 */
adca7326 631
62467795
AN
632/**
633 * Textarea functions for the Atto editor.
634 *
635 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
636 *
637 * @namespace M.editor_atto
638 * @class EditorTextArea
639 */
640
641function EditorTextArea() {}
642
643EditorTextArea.ATTRS= {
644};
645
646EditorTextArea.prototype = {
4dbc02f6
AN
647
648 /**
649 * Return the appropriate empty content value for the current browser.
650 *
651 * Different browsers use a different content when they are empty and
652 * we must set this reliable across the board.
653 *
654 * @method _getEmptyContent
655 * @return String The content to use representing no user-provided content
656 * @private
657 */
658 _getEmptyContent: function() {
659 if (Y.UA.ie && Y.UA.ie < 10) {
660 return '<p></p>';
661 } else {
662 return '<p><br></p>';
663 }
664 },
665
adca7326 666 /**
62467795 667 * Copy and clean the text from the textarea into the contenteditable div.
adca7326 668 *
62467795
AN
669 * If the text is empty, provide a default paragraph tag to hold the content.
670 *
671 * @method updateFromTextArea
672 * @chainable
adca7326 673 */
62467795
AN
674 updateFromTextArea: function() {
675 // Clear it first.
676 this.editor.setHTML('');
677
9029ce75
EM
678 // Copy cleaned HTML to editable div.
679 this.editor.append(this._cleanHTML(this.textarea.get('value')));
62467795
AN
680
681 // Insert a paragraph in the empty contenteditable div.
682 if (this.editor.getHTML() === '') {
4dbc02f6 683 this.editor.setHTML(this._getEmptyContent());
adca7326 684 }
9029ce75
EM
685
686 return this;
adca7326
DW
687 },
688
689 /**
62467795
AN
690 * Copy the text from the contenteditable to the textarea which it replaced.
691 *
692 * @method updateOriginal
693 * @chainable
adca7326 694 */
62467795 695 updateOriginal : function() {
4dbc02f6
AN
696 // Get the previous and current value to compare them.
697 var oldValue = this.textarea.get('value'),
698 newValue = this.getCleanHTML();
699
700 if (newValue === "" && this.isActive()) {
701 // The content was entirely empty so get the empty content placeholder.
702 newValue = this._getEmptyContent();
703 }
5ec54dd1 704
4dbc02f6
AN
705 // Only call this when there has been an actual change to reduce processing.
706 if (oldValue !== newValue) {
707 // Insert the cleaned content.
708 this.textarea.set('value', newValue);
5ec54dd1 709
4dbc02f6
AN
710 // Trigger the onchange callback on the textarea, essentially to notify moodle-core-formchangechecker.
711 this.textarea.simulate('change');
712
713 // Trigger handlers for this action.
714 this.fire('change');
715 }
716
717 return this;
62467795
AN
718 }
719};
534cf7b7 720
62467795
AN
721Y.Base.mix(Y.M.editor_atto.Editor, [EditorTextArea]);
722// This file is part of Moodle - http://moodle.org/
723//
724// Moodle is free software: you can redistribute it and/or modify
725// it under the terms of the GNU General Public License as published by
726// the Free Software Foundation, either version 3 of the License, or
727// (at your option) any later version.
728//
729// Moodle is distributed in the hope that it will be useful,
730// but WITHOUT ANY WARRANTY; without even the implied warranty of
731// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
732// GNU General Public License for more details.
733//
734// You should have received a copy of the GNU General Public License
735// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
adca7326 736
2ba6706d
DW
737/**
738 * A autosave function for the Atto editor.
739 *
740 * @module moodle-editor_atto-autosave
741 * @submodule autosave-base
742 * @package editor_atto
743 * @copyright 2014 Damyon Wiese
744 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
745 */
746
6bfd450a 747var SUCCESS_MESSAGE_TIMEOUT = 5000,
19549f8b
DW
748 RECOVER_MESSAGE_TIMEOUT = 60000,
749 LOGNAME_AUTOSAVE = 'moodle-editor_atto-editor-autosave';
2ba6706d
DW
750
751function EditorAutosave() {}
752
753EditorAutosave.ATTRS= {
6bfd450a
DW
754 /**
755 * Enable/Disable auto save for this instance.
756 *
757 * @attribute autosaveEnabled
758 * @type Boolean
759 * @writeOnce
760 */
761 autosaveEnabled: {
762 value: true,
763 writeOnce: true
764 },
765
766 /**
767 * The time between autosaves (in seconds).
768 *
769 * @attribute autosaveFrequency
19549f8b 770 * @type Number
6bfd450a
DW
771 * @default 60
772 * @writeOnce
773 */
774 autosaveFrequency: {
775 value: 60,
776 writeOnce: true
56579fb6
DW
777 },
778
c07f86ce
DW
779 /**
780 * Unique hash for this page instance. Calculated from $PAGE->url in php.
781 *
782 * @attribute pageHash
783 * @type String
784 * @writeOnce
785 */
786 pageHash: {
787 value: '',
788 writeOnce: true
789 },
790
56579fb6
DW
791 /**
792 * The relative path to the ajax script.
793 *
794 * @attribute autosaveAjaxScript
795 * @type String
796 * @default '/lib/editor/atto/autosave-ajax.php'
797 * @readOnly
798 */
799 autosaveAjaxScript: {
800 value: '/lib/editor/atto/autosave-ajax.php',
801 readOnly: true
6bfd450a 802 }
2ba6706d
DW
803};
804
805EditorAutosave.prototype = {
806
807 /**
808 * The text that was auto saved in the last request.
809 *
810 * @property lastText
811 * @type string
812 */
e924e1b3 813 lastText: "",
2ba6706d
DW
814
815 /**
816 * Autosave instance.
817 *
818 * @property autosaveInstance
819 * @type string
820 */
821 autosaveInstance: null,
822
823 /**
824 * Initialize the autosave process
825 *
826 * @method setupAutosave
827 * @chainable
828 */
829 setupAutosave: function() {
6bfd450a 830 var draftid = -1,
c4e2c671 831 form,
6bfd450a 832 optiontype = null,
557f44d9
AN
833 options = this.get('filepickeroptions'),
834 params,
835 url;
2ba6706d
DW
836
837 if (!this.get('autosaveEnabled')) {
838 // Autosave disabled for this instance.
839 return;
840 }
841
2ba6706d 842 this.autosaveInstance = Y.stamp(this);
2ba6706d
DW
843 for (optiontype in options) {
844 if (typeof options[optiontype].itemid !== "undefined") {
845 draftid = options[optiontype].itemid;
846 }
847 }
848
849 // First see if there are any saved drafts.
850 // Make an ajax request.
56579fb6 851 url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
2ba6706d
DW
852 params = {
853 sesskey: M.cfg.sesskey,
854 contextid: this.get('contextid'),
855 action: 'resume',
856 drafttext: '',
857 draftid: draftid,
858 elementid: this.get('elementid'),
859 pageinstance: this.autosaveInstance,
c07f86ce 860 pagehash: this.get('pageHash')
2ba6706d
DW
861 };
862
863 Y.io(url, {
864 method: 'POST',
865 data: params,
19549f8b 866 context: this,
2ba6706d
DW
867 on: {
868 success: function(id,o) {
557f44d9 869 var response_json;
5aafc6f4 870 if (typeof o.responseText !== "undefined" && o.responseText !== "") {
b6da971b 871 response_json = JSON.parse(o.responseText);
e924e1b3
MN
872
873 // Revert untouched editor contents to an empty string.
7b62567e 874 // Check for FF and Chrome.
3c15d25b
MN
875 if (response_json.result === '<p></p>' || response_json.result === '<p><br></p>' ||
876 response_json.result === '<br>') {
e924e1b3
MN
877 response_json.result = '';
878 }
879
7b62567e
MN
880 // Check for IE 9 and 10.
881 if (response_json.result === '<p>&nbsp;</p>' || response_json.result === '<p><br>&nbsp;</p>') {
882 response_json.result = '';
883 }
884
5aafc6f4 885 if (response_json.error || typeof response_json.result === 'undefined') {
b6da971b 886 Y.log('Error occurred recovering draft text: ' + response_json.error, 'debug', LOGNAME_AUTOSAVE);
557f44d9
AN
887 this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
888 NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
c942fddf
DW
889 } else if (response_json.result !== this.textarea.get('value') &&
890 response_json.result !== '') {
5aafc6f4
FM
891 Y.log('Autosave text found - recover it.', 'debug', LOGNAME_AUTOSAVE);
892 this.recoverText(response_json.result);
b6da971b 893 }
942501f3 894 this._fireSelectionChanged();
2ba6706d 895 }
b6da971b
DC
896 },
897 failure: function() {
557f44d9
AN
898 this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
899 NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
2ba6706d 900 }
19549f8b 901 }
2ba6706d
DW
902 });
903
904 // Now setup the timer for periodic saves.
905
6bfd450a
DW
906 var delay = parseInt(this.get('autosaveFrequency'), 10) * 1000;
907 Y.later(delay, this, this.saveDraft, false, true);
2ba6706d
DW
908
909 // Now setup the listener for form submission.
c4e2c671
AN
910 form = this.textarea.ancestor('form');
911 if (form) {
912 form.on('submit', this.resetAutosave, this);
913 }
2ba6706d
DW
914 return this;
915 },
916
917 /**
918 * Clear the autosave text because the form was submitted normally.
919 *
920 * @method resetAutosave
921 * @chainable
922 */
923 resetAutosave: function() {
924 // Make an ajax request to reset the autosaved text.
557f44d9
AN
925 var url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
926 var params = {
2ba6706d
DW
927 sesskey: M.cfg.sesskey,
928 contextid: this.get('contextid'),
929 action: 'reset',
930 elementid: this.get('elementid'),
931 pageinstance: this.autosaveInstance,
c07f86ce 932 pagehash: this.get('pageHash')
2ba6706d
DW
933 };
934
2ba6706d
DW
935 Y.io(url, {
936 method: 'POST',
937 data: params,
938 sync: true
939 });
940 return this;
941 },
942
943
944 /**
6bfd450a 945 * Recover a previous version of this text and show a message.
2ba6706d
DW
946 *
947 * @method recoverText
948 * @param {String} text
949 * @chainable
950 */
951 recoverText: function(text) {
6bfd450a
DW
952 this.editor.setHTML(text);
953 this.saveSelection();
954 this.updateOriginal();
955 this.lastText = text;
956
557f44d9
AN
957 this.showMessage(M.util.get_string('textrecovered', 'editor_atto'),
958 NOTIFY_INFO, RECOVER_MESSAGE_TIMEOUT);
2ba6706d
DW
959
960 return this;
961 },
962
963 /**
964 * Save a single draft via ajax.
965 *
966 * @method saveDraft
967 * @chainable
968 */
969 saveDraft: function() {
557f44d9 970 var url, params;
3a7887ba
DW
971 // Only copy the text from the div to the textarea if the textarea is not currently visible.
972 if (!this.editor.get('hidden')) {
973 this.updateOriginal();
974 }
2ba6706d
DW
975 var newText = this.textarea.get('value');
976
977 if (newText !== this.lastText) {
19549f8b 978 Y.log('Autosave text', 'debug', LOGNAME_AUTOSAVE);
2ba6706d
DW
979
980 // Make an ajax request.
56579fb6 981 url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
2ba6706d
DW
982 params = {
983 sesskey: M.cfg.sesskey,
984 contextid: this.get('contextid'),
985 action: 'save',
986 drafttext: newText,
987 elementid: this.get('elementid'),
c07f86ce 988 pagehash: this.get('pageHash'),
2ba6706d
DW
989 pageinstance: this.autosaveInstance
990 };
19549f8b
DW
991
992 // Reusable error handler - must be passed the correct context.
993 var ajaxErrorFunction = function(code, response) {
994 var errorDuration = parseInt(this.get('autosaveFrequency'), 10) * 1000;
995 Y.log('Error while autosaving text:' + code, 'warn', LOGNAME_AUTOSAVE);
996 Y.log(response, 'warn', LOGNAME_AUTOSAVE);
997 this.showMessage(M.util.get_string('autosavefailed', 'editor_atto'), NOTIFY_WARNING, errorDuration);
998 };
2ba6706d
DW
999
1000 Y.io(url, {
1001 method: 'POST',
1002 data: params,
19549f8b 1003 context: this,
2ba6706d 1004 on: {
19549f8b
DW
1005 error: ajaxErrorFunction,
1006 failure: ajaxErrorFunction,
c195bbff 1007 success: function(code, response) {
53cca227 1008 if (response.responseText !== "") {
e7a8977e 1009 Y.soon(Y.bind(ajaxErrorFunction, this, [code, response]));
c195bbff
DW
1010 } else {
1011 // All working.
1012 this.lastText = newText;
557f44d9
AN
1013 this.showMessage(M.util.get_string('autosavesucceeded', 'editor_atto'),
1014 NOTIFY_INFO, SUCCESS_MESSAGE_TIMEOUT);
c195bbff 1015 }
2ba6706d 1016 }
19549f8b 1017 }
2ba6706d 1018 });
2ba6706d
DW
1019 }
1020 return this;
1021 }
1022};
1023
1024Y.Base.mix(Y.M.editor_atto.Editor, [EditorAutosave]);
1025// This file is part of Moodle - http://moodle.org/
1026//
1027// Moodle is free software: you can redistribute it and/or modify
1028// it under the terms of the GNU General Public License as published by
1029// the Free Software Foundation, either version 3 of the License, or
1030// (at your option) any later version.
1031//
1032// Moodle is distributed in the hope that it will be useful,
1033// but WITHOUT ANY WARRANTY; without even the implied warranty of
1034// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1035// GNU General Public License for more details.
1036//
1037// You should have received a copy of the GNU General Public License
1038// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
1039
62467795
AN
1040/**
1041 * @module moodle-editor_atto-editor
1042 * @submodule clean
1043 */
adca7326 1044
62467795
AN
1045/**
1046 * Functions for the Atto editor to clean the generated content.
1047 *
1048 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1049 *
1050 * @namespace M.editor_atto
1051 * @class EditorClean
1052 */
adca7326 1053
62467795 1054function EditorClean() {}
adca7326 1055
62467795
AN
1056EditorClean.ATTRS= {
1057};
adca7326 1058
62467795 1059EditorClean.prototype = {
8951d614 1060 /**
62467795
AN
1061 * Clean the generated HTML content without modifying the editor content.
1062 *
1063 * This includes removes all YUI ids from the generated content.
8951d614 1064 *
62467795 1065 * @return {string} The cleaned HTML content.
8951d614 1066 */
62467795
AN
1067 getCleanHTML: function() {
1068 // Clone the editor so that we don't actually modify the real content.
9389ff57
FM
1069 var editorClone = this.editor.cloneNode(true),
1070 html;
8951d614 1071
62467795
AN
1072 // Remove all YUI IDs.
1073 Y.each(editorClone.all('[id^="yui"]'), function(node) {
1074 node.removeAttribute('id');
1075 });
8951d614 1076
62467795 1077 editorClone.all('.atto_control').remove(true);
9389ff57
FM
1078 html = editorClone.get('innerHTML');
1079
1080 // Revert untouched editor contents to an empty string.
1081 if (html === '<p></p>' || html === '<p><br></p>') {
1082 return '';
1083 }
8951d614 1084
62467795 1085 // Remove any and all nasties from source.
9389ff57 1086 return this._cleanHTML(html);
8951d614
JM
1087 },
1088
adca7326 1089 /**
62467795
AN
1090 * Clean the HTML content of the editor.
1091 *
1092 * @method cleanEditorHTML
1093 * @chainable
adca7326 1094 */
62467795
AN
1095 cleanEditorHTML: function() {
1096 var startValue = this.editor.get('innerHTML');
1097 this.editor.set('innerHTML', this._cleanHTML(startValue));
5ec54dd1 1098
62467795
AN
1099 return this;
1100 },
fe0d2477 1101
62467795
AN
1102 /**
1103 * Clean the specified HTML content and remove any content which could cause issues.
1104 *
1105 * @method _cleanHTML
1106 * @private
1107 * @param {String} content The content to clean
1108 * @return {String} The cleaned HTML
1109 */
1110 _cleanHTML: function(content) {
3ef96361 1111 // Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
62467795
AN
1112
1113 var rules = [
3ef96361
EM
1114 // Remove any style blocks. Some browsers do not work well with them in a contenteditable.
1115 // Plus style blocks are not allowed in body html, except with "scoped", which most browsers don't support as of 2015.
1116 // Reference: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
1117 {regex: /<style[^>]*>[\s\S]*?<\/style>/gi, replace: ""},
1118
a7fdadc9
EM
1119 // Remove any open HTML comment opens that are not followed by a close. This can completely break page layout.
1120 {regex: /<!--(?![\s\S]*?-->)/gi, replace: ""},
62467795
AN
1121
1122 // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
a7fdadc9 1123 // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body, link.
d784f5ed 1124 {regex: /<\/?(?:title|meta|style|st\d|head|font|html|body|link)[^>]*?>/gi, replace: ""}
62467795 1125 ];
adca7326 1126
3ef96361
EM
1127 return this._filterContentWithRules(content, rules);
1128 },
1129
1130 /**
1131 * Take the supplied content and run on the supplied regex rules.
1132 *
1133 * @method _filterContentWithRules
1134 * @private
1135 * @param {String} content The content to clean
1136 * @param {Array} rules An array of structures: [ {regex: /something/, replace: "something"}, {...}, ...]
1137 * @return {String} The cleaned content
1138 */
1139 _filterContentWithRules: function(content, rules) {
62467795
AN
1140 var i = 0;
1141 for (i = 0; i < rules.length; i++) {
1142 content = content.replace(rules[i].regex, rules[i].replace);
adca7326
DW
1143 }
1144
62467795 1145 return content;
a7fdadc9
EM
1146 },
1147
1148 /**
1149 * Intercept and clean html paste events.
1150 *
1151 * @method pasteCleanup
1152 * @param {Object} sourceEvent The YUI EventFacade object
1153 * @return {Boolean} True if the passed event should continue, false if not.
1154 */
1155 pasteCleanup: function(sourceEvent) {
1156 // We only expect paste events, but we will check anyways.
1157 if (sourceEvent.type === 'paste') {
1158 // The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
1159 var event = sourceEvent._event;
1160 // Check if we have a valid clipboardData object in the event.
1161 // IE has a clipboard object at window.clipboardData, but as of IE 11, it does not provide HTML content access.
73e14433
EM
1162 if (event && event.clipboardData && event.clipboardData.getData && event.clipboardData.types) {
1163 // Check if there is HTML type to be pasted, if we can get it, we want to scrub before insert.
a7fdadc9
EM
1164 var types = event.clipboardData.types;
1165 var isHTML = false;
73e14433
EM
1166 // Different browsers use different containers to hold the types, so test various functions.
1167 if (typeof types.contains === 'function') {
a7fdadc9
EM
1168 isHTML = types.contains('text/html');
1169 } else if (typeof types.indexOf === 'function') {
1170 isHTML = (types.indexOf('text/html') > -1);
a7fdadc9
EM
1171 }
1172
1173 if (isHTML) {
1174 // Get the clipboard content.
1175 var content;
1176 try {
1177 content = event.clipboardData.getData('text/html');
1178 } catch (error) {
1179 // Something went wrong. Fallback.
1180 this.fallbackPasteCleanupDelayed();
1181 return true;
1182 }
1183
1184 // Stop the original paste.
1185 sourceEvent.preventDefault();
1186
1187 // Scrub the paste content.
3ef96361 1188 content = this._cleanPasteHTML(content);
a7fdadc9
EM
1189
1190 // Save the current selection.
1191 // Using saveSelection as it produces a more consistent experience.
1192 var selection = window.rangy.saveSelection();
1193
1194 // Insert the content.
1195 this.insertContentAtFocusPoint(content);
1196
1197 // Restore the selection, and collapse to end.
1198 window.rangy.restoreSelection(selection);
1199 window.rangy.getSelection().collapseToEnd();
1200
1201 // Update the text area.
1202 this.updateOriginal();
1203 return false;
1204 } else {
73e14433
EM
1205 // Due to poor cross browser clipboard compatibility, the failure to find html doesn't mean it isn't there.
1206 // Wait for the clipboard event to finish then fallback clean the entire editor.
1207 this.fallbackPasteCleanupDelayed();
a7fdadc9
EM
1208 return true;
1209 }
1210 } else {
1211 // If we reached a here, this probably means the browser has limited (or no) clipboard support.
73e14433 1212 // Wait for the clipboard event to finish then fallback clean the entire editor.
a7fdadc9
EM
1213 this.fallbackPasteCleanupDelayed();
1214 return true;
1215 }
1216 }
1217
1218 // We should never get here - we must have received a non-paste event for some reason.
1219 // Um, just call updateOriginalDelayed() - it's safe.
1220 this.updateOriginalDelayed();
1221 return true;
1222 },
1223
1224 /**
1225 * Cleanup code after a paste event if we couldn't intercept the paste content.
1226 *
1227 * @method fallbackPasteCleanup
1228 * @chainable
1229 */
1230 fallbackPasteCleanup: function() {
1231 Y.log('Using fallbackPasteCleanup for atto cleanup', 'debug', LOGNAME);
1232
1233 // Save the current selection (cursor position).
1234 var selection = window.rangy.saveSelection();
1235
1236 // Get, clean, and replace the content in the editable.
1237 var content = this.editor.get('innerHTML');
3ef96361 1238 this.editor.set('innerHTML', this._cleanPasteHTML(content));
a7fdadc9
EM
1239
1240 // Update the textarea.
1241 this.updateOriginal();
1242
1243 // Restore the selection (cursor position).
1244 window.rangy.restoreSelection(selection);
1245
1246 return this;
1247 },
1248
1249 /**
1250 * Calls fallbackPasteCleanup on a short timer to allow the paste event handlers to complete.
1251 *
1252 * @method fallbackPasteCleanupDelayed
1253 * @chainable
1254 */
1255 fallbackPasteCleanupDelayed: function() {
1256 Y.soon(Y.bind(this.fallbackPasteCleanup, this));
1257
1258 return this;
3ef96361
EM
1259 },
1260
1261 /**
1262 * Cleanup html that comes from WYSIWYG paste events. These are more likely to contain messy code that we should strip.
1263 *
1264 * @method _cleanPasteHTML
1265 * @private
1266 * @param {String} content The html content to clean
1267 * @return {String} The cleaned HTML
1268 */
1269 _cleanPasteHTML: function(content) {
1270 // Return an empty string if passed an invalid or empty object.
1271 if (!content || content.length === 0) {
1272 return "";
1273 }
1274
1275 // Rules that get rid of the real-nasties and don't care about normalize code (correct quotes, white spaces, etc).
1276 var rules = [
6ea68e23 1277 // Stuff that is specifically from MS Word and similar office packages.
cfb32192
DM
1278 // Remove all garbage after closing html tag.
1279 {regex: /<\s*\/html\s*>([\s\S]+)$/gi, replace: ""},
6ea68e23
EM
1280 // Remove if comment blocks.
1281 {regex: /<!--\[if[\s\S]*?endif\]-->/gi, replace: ""},
1282 // Remove start and end fragment comment blocks.
1283 {regex: /<!--(Start|End)Fragment-->/gi, replace: ""},
3ef96361
EM
1284 // Remove any xml blocks.
1285 {regex: /<xml[^>]*>[\s\S]*?<\/xml>/gi, replace: ""},
1286 // Remove any <?xml><\?xml> blocks.
1287 {regex: /<\?xml[^>]*>[\s\S]*?<\\\?xml>/gi, replace: ""},
1288 // Remove <o:blah>, <\o:blah>.
df7a9fd4 1289 {regex: /<\/?\w+:[^>]*>/gi, replace: ""}
3ef96361
EM
1290 ];
1291
1292 // Apply the first set of harsher rules.
1293 content = this._filterContentWithRules(content, rules);
1294
1295 // Apply the standard rules, which mainly cleans things like headers, links, and style blocks.
1296 content = this._cleanHTML(content);
1297
1298 // Check if the string is empty or only contains whitespace.
1299 if (content.length === 0 || !content.match(/\S/)) {
1300 return content;
1301 }
1302
1303 // Now we let the browser normalize the code by loading it into the DOM and then get the html back.
1304 // This gives us well quoted, well formatted code to continue our work on. Word may provide very poorly formatted code.
1305 var holder = document.createElement('div');
1306 holder.innerHTML = content;
1307 content = holder.innerHTML;
1308 // Free up the DOM memory.
1309 holder.innerHTML = "";
1310
1311 // Run some more rules that care about quotes and whitespace.
1312 rules = [
8e202bd8
EM
1313 // Get all style attributes so we can work on them.
1314 {regex: /(<[^>]*?style\s*?=\s*?")([^>"]*)(")/gi, replace: function(match, group1, group2, group3) {
1315 // Remove MSO-blah, MSO:blah style attributes.
1316 group2 = group2.replace(/(?:^|;)[\s]*MSO[-:](?:&[\w]*;|[^;"])*/gi,"");
387c8026
AG
1317 // Remove backgroud color style.
1318 group2 = group2.replace(/background-color:.*?;/gi,"");
8e202bd8
EM
1319 return group1 + group2 + group3;
1320 }},
1321 // Get all class attributes so we can work on them.
1322 {regex: /(<[^>]*?class\s*?=\s*?")([^>"]*)(")/gi, replace: function(match, group1, group2, group3) {
1323 // Remove MSO classes.
1324 group2 = group2.replace(/(?:^|[\s])[\s]*MSO[_a-zA-Z0-9\-]*/gi,"");
1325 // Remove Apple- classes.
1326 group2 = group2.replace(/(?:^|[\s])[\s]*Apple-[_a-zA-Z0-9\-]*/gi,"");
1327 return group1 + group2 + group3;
1328 }},
3ef96361 1329 // Remove OLE_LINK# anchors that may litter the code.
1b6ce030 1330 {regex: /<a [^>]*?name\s*?=\s*?"OLE_LINK\d*?"[^>]*?>\s*?<\/a>/gi, replace: ""}
3ef96361
EM
1331 ];
1332
1333 // Apply the rules.
1334 content = this._filterContentWithRules(content, rules);
1335
1336 // Reapply the standard cleaner to the content.
1337 content = this._cleanHTML(content);
1338
1b6ce030
EM
1339 // Clean unused spans out of the content.
1340 content = this._cleanSpans(content);
1341
3ef96361 1342 return content;
1b6ce030
EM
1343 },
1344
1345 /**
1346 * Clean empty or un-unused spans from passed HTML.
1347 *
1348 * This code intentionally doesn't use YUI Nodes. YUI was quite a bit slower at this, so using raw DOM objects instead.
1349 *
1350 * @method _cleanSpans
1351 * @private
1352 * @param {String} content The content to clean
1353 * @return {String} The cleaned HTML
1354 */
1355 _cleanSpans: function(content) {
1356 // Return an empty string if passed an invalid or empty object.
1357 if (!content || content.length === 0) {
1358 return "";
1359 }
1360 // Check if the string is empty or only contains whitespace.
1361 if (content.length === 0 || !content.match(/\S/)) {
1362 return content;
1363 }
1364
1365 var rules = [
1366 // Remove unused class, style, or id attributes. This will make empty tag detection easier later.
1367 {regex: /(<[^>]*?)(?:[\s]*(?:class|style|id)\s*?=\s*?"\s*?")+/gi, replace: "$1"}
1368 ];
1369 // Apply the rules.
1370 content = this._filterContentWithRules(content, rules);
1371
1372 // Reference: "http://stackoverflow.com/questions/8131396/remove-nested-span-without-id"
1373
1374 // This is better to run detached from the DOM, so the browser doesn't try to update on each change.
1375 var holder = document.createElement('div');
1376 holder.innerHTML = content;
1377 var spans = holder.getElementsByTagName('span');
1378
1379 // Since we will be removing elements from the list, we should copy it to an array, making it static.
1380 var spansarr = Array.prototype.slice.call(spans, 0);
1381
1382 spansarr.forEach(function(span) {
1383 if (!span.hasAttributes()) {
1384 // If no attributes (id, class, style, etc), this span is has no effect.
1385 // Move each child (if they exist) to the parent in place of this span.
1386 while (span.firstChild) {
1387 span.parentNode.insertBefore(span.firstChild, span);
1388 }
1389
1390 // Remove the now empty span.
1391 span.parentNode.removeChild(span);
1392 }
1393 });
1394
1395 return holder.innerHTML;
62467795
AN
1396 }
1397};
adca7326 1398
62467795
AN
1399Y.Base.mix(Y.M.editor_atto.Editor, [EditorClean]);
1400// This file is part of Moodle - http://moodle.org/
1401//
1402// Moodle is free software: you can redistribute it and/or modify
1403// it under the terms of the GNU General Public License as published by
1404// the Free Software Foundation, either version 3 of the License, or
1405// (at your option) any later version.
1406//
1407// Moodle is distributed in the hope that it will be useful,
1408// but WITHOUT ANY WARRANTY; without even the implied warranty of
1409// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1410// GNU General Public License for more details.
1411//
1412// You should have received a copy of the GNU General Public License
1413// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
adca7326 1414
dc9ca4ac
DT
1415/**
1416 * @module moodle-editor_atto-editor
1417 * @submodule commands
1418 */
1419
1420/**
1421 * Selection functions for the Atto editor.
1422 *
1423 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1424 *
1425 * @namespace M.editor_atto
1426 * @class EditorCommand
1427 */
1428
1429function EditorCommand() {}
1430
1431EditorCommand.ATTRS= {
1432};
1433
1434EditorCommand.prototype = {
1435 /**
1436 * Applies a callback method to editor if selection is uncollapsed or waits for input to select first.
1437 * @method applyFormat
1438 * @param e EventTarget Event to be passed to callback if selection is uncollapsed
1439 * @param method callback A callback method which changes editor when text is selected.
1440 * @param object context Context to be used for callback method
1441 * @param array args Array of arguments to pass to callback
1442 */
1443 applyFormat: function(e, callback, context, args) {
1444 function handleInsert(e, callback, context, args, anchorNode, anchorOffset) {
1445 // After something is inputed, select it and apply the formating function.
1446 Y.soon(Y.bind(function(e, callback, context, args, anchorNode, anchorOffset) {
1447 var selection = window.rangy.getSelection();
1448
1449 // Set the start of the selection to where it was when the method was first called.
1450 var range = selection.getRangeAt(0);
1451 range.setStart(anchorNode, anchorOffset);
1452 selection.setSingleRange(range);
1453
1454 // Now apply callback to the new text that is selected.
1455 callback.apply(context, [e, args]);
1456
1457 // Collapse selection so cursor is at end of inserted material.
1458 selection.collapseToEnd();
c8719db1
DT
1459
1460 // Save save selection and editor contents.
1461 this.saveSelection();
1462 this.updateOriginal();
dc9ca4ac
DT
1463 }, this, e, callback, context, args, anchorNode, anchorOffset));
1464 }
1465
1466 // Set default context for the method.
1467 context = context || this;
1468
1469 // Check whether range is collapsed.
1470 var selection = window.rangy.getSelection();
1471
1472 if (selection.isCollapsed) {
1473 // Selection is collapsed so listen for input into editor.
1474 var handle = this.editor.once('input', handleInsert, this, callback, context, args,
1475 selection.anchorNode, selection.anchorOffset);
1476
1477 // Cancel if selection changes before input.
1478 this.editor.onceAfter(['click', 'selectstart'], handle.detach, handle);
1479
1480 return;
1481 }
1482
1483 // The range is not collapsed; so apply callback method immediately.
1484 callback.apply(context, [e, args]);
1485
c8719db1
DT
1486 // Save save selection and editor contents.
1487 this.saveSelection();
1488 this.updateOriginal();
dc9ca4ac
DT
1489 },
1490
1491 /**
1492 * Replaces all the tags in a node list with new type.
1493 * @method replaceTags
1494 * @param NodeList nodelist
1495 * @param String tag
1496 */
1497 replaceTags: function(nodelist, tag) {
1498 // We mark elements in the node list for iterations.
1499 nodelist.setAttribute('data-iterate', true);
1500 var node = this.editor.one('[data-iterate="true"]');
1501 while (node) {
1502 var clone = Y.Node.create('<' + tag + ' />')
1503 .setAttrs(node.getAttrs())
1504 .removeAttribute('data-iterate');
1505 // Copy class and style if not blank.
1506 if (node.getAttribute('style')) {
1507 clone.setAttribute('style', node.getAttribute('style'));
1508 }
1509 if (node.getAttribute('class')) {
1510 clone.setAttribute('class', node.getAttribute('class'));
1511 }
1512 // We use childNodes here because we are interested in both type 1 and 3 child nodes.
1513 var children = node.getDOMNode().childNodes, child;
1514 child = children[0];
1515 while (typeof child !== "undefined") {
1516 clone.append(child);
1517 child = children[0];
1518 }
1519 node.replace(clone);
1520 node = this.editor.one('[data-iterate="true"]');
1521 }
1522 },
1523
1524 /**
1525 * Change all tags with given type to a span with CSS class attribute.
1526 * @method changeToCSS
1527 * @param String tag Tag type to be changed to span
1528 * @param String markerClass CSS class that corresponds to desired tag
1529 */
1530 changeToCSS: function(tag, markerClass) {
1531 // Save the selection.
1532 var selection = window.rangy.saveSelection();
1533
1534 // Remove display:none from rangy markers so browser doesn't delete them.
1535 this.editor.all('.rangySelectionBoundary').setStyle('display', null);
1536
1537 // Replace tags with CSS classes.
1538 this.editor.all(tag).addClass(markerClass);
1539 this.replaceTags(this.editor.all('.' + markerClass), 'span');
1540
1541 // Restore selection and toggle class.
1542 window.rangy.restoreSelection(selection);
1543 },
1544
1545 /**
1546 * Change spans with CSS classes in editor into elements with given tag.
1547 * @method changeToCSS
1548 * @param String markerClass CSS class that corresponds to desired tag
1549 * @param String tag New tag type to be created
1550 */
1551 changeToTags: function(markerClass, tag) {
1552 // Save the selection.
1553 var selection = window.rangy.saveSelection();
1554
1555 // Remove display:none from rangy markers so browser doesn't delete them.
1556 this.editor.all('.rangySelectionBoundary').setStyle('display', null);
1557
1558 // Replace spans with given tag.
1559 this.replaceTags(this.editor.all('span[class="' + markerClass + '"]'), tag);
1560 this.editor.all(tag + '[class="' + markerClass + '"]').removeAttribute('class');
1561 this.editor.all('.' + markerClass).each(function(n) {
1562 n.wrap('<' + tag + '/>');
1563 n.removeClass(markerClass);
1564 });
1565
1566 // Remove CSS classes.
1567 this.editor.all('[class="' + markerClass + '"]').removeAttribute('class');
1568 this.editor.all(tag).removeClass(markerClass);
1569
1570 // Restore selection.
1571 window.rangy.restoreSelection(selection);
1572 }
1573};
1574
1575Y.Base.mix(Y.M.editor_atto.Editor, [EditorCommand]);
1576// This file is part of Moodle - http://moodle.org/
1577//
1578// Moodle is free software: you can redistribute it and/or modify
1579// it under the terms of the GNU General Public License as published by
1580// the Free Software Foundation, either version 3 of the License, or
1581// (at your option) any later version.
1582//
1583// Moodle is distributed in the hope that it will be useful,
1584// but WITHOUT ANY WARRANTY; without even the implied warranty of
1585// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1586// GNU General Public License for more details.
1587//
1588// You should have received a copy of the GNU General Public License
1589// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
1590
62467795
AN
1591/**
1592 * @module moodle-editor_atto-editor
1593 * @submodule toolbar
1594 */
adca7326 1595
62467795
AN
1596/**
1597 * Toolbar functions for the Atto editor.
1598 *
1599 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1600 *
1601 * @namespace M.editor_atto
1602 * @class EditorToolbar
1603 */
adca7326 1604
62467795 1605function EditorToolbar() {}
34f5867a 1606
62467795
AN
1607EditorToolbar.ATTRS= {
1608};
adca7326 1609
62467795 1610EditorToolbar.prototype = {
adca7326 1611 /**
62467795
AN
1612 * A reference to the toolbar Node.
1613 *
1614 * @property toolbar
1615 * @type Node
adca7326 1616 */
62467795 1617 toolbar: null,
adca7326 1618
c63f9053
AN
1619 /**
1620 * A reference to any currently open menus in the toolbar.
1621 *
1622 * @property openMenus
1623 * @type Array
1624 */
1625 openMenus: null,
1626
86a83e3a 1627 /**
62467795 1628 * Setup the toolbar on the editor.
86a83e3a 1629 *
62467795
AN
1630 * @method setupToolbar
1631 * @chainable
86a83e3a 1632 */
62467795
AN
1633 setupToolbar: function() {
1634 this.toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" role="toolbar" aria-live="off"/>');
c63f9053 1635 this.openMenus = [];
62467795 1636 this._wrapper.appendChild(this.toolbar);
86a83e3a 1637
62467795
AN
1638 if (this.textareaLabel) {
1639 this.toolbar.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
1640 }
86a83e3a 1641
62467795
AN
1642 // Add keyboard navigation for the toolbar.
1643 this.setupToolbarNavigation();
86a83e3a 1644
62467795
AN
1645 return this;
1646 }
1647};
86a83e3a 1648
62467795
AN
1649Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbar]);
1650// This file is part of Moodle - http://moodle.org/
1651//
1652// Moodle is free software: you can redistribute it and/or modify
1653// it under the terms of the GNU General Public License as published by
1654// the Free Software Foundation, either version 3 of the License, or
1655// (at your option) any later version.
1656//
1657// Moodle is distributed in the hope that it will be useful,
1658// but WITHOUT ANY WARRANTY; without even the implied warranty of
1659// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1660// GNU General Public License for more details.
1661//
1662// You should have received a copy of the GNU General Public License
1663// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
1664
1665/**
1666 * @module moodle-editor_atto-editor
1667 * @submodule toolbarnav
1668 */
1669
1670/**
1671 * Toolbar Navigation functions for the Atto editor.
1672 *
1673 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1674 *
1675 * @namespace M.editor_atto
1676 * @class EditorToolbarNav
1677 */
1678
1679function EditorToolbarNav() {}
1680
1681EditorToolbarNav.ATTRS= {
1682};
1683
1684EditorToolbarNav.prototype = {
1685 /**
1686 * The current focal point for tabbing.
1687 *
1688 * @property _tabFocus
1689 * @type Node
1690 * @default null
1691 * @private
1692 */
1693 _tabFocus: null,
1694
1695 /**
1696 * Set up the watchers for toolbar navigation.
1697 *
1698 * @method setupToolbarNavigation
1699 * @chainable
1700 */
1701 setupToolbarNavigation: function() {
1702 // Listen for Arrow left and Arrow right keys.
1703 this._wrapper.delegate('key',
1704 this.toolbarKeyboardNavigation,
1705 'down:37,39',
1706 '.' + CSS.TOOLBAR,
1707 this);
c63f9053
AN
1708 this._wrapper.delegate('focus',
1709 function(e) {
1710 this._setTabFocus(e.currentTarget);
1711 }, '.' + CSS.TOOLBAR + ' button', this);
62467795
AN
1712
1713 return this;
86a83e3a
DW
1714 },
1715
adca7326 1716 /**
62467795
AN
1717 * Implement arrow key navigation for the buttons in the toolbar.
1718 *
1719 * @method toolbarKeyboardNavigation
1720 * @param {EventFacade} e - the keyboard event.
adca7326 1721 */
62467795
AN
1722 toolbarKeyboardNavigation: function(e) {
1723 // Prevent the default browser behaviour.
1724 e.preventDefault();
adca7326 1725
62467795 1726 // On cursor moves we loops through the buttons.
b9d065ed 1727 var buttons = this.toolbar.all('button'),
62467795 1728 direction = 1,
af31595b 1729 button,
62467795
AN
1730 current = e.target.ancestor('button', true);
1731
b9d065ed
FM
1732 if (e.keyCode === 37) {
1733 // Moving left so reverse the direction.
1734 direction = -1;
26f8822d 1735 }
b269f635 1736
b9d065ed
FM
1737 button = this._findFirstFocusable(buttons, current, direction);
1738 if (button) {
1739 button.focus();
5e543b4f 1740 this._setTabFocus(button);
b9d065ed
FM
1741 } else {
1742 Y.log("Unable to find a button to focus on", 'debug', LOGNAME);
62467795 1743 }
b9d065ed 1744 },
adca7326 1745
b9d065ed
FM
1746 /**
1747 * Find the first focusable button.
1748 *
1749 * @param {NodeList} buttons A list of nodes.
1750 * @param {Node} startAt The node in the list to start the search from.
1751 * @param {Number} direction The direction in which to search (1 or -1).
1752 * @return {Node | Undefined} The Node or undefined.
1753 * @method _findFirstFocusable
1754 * @private
1755 */
1756 _findFirstFocusable: function(buttons, startAt, direction) {
1757 var checkCount = 0,
1758 group,
1759 candidate,
1760 button,
1761 index;
1762
1763 // Determine which button to start the search from.
1764 index = buttons.indexOf(startAt);
1765 if (index < -1) {
1766 Y.log("Unable to find the button in the list of buttons", 'debug', LOGNAME);
1767 index = 0;
62467795
AN
1768 }
1769
b9d065ed 1770 // Try to find the next.
af31595b 1771 while (checkCount < buttons.size()) {
62467795
AN
1772 index += direction;
1773 if (index < 0) {
1774 index = buttons.size() - 1;
1775 } else if (index >= buttons.size()) {
1776 // Handle wrapping.
1777 index = 0;
1778 }
af31595b
FM
1779
1780 candidate = buttons.item(index);
adca7326 1781
62467795
AN
1782 // Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
1783 checkCount++;
af31595b 1784
62467795 1785 // Loop while:
af31595b
FM
1786 // * we haven't checked every button;
1787 // * the button is hidden or disabled;
1788 // * the group is hidden.
1789 if (candidate.hasAttribute('hidden') || candidate.hasAttribute('disabled')) {
1790 continue;
1791 }
1792 group = candidate.ancestor('.atto_group');
1793 if (group.hasAttribute('hidden')) {
1794 continue;
1795 }
1796
1797 button = candidate;
1798 break;
62467795 1799 }
af31595b 1800
b9d065ed
FM
1801 return button;
1802 },
1803
1804 /**
1805 * Check the tab focus.
1806 *
1807 * When we disable or hide a button, we should call this method to ensure that the
1808 * focus is not currently set on an inaccessible button, otherwise tabbing to the toolbar
1809 * would be impossible.
1810 *
1811 * @method checkTabFocus
1812 * @chainable
1813 */
1814 checkTabFocus: function() {
1815 if (this._tabFocus) {
1816 if (this._tabFocus.hasAttribute('disabled') || this._tabFocus.hasAttribute('hidden')
1817 || this._tabFocus.ancestor('.atto_group').hasAttribute('hidden')) {
1818 // Find first available button.
557f44d9 1819 var button = this._findFirstFocusable(this.toolbar.all('button'), this._tabFocus, -1);
b9d065ed
FM
1820 if (button) {
1821 if (this._tabFocus.compareTo(document.activeElement)) {
1822 // We should also move the focus, because the inaccessible button also has the focus.
1823 button.focus();
1824 }
1825 this._setTabFocus(button);
1826 }
1827 }
62467795 1828 }
b9d065ed 1829 return this;
62467795 1830 },
d088a835 1831
62467795
AN
1832 /**
1833 * Sets tab focus for the toolbar to the specified Node.
1834 *
1835 * @method _setTabFocus
1836 * @param {Node} button The node that focus should now be set to
1837 * @chainable
1838 * @private
1839 */
1840 _setTabFocus: function(button) {
1841 if (this._tabFocus) {
1842 // Unset the previous entry.
1843 this._tabFocus.setAttribute('tabindex', '-1');
1844 }
26f8822d 1845
62467795
AN
1846 // Set up the new entry.
1847 this._tabFocus = button;
1848 this._tabFocus.setAttribute('tabindex', 0);
4c37c1f4 1849
62467795
AN
1850 // And update the activedescendant to point at the currently selected button.
1851 this.toolbar.setAttribute('aria-activedescendant', this._tabFocus.generateID());
3ee53a42 1852
62467795
AN
1853 return this;
1854 }
1855};
67d3fe45 1856
62467795
AN
1857Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbarNav]);
1858// This file is part of Moodle - http://moodle.org/
1859//
1860// Moodle is free software: you can redistribute it and/or modify
1861// it under the terms of the GNU General Public License as published by
1862// the Free Software Foundation, either version 3 of the License, or
1863// (at your option) any later version.
1864//
1865// Moodle is distributed in the hope that it will be useful,
1866// but WITHOUT ANY WARRANTY; without even the implied warranty of
1867// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1868// GNU General Public License for more details.
1869//
1870// You should have received a copy of the GNU General Public License
1871// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
adca7326 1872
62467795
AN
1873/**
1874 * @module moodle-editor_atto-editor
1875 * @submodule selection
1876 */
adca7326 1877
62467795
AN
1878/**
1879 * Selection functions for the Atto editor.
1880 *
1881 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
1882 *
1883 * @namespace M.editor_atto
1884 * @class EditorSelection
1885 */
fcb5b5c4 1886
62467795 1887function EditorSelection() {}
fcb5b5c4 1888
62467795
AN
1889EditorSelection.ATTRS= {
1890};
3ee53a42 1891
62467795 1892EditorSelection.prototype = {
3ee53a42
DW
1893
1894 /**
62467795
AN
1895 * List of saved selections per editor instance.
1896 *
1897 * @property _selections
1898 * @private
3ee53a42 1899 */
62467795 1900 _selections: null,
adca7326
DW
1901
1902 /**
62467795
AN
1903 * A unique identifier for the last selection recorded.
1904 *
1905 * @property _lastSelection
1906 * @param lastselection
1907 * @type string
1908 * @private
adca7326 1909 */
62467795 1910 _lastSelection: null,
adca7326
DW
1911
1912 /**
62467795
AN
1913 * Whether focus came from a click event.
1914 *
1915 * This is used to determine whether to restore the selection or not.
1916 *
1917 * @property _focusFromClick
1918 * @type Boolean
1919 * @default false
1920 * @private
adca7326 1921 */
62467795 1922 _focusFromClick: false,
adca7326 1923
90902953
DM
1924 /**
1925 * Whether if the last gesturemovestart event target was contained in this editor or not.
1926 *
1927 * @property _gesturestartededitor
1928 * @type Boolean
1929 * @default false
1930 * @private
1931 */
1932 _gesturestartededitor: false,
1933
adca7326 1934 /**
62467795
AN
1935 * Set up the watchers for selection save and restoration.
1936 *
1937 * @method setupSelectionWatchers
1938 * @chainable
adca7326 1939 */
62467795
AN
1940 setupSelectionWatchers: function() {
1941 // Save the selection when a change was made.
1942 this.on('atto:selectionchanged', this.saveSelection, this);
adca7326 1943
62467795 1944 this.editor.on('focus', this.restoreSelection, this);
adca7326 1945
62467795
AN
1946 // Do not restore selection when focus is from a click event.
1947 this.editor.on('mousedown', function() {
1948 this._focusFromClick = true;
1949 }, this);
adca7326 1950
62467795
AN
1951 // Copy the current value back to the textarea when focus leaves us and save the current selection.
1952 this.editor.on('blur', function() {
1953 // Clear the _focusFromClick value.
1954 this._focusFromClick = false;
adca7326 1955
62467795
AN
1956 // Update the original text area.
1957 this.updateOriginal();
1958 }, this);
adca7326 1959
8bca3609 1960 this.editor.on(['keyup', 'focus'], function(e) {
ee376395 1961 Y.soon(Y.bind(this._hasSelectionChanged, this, e));
8bca3609 1962 }, this);
e3050641 1963
90902953
DM
1964 Y.one(document.body).on('gesturemovestart', function(e) {
1965 if (this._wrapper.contains(e.target._node)) {
1966 this._gesturestartededitor = true;
1967 } else {
1968 this._gesturestartededitor = false;
1969 }
1970 }, null, this);
923589d7 1971
90902953
DM
1972 Y.one(document.body).on('gesturemoveend', function(e) {
1973 if (!this._gesturestartededitor) {
1974 // Ignore the event if movestart target was not contained in the editor.
923589d7
DM
1975 return;
1976 }
1977 Y.soon(Y.bind(this._hasSelectionChanged, this, e));
1f0d77f7
EM
1978 }, {
1979 // Standalone will make sure all editors receive the end event.
1980 standAlone: true
1981 }, this);
e3050641 1982
62467795 1983 return this;
b269f635
DW
1984 },
1985
adca7326 1986 /**
62467795
AN
1987 * Work out if the cursor is in the editable area for this editor instance.
1988 *
1989 * @method isActive
1990 * @return {boolean}
adca7326 1991 */
62467795
AN
1992 isActive: function() {
1993 var range = rangy.createRange(),
1994 selection = rangy.getSelection();
adca7326 1995
62467795
AN
1996 if (!selection.rangeCount) {
1997 // If there was no range count, then there is no selection.
1998 return false;
1999 }
adca7326 2000
1ce04361 2001 // We can't be active if the editor doesn't have focus at the moment.
9b9a3abf
DT
2002 if (!document.activeElement ||
2003 !(this.editor.compareTo(document.activeElement) || this.editor.contains(document.activeElement))) {
1ce04361
AN
2004 return false;
2005 }
2006
62467795
AN
2007 // Check whether the range intersects the editor selection.
2008 range.selectNode(this.editor.getDOMNode());
2009 return range.intersectsRange(selection.getRangeAt(0));
adca7326
DW
2010 },
2011
2012 /**
62467795
AN
2013 * Create a cross browser selection object that represents a YUI node.
2014 *
2015 * @method getSelectionFromNode
2016 * @param {Node} YUI Node to base the selection upon.
2017 * @return {[rangy.Range]}
adca7326 2018 */
62467795 2019 getSelectionFromNode: function(node) {
d321f68b
DW
2020 var range = rangy.createRange();
2021 range.selectNode(node.getDOMNode());
2022 return [range];
adca7326
DW
2023 },
2024
26f8822d 2025 /**
62467795
AN
2026 * Save the current selection to an internal property.
2027 *
2028 * This allows more reliable return focus, helping improve keyboard navigation.
2029 *
2030 * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/restoreSelection"}}{{/crossLink}}.
2031 *
2032 * @method saveSelection
26f8822d 2033 */
62467795 2034 saveSelection: function() {
36265972
AN
2035 if (this.isActive()) {
2036 this._selections = this.getSelection();
2037 }
26f8822d
DW
2038 },
2039
2040 /**
62467795
AN
2041 * Restore any stored selection when the editor gets focus again.
2042 *
2043 * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/saveSelection"}}{{/crossLink}}.
2044 *
2045 * @method restoreSelection
26f8822d 2046 */
62467795
AN
2047 restoreSelection: function() {
2048 if (!this._focusFromClick) {
2049 if (this._selections) {
2050 this.setSelection(this._selections);
26f8822d
DW
2051 }
2052 }
62467795 2053 this._focusFromClick = false;
26f8822d
DW
2054 },
2055
adca7326 2056 /**
62467795
AN
2057 * Get the selection object that can be passed back to setSelection.
2058 *
2059 * @method getSelection
2060 * @return {array} An array of rangy ranges.
adca7326 2061 */
62467795 2062 getSelection: function() {
d321f68b 2063 return rangy.getSelection().getAllRanges();
adca7326
DW
2064 },
2065
2066 /**
62467795
AN
2067 * Check that a YUI node it at least partly contained by the current selection.
2068 *
2069 * @method selectionContainsNode
2070 * @param {Node} The node to check.
2071 * @return {boolean}
adca7326 2072 */
62467795 2073 selectionContainsNode: function(node) {
d321f68b 2074 return rangy.getSelection().containsNode(node.getDOMNode(), true);
adca7326
DW
2075 },
2076
3ee53a42 2077 /**
62467795
AN
2078 * Runs a filter on each node in the selection, and report whether the
2079 * supplied selector(s) were found in the supplied Nodes.
3ee53a42 2080 *
62467795
AN
2081 * By default, all specified nodes must match the selection, but this
2082 * can be controlled with the requireall property.
2083 *
2084 * @method selectionFilterMatches
3ee53a42 2085 * @param {String} selector
62467795
AN
2086 * @param {NodeList} [selectednodes] For performance this should be passed. If not passed, this will be looked up each time.
2087 * @param {Boolean} [requireall=true] Used to specify that "any" match is good enough.
3ee53a42
DW
2088 * @return {Boolean}
2089 */
62467795
AN
2090 selectionFilterMatches: function(selector, selectednodes, requireall) {
2091 if (typeof requireall === 'undefined') {
d321f68b
DW
2092 requireall = true;
2093 }
3ee53a42
DW
2094 if (!selectednodes) {
2095 // Find this because it was not passed as a param.
62467795 2096 selectednodes = this.getSelectedNodes();
3ee53a42 2097 }
62467795
AN
2098 var allmatch = selectednodes.size() > 0,
2099 anymatch = false;
2100
2101 var editor = this.editor,
2102 stopFn = function(node) {
e76ae807
FM
2103 // The function getSelectedNodes only returns nodes within the editor, so this test is safe.
2104 return node === editor;
62467795
AN
2105 };
2106
e76ae807
FM
2107 // If we do not find at least one match in the editor, no point trying to find them in the selection.
2108 if (!editor.one(selector)) {
2109 return false;
2110 }
2111
67d3fe45
SH
2112 selectednodes.each(function(node){
2113 // Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
d321f68b
DW
2114 if (requireall) {
2115 // Check for at least one failure.
62467795 2116 if (!allmatch || !node.ancestor(selector, true, stopFn)) {
d321f68b
DW
2117 allmatch = false;
2118 }
2119 } else {
2120 // Check for at least one match.
62467795 2121 if (!anymatch && node.ancestor(selector, true, stopFn)) {
d321f68b
DW
2122 anymatch = true;
2123 }
3ee53a42 2124 }
67d3fe45 2125 }, this);
d321f68b
DW
2126 if (requireall) {
2127 return allmatch;
2128 } else {
2129 return anymatch;
2130 }
3ee53a42
DW
2131 },
2132
2133 /**
67d3fe45
SH
2134 * Get the deepest possible list of nodes in the current selection.
2135 *
62467795
AN
2136 * @method getSelectedNodes
2137 * @return {NodeList}
3ee53a42 2138 */
62467795 2139 getSelectedNodes: function() {
d321f68b
DW
2140 var results = new Y.NodeList(),
2141 nodes,
2142 selection,
67d3fe45 2143 range,
d321f68b
DW
2144 node,
2145 i;
2146
2147 selection = rangy.getSelection();
2148
2149 if (selection.rangeCount) {
2150 range = selection.getRangeAt(0);
2151 } else {
2152 // Empty range.
2153 range = rangy.createRange();
67d3fe45 2154 }
d321f68b
DW
2155
2156 if (range.collapsed) {
e9883836
FM
2157 // We do not want to select all the nodes in the editor if we managed to
2158 // have a collapsed selection directly in the editor.
17f253fa
AN
2159 // It's also possible for the commonAncestorContainer to be the document, which selectNode does not handle
2160 // so we must filter that out here too.
2161 if (range.commonAncestorContainer !== this.editor.getDOMNode()
2162 && range.commonAncestorContainer !== Y.config.doc) {
e9883836
FM
2163 range = range.cloneRange();
2164 range.selectNode(range.commonAncestorContainer);
2165 }
d321f68b
DW
2166 }
2167
2168 nodes = range.getNodes();
2169
2170 for (i = 0; i < nodes.length; i++) {
2171 node = Y.one(nodes[i]);
62467795 2172 if (this.editor.contains(node)) {
d321f68b 2173 results.push(node);
67d3fe45
SH
2174 }
2175 }
2176 return results;
3ee53a42
DW
2177 },
2178
2179 /**
62467795 2180 * Check whether the current selection has changed since this method was last called.
67d3fe45 2181 *
62467795 2182 * If the selection has changed, the atto:selectionchanged event is also fired.
67d3fe45 2183 *
62467795 2184 * @method _hasSelectionChanged
67d3fe45
SH
2185 * @private
2186 * @param {EventFacade} e
62467795 2187 * @return {Boolean}
3ee53a42 2188 */
62467795 2189 _hasSelectionChanged: function(e) {
d321f68b 2190 var selection = rangy.getSelection(),
67d3fe45 2191 range,
d321f68b
DW
2192 changed = false;
2193
2194 if (selection.rangeCount) {
2195 range = selection.getRangeAt(0);
2196 } else {
2197 // Empty range.
2198 range = rangy.createRange();
67d3fe45 2199 }
d321f68b 2200
62467795
AN
2201 if (this._lastSelection) {
2202 if (!this._lastSelection.equals(range)) {
d321f68b 2203 changed = true;
62467795 2204 return this._fireSelectionChanged(e);
d321f68b 2205 }
67d3fe45 2206 }
62467795 2207 this._lastSelection = range;
d321f68b 2208 return changed;
67d3fe45 2209 },
3ee53a42 2210
67d3fe45
SH
2211 /**
2212 * Fires the atto:selectionchanged event.
2213 *
62467795 2214 * When the selectionchanged event is fired, the following arguments are provided:
67d3fe45 2215 * - event : the original event that lead to this event being fired.
67d3fe45
SH
2216 * - selectednodes : an array containing nodes that are entirely selected of contain partially selected content.
2217 *
62467795 2218 * @method _fireSelectionChanged
67d3fe45
SH
2219 * @private
2220 * @param {EventFacade} e
2221 */
62467795 2222 _fireSelectionChanged: function(e) {
67d3fe45 2223 this.fire('atto:selectionchanged', {
62467795
AN
2224 event: e,
2225 selectedNodes: this.getSelectedNodes()
67d3fe45
SH
2226 });
2227 },
2228
adca7326 2229 /**
62467795
AN
2230 * Get the DOM node representing the common anscestor of the selection nodes.
2231 *
2232 * @method getSelectionParentNode
2233 * @return {Element|boolean} The DOM Node for this parent, or false if no seletion was made.
adca7326 2234 */
62467795 2235 getSelectionParentNode: function() {
d321f68b
DW
2236 var selection = rangy.getSelection();
2237 if (selection.rangeCount) {
2238 return selection.getRangeAt(0).commonAncestorContainer;
34f5867a 2239 }
34f5867a 2240 return false;
adca7326
DW
2241 },
2242
adca7326
DW
2243 /**
2244 * Set the current selection. Used to restore a selection.
62467795
AN
2245 *
2246 * @method selection
2247 * @param {array} ranges A list of rangy.range objects in the selection.
adca7326 2248 */
62467795 2249 setSelection: function(ranges) {
d321f68b
DW
2250 var selection = rangy.getSelection();
2251 selection.setRanges(ranges);
34f5867a
DW
2252 },
2253
62467795
AN
2254 /**
2255 * Inserts the given HTML into the editable content at the currently focused point.
2256 *
2257 * @method insertContentAtFocusPoint
2258 * @param {String} html
39c6f62d 2259 * @return {Node} The YUI Node object added to the DOM.
62467795
AN
2260 */
2261 insertContentAtFocusPoint: function(html) {
2262 var selection = rangy.getSelection(),
2263 range,
2264 node = Y.Node.create(html);
2265 if (selection.rangeCount) {
2266 range = selection.getRangeAt(0);
2267 }
2268 if (range) {
2269 range.deleteContents();
2270 range.insertNode(node.getDOMNode());
2271 }
39c6f62d 2272 return node;
62467795
AN
2273 }
2274
2275};
2276
2277Y.Base.mix(Y.M.editor_atto.Editor, [EditorSelection]);
2278// This file is part of Moodle - http://moodle.org/
2279//
2280// Moodle is free software: you can redistribute it and/or modify
2281// it under the terms of the GNU General Public License as published by
2282// the Free Software Foundation, either version 3 of the License, or
2283// (at your option) any later version.
2284//
2285// Moodle is distributed in the hope that it will be useful,
2286// but WITHOUT ANY WARRANTY; without even the implied warranty of
2287// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2288// GNU General Public License for more details.
2289//
2290// You should have received a copy of the GNU General Public License
2291// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
2292
2293/**
2294 * @module moodle-editor_atto-editor
2295 * @submodule styling
2296 */
2297
2298/**
2299 * Editor styling functions for the Atto editor.
2300 *
2301 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2302 *
2303 * @namespace M.editor_atto
2304 * @class EditorStyling
2305 */
2306
2307function EditorStyling() {}
2308
2309EditorStyling.ATTRS= {
2310};
2311
2312EditorStyling.prototype = {
f6bef145
FM
2313 /**
2314 * Disable CSS styling.
2315 *
62467795 2316 * @method disableCssStyling
f6bef145 2317 */
62467795 2318 disableCssStyling: function() {
f6bef145
FM
2319 try {
2320 document.execCommand("styleWithCSS", 0, false);
2321 } catch (e1) {
2322 try {
2323 document.execCommand("useCSS", 0, true);
2324 } catch (e2) {
2325 try {
2326 document.execCommand('styleWithCSS', false, false);
2327 } catch (e3) {
2328 // We did our best.
2329 }
2330 }
2331 }
2332 },
2333
2334 /**
2335 * Enable CSS styling.
2336 *
62467795 2337 * @method enableCssStyling
f6bef145 2338 */
62467795 2339 enableCssStyling: function() {
f6bef145
FM
2340 try {
2341 document.execCommand("styleWithCSS", 0, true);
2342 } catch (e1) {
2343 try {
2344 document.execCommand("useCSS", 0, false);
2345 } catch (e2) {
2346 try {
2347 document.execCommand('styleWithCSS', false, true);
2348 } catch (e3) {
2349 // We did our best.
2350 }
2351 }
2352 }
2faf4c45
SH
2353 },
2354
bed1abbc
AD
2355 /**
2356 * Change the formatting for the current selection.
62467795
AN
2357 *
2358 * This will wrap the selection in span tags, adding the provided classes.
bed1abbc
AD
2359 *
2360 * If the selection covers multiple block elements, multiple spans will be inserted to preserve the original structure.
2361 *
62467795 2362 * @method toggleInlineSelectionClass
bed1abbc
AD
2363 * @param {Array} toggleclasses - Class names to be toggled on or off.
2364 */
62467795 2365 toggleInlineSelectionClass: function(toggleclasses) {
af6a2e94 2366 var classname = toggleclasses.join(" ");
b3c10f9f 2367 var cssApplier = rangy.createClassApplier(classname, {normalize: true});
af6a2e94
DW
2368
2369 cssApplier.toggleSelection();
af6a2e94
DW
2370 },
2371
2372 /**
2373 * Change the formatting for the current selection.
2374 *
2375 * This will set inline styles on the current selection.
2376 *
7bb2069b 2377 * @method formatSelectionInlineStyle
af6a2e94
DW
2378 * @param {Array} styles - Style attributes to set on the nodes.
2379 */
2380 formatSelectionInlineStyle: function(styles) {
2381 var classname = this.PLACEHOLDER_CLASS;
b3c10f9f 2382 var cssApplier = rangy.createClassApplier(classname, {normalize: true});
af6a2e94
DW
2383
2384 cssApplier.applyToSelection();
2385
2386 this.editor.all('.' + classname).each(function (node) {
2387 node.removeClass(classname).setStyles(styles);
2388 }, this);
2389
af6a2e94
DW
2390 },
2391
2392 /**
2393 * Change the formatting for the current selection.
2394 *
2395 * Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
2396 *
2397 * @method formatSelectionBlock
2398 * @param {String} [blocktag] Change the block level tag to this. Empty string, means do not change the tag.
2399 * @param {Object} [attributes] The keys and values for attributes to be added/changed in the block tag.
2400 * @return {Node|boolean} The Node that was formatted if a change was made, otherwise false.
2401 */
2402 formatSelectionBlock: function(blocktag, attributes) {
2403 // First find the nearest ancestor of the selection that is a block level element.
62467795 2404 var selectionparentnode = this.getSelectionParentNode(),
af6a2e94
DW
2405 boundary,
2406 cell,
2407 nearestblock,
2408 newcontent,
2409 match,
2410 replacement;
bed1abbc
AD
2411
2412 if (!selectionparentnode) {
2413 // No selection, nothing to format.
af6a2e94 2414 return false;
bed1abbc
AD
2415 }
2416
af6a2e94 2417 boundary = this.editor;
bed1abbc 2418
af6a2e94
DW
2419 selectionparentnode = Y.one(selectionparentnode);
2420
2421 // If there is a table cell in between the selectionparentnode and the boundary,
2422 // move the boundary to the table cell.
2423 // This is because we might have a table in a div, and we select some text in a cell,
2424 // want to limit the change in style to the table cell, not the entire table (via the outer div).
2425 cell = selectionparentnode.ancestor(function (node) {
2426 var tagname = node.get('tagName');
2427 if (tagname) {
2428 tagname = tagname.toLowerCase();
bed1abbc 2429 }
af6a2e94
DW
2430 return (node === boundary) ||
2431 (tagname === 'td') ||
2432 (tagname === 'th');
2433 }, true);
bed1abbc 2434
af6a2e94
DW
2435 if (cell) {
2436 // Limit the scope to the table cell.
2437 boundary = cell;
2438 }
adca7326 2439
af6a2e94
DW
2440 nearestblock = selectionparentnode.ancestor(this.BLOCK_TAGS.join(', '), true);
2441 if (nearestblock) {
2442 // Check that the block is contained by the boundary.
2443 match = nearestblock.ancestor(function (node) {
2444 return node === boundary;
2445 }, false);
bed1abbc 2446
af6a2e94
DW
2447 if (!match) {
2448 nearestblock = false;
bed1abbc 2449 }
af6a2e94 2450 }
bed1abbc 2451
af6a2e94
DW
2452 // No valid block element - make one.
2453 if (!nearestblock) {
2454 // There is no block node in the content, wrap the content in a p and use that.
2455 newcontent = Y.Node.create('<p></p>');
2456 boundary.get('childNodes').each(function (child) {
2457 newcontent.append(child.remove());
2458 });
2459 boundary.append(newcontent);
2460 nearestblock = newcontent;
2461 }
2462
2463 // Guaranteed to have a valid block level element contained in the contenteditable region.
2464 // Change the tag to the new block level tag.
2465 if (blocktag && blocktag !== '') {
2466 // Change the block level node for a new one.
2467 replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
2468 // Copy all attributes.
2469 replacement.setAttrs(nearestblock.getAttrs());
2470 // Copy all children.
2471 nearestblock.get('childNodes').each(function (child) {
2472 child.remove();
2473 replacement.append(child);
2474 });
2475
2476 nearestblock.replace(replacement);
2477 nearestblock = replacement;
bed1abbc 2478 }
af6a2e94
DW
2479
2480 // Set the attributes on the block level tag.
2481 if (attributes) {
2482 nearestblock.setAttrs(attributes);
2483 }
2484
2485 // Change the selection to the modified block. This makes sense when we might apply multiple styles
2486 // to the block.
2487 var selection = this.getSelectionFromNode(nearestblock);
2488 this.setSelection(selection);
2489
2490 return nearestblock;
bed1abbc 2491 }
af6a2e94 2492
adca7326 2493};
67d3fe45 2494
62467795
AN
2495Y.Base.mix(Y.M.editor_atto.Editor, [EditorStyling]);
2496// This file is part of Moodle - http://moodle.org/
2497//
2498// Moodle is free software: you can redistribute it and/or modify
2499// it under the terms of the GNU General Public License as published by
2500// the Free Software Foundation, either version 3 of the License, or
2501// (at your option) any later version.
2502//
2503// Moodle is distributed in the hope that it will be useful,
2504// but WITHOUT ANY WARRANTY; without even the implied warranty of
2505// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
2506// GNU General Public License for more details.
2507//
2508// You should have received a copy of the GNU General Public License
2509// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
2510
2511/**
2512 * @module moodle-editor_atto-editor
2513 * @submodule filepicker
2514 */
adca7326
DW
2515
2516/**
62467795 2517 * Filepicker options for the Atto editor.
adca7326 2518 *
62467795
AN
2519 * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
2520 *
2521 * @namespace M.editor_atto
2522 * @class EditorFilepicker
adca7326 2523 */
adca7326 2524
62467795 2525function EditorFilepicker() {}
adca7326 2526
62467795 2527EditorFilepicker.ATTRS= {
adca7326 2528 /**
62467795 2529 * The options for the filepicker.
adca7326 2530 *
62467795
AN
2531 * @attribute filepickeroptions
2532 * @type object
2533 * @default {}
adca7326 2534 */
62467795
AN
2535 filepickeroptions: {
2536 value: {}
adca7326 2537 }
62467795 2538};
adca7326 2539
62467795
AN
2540EditorFilepicker.prototype = {
2541 /**
2542 * Should we show the filepicker for this filetype?
2543 *
2544 * @method canShowFilepicker
2545 * @param string type The media type for the file picker.
2546 * @return {boolean}
2547 */
2548 canShowFilepicker: function(type) {
2549 return (typeof this.get('filepickeroptions')[type] !== 'undefined');
2550 },
adca7326 2551
62467795
AN
2552 /**
2553 * Show the filepicker.
2554 *
2555 * This depends on core_filepicker, and then call that modules show function.
2556 *
2557 * @method showFilepicker
2558 * @param {string} type The media type for the file picker.
2559 * @param {function} callback The callback to use when selecting an item of media.
2560 * @param {object} [context] The context from which to call the callback.
2561 */
2562 showFilepicker: function(type, callback, context) {
2563 var self = this;
2564 Y.use('core_filepicker', function (Y) {
2565 var options = Y.clone(self.get('filepickeroptions')[type], true);
2566 options.formcallback = callback;
2567 if (context) {
2568 options.magicscope = context;
2569 }
adca7326 2570
62467795
AN
2571 M.core_filepicker.show(Y, options);
2572 });
d088a835 2573 }
62467795 2574};
d088a835 2575
62467795 2576Y.Base.mix(Y.M.editor_atto.Editor, [EditorFilepicker]);
adca7326
DW
2577
2578
3ee53a42
DW
2579}, '@VERSION@', {
2580 "requires": [
2581 "node",
f1018cd6 2582 "transition",
3ee53a42
DW
2583 "io",
2584 "overlay",
2585 "escape",
2586 "event",
2587 "event-simulate",
2588 "event-custom",
dc9ca4ac 2589 "node-event-html5",
3ee53a42 2590 "yui-throttle",
d321f68b 2591 "moodle-core-notification-dialogue",
2ba6706d 2592 "moodle-core-notification-confirm",
62467795 2593 "moodle-editor_atto-rangy",
ee376395
AN
2594 "handlebars",
2595 "timers"
3ee53a42
DW
2596 ]
2597});