AutorÃa | Ultima modificación | Ver Log |
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-unused-vars */
/**
* The Atto WYSIWG pluggable editor, written for Moodle.
*
* @module moodle-editor_atto-editor
* @package editor_atto
* @copyright 2013 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @main moodle-editor_atto-editor
*/
/**
* @module moodle-editor_atto-editor
* @submodule editor-base
*/
var LOGNAME = 'moodle-editor_atto-editor';
var CSS = {
CONTENT: 'editor_atto_content',
CONTENTWRAPPER: 'editor_atto_content_wrap',
TOOLBAR: 'editor_atto_toolbar',
WRAPPER: 'editor_atto',
HIGHLIGHT: 'highlight'
},
rangy = window.rangy;
/**
* The Atto editor for Moodle.
*
* @namespace M.editor_atto
* @class Editor
* @constructor
* @uses M.editor_atto.EditorClean
* @uses M.editor_atto.EditorFilepicker
* @uses M.editor_atto.EditorSelection
* @uses M.editor_atto.EditorStyling
* @uses M.editor_atto.EditorTextArea
* @uses M.editor_atto.EditorToolbar
* @uses M.editor_atto.EditorToolbarNav
*/
function Editor() {
Editor.superclass.constructor.apply(this, arguments);
}
Y.extend(Editor, Y.Base, {
/**
* List of known block level tags.
* Taken from "https://developer.mozilla.org/en-US/docs/HTML/Block-level_elements".
*
* @property BLOCK_TAGS
* @type {Array}
*/
BLOCK_TAGS: [
'address',
'article',
'aside',
'audio',
'blockquote',
'canvas',
'dd',
'div',
'dl',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'noscript',
'ol',
'output',
'p',
'pre',
'section',
'table',
'tfoot',
'ul',
'video'
],
PLACEHOLDER_CLASS: 'atto-tmp-class',
ALL_NODES_SELECTOR: '[style],font[face]',
FONT_FAMILY: 'fontFamily',
/**
* The wrapper containing the editor.
*
* @property _wrapper
* @type Node
* @private
*/
_wrapper: null,
/**
* A reference to the content editable Node.
*
* @property editor
* @type Node
*/
editor: null,
/**
* A reference to the original text area.
*
* @property textarea
* @type Node
*/
textarea: null,
/**
* A reference to the label associated with the original text area.
*
* @property textareaLabel
* @type Node
*/
textareaLabel: null,
/**
* A reference to the list of plugins.
*
* @property plugins
* @type object
*/
plugins: null,
/**
* An indicator of the current input direction.
*
* @property coreDirection
* @type string
*/
coreDirection: null,
/**
* Enable/disable the empty placeholder content.
*
* @property enableAppropriateEmptyContent
* @type Boolean
*/
enableAppropriateEmptyContent: null,
/**
* Event Handles to clear on editor destruction.
*
* @property _eventHandles
* @private
*/
_eventHandles: null,
initializer: function() {
var template;
// Note - it is not safe to use a CSS selector like '#' + elementid because the id
// may have colons in it - e.g. quiz.
this.textarea = Y.one(document.getElementById(this.get('elementid')));
if (!this.textarea) {
// No text area found.
Y.log('Text area not found - unable to setup editor for ' + this.get('elementid'),
'error', LOGNAME);
return;
}
var extraclasses = this.textarea.getAttribute('class');
this._eventHandles = [];
var description = Y.Node.create('<div class="sr-only">' + M.util.get_string('richtexteditor', 'editor_atto') + '</div>');
this._wrapper = Y.Node.create('<div class="' + CSS.WRAPPER + '" role="application" />');
this._wrapper.appendChild(description);
this._wrapper.setAttribute('aria-describedby', description.generateID());
template = Y.Handlebars.compile('<div id="{{elementid}}editable" ' +
'contenteditable="true" ' +
'role="textbox" ' +
'spellcheck="true" ' +
'aria-live="off" ' +
'class="{{CSS.CONTENT}} ' + extraclasses + '" ' +
'/>');
this.editor = Y.Node.create(template({
elementid: this.get('elementid'),
CSS: CSS
}));
// Add a labelled-by attribute to the contenteditable.
this.textareaLabel = Y.one('[for="' + this.get('elementid') + '"]');
if (this.textareaLabel) {
this.textareaLabel.generateID();
this.editor.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
}
// Set diretcion according to current page language.
this.coreDirection = Y.one('body').hasClass('dir-rtl') ? 'rtl' : 'ltr';
// Enable the placeholder for empty content.
this.enablePlaceholderForEmptyContent();
// Add everything to the wrapper.
this.setupToolbar();
// Editable content wrapper.
var content = Y.Node.create('<div class="' + CSS.CONTENTWRAPPER + '" />');
content.appendChild(this.editor);
this._wrapper.appendChild(content);
// Style the editor. According to the styles.css: 20 is the line-height, 8 is padding-top + padding-bottom.
this.editor.setStyle('minHeight', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
if (Y.UA.ie === 0) {
// We set a height here to force the overflow because decent browsers allow the CSS property resize.
this.editor.setStyle('height', ((20 * this.textarea.getAttribute('rows')) + 8) + 'px');
}
// Disable odd inline CSS styles.
this.disableCssStyling();
// Use paragraphs not divs.
if (document.queryCommandSupported('DefaultParagraphSeparator')) {
document.execCommand('DefaultParagraphSeparator', false, 'p');
}
// Add the toolbar and editable zone to the page.
this.textarea.get('parentNode').insert(this._wrapper, this.textarea).
setAttribute('class', 'editor_atto_wrap');
// Hide the old textarea.
this.textarea.hide();
// Set up custom event for editor updated.
Y.mix(Y.Node.DOM_EVENTS, {'form:editorUpdated': true});
this.textarea.on('form:editorUpdated', function() {
this.updateEditorState();
}, this);
// Copy the text to the contenteditable div.
this.updateFromTextArea();
// Publish the events that are defined by this editor.
this.publishEvents();
// Add handling for saving and restoring selections on cursor/focus changes.
this.setupSelectionWatchers();
// Add polling to update the textarea periodically when typing long content.
this.setupAutomaticPolling();
// Setup plugins.
this.setupPlugins();
// Initialize the auto-save timer.
this.setupAutosave();
// Preload the icons for the notifications.
this.setupNotifications();
},
/**
* Focus on the editable area for this editor.
*
* @method focus
* @chainable
*/
focus: function() {
this.editor.focus();
return this;
},
/**
* Publish events for this editor instance.
*
* @method publishEvents
* @private
* @chainable
*/
publishEvents: function() {
/**
* Fired when changes are made within the editor.
*
* @event change
*/
this.publish('change', {
broadcast: true,
preventable: true
});
/**
* Fired when all plugins have completed loading.
*
* @event pluginsloaded
*/
this.publish('pluginsloaded', {
fireOnce: true
});
this.publish('atto:selectionchanged', {
prefix: 'atto'
});
return this;
},
/**
* Set up automated polling of the text area to update the textarea.
*
* @method setupAutomaticPolling
* @chainable
*/
setupAutomaticPolling: function() {
this._registerEventHandle(this.editor.on(['keyup', 'cut'], this.updateOriginal, this));
this._registerEventHandle(this.editor.on('paste', this.pasteCleanup, this));
// Call this.updateOriginal after dropped content has been processed.
this._registerEventHandle(this.editor.on('drop', this.updateOriginalDelayed, this));
return this;
},
/**
* Calls updateOriginal on a short timer to allow native event handlers to run first.
*
* @method updateOriginalDelayed
* @chainable
*/
updateOriginalDelayed: function() {
Y.soon(Y.bind(this.updateOriginal, this));
return this;
},
setupPlugins: function() {
// Clear the list of plugins.
this.plugins = {};
var plugins = this.get('plugins');
var groupIndex,
group,
pluginIndex,
plugin,
pluginConfig;
for (groupIndex in plugins) {
group = plugins[groupIndex];
if (!group.plugins) {
// No plugins in this group - skip it.
continue;
}
for (pluginIndex in group.plugins) {
plugin = group.plugins[pluginIndex];
pluginConfig = Y.mix({
name: plugin.name,
group: group.group,
editor: this.editor,
toolbar: this.toolbar,
host: this
}, plugin);
// Add a reference to the current editor.
if (typeof Y.M['atto_' + plugin.name] === "undefined") {
Y.log("Plugin '" + plugin.name + "' could not be found - skipping initialisation", "warn", LOGNAME);
continue;
}
this.plugins[plugin.name] = new Y.M['atto_' + plugin.name].Button(pluginConfig);
}
}
// Some plugins need to perform actions once all plugins have loaded.
this.fire('pluginsloaded');
return this;
},
enablePlugins: function(plugin) {
this._setPluginState(true, plugin);
},
disablePlugins: function(plugin) {
this._setPluginState(false, plugin);
},
_setPluginState: function(enable, plugin) {
var target = 'disableButtons';
if (enable) {
target = 'enableButtons';
}
if (plugin) {
this.plugins[plugin][target]();
} else {
Y.Object.each(this.plugins, function(currentPlugin) {
currentPlugin[target]();
}, this);
}
},
/**
* Update the state of the editor.
*/
updateEditorState: function() {
var disabled = this.textarea.hasAttribute('readonly'),
editorfield = Y.one('#' + this.get('elementid') + 'editable');
// Enable/Disable all plugins.
this._setPluginState(!disabled);
// Enable/Disable content of editor.
if (editorfield) {
editorfield.setAttribute('contenteditable', !disabled);
}
},
/**
* Enable the empty placeholder for empty content.
*/
enablePlaceholderForEmptyContent: function() {
this.enableAppropriateEmptyContent = true;
},
/**
* Disable the empty placeholder for empty content.
*/
disablePlaceholderForEmptyContent: function() {
this.enableAppropriateEmptyContent = false;
},
/**
* Register an event handle for disposal in the destructor.
*
* @method _registerEventHandle
* @param {EventHandle} The Event Handle as returned by Y.on, and Y.delegate.
* @private
*/
_registerEventHandle: function(handle) {
this._eventHandles.push(handle);
}
}, {
NS: 'editor_atto',
ATTRS: {
/**
* The unique identifier for the form element representing the editor.
*
* @attribute elementid
* @type String
* @writeOnce
*/
elementid: {
value: null,
writeOnce: true
},
/**
* The contextid of the form.
*
* @attribute contextid
* @type Integer
* @writeOnce
*/
contextid: {
value: null,
writeOnce: true
},
/**
* Plugins with their configuration.
*
* The plugins structure is:
*
* [
* {
* "group": "groupName",
* "plugins": [
* "pluginName": {
* "configKey": "configValue"
* },
* "pluginName": {
* "configKey": "configValue"
* }
* ]
* },
* {
* "group": "groupName",
* "plugins": [
* "pluginName": {
* "configKey": "configValue"
* }
* ]
* }
* ]
*
* @attribute plugins
* @type Object
* @writeOnce
*/
plugins: {
value: {},
writeOnce: true
}
}
});
// The Editor publishes custom events that can be subscribed to.
Y.augment(Editor, Y.EventTarget);
Y.namespace('M.editor_atto').Editor = Editor;
// Function for Moodle's initialisation.
Y.namespace('M.editor_atto.Editor').init = function(config) {
return new Y.M.editor_atto.Editor(config);
};