first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,353 @@
YUI.add('moodle-editor_atto-menu', function (Y, NAME) {
// 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/>.
/**
* A Menu for the Atto editor.
*
* @module moodle-editor_atto-menu
* @submodule menu-base
* @package editor_atto
* @copyright 2013 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
var LOGNAME = 'moodle-editor_atto-menu';
var MENUDIALOGUE = '' +
'<div class="open {{config.buttonClass}} atto_menu" ' +
'style="min-width:{{config.innerOverlayWidth}};">' +
'<ul class="dropdown-menu" role="menu" id="{{config.buttonId}}_menu" aria-labelledby="{{config.buttonId}}">' +
'{{#each config.items}}' +
'<li role="none" class="atto_menuentry">' +
'<a href="#" role="menuitem" data-index="{{@index}}" {{#each data}}data-{{@key}}="{{this}}"{{/each}}>' +
'{{{text}}}' +
'</a>' +
'</li>' +
'{{/each}}' +
'</ul>' +
'</div>';
/**
* A Menu for the Atto editor used in Moodle.
*
* This is a drop down list of buttons triggered (and aligned to) a
* location.
*
* @namespace M.editor_atto
* @class Menu
* @main
* @constructor
* @extends M.core.dialogue
*/
var Menu = function() {
Menu.superclass.constructor.apply(this, arguments);
};
Y.extend(Menu, M.core.dialogue, {
/**
* A list of the menu handlers which have been attached here.
*
* @property _menuHandlers
* @type Array
* @private
*/
_menuHandlers: null,
/**
* The menu button that controls this menu.
*/
_menuButton: null,
initializer: function(config) {
var headerText,
bb;
this._menuHandlers = [];
this._menuButton = document.getElementById(config.buttonId);
// Create the actual button.
var template = Y.Handlebars.compile(MENUDIALOGUE),
menu = Y.Node.create(template({
config: config
}));
this.set('bodyContent', menu);
bb = this.get('boundingBox');
bb.addClass('editor_atto_controlmenu');
bb.addClass('editor_atto_menu');
// Get the dialogue container for this menu.
var content = bb.one('.moodle-dialogue-wrap');
content.removeClass('moodle-dialogue-wrap')
.addClass('moodle-dialogue-content');
// Remove the dialog role attribute.
content.removeAttribute('role');
// Remove aria-labelledby in the container. The aria-labelledby attribute is properly set in the menu's template.
content.removeAttribute('aria-labelledby');
// Render heading if necessary.
headerText = this.get('headerText').trim();
if (headerText) {
var heading = Y.Node.create('<h3/>')
.addClass('accesshide')
.setHTML(headerText);
this.get('bodyContent').prepend(heading);
}
// Hide the header and footer node entirely.
this.headerNode.hide();
this.footerNode.hide();
this._setupHandlers();
},
/**
* Setup the Event handlers.
*
* @method _setupHandlers
* @private
*/
_setupHandlers: function() {
var contentBox = this.get('contentBox');
// Handle menu item selection.
this._menuHandlers.push(
// Select the menu item on space, and enter.
contentBox.delegate('key', this._chooseMenuItem, '32, enter', '.atto_menuentry', this),
// Move up and down the menu on up/down.
contentBox.delegate('key', this._handleKeyboardEvent, 'down:38,40', '.dropdown-menu', this),
// Hide the menu when clicking outside of it.
contentBox.on('focusoutside', this.hide, this),
// Hide the menu on left/right, and escape keys.
contentBox.delegate('key', this.hide, 'down:37,39,esc', '.dropdown-menu', this)
);
},
/**
* Simulate other types of menu selection.
*
* @method _chooseMenuItem
* @param {EventFacade} e
*/
_chooseMenuItem: function(e) {
e.target.simulate('click');
e.preventDefault();
},
/**
* Hide a menu, removing all of the event handlers which trigger the hide.
*
* @method hide
* @param {EventFacade} e
*/
hide: function(e) {
if (this.get('preventHideMenu') === true) {
return;
}
// We must prevent the default action (left/right/escape) because
// there are other listeners on the toolbar which will focus on the
// editor.
if (e) {
e.preventDefault();
}
// Remove menu button's aria-expanded attribute when this menu is hidden.
if (this._menuButton) {
this._menuButton.removeAttribute('aria-expanded');
}
return Menu.superclass.hide.call(this, arguments);
},
/**
* Implement arrow-key navigation for the items in a toolbar menu.
*
* @method _handleKeyboardEvent
* @param {EventFacade} e The keyboard event.
* @static
*/
_handleKeyboardEvent: function(e) {
// Prevent the default browser behaviour.
e.preventDefault();
// Get a list of all buttons in the menu.
var buttons = e.currentTarget.all('a[role="menuitem"]');
// On cursor moves we loops through the buttons.
var found = false,
index = 0,
direction = 1,
checkCount = 0,
current = e.target.ancestor('a[role="menuitem"]', true),
next;
// Determine which button is currently selected.
while (!found && index < buttons.size()) {
if (buttons.item(index) === current) {
found = true;
} else {
index++;
}
}
if (!found) {
Y.log("Unable to find this menu item in the menu", 'debug', LOGNAME);
return;
}
if (e.keyCode === 38) {
// Moving up so reverse the direction.
direction = -1;
}
// Try to find the next
do {
index += direction;
if (index < 0) {
index = buttons.size() - 1;
} else if (index >= buttons.size()) {
// Handle wrapping.
index = 0;
}
next = buttons.item(index);
// Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
checkCount++;
// Loop while:
// * we are not in a loop and have not already checked every button; and
// * we are on a different button; and
// * the next menu item is not hidden.
} while (checkCount < buttons.size() && next !== current && next.hasAttribute('hidden'));
if (next) {
next.focus();
}
e.preventDefault();
e.stopImmediatePropagation();
}
}, {
NAME: "menu",
ATTRS: {
/**
* The header for the drop down (only accessible to screen readers).
*
* @attribute headerText
* @type String
* @default ''
*/
headerText: {
value: ''
}
}
});
Y.Base.modifyAttrs(Menu, {
/**
* The width for this menu.
*
* @attribute width
* @default 'auto'
*/
width: {
value: 'auto'
},
/**
* When to hide this menu.
*
* By default, this attribute consists of:
* <ul>
* <li>an object which will cause the menu to hide when the user clicks outside of the menu</li>
* </ul>
*
* @attribute hideOn
*/
hideOn: {
value: [
{
eventName: 'clickoutside'
}
]
},
/**
* The default list of extra classes for this menu.
*
* @attribute extraClasses
* @type Array
* @default editor_atto_menu
*/
extraClasses: {
value: [
'editor_atto_menu'
]
},
/**
* Override the responsive nature of the core dialogues.
*
* @attribute responsive
* @type boolean
* @default false
*/
responsive: {
value: false
},
/**
* The default visibility of the menu.
*
* @attribute visible
* @type boolean
* @default false
*/
visible: {
value: false
},
/**
* Whether to centre the menu.
*
* @attribute center
* @type boolean
* @default false
*/
center: {
value: false
},
/**
* Hide the close button.
* @attribute closeButton
* @type boolean
* @default false
*/
closeButton: {
value: false
}
});
Y.namespace('M.editor_atto').Menu = Menu;
}, '@VERSION@', {"requires": ["moodle-core-notification-dialogue", "node", "event", "event-custom"]});
@@ -0,0 +1 @@
YUI.add("moodle-editor_atto-menu",function(n,e){var t=function(){t.superclass.constructor.apply(this,arguments)};n.extend(t,M.core.dialogue,{_menuHandlers:null,_menuButton:null,initializer:function(e){var t;this._menuHandlers=[],this._menuButton=document.getElementById(e.buttonId),t=n.Handlebars.compile('<div class="open {{config.buttonClass}} atto_menu" style="min-width:{{config.innerOverlayWidth}};"><ul class="dropdown-menu" role="menu" id="{{config.buttonId}}_menu" aria-labelledby="{{config.buttonId}}">{{#each config.items}}<li role="none" class="atto_menuentry"><a href="#" role="menuitem" data-index="{{@index}}" {{#each data}}data-{{@key}}="{{this}}"{{/each}}>{{{text}}}</a></li>{{/each}}</ul></div>'),e=n.Node.create(t({config:e})),this.set("bodyContent",e),(t=this.get("boundingBox")).addClass("editor_atto_controlmenu"),t.addClass("editor_atto_menu"),(e=t.one(".moodle-dialogue-wrap")).removeClass("moodle-dialogue-wrap").addClass("moodle-dialogue-content"),e.removeAttribute("role"),e.removeAttribute("aria-labelledby"),(t=this.get("headerText").trim())&&(e=n.Node.create("<h3/>").addClass("accesshide").setHTML(t),this.get("bodyContent").prepend(e)),this.headerNode.hide(),this.footerNode.hide(),this._setupHandlers()},_setupHandlers:function(){var e=this.get("contentBox");this._menuHandlers.push(e.delegate("key",this._chooseMenuItem,"32, enter",".atto_menuentry",this),e.delegate("key",this._handleKeyboardEvent,"down:38,40",".dropdown-menu",this),e.on("focusoutside",this.hide,this),e.delegate("key",this.hide,"down:37,39,esc",".dropdown-menu",this))},_chooseMenuItem:function(e){e.target.simulate("click"),e.preventDefault()},hide:function(e){if(!0!==this.get("preventHideMenu"))return e&&e.preventDefault(),this._menuButton&&this._menuButton.removeAttribute("aria-expanded"),t.superclass.hide.call(this,arguments)},_handleKeyboardEvent:function(e){var t,n,o,a,i,d,s;for(e.preventDefault(),t=e.currentTarget.all('a[role="menuitem"]'),n=!1,a=1,d=e.target.ancestor('a[role="menuitem"]',!(i=o=0));!n&&o<t.size();)t.item(o)===d?n=!0:o++;if(n){for(38===e.keyCode&&(a=-1);(o+=a)<0?o=t.size()-1:o>=t.size()&&(o=0),s=t.item(o),++i<t.size()&&s!==d&&s.hasAttribute("hidden"););s&&s.focus(),e.preventDefault(),e.stopImmediatePropagation()}}},{NAME:"menu",ATTRS:{headerText:{value:""}}}),n.Base.modifyAttrs(t,{width:{value:"auto"},hideOn:{value:[{eventName:"clickoutside"}]},extraClasses:{value:["editor_atto_menu"]},responsive:{value:!1},visible:{value:!1},center:{value:!1},closeButton:{value:!1}}),n.namespace("M.editor_atto").Menu=t},"@VERSION@",{requires:["moodle-core-notification-dialogue","node","event","event-custom"]});
@@ -0,0 +1,352 @@
YUI.add('moodle-editor_atto-menu', function (Y, NAME) {
// 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/>.
/**
* A Menu for the Atto editor.
*
* @module moodle-editor_atto-menu
* @submodule menu-base
* @package editor_atto
* @copyright 2013 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
var LOGNAME = 'moodle-editor_atto-menu';
var MENUDIALOGUE = '' +
'<div class="open {{config.buttonClass}} atto_menu" ' +
'style="min-width:{{config.innerOverlayWidth}};">' +
'<ul class="dropdown-menu" role="menu" id="{{config.buttonId}}_menu" aria-labelledby="{{config.buttonId}}">' +
'{{#each config.items}}' +
'<li role="none" class="atto_menuentry">' +
'<a href="#" role="menuitem" data-index="{{@index}}" {{#each data}}data-{{@key}}="{{this}}"{{/each}}>' +
'{{{text}}}' +
'</a>' +
'</li>' +
'{{/each}}' +
'</ul>' +
'</div>';
/**
* A Menu for the Atto editor used in Moodle.
*
* This is a drop down list of buttons triggered (and aligned to) a
* location.
*
* @namespace M.editor_atto
* @class Menu
* @main
* @constructor
* @extends M.core.dialogue
*/
var Menu = function() {
Menu.superclass.constructor.apply(this, arguments);
};
Y.extend(Menu, M.core.dialogue, {
/**
* A list of the menu handlers which have been attached here.
*
* @property _menuHandlers
* @type Array
* @private
*/
_menuHandlers: null,
/**
* The menu button that controls this menu.
*/
_menuButton: null,
initializer: function(config) {
var headerText,
bb;
this._menuHandlers = [];
this._menuButton = document.getElementById(config.buttonId);
// Create the actual button.
var template = Y.Handlebars.compile(MENUDIALOGUE),
menu = Y.Node.create(template({
config: config
}));
this.set('bodyContent', menu);
bb = this.get('boundingBox');
bb.addClass('editor_atto_controlmenu');
bb.addClass('editor_atto_menu');
// Get the dialogue container for this menu.
var content = bb.one('.moodle-dialogue-wrap');
content.removeClass('moodle-dialogue-wrap')
.addClass('moodle-dialogue-content');
// Remove the dialog role attribute.
content.removeAttribute('role');
// Remove aria-labelledby in the container. The aria-labelledby attribute is properly set in the menu's template.
content.removeAttribute('aria-labelledby');
// Render heading if necessary.
headerText = this.get('headerText').trim();
if (headerText) {
var heading = Y.Node.create('<h3/>')
.addClass('accesshide')
.setHTML(headerText);
this.get('bodyContent').prepend(heading);
}
// Hide the header and footer node entirely.
this.headerNode.hide();
this.footerNode.hide();
this._setupHandlers();
},
/**
* Setup the Event handlers.
*
* @method _setupHandlers
* @private
*/
_setupHandlers: function() {
var contentBox = this.get('contentBox');
// Handle menu item selection.
this._menuHandlers.push(
// Select the menu item on space, and enter.
contentBox.delegate('key', this._chooseMenuItem, '32, enter', '.atto_menuentry', this),
// Move up and down the menu on up/down.
contentBox.delegate('key', this._handleKeyboardEvent, 'down:38,40', '.dropdown-menu', this),
// Hide the menu when clicking outside of it.
contentBox.on('focusoutside', this.hide, this),
// Hide the menu on left/right, and escape keys.
contentBox.delegate('key', this.hide, 'down:37,39,esc', '.dropdown-menu', this)
);
},
/**
* Simulate other types of menu selection.
*
* @method _chooseMenuItem
* @param {EventFacade} e
*/
_chooseMenuItem: function(e) {
e.target.simulate('click');
e.preventDefault();
},
/**
* Hide a menu, removing all of the event handlers which trigger the hide.
*
* @method hide
* @param {EventFacade} e
*/
hide: function(e) {
if (this.get('preventHideMenu') === true) {
return;
}
// We must prevent the default action (left/right/escape) because
// there are other listeners on the toolbar which will focus on the
// editor.
if (e) {
e.preventDefault();
}
// Remove menu button's aria-expanded attribute when this menu is hidden.
if (this._menuButton) {
this._menuButton.removeAttribute('aria-expanded');
}
return Menu.superclass.hide.call(this, arguments);
},
/**
* Implement arrow-key navigation for the items in a toolbar menu.
*
* @method _handleKeyboardEvent
* @param {EventFacade} e The keyboard event.
* @static
*/
_handleKeyboardEvent: function(e) {
// Prevent the default browser behaviour.
e.preventDefault();
// Get a list of all buttons in the menu.
var buttons = e.currentTarget.all('a[role="menuitem"]');
// On cursor moves we loops through the buttons.
var found = false,
index = 0,
direction = 1,
checkCount = 0,
current = e.target.ancestor('a[role="menuitem"]', true),
next;
// Determine which button is currently selected.
while (!found && index < buttons.size()) {
if (buttons.item(index) === current) {
found = true;
} else {
index++;
}
}
if (!found) {
return;
}
if (e.keyCode === 38) {
// Moving up so reverse the direction.
direction = -1;
}
// Try to find the next
do {
index += direction;
if (index < 0) {
index = buttons.size() - 1;
} else if (index >= buttons.size()) {
// Handle wrapping.
index = 0;
}
next = buttons.item(index);
// Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
checkCount++;
// Loop while:
// * we are not in a loop and have not already checked every button; and
// * we are on a different button; and
// * the next menu item is not hidden.
} while (checkCount < buttons.size() && next !== current && next.hasAttribute('hidden'));
if (next) {
next.focus();
}
e.preventDefault();
e.stopImmediatePropagation();
}
}, {
NAME: "menu",
ATTRS: {
/**
* The header for the drop down (only accessible to screen readers).
*
* @attribute headerText
* @type String
* @default ''
*/
headerText: {
value: ''
}
}
});
Y.Base.modifyAttrs(Menu, {
/**
* The width for this menu.
*
* @attribute width
* @default 'auto'
*/
width: {
value: 'auto'
},
/**
* When to hide this menu.
*
* By default, this attribute consists of:
* <ul>
* <li>an object which will cause the menu to hide when the user clicks outside of the menu</li>
* </ul>
*
* @attribute hideOn
*/
hideOn: {
value: [
{
eventName: 'clickoutside'
}
]
},
/**
* The default list of extra classes for this menu.
*
* @attribute extraClasses
* @type Array
* @default editor_atto_menu
*/
extraClasses: {
value: [
'editor_atto_menu'
]
},
/**
* Override the responsive nature of the core dialogues.
*
* @attribute responsive
* @type boolean
* @default false
*/
responsive: {
value: false
},
/**
* The default visibility of the menu.
*
* @attribute visible
* @type boolean
* @default false
*/
visible: {
value: false
},
/**
* Whether to centre the menu.
*
* @attribute center
* @type boolean
* @default false
*/
center: {
value: false
},
/**
* Hide the close button.
* @attribute closeButton
* @type boolean
* @default false
*/
closeButton: {
value: false
}
});
Y.namespace('M.editor_atto').Menu = Menu;
}, '@VERSION@', {"requires": ["moodle-core-notification-dialogue", "node", "event", "event-custom"]});
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "moodle-editor_atto-editor",
"builds": {
"moodle-editor_atto-editor": {
"jsfiles": [
"editor.js",
"notify.js",
"textarea.js",
"autosave.js",
"autosave-io.js",
"clean.js",
"commands.js",
"toolbar.js",
"toolbar-keyboardnav.js",
"selection.js",
"styling.js",
"filepicker.js"
]
},
"moodle-editor_atto-plugin": {
"jsfiles": [
"editor-plugin.js",
"editor-plugin-buttons.js",
"editor-plugin-dialogue.js"
]
},
"moodle-editor_atto-menu": {
"jsfiles": [
"menu.js"
]
}
}
}
+245
View File
@@ -0,0 +1,245 @@
// 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/>.
/**
* A autosave function for the Atto editor.
*
* @module moodle-editor_atto-autosave-io
* @submodule autosave-io
* @package editor_atto
* @copyright 2016 Frédéric Massart
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
var EditorAutosaveIoDispatcherInstance = null;
function EditorAutosaveIoDispatcher() {
EditorAutosaveIoDispatcher.superclass.constructor.apply(this, arguments);
this._submitEvents = {};
this._queue = [];
this._throttle = null;
}
EditorAutosaveIoDispatcher.NAME = 'EditorAutosaveIoDispatcher';
EditorAutosaveIoDispatcher.ATTRS = {
/**
* The relative path to the ajax script.
*
* @attribute autosaveAjaxScript
* @type String
* @default '/lib/editor/atto/autosave-ajax.php'
* @readOnly
*/
autosaveAjaxScript: {
value: '/lib/editor/atto/autosave-ajax.php',
readOnly: true
},
/**
* The time buffer for the throttled requested.
*
* @attribute delay
* @type Number
* @default 50
* @readOnly
*/
delay: {
value: 50,
readOnly: true
}
};
Y.extend(EditorAutosaveIoDispatcher, Y.Base, {
/**
* Dispatch an IO request.
*
* This method will put the requests in a queue in order to attempt to bulk them.
*
* @param {Object} params The parameters of the request.
* @param {Object} context The context in which the callbacks are called.
* @param {Object} callbacks Object with 'success', 'complete', 'end', 'failure' and 'start' as
* optional keys defining the callbacks to call. Success and Complete
* functions will receive the response as parameter. Success and Complete
* may receive an object containing the error key, use this to confirm
* that no errors occured.
* @return {Void}
*/
dispatch: function(params, context, callbacks) {
if (this._throttle) {
this._throttle.cancel();
}
this._throttle = Y.later(this.get('delay'), this, this._processDispatchQueue);
this._queue.push([params, context, callbacks]);
},
/**
* Dispatches the requests in the queue.
*
* @return {Void}
*/
_processDispatchQueue: function() {
var queue = this._queue,
data = {};
this._queue = [];
if (queue.length < 1) {
return;
}
Y.Array.each(queue, function(item, index) {
data[index] = item[0];
});
Y.io(M.cfg.wwwroot + this.get('autosaveAjaxScript'), {
method: 'POST',
data: Y.QueryString.stringify({
actions: data,
sesskey: M.cfg.sesskey
}),
on: {
start: this._makeIoEventCallback('start', queue),
complete: this._makeIoEventCallback('complete', queue),
failure: this._makeIoEventCallback('failure', queue),
end: this._makeIoEventCallback('end', queue),
success: this._makeIoEventCallback('success', queue)
}
});
},
/**
* Creates a function that dispatches an IO response to callbacks.
*
* @param {String} event The type of event.
* @param {Array} queue The queue.
* @return {Function}
*/
_makeIoEventCallback: function(event, queue) {
var noop = function() {};
return function() {
var response = arguments[1],
parsed = {};
if ((event == 'complete' || event == 'success') && (typeof response !== 'undefined'
&& typeof response.responseText !== 'undefined' && response.responseText !== '')) {
// Success and complete events need to parse the response.
parsed = JSON.parse(response.responseText) || {};
}
Y.Array.each(queue, function(item, index) {
var context = item[1],
cb = (item[2] && item[2][event]) || noop,
arg;
if (parsed && parsed.error) {
// The response is an error, we send it to everyone.
arg = parsed;
} else if (parsed) {
// The response was parsed, we only communicate the relevant portion of the response.
arg = parsed[index];
}
cb.apply(context, [arg]);
});
};
},
/**
* Form submit handler.
*
* @param {EventFacade} e The event.
* @return {Void}
*/
_onSubmit: function(e) {
var data = {},
id = e.currentTarget.generateID(),
params = this._submitEvents[id];
if (!params || params.ios.length < 1) {
return;
}
Y.Array.each(params.ios, function(param, index) {
data[index] = param;
});
Y.io(M.cfg.wwwroot + this.get('autosaveAjaxScript'), {
method: 'POST',
data: Y.QueryString.stringify({
actions: data,
sesskey: M.cfg.sesskey
}),
sync: true
});
},
/**
* Registers a request to be made on form submission.
*
* @param {Node} node The forum node we will listen to.
* @param {Object} params Parameters for the IO request.
* @return {Void}
*/
whenSubmit: function(node, params) {
if (typeof this._submitEvents[node.generateID()] === 'undefined') {
this._submitEvents[node.generateID()] = {
event: node.on('submit', this._onSubmit, this),
ajaxEvent: node.on(M.core.event.FORM_SUBMIT_AJAX, this._onSubmit, this),
ios: []
};
}
this._submitEvents[node.get('id')].ios.push([params]);
}
});
EditorAutosaveIoDispatcherInstance = new EditorAutosaveIoDispatcher();
function EditorAutosaveIo() {}
EditorAutosaveIo.prototype = {
/**
* Dispatch an IO request.
*
* This method will put the requests in a queue in order to attempt to bulk them.
*
* @param {Object} params The parameters of the request.
* @param {Object} context The context in which the callbacks are called.
* @param {Object} callbacks Object with 'success', 'complete', 'end', 'failure' and 'start' as
* optional keys defining the callbacks to call. Success and Complete
* functions will receive the response as parameter. Success and Complete
* may receive an object containing the error key, use this to confirm
* that no errors occured.
* @return {Void}
*/
autosaveIo: function(params, context, callbacks) {
EditorAutosaveIoDispatcherInstance.dispatch(params, context, callbacks);
},
/**
* Registers a request to be made on form submission.
*
* @param {Node} form The forum node we will listen to.
* @param {Object} params Parameters for the IO request.
* @return {Void}
*/
autosaveIoOnSubmit: function(form, params) {
EditorAutosaveIoDispatcherInstance.whenSubmit(form, params);
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorAutosaveIo]);
+287
View File
@@ -0,0 +1,287 @@
// 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 */
/**
* A autosave function for the Atto editor.
*
* @module moodle-editor_atto-autosave
* @submodule autosave-base
* @package editor_atto
* @copyright 2014 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
var SUCCESS_MESSAGE_TIMEOUT = 5000,
RECOVER_MESSAGE_TIMEOUT = 60000,
LOGNAME_AUTOSAVE = 'moodle-editor_atto-editor-autosave';
function EditorAutosave() {}
EditorAutosave.ATTRS = {
/**
* Enable/Disable auto save for this instance.
*
* @attribute autosaveEnabled
* @type Boolean
* @writeOnce
*/
autosaveEnabled: {
value: true,
writeOnce: true
},
/**
* The time between autosaves (in seconds).
*
* @attribute autosaveFrequency
* @type Number
* @default 60
* @writeOnce
*/
autosaveFrequency: {
value: 60,
writeOnce: true
},
/**
* Unique hash for this page instance. Calculated from $PAGE->url in php.
*
* @attribute pageHash
* @type String
* @writeOnce
*/
pageHash: {
value: '',
writeOnce: true
}
};
EditorAutosave.prototype = {
/**
* The text that was auto saved in the last request.
*
* @property lastText
* @type string
*/
lastText: "",
/**
* Autosave instance.
*
* @property autosaveInstance
* @type string
*/
autosaveInstance: null,
/**
* Autosave Timer.
*
* @property autosaveTimer
* @type object
*/
autosaveTimer: null,
/**
* Initialize the autosave process
*
* @method setupAutosave
* @chainable
*/
setupAutosave: function() {
var draftid = -1,
form,
optiontype = null,
options = this.get('filepickeroptions'),
params;
if (!this.get('autosaveEnabled')) {
// Autosave disabled for this instance.
return;
}
this.autosaveInstance = Y.stamp(this);
for (optiontype in options) {
if (typeof options[optiontype].itemid !== "undefined") {
draftid = options[optiontype].itemid;
}
}
// First see if there are any saved drafts.
// Make an ajax request.
params = {
contextid: this.get('contextid'),
action: 'resume',
draftid: draftid,
elementid: this.get('elementid'),
pageinstance: this.autosaveInstance,
pagehash: this.get('pageHash')
};
this.autosaveIo(params, this, {
success: function(response) {
if (response === null) {
// This can happen when there is nothing to resume from.
return;
} else if (!response) {
Y.log('Invalid response received.', 'debug', LOGNAME_AUTOSAVE);
return;
}
// Revert untouched editor contents to an empty string.
var emptyContents = [
// For FF and Chrome.
'<p></p>',
'<p><br></p>',
'<br>',
'<p dir="rtl" style="text-align: right;"></p>',
'<p dir="rtl" style="text-align: right;"><br></p>',
'<p dir="ltr" style="text-align: left;"></p>',
'<p dir="ltr" style="text-align: left;"><br></p>',
// For IE 9 and 10.
'<p>&nbsp;</p>',
'<p><br>&nbsp;</p>',
'<p dir="rtl" style="text-align: right;">&nbsp;</p>',
'<p dir="rtl" style="text-align: right;"><br>&nbsp;</p>',
'<p dir="ltr" style="text-align: left;">&nbsp;</p>',
'<p dir="ltr" style="text-align: left;"><br>&nbsp;</p>'
];
if (emptyContents.includes(response.result)) {
response.result = '';
}
if (response.error || typeof response.result === 'undefined') {
Y.log('Error occurred recovering draft text: ' + response.error, 'debug', LOGNAME_AUTOSAVE);
this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
} else if (response.result !== this.textarea.get('value') &&
response.result !== '') {
Y.log('Autosave text found - recover it.', 'debug', LOGNAME_AUTOSAVE);
this.recoverText(response.result);
}
this._fireSelectionChanged();
},
failure: function() {
this.showMessage(M.util.get_string('errortextrecovery', 'editor_atto'),
NOTIFY_WARNING, RECOVER_MESSAGE_TIMEOUT);
}
});
// Now setup the timer for periodic saves.
var delay = parseInt(this.get('autosaveFrequency'), 10) * 1000;
this.autosaveTimer = Y.later(delay, this, this.saveDraft, false, true);
// Now setup the listener for form submission.
form = this.textarea.ancestor('form');
if (form) {
this.autosaveIoOnSubmit(form, {
action: 'reset',
contextid: this.get('contextid'),
elementid: this.get('elementid'),
pageinstance: this.autosaveInstance,
pagehash: this.get('pageHash')
});
}
return this;
},
/**
* Recover a previous version of this text and show a message.
*
* @method recoverText
* @param {String} text
* @chainable
*/
recoverText: function(text) {
this.editor.setHTML(text);
this.saveSelection();
this.updateOriginal();
this.lastText = text;
this.showMessage(M.util.get_string('textrecovered', 'editor_atto'),
NOTIFY_INFO, RECOVER_MESSAGE_TIMEOUT);
// Fire an event that the editor content has changed.
require(['core_editor/events'], function(editorEvents) {
editorEvents.notifyEditorContentRestored(this.editor.getDOMNode());
}.bind(this));
return this;
},
/**
* Save a single draft via ajax.
*
* @method saveDraft
* @chainable
*/
saveDraft: function() {
var url, params;
if (!this.editor.getDOMNode()) {
// Stop autosaving if the editor was removed from the page.
this.autosaveTimer.cancel();
return;
}
// Only copy the text from the div to the textarea if the textarea is not currently visible.
if (!this.editor.get('hidden')) {
this.updateOriginal();
}
var newText = this.textarea.get('value');
if (newText !== this.lastText) {
Y.log('Autosave text', 'debug', LOGNAME_AUTOSAVE);
// Make an ajax request.
url = M.cfg.wwwroot + this.get('autosaveAjaxScript');
params = {
sesskey: M.cfg.sesskey,
contextid: this.get('contextid'),
action: 'save',
drafttext: newText,
elementid: this.get('elementid'),
pagehash: this.get('pageHash'),
pageinstance: this.autosaveInstance
};
// Reusable error handler - must be passed the correct context.
var ajaxErrorFunction = function(response) {
var errorDuration = parseInt(this.get('autosaveFrequency'), 10) * 1000;
Y.log('Error while autosaving text', 'warn', LOGNAME_AUTOSAVE);
Y.log(response, 'warn', LOGNAME_AUTOSAVE);
this.showMessage(M.util.get_string('autosavefailed', 'editor_atto'), NOTIFY_WARNING, errorDuration);
};
this.autosaveIo(params, this, {
failure: ajaxErrorFunction,
success: function(response) {
if (response && response.error) {
Y.soon(Y.bind(ajaxErrorFunction, this, [response]));
} else {
// All working.
this.lastText = newText;
this.showMessage(M.util.get_string('autosavesucceeded', 'editor_atto'),
NOTIFY_INFO, SUCCESS_MESSAGE_TIMEOUT);
}
}
});
}
return this;
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorAutosave]);
+656
View File
@@ -0,0 +1,656 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule clean
*/
/**
* Functions for the Atto editor to clean the generated content.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorClean
*/
function EditorClean() {}
EditorClean.ATTRS = {
};
EditorClean.prototype = {
/**
* Clean the generated HTML content without modifying the editor content.
*
* This includes removes all YUI ids from the generated content.
*
* @return {string} The cleaned HTML content.
*/
getCleanHTML: function() {
// Clone the editor so that we don't actually modify the real content.
var editorClone = this.editor.cloneNode(true),
html;
// Remove all YUI IDs.
Y.each(editorClone.all('[id^="yui"]'), function(node) {
node.removeAttribute('id');
});
editorClone.all('.atto_control').remove(true);
html = editorClone.get('innerHTML');
// Revert untouched editor contents to an empty string.
var emptyContents = [
// For FF and Chrome.
'<p></p>',
'<p><br></p>',
'<br>',
'<p dir="rtl" style="text-align: right;"></p>',
'<p dir="rtl" style="text-align: right;"><br></p>',
'<p dir="ltr" style="text-align: left;"></p>',
'<p dir="ltr" style="text-align: left;"><br></p>',
// For IE 9 and 10.
'<p>&nbsp;</p>',
'<p><br>&nbsp;</p>',
'<p dir="rtl" style="text-align: right;">&nbsp;</p>',
'<p dir="rtl" style="text-align: right;"><br>&nbsp;</p>',
'<p dir="ltr" style="text-align: left;">&nbsp;</p>',
'<p dir="ltr" style="text-align: left;"><br>&nbsp;</p>'
];
if (emptyContents.includes(html)) {
return '';
}
// Remove any and all nasties from source.
return this._cleanHTML(html);
},
/**
* Clean the HTML content of the editor.
*
* @method cleanEditorHTML
* @chainable
*/
cleanEditorHTML: function() {
var startValue = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanHTML(startValue));
return this;
},
/**
* Clean the specified HTML content and remove any content which could cause issues.
*
* @method _cleanHTML
* @private
* @param {String} content The content to clean
* @param {Boolean} deepClean If true, do a more in depth (and resource intensive) cleaning of the HTML.
* @return {String} The cleaned HTML
*/
_cleanHTML: function(content, deepClean) {
// Removing limited things that can break the page or a disallowed, like unclosed comments, style blocks, etc.
var rules = [
// Remove any style blocks. Some browsers do not work well with them in a contenteditable.
// Plus style blocks are not allowed in body html, except with "scoped", which most browsers don't support as of 2015.
// Reference: "http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work"
{regex: /<style[^>]*>[\s\S]*?<\/style>/gi, replace: ""},
// Remove any open HTML comment opens that are not followed by a close. This can completely break page layout.
{regex: /<!--(?![\s\S]*?-->)/gi, replace: ""},
// Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
// Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body, link.
{regex: /<\/?(?:title|meta|style|st\d|head\b|font|html|body|link)[^>]*?>/gi, replace: ""}
];
content = this._filterContentWithRules(content, rules);
if (deepClean) {
content = this._cleanHTMLLists(content);
}
return content;
},
/**
* Take the supplied content and run on the supplied regex rules.
*
* @method _filterContentWithRules
* @private
* @param {String} content The content to clean
* @param {Array} rules An array of structures: [ {regex: /something/, replace: "something"}, {...}, ...]
* @return {String} The cleaned content
*/
_filterContentWithRules: function(content, rules) {
var i = 0;
for (i = 0; i < rules.length; i++) {
content = content.replace(rules[i].regex, rules[i].replace);
}
return content;
},
/**
* Intercept and clean html paste events.
*
* @method pasteCleanup
* @param {Object} sourceEvent The YUI EventFacade object
* @return {Boolean} True if the passed event should continue, false if not.
*/
pasteCleanup: function(sourceEvent) {
// We only expect paste events, but we will check anyways.
if (sourceEvent.type === 'paste') {
// Register the delayed paste cleanup. We will cancel it if we register the fallback cleanup.
var delayedCleanup = this.postPasteCleanupDelayed();
// The YUI event wrapper doesn't provide paste event info, so we need the underlying event.
var event = sourceEvent._event;
// Check if we have a valid clipboardData object in the event.
// IE has a clipboard object at window.clipboardData, but as of IE 11, it does not provide HTML content access.
if (event && event.clipboardData && event.clipboardData.getData && event.clipboardData.types) {
// Check if there is HTML type to be pasted, if we can get it, we want to scrub before insert.
var types = event.clipboardData.types;
var isHTML = false;
// Different browsers use different containers to hold the types, so test various functions.
if (typeof types.contains === 'function') {
isHTML = types.contains('text/html');
} else if (typeof types.indexOf === 'function') {
isHTML = (types.indexOf('text/html') > -1);
}
var content;
if (isHTML) {
// Get the clipboard content.
try {
content = event.clipboardData.getData('text/html');
} catch (error) {
// Something went wrong. Fallback.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
// Stop the original paste.
sourceEvent.preventDefault();
// Scrub the paste content.
content = this._cleanPasteHTML(content);
// Insert the content.
this.insertContentAtFocusPoint(content);
// Update the text area.
this.updateOriginal();
return false;
} else {
try {
// Plaintext clipboard content can only be retrieved this way.
content = event.clipboardData.getData('text');
} catch (error) {
// Something went wrong. Fallback.
// Due to poor cross browser clipboard compatibility, the failure to find html doesn't mean it isn't there.
// Wait for the clipboard event to finish then fallback clean the entire editor.
delayedCleanup.cancel();
this.fallbackPasteCleanupDelayed();
return true;
}
}
} else {
// If we reached a here, this probably means the browser has limited (or no) clipboard support.
// Wait for the clipboard event to finish then fallback clean the entire editor.
this.fallbackPasteCleanupDelayed();
return true;
}
}
// We should never get here - we must have received a non-paste event for some reason.
// Um, just call updateOriginalDelayed() - it's safe.
this.updateOriginalDelayed();
return true;
},
/**
* Calls postPasteCleanup on a short timer to allow the paste event handlers to complete, then deep clean the content.
*
* @method postPasteCleanupDelayed
* @return {object}
* @chainable
*/
postPasteCleanupDelayed: function() {
Y.soon(Y.bind(this.postPasteCleanup, this));
return this;
},
/**
* Do additional cleanup after the paste is complete.
*
* @method postPasteCleanup
* @return {object}
* @chainable
*/
postPasteCleanup: function() {
Y.log('Executing delayed post paste cleanup', 'debug', LOGNAME);
// Save the current selection (cursor position).
var selection = window.rangy.saveSelection();
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanHTML(content, true));
// Update the textarea.
this.updateOriginal();
// Restore the selection (cursor position).
window.rangy.restoreSelection(selection);
return this;
},
/**
* Cleanup code after a paste event if we couldn't intercept the paste content.
*
* @method fallbackPasteCleanup
* @return {object}
* @chainable
*/
fallbackPasteCleanup: function() {
Y.log('Using fallbackPasteCleanup for atto cleanup', 'debug', LOGNAME);
// Save the current selection (cursor position).
var selection = window.rangy.saveSelection();
// Get, clean, and replace the content in the editable.
var content = this.editor.get('innerHTML');
this.editor.set('innerHTML', this._cleanHTML(this._cleanPasteHTML(content), true));
// Update the textarea.
this.updateOriginal();
// Restore the selection (cursor position).
window.rangy.restoreSelection(selection);
return this;
},
/**
* Calls fallbackPasteCleanup on a short timer to allow the paste event handlers to complete.
*
* @method fallbackPasteCleanupDelayed
* @chainable
*/
fallbackPasteCleanupDelayed: function() {
Y.soon(Y.bind(this.fallbackPasteCleanup, this));
return this;
},
/**
* Cleanup html that comes from WYSIWYG paste events. These are more likely to contain messy code that we should strip.
*
* @method _cleanPasteHTML
* @private
* @param {String} content The html content to clean
* @return {String} The cleaned HTML
*/
_cleanPasteHTML: function(content) {
// Return an empty string if passed an invalid or empty object.
if (!content || content.length === 0) {
return "";
}
// Rules that get rid of the real-nasties and don't care about normalize code (correct quotes, white spaces, etc).
var rules = [
// Stuff that is specifically from MS Word and similar office packages.
// Remove all garbage after closing html tag.
{regex: /<\s*\/html\s*>([\s\S]+)$/gi, replace: ""},
// Remove if comment blocks.
{regex: /<!--\[if[\s\S]*?endif\]-->/gi, replace: ""},
// Remove start and end fragment comment blocks.
{regex: /<!--(Start|End)Fragment-->/gi, replace: ""},
// Remove any xml blocks.
{regex: /<xml[^>]*>[\s\S]*?<\/xml>/gi, replace: ""},
// Remove any <?xml><\?xml> blocks.
{regex: /<\?xml[^>]*>[\s\S]*?<\\\?xml>/gi, replace: ""},
// Remove <o:blah>, <\o:blah>.
{regex: /<\/?\w+:[^>]*>/gi, replace: ""}
];
// Apply the first set of harsher rules.
content = this._filterContentWithRules(content, rules);
// Apply the standard rules, which mainly cleans things like headers, links, and style blocks.
content = this._cleanHTML(content);
// Check if the string is empty or only contains whitespace.
if (content.length === 0 || !content.match(/\S/)) {
return content;
}
// Now we let the browser normalize the code by loading it into the DOM and then get the html back.
// This gives us well quoted, well formatted code to continue our work on. Word may provide very poorly formatted code.
var holder = document.createElement('div');
holder.innerHTML = content;
content = holder.innerHTML;
// Free up the DOM memory.
holder.innerHTML = "";
// Run some more rules that care about quotes and whitespace.
rules = [
// Get all class attributes so we can work on them.
{regex: /(<[^>]*?class\s*?=\s*?")([^>"]*)(")/gi, replace: function(match, group1, group2, group3) {
// Remove MSO classes.
group2 = group2.replace(/(?:^|[\s])[\s]*MSO[_a-zA-Z0-9\-]*/gi, "");
// Remove Apple- classes.
group2 = group2.replace(/(?:^|[\s])[\s]*Apple-[_a-zA-Z0-9\-]*/gi, "");
return group1 + group2 + group3;
}},
// Remove OLE_LINK# anchors that may litter the code.
{regex: /<a [^>]*?name\s*?=\s*?"OLE_LINK\d*?"[^>]*?>\s*?<\/a>/gi, replace: ""}
];
// Clean all style attributes from the text.
content = this._cleanStyles(content);
// Apply the rules.
content = this._filterContentWithRules(content, rules);
// Reapply the standard cleaner to the content.
content = this._cleanHTML(content);
// Clean unused spans out of the content.
content = this._cleanSpans(content);
return content;
},
/**
* Clean all inline styles from pasted text.
*
* This code intentionally doesn't use YUI Nodes. YUI was quite a bit slower at this, so using raw DOM objects instead.
*
* @method _cleanStyles
* @private
* @param {String} content The content to clean
* @return {String} The cleaned HTML
*/
_cleanStyles: function(content) {
var holder = document.createElement('div');
holder.innerHTML = content;
var elementsWithStyle = holder.querySelectorAll('[style]');
var i = 0;
for (i = 0; i < elementsWithStyle.length; i++) {
elementsWithStyle[i].removeAttribute('style');
}
var elementsWithClass = holder.querySelectorAll('[class]');
for (i = 0; i < elementsWithClass.length; i++) {
elementsWithClass[i].removeAttribute('class');
}
return holder.innerHTML;
},
/**
* Clean empty or un-unused spans from passed HTML.
*
* This code intentionally doesn't use YUI Nodes. YUI was quite a bit slower at this, so using raw DOM objects instead.
*
* @method _cleanSpans
* @private
* @param {String} content The content to clean
* @return {String} The cleaned HTML
*/
_cleanSpans: function(content) {
// Return an empty string if passed an invalid or empty object.
if (!content || content.length === 0) {
return "";
}
// Check if the string is empty or only contains whitespace.
if (content.length === 0 || !content.match(/\S/)) {
return content;
}
var rules = [
// Remove unused class, style, or id attributes. This will make empty tag detection easier later.
{regex: /(<[^>]*?)(?:[\s]*(?:class|style|id)\s*?=\s*?"\s*?")+/gi, replace: "$1"}
];
// Apply the rules.
content = this._filterContentWithRules(content, rules);
// Reference: "http://stackoverflow.com/questions/8131396/remove-nested-span-without-id"
// This is better to run detached from the DOM, so the browser doesn't try to update on each change.
var holder = document.createElement('div');
holder.innerHTML = content;
var spans = holder.getElementsByTagName('span');
// Since we will be removing elements from the list, we should copy it to an array, making it static.
var spansarr = Array.prototype.slice.call(spans, 0);
spansarr.forEach(function(span) {
if (!span.hasAttributes()) {
// If no attributes (id, class, style, etc), this span is has no effect.
// Move each child (if they exist) to the parent in place of this span.
while (span.firstChild) {
span.parentNode.insertBefore(span.firstChild, span);
}
// Remove the now empty span.
span.parentNode.removeChild(span);
}
});
return holder.innerHTML;
},
/**
* This is a function that searches for, and attempts to correct certain issues with ul/ol html lists.
* This is needed because these lists are used heavily in page layout, and content with bad tags can
* lead to broke course pages.
*
* The theory of operation here is to linearly process the incoming content, counting the opening and closing
* of list tags, and determining when there is a mismatch.
*
* The specific issues this should be able to correct are:
* - Orphaned li elements will be wrapped in a set of ul tags.
* - li elements inside li elements.
* - An extra closing ul, or ol tag will be discarded.
* - An extra closing li tag will have an opening tag added if appropriate, or will be discarded.
* - If there is an unmatched list open tag, a matching close tag will be inserted.
*
* It does it's best to match the case of corrected tags. Even though not required by html spec,
* it seems like the safer route.
*
* A note on parent elements of li. This code assumes that li must have a ol or ul parent.
* There are two other potential other parents of li. They are menu and dir. The dir tag was deprecated in
* HTML4, and removed in HTML5. The menu tag is experimental as of this writing, and basically doesn't work
* in any browsers, even Firefox, which theoretically has limited support for it. If other parents of li
* become viable, they will need to be added to this code.
*
* @method _cleanHTMLLists
* @private
* @param {String} content The content to clean
* @return {String} The cleaned content
*/
_cleanHTMLLists: function(content) {
var output = '',
toProcess = content,
match = null,
openTags = [],
currentTag = null,
previousTag = null;
// Use a regular expression to find the next open or close li, ul, or ol tag.
// Keep going until there are no more matching tags left.
// This expression looks for whole words by employing the word boundary (\b) metacharacter.
while ((match = toProcess.match(/<(\/?)(li|ul|ol)\b[^>]*>/i))) {
currentTag = {
tag: match[2],
tagLowerCase: match[2].toLowerCase(),
fullTag: match[0],
isOpen: (match[1].length == 1) ? false : true
};
// Get the most recent open tag.
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
// Slice up the content based on the match and add content before the match to output.
output += toProcess.slice(0, match.index);
toProcess = toProcess.slice(match.index + match[0].length);
// Now the full content is in output + currentTag.fullTag + toProcess. When making fixes, it is best to push the fix and
// fullTag back onto the front or toProcess, then restart the loop. This allows processing to follow the normal path
// most often. But sometimes we will need to modify output to insert or remove tags in the already complete code.
if (currentTag.isOpen) {
// We are at the opening phase of a tag.
// We have to do special processing for list items, as they can only be children of ul and ol tags.
if (currentTag.tagLowerCase === 'li') {
if (!previousTag) {
// This means we have are opening a li, but aren't in a list. This is not allowed!
// We are going to check for the count of open and close ol tags ahead to decide what to do.
var closeCount = (toProcess.match(/<\/(ol)[ >]/ig) || []).length;
var openCount = (toProcess.match(/<(ol)[ >]/ig) || []).length;
if (closeCount > openCount) {
// There are more close ol's ahead than opens ahead. So open the ol and try again.
Y.log('Adding an opening ol for orphan li', 'debug', LOGNAME);
toProcess = '<ol>' + currentTag.fullTag + toProcess;
continue;
}
// For the other cases, just open a ul and try again. Later the closing ul will get matched if it exists,
// or if it doesn't one will automatically get inserted.
Y.log('Adding an opening ul for orphan li', 'debug', LOGNAME);
toProcess = '<ul>' + currentTag.fullTag + toProcess;
continue;
}
if (previousTag.tagLowerCase === 'li') {
// You aren't allowed to nest li tags. Close the current one before starting the new one.
Y.log('Adding a closing ' + previousTag.tag + ' before opening a new one.', 'debug', LOGNAME);
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
}
// Previous tag must be a list at this point, so we can continue.
}
// If we made it this far, record the tag to the open tags list.
openTags.push({
tag: currentTag.tag,
tagLowerCase: currentTag.tagLowerCase,
position: output.length,
length: currentTag.fullTag.length
});
} else {
// We are processing a closing tag.
if (openTags.length == 0) {
// We are closing a tag that isn't open. That's a problem. Just discarding should be safe.
Y.log('Discarding extra ' + currentTag.fullTag + ' tag.', 'debug', LOGNAME);
continue;
}
if (previousTag.tagLowerCase === currentTag.tagLowerCase) {
// Closing a tag that matches the open tag. This is the nominal case. Pop it off, and update previousTag.
if (currentTag.tag != previousTag.tag) {
// This would mean cases don't match between the opening and closing tag.
// We are going to swap them to match, even though not required.
currentTag.fullTag = currentTag.fullTag.replace(currentTag.tag, previousTag.tag);
}
openTags.pop();
previousTag = (openTags.length) ? openTags[openTags.length - 1] : null;
} else {
// We are closing a tag that isn't the most recent open one open, so we have a mismatch.
if (currentTag.tagLowerCase === 'li' && previousTag.liEnd && (previousTag.liEnd < output.length)) {
// We are closing an unopened li, but the parent list has complete li tags more than 0 chars ago.
// Assume we are missing an open li at the end of the previous li, and insert there.
Y.log('Inserting opening ' + currentTag.tag + ' after previous li.', 'debug', LOGNAME);
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.liEnd);
} else if (currentTag.tagLowerCase === 'li' && !previousTag.liEnd &&
((previousTag.position + previousTag.length) < output.length)) {
// We are closing an unopened li, and the parent has no previous lis in it, but opened more than 0
// chars ago. Assume we are missing a starting li, and insert it right after the list opened.
Y.log('Inserting opening ' + currentTag.tag + ' at start of parent.', 'debug', LOGNAME);
output = this._insertString(output, '<' + currentTag.tag + '>', previousTag.position + previousTag.length);
} else if (previousTag.tagLowerCase === 'li') {
// We must be trying to close a ul/ol while in a li. Just assume we are missing a closing li.
Y.log('Adding a closing ' + previousTag.tag + ' before closing ' + currentTag.tag + '.', 'debug', LOGNAME);
toProcess = '</' + previousTag.tag + '>' + currentTag.fullTag + toProcess;
continue;
} else {
// Here we must be trying to close a tag that isn't open, or is open higher up. Just discard.
// If there ends up being a missing close tag later on, that will get fixed separately.
Y.log('Discarding incorrect ' + currentTag.fullTag + '.', 'debug', LOGNAME);
continue;
}
}
// If we have a valid closing li tag, and a list, record where the li ended.
if (currentTag.tagLowerCase === 'li' && previousTag) {
previousTag.liEnd = output.length + currentTag.fullTag.length;
}
}
// Now we can add the tag to the output.
output += currentTag.fullTag;
}
// Add anything left in toProcess to the output.
output += toProcess;
// Anything still in the openTags list are extra and need to be dealt with.
if (openTags.length) {
// Work on the list in reverse order so positions stay correct.
while ((currentTag = openTags.pop())) {
if (currentTag.liEnd) {
// We have a position for the last list item in this element. Insert the closing it after that.
output = this._insertString(output, '</' + currentTag.tag + '>', currentTag.liEnd);
Y.log('Adding closing ' + currentTag.tag + ' based on last li location.', 'debug', LOGNAME);
} else {
// If there weren't any children list items, then we should just remove the tag where it started.
// This will also remote an open li tag that runs to the end of the content, since it has no children lis.
output = output.slice(0, currentTag.position) + output.slice(currentTag.position + currentTag.length);
Y.log('Removing opening ' + currentTag.fullTag + ' because it was missing closing.', 'debug', LOGNAME);
}
}
}
return output;
},
/**
* Insert a string in the middle of an existing string at the specified location.
*
* @method _insertString
* @param {String} content The subject of the insertion.
* @param {String} insert The string that will be inserted.
* @param {Number} position The location to make the insertion.
* @return {String} The string with the new content inserted.
*/
_insertString: function(content, insert, position) {
return content.slice(0, position) + insert + content.slice(position);
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorClean]);
+177
View File
@@ -0,0 +1,177 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule commands
*/
/**
* Selection functions for the Atto editor.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorCommand
*/
function EditorCommand() {}
EditorCommand.ATTRS = {
};
EditorCommand.prototype = {
/**
* Applies a callback method to editor if selection is uncollapsed or waits for input to select first.
* @method applyFormat
* @param e EventTarget Event to be passed to callback if selection is uncollapsed
* @param method callback A callback method which changes editor when text is selected.
* @param object context Context to be used for callback method
* @param array args Array of arguments to pass to callback
*/
applyFormat: function(e, callback, context, args) {
function handleInsert(e, callback, context, args, anchorNode, anchorOffset) {
// After something is inputed, select it and apply the formating function.
Y.soon(Y.bind(function(e, callback, context, args, anchorNode, anchorOffset) {
var selection = window.rangy.getSelection();
// Set the start of the selection to where it was when the method was first called.
var range = selection.getRangeAt(0);
range.setStart(anchorNode, anchorOffset);
selection.setSingleRange(range);
// Now apply callback to the new text that is selected.
callback.apply(context, [e, args]);
// Collapse selection so cursor is at end of inserted material.
selection.collapseToEnd();
// Save save selection and editor contents.
this.saveSelection();
this.updateOriginal();
}, this, e, callback, context, args, anchorNode, anchorOffset));
}
// Set default context for the method.
context = context || this;
// Check whether range is collapsed.
var selection = window.rangy.getSelection();
if (selection.isCollapsed) {
// Selection is collapsed so listen for input into editor.
var handle = this.editor.once('input', handleInsert, this, callback, context, args,
selection.anchorNode, selection.anchorOffset);
// Cancel if selection changes before input.
this.editor.onceAfter(['click', 'selectstart'], handle.detach, handle);
return;
}
// The range is not collapsed; so apply callback method immediately.
callback.apply(context, [e, args]);
// Save save selection and editor contents.
this.saveSelection();
this.updateOriginal();
},
/**
* Replaces all the tags in a node list with new type.
* @method replaceTags
* @param NodeList nodelist
* @param String tag
*/
replaceTags: function(nodelist, tag) {
// We mark elements in the node list for iterations.
nodelist.setAttribute('data-iterate', true);
var node = this.editor.one('[data-iterate="true"]');
while (node) {
var clone = Y.Node.create('<' + tag + ' />')
.setAttrs(node.getAttrs())
.removeAttribute('data-iterate');
// Copy class and style if not blank.
if (node.getAttribute('style')) {
clone.setAttribute('style', node.getAttribute('style'));
}
if (node.getAttribute('class')) {
clone.setAttribute('class', node.getAttribute('class'));
}
// We use childNodes here because we are interested in both type 1 and 3 child nodes.
var children = node.getDOMNode().childNodes;
var child;
child = children[0];
while (typeof child !== "undefined") {
clone.append(child);
child = children[0];
}
node.replace(clone);
node = this.editor.one('[data-iterate="true"]');
}
},
/**
* Change all tags with given type to a span with CSS class attribute.
* @method changeToCSS
* @param String tag Tag type to be changed to span
* @param String markerClass CSS class that corresponds to desired tag
*/
changeToCSS: function(tag, markerClass) {
// Save the selection.
var selection = window.rangy.saveSelection();
// Remove display:none from rangy markers so browser doesn't delete them.
this.editor.all('.rangySelectionBoundary').setStyle('display', null);
// Replace tags with CSS classes.
this.editor.all(tag).addClass(markerClass);
this.replaceTags(this.editor.all('.' + markerClass), 'span');
// Restore selection and toggle class.
window.rangy.restoreSelection(selection);
},
/**
* Change spans with CSS classes in editor into elements with given tag.
* @method changeToCSS
* @param String markerClass CSS class that corresponds to desired tag
* @param String tag New tag type to be created
*/
changeToTags: function(markerClass, tag) {
// Save the selection.
var selection = window.rangy.saveSelection();
// Remove display:none from rangy markers so browser doesn't delete them.
this.editor.all('.rangySelectionBoundary').setStyle('display', null);
// Replace spans with given tag.
this.replaceTags(this.editor.all('span[class="' + markerClass + '"]'), tag);
this.editor.all(tag + '[class="' + markerClass + '"]').removeAttribute('class');
this.editor.all('.' + markerClass).each(function(n) {
n.wrap('<' + tag + '/>');
n.removeClass(markerClass);
});
// Remove CSS classes.
this.editor.all('[class="' + markerClass + '"]').removeAttribute('class');
this.editor.all(tag).removeClass(markerClass);
// Restore selection.
window.rangy.restoreSelection(selection);
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorCommand]);
@@ -0,0 +1,975 @@
// 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/>.
/**
* @module moodle-editor_atto-plugin
* @submodule buttons
*/
/**
* Button functions for an Atto Plugin.
*
* See {{#crossLink "M.editor_atto.EditorPlugin"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorPluginButtons
*/
var MENUTEMPLATE = '' +
'<button class="{{buttonClass}} atto_hasmenu" ' +
'id="{{id}}" ' +
'tabindex="-1" ' +
'title="{{title}}" ' +
'aria-label="{{title}}" ' +
'type="button" ' +
'aria-haspopup="true" ' +
'aria-controls="{{id}}_menu">' +
'<span class="editor_atto_menu_icon"></span>' +
'<span class="editor_atto_menu_expand"></span>' +
'</button>';
var DISABLED = 'disabled',
HIGHLIGHT = 'highlight',
LOGNAME = 'moodle-editor_atto-editor-plugin',
CSS = {
EDITORWRAPPER: '.editor_atto_content',
MENUICON: '.editor_atto_menu_icon',
MENUEXPAND: '.editor_atto_menu_expand'
};
function EditorPluginButtons() {}
EditorPluginButtons.ATTRS = {
};
EditorPluginButtons.prototype = {
/**
* All of the buttons that belong to this plugin instance.
*
* Buttons are stored by button name.
*
* @property buttons
* @type object
*/
buttons: null,
/**
* A list of each of the button names.
*
* @property buttonNames
* @type array
*/
buttonNames: null,
/**
* A read-only view of the current state for each button. Mappings are stored by name.
*
* Possible states are:
* <ul>
* <li>{{#crossLink "M.editor_atto.EditorPluginButtons/ENABLED:property"}}{{/crossLink}}; and</li>
* <li>{{#crossLink "M.editor_atto.EditorPluginButtons/DISABLED:property"}}{{/crossLink}}.</li>
* </ul>
*
* @property buttonStates
* @type object
*/
buttonStates: null,
/**
* The menus belonging to this plugin instance.
*
* @property menus
* @type object
*/
menus: null,
/**
* The state for a disabled button.
*
* @property DISABLED
* @type Number
* @static
* @value 0
*/
DISABLED: 0,
/**
* The state for an enabled button.
*
* @property ENABLED
* @type Number
* @static
* @value 1
*/
ENABLED: 1,
/**
* The list of Event Handlers for buttons.
*
* @property _buttonHandlers
* @protected
* @type array
*/
_buttonHandlers: null,
/**
* Hide handlers which are cancelled when the menu is hidden.
*
* @property _menuHideHandlers
* @protected
* @type array
*/
_menuHideHandlers: null,
/**
* A textual description of the primary keyboard shortcut for this
* plugin.
*
* This will be null if no keyboard shortcut has been registered.
*
* @property _primaryKeyboardShortcut
* @protected
* @type String
* @default null
*/
_primaryKeyboardShortcut: null,
/**
* An list of objects returned by Y.soon().
*
* The keys will be the buttonName of the button, and the value the Y.soon() object.
*
* @property _highlightQueue
* @protected
* @type Object
* @default null
*/
_highlightQueue: null,
/**
* Add a button for this plugin to the toolbar.
*
* @method addButton
* @param {object} config The configuration for this button
* @param {string} [config.iconurl] The URL for the icon. If not specified, then the icon and component will be used instead.
* @param {string} [config.icon] The icon identifier.
* @param {string} [config.iconComponent='core'] The icon component.
* @param {string} [config.keys] The shortcut key that can call this plugin from the keyboard.
* @param {string} [config.keyDescription] An optional description for the keyboard shortcuts.
* If not specified, this is automatically generated based on config.keys.
* If multiple key bindings are supplied to config.keys, then only the first is used.
* If set to false, then no description is added to the title.
* @param {string} [config.tags] The tags that trigger this button to be highlighted.
* @param {boolean} [config.tagMatchRequiresAll=true] Working in combination with the tags parameter, when true
* every tag of the selection has to match. When false, only one match is needed. Only set this to false when
* necessary as it is much less efficient.
* See {{#crossLink "M.editor_atto.EditorSelection/selectionFilterMatches:method"}}{{/crossLink}} for more information.
* @param {string} [config.title=this.name] The string identifier in the plugin's language file.
* @param {string} [config.buttonName=this.name] The name of the button. This is used in the buttons object, and if
* specified, in the class for the button.
* @param {function} config.callback A callback function to call when the button is clicked.
* @param {object} [config.callbackArgs] Any arguments to pass to the callback.
* @param {boolean} [config.inlineFormat] Delay callback for text input if selection is collapsed.
* @return {Node} The Node representing the newly created button.
*/
addButton: function(config) {
var group = this.get('group'),
pluginname = this.name,
buttonClass = 'atto_' + pluginname + '_button',
button,
host = this.get('host');
if (config.exec) {
buttonClass = buttonClass + '_' + config.exec;
}
if (!config.buttonName) {
// Set a default button name - this is used as an identifier in the button object.
config.buttonName = config.exec || pluginname;
} else {
buttonClass = buttonClass + '_' + config.buttonName;
}
config.buttonClass = buttonClass;
// Normalize icon configuration.
config = this._normalizeIcon(config);
if (!config.title) {
config.title = 'pluginname';
}
var title = M.util.get_string(config.title, 'atto_' + pluginname);
// Create the actual button.
button = Y.Node.create('<button type="button" class="' + buttonClass + '"' +
'tabindex="-1"></button>');
button.setAttribute('title', title);
button.setAttribute('aria-label', title);
window.require(['core/templates'], function(Templates) {
// The button already has title and label, so no need to set them again on the icon.
Templates.renderPix(config.icon, config.iconComponent, '').then(function(iconhtml) {
button.append(iconhtml);
});
});
// Append it to the group.
group.append(button);
var currentfocus = this.toolbar.getAttribute('aria-activedescendant');
if (!currentfocus) {
// Initially set the first button in the toolbar to be the default on keyboard focus.
button.setAttribute('tabindex', '0');
this.toolbar.setAttribute('aria-activedescendant', button.generateID());
this.get('host')._tabFocus = button;
}
// Normalize the callback parameters.
config = this._normalizeCallback(config);
// Add the standard click handler to the button.
this._buttonHandlers.push(
this.toolbar.delegate('click', config.callback, '.' + buttonClass, this)
);
// Handle button click via shortcut key.
if (config.keys) {
if (typeof config.keyDescription !== 'undefined') {
// A keyboard shortcut description was specified - use it.
this._primaryKeyboardShortcut[buttonClass] = config.keyDescription;
}
this._addKeyboardListener(config.callback, config.keys, buttonClass);
if (this._primaryKeyboardShortcut[buttonClass]) {
// If we have a valid keyboard shortcut description, then set it with the title.
title = M.util.get_string('plugin_title_shortcut', 'editor_atto', {
title: title,
shortcut: this._primaryKeyboardShortcut[buttonClass]
});
button.setAttribute('title', title);
button.setAttribute('aria-label', title);
}
}
// Handle highlighting of the button.
if (config.tags) {
var tagMatchRequiresAll = true;
if (typeof config.tagMatchRequiresAll === 'boolean') {
tagMatchRequiresAll = config.tagMatchRequiresAll;
}
this._buttonHandlers.push(
host.on(['atto:selectionchanged', 'change'], function(e) {
if (typeof this._highlightQueue[config.buttonName] !== 'undefined') {
this._highlightQueue[config.buttonName].cancel();
}
// Async the highlighting.
this._highlightQueue[config.buttonName] = Y.soon(Y.bind(function(e) {
if (host.selectionFilterMatches(config.tags, e.selectedNodes, tagMatchRequiresAll)) {
this.highlightButtons(config.buttonName);
} else {
this.unHighlightButtons(config.buttonName);
}
}, this, e));
}, this)
);
}
// Add the button reference to the buttons array for later reference.
this.buttonNames.push(config.buttonName);
this.buttons[config.buttonName] = button;
this.buttonStates[config.buttonName] = this.ENABLED;
return button;
},
/**
* Add a basic button which ties into the execCommand.
*
* See {{#crossLink "M.editor_atto.EditorPluginButtons/addButton:method"}}addButton{{/crossLink}}
* for full details of the optional parameters.
*
* @method addBasicButton
* @param {object} config The button configuration
* @param {string} config.exec The execCommand to call on the document.
* @param {string} [config.iconurl] The URL for the icon. If not specified, then the icon and component will be used instead.
* @param {string} [config.icon] The icon identifier.
* @param {string} [config.iconComponent='core'] The icon component.
* @param {string} [config.keys] The shortcut key that can call this plugin from the keyboard.
* @param {string} [config.tags] The tags that trigger this button to be highlighted.
* @param {boolean} [config.tagMatchRequiresAll=false] Working in combination with the tags parameter, highlight
* this button when any match is good enough.
*
* See {{#crossLink "M.editor_atto.EditorSelection/selectionFilterMatches:method"}}{{/crossLink}} for more information.
* @param {string} [config.title=this.name] The string identifier in the plugin's language file.
* @param {string} [config.buttonName=this.name] The name of the button. This is used in the buttons object, and if
* specified, in the class for the button.
* @return {Node} The Node representing the newly created button.
*/
addBasicButton: function(config) {
if (!config.exec) {
Y.log('No exec command specified. Cannot proceed.',
'warn', 'moodle-editor_atto-plugin');
return null;
}
// The default icon - true for most core plugins.
if (!config.icon) {
config.icon = 'e/' + config.exec;
}
// The default callback.
config.callback = function() {
document.execCommand(config.exec, false, null);
// And mark the text area as updated.
this.markUpdated();
};
// Return the newly created button.
return this.addButton(config);
},
/**
* Add a menu for this plugin to the editor toolbar.
*
* @method addToolbarMenu
* @param {object} config The configuration for this button
* @param {string} [config.iconurl] The URL for the icon. If not specified, then the icon and component will be used instead.
* @param {string} [config.icon] The icon identifier.
* @param {string} [config.iconComponent='core'] The icon component.
* @param {string} [config.title=this.name] The string identifier in the plugin's language file.
* @param {string} [config.buttonName=this.name] The name of the button. This is used in the buttons object, and if
* specified, in the class for the button.
* @param {function} config.callback A callback function to call when the button is clicked.
* @param {object} [config.callbackArgs] Any arguments to pass to the callback.
* @param {boolean} [config.inlineFormat] Delay callback for text input if selection is collapsed.
* @param {array} config.entries List of menu entries with the string (entry.text) and the handlers (entry.handler).
* @param {number} [config.overlayWidth=14] The width of the menu. This will be suffixed with the 'em' unit.
* @param {string} [config.menuColor] menu icon background color
* @return {Node} The Node representing the newly created button.
*/
addToolbarMenu: function(config) {
var group = this.get('group'),
pluginname = this.name,
buttonClass = 'atto_' + pluginname + '_button',
button,
currentFocus;
if (!config.buttonName) {
// Set a default button name - this is used as an identifier in the button object.
config.buttonName = pluginname;
} else {
buttonClass = buttonClass + '_' + config.buttonName;
}
config.buttonClass = buttonClass;
// Normalize icon configuration.
config = this._normalizeIcon(config);
if (!config.title) {
config.title = 'pluginname';
}
var title = M.util.get_string(config.title, 'atto_' + pluginname);
if (!config.menuColor) {
config.menuColor = 'transparent';
}
// Create the actual button.
var id = 'atto_' + pluginname + '_menubutton_' + Y.stamp(this);
var template = Y.Handlebars.compile(MENUTEMPLATE);
button = Y.Node.create(template({
buttonClass: buttonClass,
config: config,
title: title,
id: id
}));
// Add this button id to the config. It will be used in the menu later.
config.buttonId = id;
window.require(['core/templates'], function(Templates) {
// The button already has title and label, so no need to set them again on the icon.
Templates.renderPix(config.icon, config.iconComponent, '').then(function(iconhtml) {
button.one(CSS.MENUICON).append(iconhtml);
});
Templates.renderPix('t/expanded', 'core', '').then(function(iconhtml) {
button.one(CSS.MENUEXPAND).append(iconhtml);
});
});
// Append it to the group.
group.append(button);
group.append(Y.Node.create('<div class="menuplaceholder" id="' + id + '_menu"></div>'));
config.attachmentPoint = '#' + id + '_menu';
currentFocus = this.toolbar.getAttribute('aria-activedescendant');
if (!currentFocus) {
// Initially set the first button in the toolbar to be the default on keyboard focus.
button.setAttribute('tabindex', '0');
this.toolbar.setAttribute('aria-activedescendant', button.generateID());
}
// Add the standard click handler to the menu.
this._buttonHandlers.push(
this.toolbar.delegate('click', this._showToolbarMenu, '.' + buttonClass, this, config),
this.toolbar.delegate('key', this._showToolbarMenuAndFocus, '40, 32, enter', '.' + buttonClass, this, config)
);
// Add the button reference to the buttons array for later reference.
this.buttonNames.push(config.buttonName);
this.buttons[config.buttonName] = button;
this.buttonStates[config.buttonName] = this.ENABLED;
return button;
},
/**
* Display a toolbar menu.
*
* @method _showToolbarMenu
* @param {EventFacade} e
* @param {object} config The configuration for the whole toolbar.
* @param {Number} [config.overlayWidth=14] The width of the menu
* @private
*/
_showToolbarMenu: function(e, config) {
// Prevent default primarily to prevent arrow press changes.
e.preventDefault();
if (!this.isEnabled()) {
// Exit early if the plugin is disabled.
return;
}
// Ensure menu button was clicked, and isn't itself disabled.
var menuButton = e.currentTarget.ancestor('button', true);
if (menuButton === null || menuButton.hasAttribute(DISABLED)) {
return;
}
var menuDialogue;
if (!this.menus[config.buttonClass]) {
if (!config.overlayWidth) {
config.overlayWidth = '14';
}
if (!config.innerOverlayWidth) {
config.innerOverlayWidth = parseInt(config.overlayWidth, 10) - 2 + 'em';
}
config.overlayWidth = parseInt(config.overlayWidth, 10) + 'em';
this.menus[config.buttonClass] = new Y.M.editor_atto.Menu(config);
this.menus[config.buttonClass].get('contentBox').delegate('click',
this._chooseMenuItem, '.atto_menuentry a', this, config);
}
// Clear the focusAfterHide for any other menus which may be open.
Y.Array.each(this.get('host').openMenus, function(menu) {
menu.set('focusAfterHide', null);
});
// Ensure that we focus on this button next time.
var creatorButton = this.buttons[config.buttonName];
creatorButton.focus();
this.get('host')._setTabFocus(creatorButton);
// Get a reference to the menu dialogue.
menuDialogue = this.menus[config.buttonClass];
// Focus on the button by default after hiding this menu.
menuDialogue.set('focusAfterHide', creatorButton);
// Display the menu.
menuDialogue.show();
// Indicate that the menu is expanded.
menuButton.setAttribute("aria-expanded", true);
// Position it next to the button which opened it.
menuDialogue.align(this.buttons[config.buttonName], [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
this.get('host').openMenus = [menuDialogue];
},
/**
* Display a toolbar menu and focus upon the first item.
*
* @method _showToolbarMenuAndFocus
* @param {EventFacade} e
* @param {object} config The configuration for the whole toolbar.
* @param {Number} [config.overlayWidth=14] The width of the menu
* @private
*/
_showToolbarMenuAndFocus: function(e, config) {
this._showToolbarMenu(e, config);
// Focus on the first element in the menu.
this.menus[config.buttonClass].get('boundingBox').one('a').focus();
},
/**
* Select a menu item and call the appropriate callbacks.
*
* @method _chooseMenuItem
* @param {EventFacade} e
* @param {object} config
* @param {M.core.dialogue} menuDialogue The Dialogue to hide.
* @private
*/
_chooseMenuItem: function(e, config, menuDialogue) {
// Get the index from the clicked anchor.
var index = e.target.ancestor('a', true).getData('index'),
// And the normalized callback configuration.
buttonConfig = this._normalizeCallback(config.items[index], config.globalItemConfig);
menuDialogue = this.menus[config.buttonClass];
// Prevent the dialogue to be closed because of some browser weirdness.
menuDialogue.set('preventHideMenu', true);
// Call the callback for this button.
buttonConfig.callback(e, buttonConfig._callback, buttonConfig.callbackArgs);
// Cancel the hide menu prevention.
menuDialogue.set('preventHideMenu', false);
// Set the focus after hide so that focus is returned to the editor and changes are made correctly.
menuDialogue.set('focusAfterHide', this.get('host').editor);
menuDialogue.hide(e);
},
/**
* Normalize and sanitize the configuration variables relating to callbacks.
*
* @method _normalizeCallback
* @param {object} config
* @param {function} config.callback A callback function to call when the button is clicked.
* @param {object} [config.callbackArgs] Any arguments to pass to the callback.
* @param {boolean} [config.inlineFormat] Delay callback for text input if selection is collapsed.
* @param {object} [inheritFrom] A parent configuration that this configuration may inherit from.
* @return {object} The normalized configuration
* @private
*/
_normalizeCallback: function(config, inheritFrom) {
if (config._callbackNormalized) {
// Return early if the callback has already been normalized.
return config;
}
if (!inheritFrom) {
// Create an empty inheritFrom to make life easier below.
inheritFrom = {};
}
// First we wrap the callback in function to handle formating of text inserted into collapsed selection.
config.inlineFormat = config.inlineFormat || inheritFrom.inlineFormat;
config._inlineCallback = config.callback || inheritFrom.callback;
config._callback = config.callback || inheritFrom.callback;
if (config.inlineFormat && typeof config._inlineCallback === 'function') {
config._callback = function(e, args) {
this.get('host').applyFormat(e, config._inlineCallback, this, args);
};
}
// We wrap the callback in function to prevent the default action, check whether the editor is
// active and focus it, and then mark the field as updated.
config.callback = Y.rbind(this._callbackWrapper, this, config._callback, config.callbackArgs);
config._callbackNormalized = true;
return config;
},
/**
* Normalize and sanitize the configuration variables relating to icons.
*
* @method _normalizeIcon
* @param {object} config
* @param {string} [config.iconurl] The URL for the icon. If not specified, then the icon and component will be used instead.
* @param {string} [config.icon] The icon identifier.
* @param {string} [config.iconComponent='core'] The icon component.
* @return {object} The normalized configuration
* @private
*/
_normalizeIcon: function(config) {
if (!config.iconurl) {
// The default icon component.
if (!config.iconComponent || config.iconComponent == 'moodle') {
config.iconComponent = 'core';
}
config.iconurl = M.util.image_url(config.icon, config.iconComponent);
}
return config;
},
/**
* A wrapper in which to run the callbacks.
*
* This handles common functionality such as:
* <ul>
* <li>preventing the default action; and</li>
* <li>focusing the editor if relevant.</li>
* </ul>
*
* @method _callbackWrapper
* @param {EventFacade} e
* @param {Function} callback The function to call which makes the relevant changes.
* @param {Array} [callbackArgs] The arguments passed to this callback.
* @return {Mixed} The value returned by the callback.
* @private
*/
_callbackWrapper: function(e, callback, callbackArgs) {
e.preventDefault();
if (!this.isEnabled()) {
// Exit early if the plugin is disabled.
return;
}
var creatorButton = e.currentTarget.ancestor('button', true);
if (creatorButton && creatorButton.hasAttribute(DISABLED)) {
// Exit early if the clicked button was disabled.
return;
}
if (!(YUI.Env.UA.android || this.get('host').isActive())) {
// We must not focus for Android here, even if the editor is not active because the keyboard auto-completion
// changes the cursor position.
// If we save that change, then when we restore the change later we get put in the wrong place.
// Android is fine to save the selection without the editor being in focus.
this.get('host').focus();
}
// Save the selection.
this.get('host').saveSelection();
// Ensure that we focus on this button next time.
if (creatorButton) {
this.get('host')._setTabFocus(creatorButton);
}
// Build the arguments list, but remove the callback we're calling.
var args = [e, callbackArgs];
// Restore selection before making changes.
this.get('host').restoreSelection();
// Actually call the callback now.
return callback.apply(this, args);
},
/**
* Add a keyboard listener to call the callback.
*
* The keyConfig will take either an array of keyConfigurations, in
* which case _addKeyboardListener is called multiple times; an object
* containing an optional eventtype, optional container, and a set of
* keyCodes, or just a string containing the keyCodes. When keyConfig is
* not an object, it is wrapped around a function that ensures that
* only the expected key modifiers were used. For instance, it checks
* that space+ctrl is not triggered when the user presses ctrl+shift+space.
* When using an object, the developer should check that manually.
*
* @method _addKeyboardListener
* @param {function} callback
* @param {array|object|string} keyConfig
* @param {string} [keyConfig.eventtype=key] The type of event
* @param {string} [keyConfig.container=.editor_atto_content] The containing element.
* @param {string} keyConfig.keyCodes The keycodes to user for the event.
* @private
*
*/
_addKeyboardListener: function(callback, keyConfig, buttonName) {
var eventtype = 'key',
container = CSS.EDITORWRAPPER,
keys,
handler,
modifier;
if (Y.Lang.isArray(keyConfig)) {
// If an Array was specified, call the add function for each element.
Y.Array.each(keyConfig, function(config) {
this._addKeyboardListener(callback, config);
}, this);
return this;
} else if (typeof keyConfig === "object") {
if (keyConfig.eventtype) {
eventtype = keyConfig.eventtype;
}
if (keyConfig.container) {
container = keyConfig.container;
}
// Must be specified.
keys = keyConfig.keyCodes;
handler = callback;
} else {
modifier = this._getDefaultMetaKey();
keys = this._getKeyEvent() + keyConfig + '+' + modifier;
if (typeof this._primaryKeyboardShortcut[buttonName] === 'undefined') {
this._primaryKeyboardShortcut[buttonName] = this._getDefaultMetaKeyDescription(keyConfig);
}
// Wrap the callback into a handler to check if it uses the specified modifiers, not more.
handler = Y.bind(function(modifiers, e) {
if (this._eventUsesExactKeyModifiers(modifiers, e)) {
callback.apply(this, [e]);
}
}, this, [modifier]);
}
this._buttonHandlers.push(
this.editor.delegate(
eventtype,
handler,
keys,
container,
this
)
);
Y.log('Atto shortcut registered: ' + keys + ' now triggers for ' + buttonName,
'debug', LOGNAME);
},
/**
* Checks if a key event was strictly defined for the modifiers passed.
*
* @method _eventUsesExactKeyModifiers
* @param {Array} modifiers List of key modifiers to check for (alt, ctrl, meta or shift).
* @param {EventFacade} e The event facade.
* @return {Boolean} True if the event was stricly using the modifiers specified.
*/
_eventUsesExactKeyModifiers: function(modifiers, e) {
var exactMatch = true,
hasKey;
if (e.type !== 'key') {
return false;
}
hasKey = Y.Array.indexOf(modifiers, 'alt') > -1;
exactMatch = exactMatch && ((e.altKey && hasKey) || (!e.altKey && !hasKey));
hasKey = Y.Array.indexOf(modifiers, 'ctrl') > -1;
exactMatch = exactMatch && ((e.ctrlKey && hasKey) || (!e.ctrlKey && !hasKey));
hasKey = Y.Array.indexOf(modifiers, 'meta') > -1;
exactMatch = exactMatch && ((e.metaKey && hasKey) || (!e.metaKey && !hasKey));
hasKey = Y.Array.indexOf(modifiers, 'shift') > -1;
exactMatch = exactMatch && ((e.shiftKey && hasKey) || (!e.shiftKey && !hasKey));
return exactMatch;
},
/**
* Determine if this plugin is enabled, based upon the state of it's buttons.
*
* @method isEnabled
* @return {boolean}
*/
isEnabled: function() {
// The first instance of an undisabled button will make this return true.
var found = Y.Object.some(this.buttonStates, function(button) {
return (button === this.ENABLED);
}, this);
return found;
},
/**
* Enable one button, or all buttons relating to this Plugin.
*
* If no button is specified, all buttons are disabled.
*
* @method disableButtons
* @param {String} [button] The name of a specific plugin to enable.
* @chainable
*/
disableButtons: function(button) {
return this._setButtonState(false, button);
},
/**
* Enable one button, or all buttons relating to this Plugin.
*
* If no button is specified, all buttons are enabled.
*
* @method enableButtons
* @param {String} [button] The name of a specific plugin to enable.
* @chainable
*/
enableButtons: function(button) {
return this._setButtonState(true, button);
},
/**
* Set the button state for one button, or all buttons associated with this plugin.
*
* @method _setButtonState
* @param {Boolean} enable Whether to enable this button.
* @param {String} [button] The name of a specific plugin to set state for.
* @chainable
* @private
*/
_setButtonState: function(enable, button) {
var attributeChange = 'setAttribute';
if (enable) {
attributeChange = 'removeAttribute';
}
if (button) {
if (this.buttons[button]) {
this.buttons[button][attributeChange](DISABLED, DISABLED);
this.buttonStates[button] = enable ? this.ENABLED : this.DISABLED;
}
} else {
Y.Array.each(this.buttonNames, function(button) {
this.buttons[button][attributeChange](DISABLED, DISABLED);
this.buttonStates[button] = enable ? this.ENABLED : this.DISABLED;
}, this);
}
this.get('host').checkTabFocus();
return this;
},
/**
* Highlight a button, or buttons in the toolbar.
*
* If no button is specified, all buttons are highlighted.
*
* @method highlightButtons
* @param {string} [button] If a plugin has multiple buttons, the specific button to highlight.
* @chainable
*/
highlightButtons: function(button) {
return this._changeButtonHighlight(true, button);
},
/**
* Un-highlight a button, or buttons in the toolbar.
*
* If no button is specified, all buttons are un-highlighted.
*
* @method unHighlightButtons
* @param {string} [button] If a plugin has multiple buttons, the specific button to highlight.
* @chainable
*/
unHighlightButtons: function(button) {
return this._changeButtonHighlight(false, button);
},
/**
* Highlight a button, or buttons in the toolbar.
*
* @method _changeButtonHighlight
* @param {boolean} highlight true
* @param {string} [button] If a plugin has multiple buttons, the specific button to highlight.
* @protected
* @chainable
*/
_changeButtonHighlight: function(highlight, button) {
var method = 'addClass';
if (!highlight) {
method = 'removeClass';
}
if (button) {
if (this.buttons[button]) {
this.buttons[button][method](HIGHLIGHT);
this.buttons[button].setAttribute('aria-pressed', highlight ? 'true' : 'false');
this._buttonHighlightToggled(button, highlight);
}
} else {
Y.Object.each(this.buttons, function(button) {
button[method](HIGHLIGHT);
button.setAttribute('aria-pressed', highlight ? 'true' : 'false');
this._buttonHighlightToggled(button, highlight);
}, this);
}
return this;
},
/**
* Fires a custom event that notifies listeners that a button's highlight has been toggled.
*
* @param {String} buttonName The button name.
* @param {Boolean} highlight True when the button was highlighted. False, otherwise.
* @private
*/
_buttonHighlightToggled: function(buttonName, highlight) {
var toggledButton = this.buttons[buttonName];
if (toggledButton) {
// Fire an event that the button highlight was toggled.
require(['editor_atto/events'], function(attoEvents) {
attoEvents.notifyButtonHighlightToggled(toggledButton.getDOMNode(), buttonName, highlight);
});
}
},
/**
* Get the default meta key to use with keyboard events.
*
* On a Mac, this will be the 'meta' key for Command; otherwise it will
* be the Control key.
*
* @method _getDefaultMetaKey
* @return {string}
* @private
*/
_getDefaultMetaKey: function() {
if (Y.UA.os === 'macintosh') {
return 'meta';
} else {
return 'ctrl';
}
},
/**
* Get the user-visible description of the meta key to use with keyboard events.
*
* On a Mac, this will be 'Command' ; otherwise it will be 'Control'.
*
* @method _getDefaultMetaKeyDescription
* @return {string}
* @private
*/
_getDefaultMetaKeyDescription: function(keyCode) {
if (Y.UA.os === 'macintosh') {
return M.util.get_string('editor_command_keycode', 'editor_atto', String.fromCharCode(keyCode).toLowerCase());
} else {
return M.util.get_string('editor_control_keycode', 'editor_atto', String.fromCharCode(keyCode).toLowerCase());
}
},
/**
* Get the standard key event to use for keyboard events.
*
* @method _getKeyEvent
* @return {string}
* @private
*/
_getKeyEvent: function() {
return 'down:';
}
};
Y.Base.mix(Y.M.editor_atto.EditorPlugin, [EditorPluginButtons]);
@@ -0,0 +1,108 @@
// 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/>.
/**
* @module moodle-editor_atto-plugin
* @submodule dialogue
*/
/**
* Dialogue functions for an Atto Plugin.
*
* See {{#crossLink "M.editor_atto.EditorPlugin"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorPluginDialogue
*/
function EditorPluginDialogue() {}
EditorPluginDialogue.ATTRS = {
};
EditorPluginDialogue.prototype = {
/**
* A reference to the instantiated dialogue.
*
* @property _dialogue
* @private
* @type M.core.Dialogue
*/
_dialogue: null,
/**
* Fetch the instantiated dialogue. If a dialogue has not yet been created, instantiate one.
*
* <em><b>Note:</b> Only one dialogue is supported through this interface.</em>
*
* For a full list of options, see documentation for {{#crossLink "M.core.dialogue"}}{{/crossLink}}.
*
* A sensible default is provided for the focusAfterHide attribute.
*
* @method getDialogue
* @param {object} config
* @param {boolean|string|Node} [config.focusAfterHide=undefined] Set the focusAfterHide setting to the
* specified Node according to the following values:
* <ul>
* <li>If true was passed, the first button for this plugin will be used instead; or</li>
* <li>If a String was passed, the named button for this plugin will be used instead; or</li>
* <li>If a Node was passed, that Node will be used instead.</li>
*
* This setting is checked each time that getDialogue is called.
*
* @return {M.core.dialogue}
*/
getDialogue: function(config) {
// Config is an optional param - define a default.
config = config || {};
var focusAfterHide = false;
if (config.focusAfterHide) {
// Remove the focusAfterHide because we may pass it a non-node value.
focusAfterHide = config.focusAfterHide;
delete config.focusAfterHide;
}
if (!this._dialogue) {
// Merge the default configuration with any provided configuration.
var dialogueConfig = Y.merge({
visible: false,
modal: true,
close: true,
draggable: true
}, config);
// Instantiate the dialogue.
this._dialogue = new M.core.dialogue(dialogueConfig);
}
if (focusAfterHide !== false) {
if (focusAfterHide === true) {
this._dialogue.set('focusAfterHide', this.buttons[this.buttonNames[0]]);
} else if (typeof focusAfterHide === 'string') {
this._dialogue.set('focusAfterHide', this.buttons[focusAfterHide]);
} else {
this._dialogue.set('focusAfterHide', focusAfterHide);
}
}
return this._dialogue;
}
};
Y.Base.mix(Y.M.editor_atto.EditorPlugin, [EditorPluginDialogue]);
+148
View File
@@ -0,0 +1,148 @@
// 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/>.
/**
* Atto editor plugin.
*
* @module moodle-editor_atto-plugin
* @submodule plugin-base
* @package editor_atto
* @copyright 2014 Andrew Nicols
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* A Plugin for the Atto Editor used in Moodle.
*
* This class should not be directly instantiated, and all Editor plugins
* should extend this class.
*
* @namespace M.editor_atto
* @class EditorPlugin
* @main
* @constructor
* @uses M.editor_atto.EditorPluginButtons
* @uses M.editor_atto.EditorPluginDialogue
*/
function EditorPlugin() {
EditorPlugin.superclass.constructor.apply(this, arguments);
}
var GROUPSELECTOR = '.atto_group.',
GROUP = '_group';
Y.extend(EditorPlugin, Y.Base, {
/**
* The name of the current plugin.
*
* @property name
* @type string
*/
name: null,
/**
* A Node reference to the editor.
*
* @property editor
* @type Node
*/
editor: null,
/**
* A Node reference to the editor toolbar.
*
* @property toolbar
* @type Node
*/
toolbar: null,
initializer: function(config) {
// Set the references to configuration parameters.
this.name = config.name;
this.toolbar = config.toolbar;
this.editor = config.editor;
// Set up the prototypal properties.
// These must be set up here becuase prototypal arrays and objects are copied across instances.
this.buttons = {};
this.buttonNames = [];
this.buttonStates = {};
this.menus = {};
this._primaryKeyboardShortcut = [];
this._buttonHandlers = [];
this._menuHideHandlers = [];
this._highlightQueue = {};
},
/**
* Mark the content ediable content as having been changed.
*
* This is a convenience function and passes through to
* {{#crossLink "M.editor_atto.EditorTextArea/updateOriginal"}}updateOriginal{{/crossLink}}.
*
* @method markUpdated
*/
markUpdated: function() {
// Save selection after changes to the DOM. If you don't do this here,
// subsequent calls to restoreSelection() will fail expecting the
// previous DOM state.
this.get('host').saveSelection();
return this.get('host').updateOriginal();
}
}, {
NAME: 'editorPlugin',
ATTRS: {
/**
* The editor instance that this plugin was instantiated by.
*
* @attribute host
* @type M.editor_atto.Editor
* @writeOnce
*/
host: {
writeOnce: true
},
/**
* The toolbar group that this button belongs to.
*
* When setting, the name of the group should be specified.
*
* When retrieving, the Node for the toolbar group is returned. If
* the group doesn't exist yet, then it is created first.
*
* @attribute group
* @type Node
* @writeOnce
*/
group: {
writeOnce: true,
getter: function(groupName) {
var group = this.toolbar.one(GROUPSELECTOR + groupName + GROUP);
if (!group) {
group = Y.Node.create('<div class="atto_group ' +
groupName + GROUP + '"></div>');
this.toolbar.append(group);
}
return group;
}
}
}
});
Y.namespace('M.editor_atto').EditorPlugin = EditorPlugin;
+533
View File
@@ -0,0 +1,533 @@
// 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);
};
+81
View File
@@ -0,0 +1,81 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule filepicker
*/
/**
* Filepicker options for the Atto editor.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorFilepicker
*/
function EditorFilepicker() {}
EditorFilepicker.ATTRS = {
/**
* The options for the filepicker.
*
* @attribute filepickeroptions
* @type object
* @default {}
*/
filepickeroptions: {
value: {}
}
};
EditorFilepicker.prototype = {
/**
* Should we show the filepicker for this filetype?
*
* @method canShowFilepicker
* @param string type The media type for the file picker.
* @return {boolean}
*/
canShowFilepicker: function(type) {
return (typeof this.get('filepickeroptions')[type] !== 'undefined');
},
/**
* Show the filepicker.
*
* This depends on core_filepicker, and then call that modules show function.
*
* @method showFilepicker
* @param {string} type The media type for the file picker.
* @param {function} callback The callback to use when selecting an item of media.
* @param {object} [context] The context from which to call the callback.
*/
showFilepicker: function(type, callback, context) {
var self = this;
Y.use('core_filepicker', function(Y) {
var options = Y.clone(self.get('filepickeroptions')[type], true);
options.formcallback = callback;
if (context) {
options.magicscope = context;
}
M.core_filepicker.show(Y, options);
});
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorFilepicker]);
+348
View File
@@ -0,0 +1,348 @@
// 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/>.
/**
* A Menu for the Atto editor.
*
* @module moodle-editor_atto-menu
* @submodule menu-base
* @package editor_atto
* @copyright 2013 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
var LOGNAME = 'moodle-editor_atto-menu';
var MENUDIALOGUE = '' +
'<div class="open {{config.buttonClass}} atto_menu" ' +
'style="min-width:{{config.innerOverlayWidth}};">' +
'<ul class="dropdown-menu" role="menu" id="{{config.buttonId}}_menu" aria-labelledby="{{config.buttonId}}">' +
'{{#each config.items}}' +
'<li role="none" class="atto_menuentry">' +
'<a href="#" role="menuitem" data-index="{{@index}}" {{#each data}}data-{{@key}}="{{this}}"{{/each}}>' +
'{{{text}}}' +
'</a>' +
'</li>' +
'{{/each}}' +
'</ul>' +
'</div>';
/**
* A Menu for the Atto editor used in Moodle.
*
* This is a drop down list of buttons triggered (and aligned to) a
* location.
*
* @namespace M.editor_atto
* @class Menu
* @main
* @constructor
* @extends M.core.dialogue
*/
var Menu = function() {
Menu.superclass.constructor.apply(this, arguments);
};
Y.extend(Menu, M.core.dialogue, {
/**
* A list of the menu handlers which have been attached here.
*
* @property _menuHandlers
* @type Array
* @private
*/
_menuHandlers: null,
/**
* The menu button that controls this menu.
*/
_menuButton: null,
initializer: function(config) {
var headerText,
bb;
this._menuHandlers = [];
this._menuButton = document.getElementById(config.buttonId);
// Create the actual button.
var template = Y.Handlebars.compile(MENUDIALOGUE),
menu = Y.Node.create(template({
config: config
}));
this.set('bodyContent', menu);
bb = this.get('boundingBox');
bb.addClass('editor_atto_controlmenu');
bb.addClass('editor_atto_menu');
// Get the dialogue container for this menu.
var content = bb.one('.moodle-dialogue-wrap');
content.removeClass('moodle-dialogue-wrap')
.addClass('moodle-dialogue-content');
// Remove the dialog role attribute.
content.removeAttribute('role');
// Remove aria-labelledby in the container. The aria-labelledby attribute is properly set in the menu's template.
content.removeAttribute('aria-labelledby');
// Render heading if necessary.
headerText = this.get('headerText').trim();
if (headerText) {
var heading = Y.Node.create('<h3/>')
.addClass('accesshide')
.setHTML(headerText);
this.get('bodyContent').prepend(heading);
}
// Hide the header and footer node entirely.
this.headerNode.hide();
this.footerNode.hide();
this._setupHandlers();
},
/**
* Setup the Event handlers.
*
* @method _setupHandlers
* @private
*/
_setupHandlers: function() {
var contentBox = this.get('contentBox');
// Handle menu item selection.
this._menuHandlers.push(
// Select the menu item on space, and enter.
contentBox.delegate('key', this._chooseMenuItem, '32, enter', '.atto_menuentry', this),
// Move up and down the menu on up/down.
contentBox.delegate('key', this._handleKeyboardEvent, 'down:38,40', '.dropdown-menu', this),
// Hide the menu when clicking outside of it.
contentBox.on('focusoutside', this.hide, this),
// Hide the menu on left/right, and escape keys.
contentBox.delegate('key', this.hide, 'down:37,39,esc', '.dropdown-menu', this)
);
},
/**
* Simulate other types of menu selection.
*
* @method _chooseMenuItem
* @param {EventFacade} e
*/
_chooseMenuItem: function(e) {
e.target.simulate('click');
e.preventDefault();
},
/**
* Hide a menu, removing all of the event handlers which trigger the hide.
*
* @method hide
* @param {EventFacade} e
*/
hide: function(e) {
if (this.get('preventHideMenu') === true) {
return;
}
// We must prevent the default action (left/right/escape) because
// there are other listeners on the toolbar which will focus on the
// editor.
if (e) {
e.preventDefault();
}
// Remove menu button's aria-expanded attribute when this menu is hidden.
if (this._menuButton) {
this._menuButton.removeAttribute('aria-expanded');
}
return Menu.superclass.hide.call(this, arguments);
},
/**
* Implement arrow-key navigation for the items in a toolbar menu.
*
* @method _handleKeyboardEvent
* @param {EventFacade} e The keyboard event.
* @static
*/
_handleKeyboardEvent: function(e) {
// Prevent the default browser behaviour.
e.preventDefault();
// Get a list of all buttons in the menu.
var buttons = e.currentTarget.all('a[role="menuitem"]');
// On cursor moves we loops through the buttons.
var found = false,
index = 0,
direction = 1,
checkCount = 0,
current = e.target.ancestor('a[role="menuitem"]', true),
next;
// Determine which button is currently selected.
while (!found && index < buttons.size()) {
if (buttons.item(index) === current) {
found = true;
} else {
index++;
}
}
if (!found) {
Y.log("Unable to find this menu item in the menu", 'debug', LOGNAME);
return;
}
if (e.keyCode === 38) {
// Moving up so reverse the direction.
direction = -1;
}
// Try to find the next
do {
index += direction;
if (index < 0) {
index = buttons.size() - 1;
} else if (index >= buttons.size()) {
// Handle wrapping.
index = 0;
}
next = buttons.item(index);
// Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
checkCount++;
// Loop while:
// * we are not in a loop and have not already checked every button; and
// * we are on a different button; and
// * the next menu item is not hidden.
} while (checkCount < buttons.size() && next !== current && next.hasAttribute('hidden'));
if (next) {
next.focus();
}
e.preventDefault();
e.stopImmediatePropagation();
}
}, {
NAME: "menu",
ATTRS: {
/**
* The header for the drop down (only accessible to screen readers).
*
* @attribute headerText
* @type String
* @default ''
*/
headerText: {
value: ''
}
}
});
Y.Base.modifyAttrs(Menu, {
/**
* The width for this menu.
*
* @attribute width
* @default 'auto'
*/
width: {
value: 'auto'
},
/**
* When to hide this menu.
*
* By default, this attribute consists of:
* <ul>
* <li>an object which will cause the menu to hide when the user clicks outside of the menu</li>
* </ul>
*
* @attribute hideOn
*/
hideOn: {
value: [
{
eventName: 'clickoutside'
}
]
},
/**
* The default list of extra classes for this menu.
*
* @attribute extraClasses
* @type Array
* @default editor_atto_menu
*/
extraClasses: {
value: [
'editor_atto_menu'
]
},
/**
* Override the responsive nature of the core dialogues.
*
* @attribute responsive
* @type boolean
* @default false
*/
responsive: {
value: false
},
/**
* The default visibility of the menu.
*
* @attribute visible
* @type boolean
* @default false
*/
visible: {
value: false
},
/**
* Whether to centre the menu.
*
* @attribute center
* @type boolean
* @default false
*/
center: {
value: false
},
/**
* Hide the close button.
* @attribute closeButton
* @type boolean
* @default false
*/
closeButton: {
value: false
}
});
Y.namespace('M.editor_atto').Menu = Menu;
+140
View File
@@ -0,0 +1,140 @@
// 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/>.
/**
* A notify function for the Atto editor.
*
* @module moodle-editor_atto-notify
* @submodule notify
* @package editor_atto
* @copyright 2014 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
var LOGNAME_NOTIFY = 'moodle-editor_atto-editor-notify',
NOTIFY_INFO = 'info',
NOTIFY_WARNING = 'warning';
function EditorNotify() {}
EditorNotify.ATTRS = {
};
EditorNotify.prototype = {
/**
* A single Y.Node for this editor. There is only ever one - it is replaced if a new message comes in.
*
* @property messageOverlay
* @type {Node}
*/
messageOverlay: null,
/**
* A single timer object that can be used to cancel the hiding behaviour.
*
* @property hideTimer
* @type {timer}
*/
hideTimer: null,
/**
* Initialize the notifications.
*
* @method setupNotifications
* @chainable
*/
setupNotifications: function() {
var preload1 = new Image(),
preload2 = new Image();
preload1.src = M.util.image_url('i/warning', 'moodle');
preload2.src = M.util.image_url('i/info', 'moodle');
return this;
},
/**
* Show a notification in a floaty overlay somewhere in the atto editor text area.
*
* @method showMessage
* @param {String} message The translated message (use get_string)
* @param {String} type Must be either "info" or "warning"
* @param {Number} timeout Time in milliseconds to show this message for.
* @chainable
*/
showMessage: function(message, type, timeout) {
var messageTypeIcon = '',
intTimeout,
bodyContent;
if (this.messageOverlay === null) {
this.messageOverlay = Y.Node.create('<div class="editor_atto_notification"></div>');
this.messageOverlay.hide(true);
this.textarea.get('parentNode').append(this.messageOverlay);
this.messageOverlay.on('click', function() {
this.messageOverlay.hide(true);
}, this);
}
if (this.hideTimer !== null) {
this.hideTimer.cancel();
}
if (type === NOTIFY_WARNING) {
messageTypeIcon = '<img src="' +
M.util.image_url('i/warning', 'moodle') +
'" alt="' + M.util.get_string('warning', 'moodle') + '"/>';
} else if (type === NOTIFY_INFO) {
messageTypeIcon = '<img src="' +
M.util.image_url('i/info', 'moodle') +
'" alt="' + M.util.get_string('info', 'moodle') + '"/>';
} else {
Y.log('Invalid message type specified: ' + type + '. Must be either "info" or "warning".', 'debug', LOGNAME_NOTIFY);
}
// Parse the timeout value.
intTimeout = parseInt(timeout, 10);
if (intTimeout <= 0) {
intTimeout = 60000;
}
// Convert class to atto_info (for example).
type = 'atto_' + type;
bodyContent = Y.Node.create('<div class="' + type + '" role="alert" aria-live="assertive">' +
messageTypeIcon + ' ' +
Y.Escape.html(message) +
'</div>');
this.messageOverlay.empty();
this.messageOverlay.append(bodyContent);
this.messageOverlay.show(true);
this.hideTimer = Y.later(intTimeout, this, function() {
Y.log('Hide Atto notification.', 'debug', LOGNAME_NOTIFY);
this.hideTimer = null;
if (this.messageOverlay.inDoc()) {
this.messageOverlay.hide(true);
}
});
return this;
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorNotify]);
+424
View File
@@ -0,0 +1,424 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule selection
*/
/**
* Selection functions for the Atto editor.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorSelection
*/
function EditorSelection() {}
EditorSelection.ATTRS = {
};
EditorSelection.prototype = {
/**
* List of saved selections per editor instance.
*
* @property _selections
* @private
*/
_selections: null,
/**
* A unique identifier for the last selection recorded.
*
* @property _lastSelection
* @param lastselection
* @type string
* @private
*/
_lastSelection: null,
/**
* Whether focus came from a click event.
*
* This is used to determine whether to restore the selection or not.
*
* @property _focusFromClick
* @type Boolean
* @default false
* @private
*/
_focusFromClick: false,
/**
* Whether if the last gesturemovestart event target was contained in this editor or not.
*
* @property _gesturestartededitor
* @type Boolean
* @default false
* @private
*/
_gesturestartededitor: false,
/**
* Set up the watchers for selection save and restoration.
*
* @method setupSelectionWatchers
* @chainable
*/
setupSelectionWatchers: function() {
// Save the selection when a change was made.
this.on('atto:selectionchanged', this.saveSelection, this);
this.editor.on('focus', this.restoreSelection, this);
// Do not restore selection when focus is from a click event.
this.editor.on('mousedown', function() {
this._focusFromClick = true;
}, this);
// Copy the current value back to the textarea when focus leaves us and save the current selection.
this.editor.on('blur', function() {
// Clear the _focusFromClick value.
this._focusFromClick = false;
// Update the original text area.
this.updateOriginal();
}, this);
this.editor.on(['keyup', 'focus'], function(e) {
Y.soon(Y.bind(this._hasSelectionChanged, this, e));
}, this);
Y.one(document.body).on('gesturemovestart', function(e) {
if (this._wrapper.contains(e.target._node)) {
this._gesturestartededitor = true;
} else {
this._gesturestartededitor = false;
}
}, null, this);
Y.one(document.body).on('gesturemoveend', function(e) {
if (!this._gesturestartededitor) {
// Ignore the event if movestart target was not contained in the editor.
return;
}
Y.soon(Y.bind(this._hasSelectionChanged, this, e));
}, {
// Standalone will make sure all editors receive the end event.
standAlone: true
}, this);
return this;
},
/**
* Work out if the cursor is in the editable area for this editor instance.
*
* @method isActive
* @return {boolean}
*/
isActive: function() {
var range = rangy.createRange(),
selection = rangy.getSelection();
if (!selection.rangeCount) {
// If there was no range count, then there is no selection.
return false;
}
// We can't be active if the editor doesn't have focus at the moment.
if (!document.activeElement ||
!(this.editor.compareTo(document.activeElement) || this.editor.contains(document.activeElement))) {
return false;
}
// Check whether the range intersects the editor selection.
range.selectNode(this.editor.getDOMNode());
return range.intersectsRange(selection.getRangeAt(0));
},
/**
* Create a cross browser selection object that represents a YUI node.
*
* @method getSelectionFromNode
* @param {Node} YUI Node to base the selection upon.
* @return {[rangy.Range]}
*/
getSelectionFromNode: function(node) {
var range = rangy.createRange();
range.selectNode(node.getDOMNode());
return [range];
},
/**
* Save the current selection to an internal property.
*
* This allows more reliable return focus, helping improve keyboard navigation.
*
* Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/restoreSelection"}}{{/crossLink}}.
*
* @method saveSelection
*/
saveSelection: function() {
if (this.isActive()) {
this._selections = this.getSelection();
}
},
/**
* Restore any stored selection when the editor gets focus again.
*
* Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/saveSelection"}}{{/crossLink}}.
*
* @method restoreSelection
*/
restoreSelection: function() {
if (!this._focusFromClick) {
if (this._selections) {
this.setSelection(this._selections);
}
}
this._focusFromClick = false;
},
/**
* Get the selection object that can be passed back to setSelection.
*
* @method getSelection
* @return {array} An array of rangy ranges.
*/
getSelection: function() {
return rangy.getSelection().getAllRanges();
},
/**
* Check that a YUI node it at least partly contained by the current selection.
*
* @method selectionContainsNode
* @param {Node} The node to check.
* @return {boolean}
*/
selectionContainsNode: function(node) {
return rangy.getSelection().containsNode(node.getDOMNode(), true);
},
/**
* Runs a filter on each node in the selection, and report whether the
* supplied selector(s) were found in the supplied Nodes.
*
* By default, all specified nodes must match the selection, but this
* can be controlled with the requireall property.
*
* @method selectionFilterMatches
* @param {String} selector
* @param {NodeList} [selectednodes] For performance this should be passed. If not passed, this will be looked up each time.
* @param {Boolean} [requireall=true] Used to specify that "any" match is good enough.
* @return {Boolean}
*/
selectionFilterMatches: function(selector, selectednodes, requireall) {
if (typeof requireall === 'undefined') {
requireall = true;
}
if (!selectednodes) {
// Find this because it was not passed as a param.
selectednodes = this.getSelectedNodes();
}
var allmatch = selectednodes.size() > 0,
anymatch = false;
var editor = this.editor,
stopFn = function(node) {
// The function getSelectedNodes only returns nodes within the editor, so this test is safe.
return node === editor;
};
// If we do not find at least one match in the editor, no point trying to find them in the selection.
if (!editor.one(selector)) {
return false;
}
selectednodes.each(function(node) {
// Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
if (requireall) {
// Check for at least one failure.
if (!allmatch || !node.ancestor(selector, true, stopFn)) {
allmatch = false;
}
} else {
// Check for at least one match.
if (!anymatch && node.ancestor(selector, true, stopFn)) {
anymatch = true;
}
}
}, this);
if (requireall) {
return allmatch;
} else {
return anymatch;
}
},
/**
* Get the deepest possible list of nodes in the current selection.
*
* @method getSelectedNodes
* @return {NodeList}
*/
getSelectedNodes: function() {
var results = new Y.NodeList(),
nodes,
selection,
range,
node,
i;
selection = rangy.getSelection();
if (selection.rangeCount) {
range = selection.getRangeAt(0);
} else {
// Empty range.
range = rangy.createRange();
}
if (range.collapsed) {
// We do not want to select all the nodes in the editor if we managed to
// have a collapsed selection directly in the editor.
// It's also possible for the commonAncestorContainer to be the document, which selectNode does not handle
// so we must filter that out here too.
if (range.commonAncestorContainer !== this.editor.getDOMNode()
&& range.commonAncestorContainer !== Y.config.doc) {
range = range.cloneRange();
range.selectNode(range.commonAncestorContainer);
}
}
nodes = range.getNodes();
for (i = 0; i < nodes.length; i++) {
node = Y.one(nodes[i]);
if (this.editor.contains(node)) {
results.push(node);
}
}
return results;
},
/**
* Check whether the current selection has changed since this method was last called.
*
* If the selection has changed, the atto:selectionchanged event is also fired.
*
* @method _hasSelectionChanged
* @private
* @param {EventFacade} e
* @return {Boolean}
*/
_hasSelectionChanged: function(e) {
var selection = rangy.getSelection(),
range,
changed = false;
if (selection.rangeCount) {
range = selection.getRangeAt(0);
} else {
// Empty range.
range = rangy.createRange();
}
if (this._lastSelection) {
if (!this._lastSelection.equals(range)) {
changed = true;
return this._fireSelectionChanged(e);
}
}
this._lastSelection = range;
return changed;
},
/**
* Fires the atto:selectionchanged event.
*
* When the selectionchanged event is fired, the following arguments are provided:
* - event : the original event that lead to this event being fired.
* - selectednodes : an array containing nodes that are entirely selected of contain partially selected content.
*
* @method _fireSelectionChanged
* @private
* @param {EventFacade} e
*/
_fireSelectionChanged: function(e) {
this.fire('atto:selectionchanged', {
event: e,
selectedNodes: this.getSelectedNodes()
});
},
/**
* Get the DOM node representing the common anscestor of the selection nodes.
*
* @method getSelectionParentNode
* @return {Element|boolean} The DOM Node for this parent, or false if no seletion was made.
*/
getSelectionParentNode: function() {
var selection = rangy.getSelection();
if (selection.rangeCount) {
return selection.getRangeAt(0).commonAncestorContainer;
}
return false;
},
/**
* Set the current selection. Used to restore a selection.
*
* @method selection
* @param {array} ranges A list of rangy.range objects in the selection.
*/
setSelection: function(ranges) {
var selection = rangy.getSelection();
selection.setRanges(ranges);
},
/**
* Inserts the given HTML into the editable content at the currently focused point.
*
* @method insertContentAtFocusPoint
* @param {String} html
* @return {Node} The YUI Node object added to the DOM.
*/
insertContentAtFocusPoint: function(html) {
var selection = rangy.getSelection(),
range,
node = Y.Node.create(html);
if (selection.rangeCount) {
range = selection.getRangeAt(0);
}
if (range) {
range.deleteContents();
range.collapse(false);
var currentnode = node.getDOMNode(),
last = currentnode.lastChild || currentnode;
range.insertNode(currentnode);
range.collapseAfter(last);
selection.setSingleRange(range);
}
return node;
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorSelection]);
+223
View File
@@ -0,0 +1,223 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule styling
*/
/**
* Editor styling functions for the Atto editor.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorStyling
*/
function EditorStyling() {}
EditorStyling.ATTRS = {
};
EditorStyling.prototype = {
/**
* Disable CSS styling.
*
* @method disableCssStyling
*/
disableCssStyling: function() {
try {
document.execCommand("styleWithCSS", 0, false);
} catch (e1) {
try {
document.execCommand("useCSS", 0, true);
} catch (e2) {
try {
document.execCommand('styleWithCSS', false, false);
} catch (e3) {
// We did our best.
}
}
}
},
/**
* Enable CSS styling.
*
* @method enableCssStyling
*/
enableCssStyling: function() {
try {
document.execCommand("styleWithCSS", 0, true);
} catch (e1) {
try {
document.execCommand("useCSS", 0, false);
} catch (e2) {
try {
document.execCommand('styleWithCSS', false, true);
} catch (e3) {
// We did our best.
}
}
}
},
/**
* Change the formatting for the current selection.
*
* This will wrap the selection in span tags, adding the provided classes.
*
* If the selection covers multiple block elements, multiple spans will be inserted to preserve the original structure.
*
* @method toggleInlineSelectionClass
* @param {Array} toggleclasses - Class names to be toggled on or off.
*/
toggleInlineSelectionClass: function(toggleclasses) {
var classname = toggleclasses.join(" ");
var cssApplier = rangy.createClassApplier(classname, {normalize: true});
cssApplier.toggleSelection();
},
/**
* Change the formatting for the current selection.
*
* This will set inline styles on the current selection.
*
* @method formatSelectionInlineStyle
* @param {Array} styles - Style attributes to set on the nodes.
*/
formatSelectionInlineStyle: function(styles) {
var classname = this.PLACEHOLDER_CLASS;
var cssApplier = rangy.createClassApplier(classname, {normalize: true});
cssApplier.applyToSelection();
this.editor.all('.' + classname).each(function(node) {
node.removeClass(classname).setStyles(styles);
}, this);
},
/**
* Change the formatting for the current selection.
*
* Also changes the selection to the newly formatted block (allows applying multiple styles to a block).
*
* @method formatSelectionBlock
* @param {String} [blocktag] Change the block level tag to this. Empty string, means do not change the tag.
* @param {Object} [attributes] The keys and values for attributes to be added/changed in the block tag.
* @return {Node|boolean} The Node that was formatted if a change was made, otherwise false.
*/
formatSelectionBlock: function(blocktag, attributes) {
// First find the nearest ancestor of the selection that is a block level element.
var selectionparentnode = this.getSelectionParentNode(),
boundary,
cell,
nearestblock,
newcontent,
match,
replacement;
if (!selectionparentnode) {
// No selection, nothing to format.
return false;
}
boundary = this.editor;
selectionparentnode = Y.one(selectionparentnode);
// If there is a table cell in between the selectionparentnode and the boundary,
// move the boundary to the table cell.
// This is because we might have a table in a div, and we select some text in a cell,
// want to limit the change in style to the table cell, not the entire table (via the outer div).
cell = selectionparentnode.ancestor(function(node) {
var tagname = node.get('tagName');
if (tagname) {
tagname = tagname.toLowerCase();
}
return (node === boundary) ||
(tagname === 'td') ||
(tagname === 'th');
}, true);
if (cell) {
// Limit the scope to the table cell.
boundary = cell;
}
nearestblock = selectionparentnode.ancestor(this.BLOCK_TAGS.join(', '), true);
if (nearestblock) {
// Check that the block is contained by the boundary.
match = nearestblock.ancestor(function(node) {
return node === boundary;
}, false);
if (!match) {
nearestblock = false;
}
}
// No valid block element - make one.
if (!nearestblock) {
var alignment;
if (this.coreDirection === 'rtl') {
alignment = 'style="text-align: right;"';
} else {
alignment = 'style="text-align: left;"';
}
// There is no block node in the content, wrap the content in a p and use that.
newcontent = Y.Node.create('<p dir="' + this.coreDirection + '" ' + alignment + '></p>');
boundary.get('childNodes').each(function(child) {
newcontent.append(child.remove());
});
boundary.append(newcontent);
nearestblock = newcontent;
}
// Guaranteed to have a valid block level element contained in the contenteditable region.
// Change the tag to the new block level tag.
if (blocktag && blocktag !== '') {
// Change the block level node for a new one.
replacement = Y.Node.create('<' + blocktag + '></' + blocktag + '>');
// Copy all attributes.
replacement.setAttrs(nearestblock.getAttrs());
// Copy all children.
nearestblock.get('childNodes').each(function(child) {
child.remove();
replacement.append(child);
});
nearestblock.replace(replacement);
nearestblock = replacement;
}
// Set the attributes on the block level tag.
if (attributes) {
nearestblock.setAttrs(attributes);
}
// Change the selection to the modified block. This makes sense when we might apply multiple styles
// to the block.
var selection = this.getSelectionFromNode(nearestblock);
this.setSelection(selection);
return nearestblock;
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorStyling]);
+120
View File
@@ -0,0 +1,120 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule textarea
*/
/**
* Textarea functions for the Atto editor.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorTextArea
*/
function EditorTextArea() {}
EditorTextArea.ATTRS = {
};
EditorTextArea.prototype = {
/**
* Return the appropriate empty content value for the current browser.
*
* Different browsers use a different content when they are empty and
* we must set this reliable across the board.
*
* @method _getEmptyContent
* @return String The content to use representing no user-provided content
* @private
*/
_getEmptyContent: function() {
if (!this.enableAppropriateEmptyContent) {
// Return the empty string if we do not enable the empty placeholder. Ex: HTML mode.
return '';
}
var alignment;
if (this.coreDirection === 'rtl') {
alignment = 'style="text-align: right;"';
} else {
alignment = 'style="text-align: left;"';
}
if (Y.UA.ie && Y.UA.ie < 10) {
return '<p dir="' + this.coreDirection + '" ' + alignment + '></p>';
} else {
return '<p dir="' + this.coreDirection + '" ' + alignment + '><br></p>';
}
},
/**
* Copy and clean the text from the textarea into the contenteditable div.
*
* If the text is empty, provide a default paragraph tag to hold the content.
*
* @method updateFromTextArea
* @chainable
*/
updateFromTextArea: function() {
// Clear it first.
this.editor.setHTML('');
// Copy cleaned HTML to editable div.
this.editor.append(this._cleanHTML(this.textarea.get('value'), true));
// Insert a paragraph in the empty contenteditable div.
if (this.editor.getHTML() === '') {
this.editor.setHTML(this._getEmptyContent());
}
return this;
},
/**
* Copy the text from the contenteditable to the textarea which it replaced.
*
* @method updateOriginal
* @chainable
*/
updateOriginal: function() {
// Get the previous and current value to compare them.
var oldValue = this.textarea.get('value'),
newValue = this.getCleanHTML();
if (newValue === "" && this.isActive()) {
// The content was entirely empty so get the empty content placeholder.
newValue = this._getEmptyContent();
}
// Only call this when there has been an actual change to reduce processing.
if (oldValue !== newValue) {
// Insert the cleaned content.
this.textarea.set('value', newValue);
// Trigger the onchange callback on the textarea, essentially to notify the formchangechecker module.
this.textarea.simulate('change');
// Trigger handlers for this action.
this.fire('change');
}
return this;
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorTextArea]);
+209
View File
@@ -0,0 +1,209 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule toolbarnav
*/
/**
* Toolbar Navigation functions for the Atto editor.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorToolbarNav
*/
function EditorToolbarNav() {}
EditorToolbarNav.ATTRS = {
};
EditorToolbarNav.prototype = {
/**
* The current focal point for tabbing.
*
* @property _tabFocus
* @type Node
* @default null
* @private
*/
_tabFocus: null,
/**
* Set up the watchers for toolbar navigation.
*
* @method setupToolbarNavigation
* @chainable
*/
setupToolbarNavigation: function() {
// Listen for Arrow left and Arrow right keys.
this._wrapper.delegate('key',
this.toolbarKeyboardNavigation,
'down:37,39',
'.' + CSS.TOOLBAR,
this);
this._wrapper.delegate('focus',
function(e) {
this._setTabFocus(e.currentTarget);
}, '.' + CSS.TOOLBAR + ' button', this);
return this;
},
/**
* Implement arrow key navigation for the buttons in the toolbar.
*
* @method toolbarKeyboardNavigation
* @param {EventFacade} e - the keyboard event.
*/
toolbarKeyboardNavigation: function(e) {
// Prevent the default browser behaviour.
e.preventDefault();
// On cursor moves we loops through the buttons.
var buttons = this.toolbar.all('button'),
direction = 1,
button,
current = e.target.ancestor('button', true),
innerButtons = e.target.all('button');
// If we are not on a button and the element we are on contains some buttons, then move between the inner buttons.
if (!current && innerButtons) {
buttons = innerButtons;
}
if (e.keyCode === 37) {
// Moving left so reverse the direction.
direction = -1;
}
button = this._findFirstFocusable(buttons, current, direction);
if (button) {
button.focus();
this._setTabFocus(button);
} else {
Y.log("Unable to find a button to focus on", 'debug', LOGNAME);
}
},
/**
* Find the first focusable button.
*
* @param {NodeList} buttons A list of nodes.
* @param {Node} startAt The node in the list to start the search from.
* @param {Number} direction The direction in which to search (1 or -1).
* @return {Node | Undefined} The Node or undefined.
* @method _findFirstFocusable
* @private
*/
_findFirstFocusable: function(buttons, startAt, direction) {
var checkCount = 0,
candidate,
button,
index;
// Determine which button to start the search from.
index = buttons.indexOf(startAt);
if (index < -1) {
Y.log("Unable to find the button in the list of buttons", 'debug', LOGNAME);
index = 0;
}
// Try to find the next.
while (checkCount < buttons.size()) {
index += direction;
if (index < 0) {
index = buttons.size() - 1;
} else if (index >= buttons.size()) {
// Handle wrapping.
index = 0;
}
candidate = buttons.item(index);
// Add a counter to ensure we don't get stuck in a loop if there's only one visible menu item.
checkCount++;
// Loop while:
// * we haven't checked every button;
// * the button is hidden or disabled;
// * the button is inside a hidden wrapper element.
if (candidate.hasAttribute('hidden') || candidate.hasAttribute('disabled') || candidate.ancestor('[hidden]')) {
continue;
}
button = candidate;
break;
}
return button;
},
/**
* Check the tab focus.
*
* When we disable or hide a button, we should call this method to ensure that the
* focus is not currently set on an inaccessible button, otherwise tabbing to the toolbar
* would be impossible.
*
* @method checkTabFocus
* @chainable
*/
checkTabFocus: function() {
if (this._tabFocus) {
if (this._tabFocus.hasAttribute('disabled') || this._tabFocus.hasAttribute('hidden')
|| this._tabFocus.ancestor('.atto_group').hasAttribute('hidden')) {
// Find first available button.
var button = this._findFirstFocusable(this.toolbar.all('button'), this._tabFocus, -1);
if (button) {
if (this._tabFocus.compareTo(document.activeElement)) {
// We should also move the focus, because the inaccessible button also has the focus.
button.focus();
}
this._setTabFocus(button);
}
}
}
return this;
},
/**
* Sets tab focus for the toolbar to the specified Node.
*
* @method _setTabFocus
* @param {Node} button The node that focus should now be set to
* @chainable
* @private
*/
_setTabFocus: function(button) {
if (this._tabFocus) {
// Unset the previous entry.
this._tabFocus.setAttribute('tabindex', '-1');
}
// Set up the new entry.
this._tabFocus = button;
this._tabFocus.setAttribute('tabindex', 0);
// And update the activedescendant to point at the currently selected button.
this.toolbar.setAttribute('aria-activedescendant', this._tabFocus.generateID());
return this;
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbarNav]);
+74
View File
@@ -0,0 +1,74 @@
// 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/>.
/**
* @module moodle-editor_atto-editor
* @submodule toolbar
*/
/**
* Toolbar functions for the Atto editor.
*
* See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
*
* @namespace M.editor_atto
* @class EditorToolbar
*/
function EditorToolbar() {}
EditorToolbar.ATTRS = {
};
EditorToolbar.prototype = {
/**
* A reference to the toolbar Node.
*
* @property toolbar
* @type Node
*/
toolbar: null,
/**
* A reference to any currently open menus in the toolbar.
*
* @property openMenus
* @type Array
*/
openMenus: null,
/**
* Setup the toolbar on the editor.
*
* @method setupToolbar
* @chainable
*/
setupToolbar: function() {
this.toolbar = Y.Node.create('<div class="' + CSS.TOOLBAR + '" role="toolbar" aria-live="off"/>');
this.openMenus = [];
this._wrapper.appendChild(this.toolbar);
if (this.textareaLabel) {
this.toolbar.setAttribute('aria-labelledby', this.textareaLabel.get("id"));
}
// Add keyboard navigation for the toolbar.
this.setupToolbarNavigation();
return this;
}
};
Y.Base.mix(Y.M.editor_atto.Editor, [EditorToolbar]);
@@ -0,0 +1,43 @@
{
"moodle-editor_atto-editor": {
"requires": [
"node",
"transition",
"io",
"overlay",
"escape",
"event",
"event-simulate",
"event-custom",
"node-event-html5",
"node-event-simulate",
"yui-throttle",
"moodle-core-notification-dialogue",
"moodle-editor_atto-rangy",
"handlebars",
"timers",
"querystring-stringify"
]
},
"moodle-editor_atto-plugin": {
"requires": [
"node",
"base",
"escape",
"event",
"event-outside",
"handlebars",
"event-custom",
"timers",
"moodle-editor_atto-menu"
]
},
"moodle-editor_atto-menu": {
"requires": [
"moodle-core-notification-dialogue",
"node",
"event",
"event-custom"
]
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "moodle-editor_atto-rangy",
"builds": {
"moodle-editor_atto-rangy": {
"prependfiles": [
"rangy-core.js",
"rangy-selectionsaverestore.js",
"rangy-serializer.js",
"rangy-classapplier.js",
"rangy-highlighter.js",
"rangy-textrange.js"
],
"jsfiles": [
"init.js"
]
}
},
"shifter": {
"lint": false
}
}
+18
View File
@@ -0,0 +1,18 @@
// 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/>.
if (!rangy.initialized) {
rangy.init();
}
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2010 Tim Down
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+611
View File
@@ -0,0 +1,611 @@
/**
* Highlighter module for Rangy, a cross-browser JavaScript range and selection library
* https://github.com/timdown/rangy
*
* Depends on Rangy core, ClassApplier and optionally TextRange modules.
*
* Copyright 2022, Tim Down
* Licensed under the MIT license.
* Version: 1.3.1
* Build date: 17 August 2022
*/
(function(factory, root) {
// No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
factory(root.rangy);
})(function(rangy) {
rangy.createModule("Highlighter", ["ClassApplier"], function(api, module) {
var dom = api.dom;
var contains = dom.arrayContains;
var getBody = dom.getBody;
var createOptions = api.util.createOptions;
var forEach = api.util.forEach;
var nextHighlightId = 1;
// Puts highlights in order, last in document first.
function compareHighlights(h1, h2) {
return h1.characterRange.start - h2.characterRange.start;
}
function getContainerElement(doc, id) {
return id ? doc.getElementById(id) : getBody(doc);
}
/*----------------------------------------------------------------------------------------------------------------*/
var highlighterTypes = {};
function HighlighterType(type, converterCreator) {
this.type = type;
this.converterCreator = converterCreator;
}
HighlighterType.prototype.create = function() {
var converter = this.converterCreator();
converter.type = this.type;
return converter;
};
function registerHighlighterType(type, converterCreator) {
highlighterTypes[type] = new HighlighterType(type, converterCreator);
}
function getConverter(type) {
var highlighterType = highlighterTypes[type];
if (highlighterType instanceof HighlighterType) {
return highlighterType.create();
} else {
throw new Error("Highlighter type '" + type + "' is not valid");
}
}
api.registerHighlighterType = registerHighlighterType;
/*----------------------------------------------------------------------------------------------------------------*/
function CharacterRange(start, end) {
this.start = start;
this.end = end;
}
CharacterRange.prototype = {
intersects: function(charRange) {
return this.start < charRange.end && this.end > charRange.start;
},
isContiguousWith: function(charRange) {
return this.start == charRange.end || this.end == charRange.start;
},
union: function(charRange) {
return new CharacterRange(Math.min(this.start, charRange.start), Math.max(this.end, charRange.end));
},
intersection: function(charRange) {
return new CharacterRange(Math.max(this.start, charRange.start), Math.min(this.end, charRange.end));
},
getComplements: function(charRange) {
var ranges = [];
if (this.start >= charRange.start) {
if (this.end <= charRange.end) {
return [];
}
ranges.push(new CharacterRange(charRange.end, this.end));
} else {
ranges.push(new CharacterRange(this.start, Math.min(this.end, charRange.start)));
if (this.end > charRange.end) {
ranges.push(new CharacterRange(charRange.end, this.end));
}
}
return ranges;
},
toString: function() {
return "[CharacterRange(" + this.start + ", " + this.end + ")]";
}
};
CharacterRange.fromCharacterRange = function(charRange) {
return new CharacterRange(charRange.start, charRange.end);
};
/*----------------------------------------------------------------------------------------------------------------*/
var textContentConverter = {
rangeToCharacterRange: function(range, containerNode) {
var bookmark = range.getBookmark(containerNode);
return new CharacterRange(bookmark.start, bookmark.end);
},
characterRangeToRange: function(doc, characterRange, containerNode) {
var range = api.createRange(doc);
range.moveToBookmark({
start: characterRange.start,
end: characterRange.end,
containerNode: containerNode
});
return range;
},
serializeSelection: function(selection, containerNode) {
var ranges = selection.getAllRanges(), rangeCount = ranges.length;
var rangeInfos = [];
var backward = rangeCount == 1 && selection.isBackward();
for (var i = 0, len = ranges.length; i < len; ++i) {
rangeInfos[i] = {
characterRange: this.rangeToCharacterRange(ranges[i], containerNode),
backward: backward
};
}
return rangeInfos;
},
restoreSelection: function(selection, savedSelection, containerNode) {
selection.removeAllRanges();
var doc = selection.win.document;
for (var i = 0, len = savedSelection.length, range, rangeInfo, characterRange; i < len; ++i) {
rangeInfo = savedSelection[i];
characterRange = rangeInfo.characterRange;
range = this.characterRangeToRange(doc, rangeInfo.characterRange, containerNode);
selection.addRange(range, rangeInfo.backward);
}
}
};
registerHighlighterType("textContent", function() {
return textContentConverter;
});
/*----------------------------------------------------------------------------------------------------------------*/
// Lazily load the TextRange-based converter so that the dependency is only checked when required.
registerHighlighterType("TextRange", (function() {
var converter;
return function() {
if (!converter) {
// Test that textRangeModule exists and is supported
var textRangeModule = api.modules.TextRange;
if (!textRangeModule) {
throw new Error("TextRange module is missing.");
} else if (!textRangeModule.supported) {
throw new Error("TextRange module is present but not supported.");
}
converter = {
rangeToCharacterRange: function(range, containerNode) {
return CharacterRange.fromCharacterRange( range.toCharacterRange(containerNode) );
},
characterRangeToRange: function(doc, characterRange, containerNode) {
var range = api.createRange(doc);
range.selectCharacters(containerNode, characterRange.start, characterRange.end);
return range;
},
serializeSelection: function(selection, containerNode) {
return selection.saveCharacterRanges(containerNode);
},
restoreSelection: function(selection, savedSelection, containerNode) {
selection.restoreCharacterRanges(containerNode, savedSelection);
}
};
}
return converter;
};
})());
/*----------------------------------------------------------------------------------------------------------------*/
function Highlight(doc, characterRange, classApplier, converter, id, containerElementId) {
if (id) {
this.id = id;
nextHighlightId = Math.max(nextHighlightId, id + 1);
} else {
this.id = nextHighlightId++;
}
this.characterRange = characterRange;
this.doc = doc;
this.classApplier = classApplier;
this.converter = converter;
this.containerElementId = containerElementId || null;
this.applied = false;
}
Highlight.prototype = {
getContainerElement: function() {
return getContainerElement(this.doc, this.containerElementId);
},
getRange: function() {
return this.converter.characterRangeToRange(this.doc, this.characterRange, this.getContainerElement());
},
fromRange: function(range) {
this.characterRange = this.converter.rangeToCharacterRange(range, this.getContainerElement());
},
getText: function() {
return this.getRange().toString();
},
containsElement: function(el) {
return this.getRange().containsNodeContents(el.firstChild);
},
unapply: function() {
this.classApplier.undoToRange(this.getRange());
this.applied = false;
},
apply: function() {
this.classApplier.applyToRange(this.getRange());
this.applied = true;
},
getHighlightElements: function() {
return this.classApplier.getElementsWithClassIntersectingRange(this.getRange());
},
toString: function() {
return "[Highlight(ID: " + this.id + ", class: " + this.classApplier.className + ", character range: " +
this.characterRange.start + " - " + this.characterRange.end + ")]";
}
};
/*----------------------------------------------------------------------------------------------------------------*/
function Highlighter(doc, type) {
type = type || "textContent";
this.doc = doc || document;
this.classAppliers = {};
this.highlights = [];
this.converter = getConverter(type);
}
Highlighter.prototype = {
addClassApplier: function(classApplier) {
this.classAppliers[classApplier.className] = classApplier;
},
getHighlightForElement: function(el) {
var highlights = this.highlights;
for (var i = 0, len = highlights.length; i < len; ++i) {
if (highlights[i].containsElement(el)) {
return highlights[i];
}
}
return null;
},
removeHighlights: function(highlights) {
for (var i = 0, len = this.highlights.length, highlight; i < len; ++i) {
highlight = this.highlights[i];
if (contains(highlights, highlight)) {
highlight.unapply();
this.highlights.splice(i--, 1);
}
}
},
removeAllHighlights: function() {
this.removeHighlights(this.highlights);
},
getIntersectingHighlights: function(ranges) {
// Test each range against each of the highlighted ranges to see whether they overlap
var intersectingHighlights = [], highlights = this.highlights;
forEach(ranges, function(range) {
//var selCharRange = converter.rangeToCharacterRange(range);
forEach(highlights, function(highlight) {
if (range.intersectsRange( highlight.getRange() ) && !contains(intersectingHighlights, highlight)) {
intersectingHighlights.push(highlight);
}
});
});
return intersectingHighlights;
},
highlightCharacterRanges: function(className, charRanges, options) {
var i, len, j;
var highlights = this.highlights;
var converter = this.converter;
var doc = this.doc;
var highlightsToRemove = [];
var classApplier = className ? this.classAppliers[className] : null;
options = createOptions(options, {
containerElementId: null,
exclusive: true
});
var containerElementId = options.containerElementId;
var exclusive = options.exclusive;
var containerElement, containerElementRange, containerElementCharRange;
if (containerElementId) {
containerElement = this.doc.getElementById(containerElementId);
if (containerElement) {
containerElementRange = api.createRange(this.doc);
containerElementRange.selectNodeContents(containerElement);
containerElementCharRange = new CharacterRange(0, containerElementRange.toString().length);
}
}
var charRange, highlightCharRange, removeHighlight, isSameClassApplier, highlightsToKeep, splitHighlight;
for (i = 0, len = charRanges.length; i < len; ++i) {
charRange = charRanges[i];
highlightsToKeep = [];
// Restrict character range to container element, if it exists
if (containerElementCharRange) {
charRange = charRange.intersection(containerElementCharRange);
}
// Ignore empty ranges
if (charRange.start == charRange.end) {
continue;
}
// Check for intersection with existing highlights. For each intersection, create a new highlight
// which is the union of the highlight range and the selected range
for (j = 0; j < highlights.length; ++j) {
removeHighlight = false;
if (containerElementId == highlights[j].containerElementId) {
highlightCharRange = highlights[j].characterRange;
isSameClassApplier = (classApplier == highlights[j].classApplier);
splitHighlight = !isSameClassApplier && exclusive;
// Replace the existing highlight if it needs to be:
// 1. merged (isSameClassApplier)
// 2. partially or entirely erased (className === null)
// 3. partially or entirely replaced (isSameClassApplier == false && exclusive == true)
if ( (highlightCharRange.intersects(charRange) || highlightCharRange.isContiguousWith(charRange)) &&
(isSameClassApplier || splitHighlight) ) {
// Remove existing highlights, keeping the unselected parts
if (splitHighlight) {
forEach(highlightCharRange.getComplements(charRange), function(rangeToAdd) {
highlightsToKeep.push( new Highlight(doc, rangeToAdd, highlights[j].classApplier, converter, null, containerElementId) );
});
}
removeHighlight = true;
if (isSameClassApplier) {
charRange = highlightCharRange.union(charRange);
}
}
}
if (removeHighlight) {
highlightsToRemove.push(highlights[j]);
highlights[j] = new Highlight(doc, highlightCharRange.union(charRange), classApplier, converter, null, containerElementId);
} else {
highlightsToKeep.push(highlights[j]);
}
}
// Add new range
if (classApplier) {
highlightsToKeep.push(new Highlight(doc, charRange, classApplier, converter, null, containerElementId));
}
this.highlights = highlights = highlightsToKeep;
}
// Remove the old highlights
forEach(highlightsToRemove, function(highlightToRemove) {
highlightToRemove.unapply();
});
// Apply new highlights
var newHighlights = [];
forEach(highlights, function(highlight) {
if (!highlight.applied) {
highlight.apply();
newHighlights.push(highlight);
}
});
return newHighlights;
},
highlightRanges: function(className, ranges, options) {
var selCharRanges = [];
var converter = this.converter;
options = createOptions(options, {
containerElement: null,
exclusive: true
});
var containerElement = options.containerElement;
var containerElementId = containerElement ? containerElement.id : null;
var containerElementRange;
if (containerElement) {
containerElementRange = api.createRange(containerElement);
containerElementRange.selectNodeContents(containerElement);
}
forEach(ranges, function(range) {
var scopedRange = containerElement ? containerElementRange.intersection(range) : range;
selCharRanges.push( converter.rangeToCharacterRange(scopedRange, containerElement || getBody(range.getDocument())) );
});
return this.highlightCharacterRanges(className, selCharRanges, {
containerElementId: containerElementId,
exclusive: options.exclusive
});
},
highlightSelection: function(className, options) {
var converter = this.converter;
var classApplier = className ? this.classAppliers[className] : false;
options = createOptions(options, {
containerElementId: null,
exclusive: true
});
var containerElementId = options.containerElementId;
var exclusive = options.exclusive;
var selection = options.selection || api.getSelection(this.doc);
var doc = selection.win.document;
var containerElement = getContainerElement(doc, containerElementId);
if (!classApplier && className !== false) {
throw new Error("No class applier found for class '" + className + "'");
}
// Store the existing selection as character ranges
var serializedSelection = converter.serializeSelection(selection, containerElement);
// Create an array of selected character ranges
var selCharRanges = [];
forEach(serializedSelection, function(rangeInfo) {
selCharRanges.push( CharacterRange.fromCharacterRange(rangeInfo.characterRange) );
});
var newHighlights = this.highlightCharacterRanges(className, selCharRanges, {
containerElementId: containerElementId,
exclusive: exclusive
});
// Restore selection
converter.restoreSelection(selection, serializedSelection, containerElement);
return newHighlights;
},
unhighlightSelection: function(selection) {
selection = selection || api.getSelection(this.doc);
var intersectingHighlights = this.getIntersectingHighlights( selection.getAllRanges() );
this.removeHighlights(intersectingHighlights);
selection.removeAllRanges();
return intersectingHighlights;
},
getHighlightsInSelection: function(selection) {
selection = selection || api.getSelection(this.doc);
return this.getIntersectingHighlights(selection.getAllRanges());
},
selectionOverlapsHighlight: function(selection) {
return this.getHighlightsInSelection(selection).length > 0;
},
serialize: function(options) {
var highlighter = this;
var highlights = highlighter.highlights;
var serializedType, serializedHighlights, convertType, serializationConverter;
highlights.sort(compareHighlights);
options = createOptions(options, {
serializeHighlightText: false,
type: highlighter.converter.type
});
serializedType = options.type;
convertType = (serializedType != highlighter.converter.type);
if (convertType) {
serializationConverter = getConverter(serializedType);
}
serializedHighlights = ["type:" + serializedType];
forEach(highlights, function(highlight) {
var characterRange = highlight.characterRange;
var containerElement;
// Convert to the current Highlighter's type, if different from the serialization type
if (convertType) {
containerElement = highlight.getContainerElement();
characterRange = serializationConverter.rangeToCharacterRange(
highlighter.converter.characterRangeToRange(highlighter.doc, characterRange, containerElement),
containerElement
);
}
var parts = [
characterRange.start,
characterRange.end,
highlight.id,
highlight.classApplier.className,
highlight.containerElementId
];
if (options.serializeHighlightText) {
parts.push(highlight.getText());
}
serializedHighlights.push( parts.join("$") );
});
return serializedHighlights.join("|");
},
deserialize: function(serialized) {
var serializedHighlights = serialized.split("|");
var highlights = [];
var firstHighlight = serializedHighlights[0];
var regexResult;
var serializationType, serializationConverter, convertType = false;
if ( firstHighlight && (regexResult = /^type:(\w+)$/.exec(firstHighlight)) ) {
serializationType = regexResult[1];
if (serializationType != this.converter.type) {
serializationConverter = getConverter(serializationType);
convertType = true;
}
serializedHighlights.shift();
} else {
throw new Error("Serialized highlights are invalid.");
}
var classApplier, highlight, characterRange, containerElementId, containerElement;
for (var i = serializedHighlights.length, parts; i-- > 0; ) {
parts = serializedHighlights[i].split("$");
characterRange = new CharacterRange(+parts[0], +parts[1]);
containerElementId = parts[4] || null;
// Convert to the current Highlighter's type, if different from the serialization type
if (convertType) {
containerElement = getContainerElement(this.doc, containerElementId);
characterRange = this.converter.rangeToCharacterRange(
serializationConverter.characterRangeToRange(this.doc, characterRange, containerElement),
containerElement
);
}
classApplier = this.classAppliers[ parts[3] ];
if (!classApplier) {
throw new Error("No class applier found for class '" + parts[3] + "'");
}
highlight = new Highlight(this.doc, characterRange, classApplier, this.converter, parseInt(parts[2]), containerElementId);
highlight.apply();
highlights.push(highlight);
}
this.highlights = highlights;
}
};
api.Highlighter = Highlighter;
api.createHighlighter = function(doc, rangeCharacterOffsetConverterType) {
return new Highlighter(doc, rangeCharacterOffsetConverterType);
};
});
return rangy;
}, this);
@@ -0,0 +1,244 @@
/**
* Selection save and restore module for Rangy.
* Saves and restores user selections using marker invisible elements in the DOM.
*
* Part of Rangy, a cross-browser JavaScript range and selection library
* https://github.com/timdown/rangy
*
* Depends on Rangy core.
*
* Copyright 2022, Tim Down
* Licensed under the MIT license.
* Version: 1.3.1
* Build date: 17 August 2022
*/
(function(factory, root) {
// No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
factory(root.rangy);
})(function(rangy) {
rangy.createModule("SaveRestore", ["WrappedSelection"], function(api, module) {
var dom = api.dom;
var removeNode = dom.removeNode;
var isDirectionBackward = api.Selection.isDirectionBackward;
var markerTextChar = "\ufeff";
function gEBI(id, doc) {
return (doc || document).getElementById(id);
}
function insertRangeBoundaryMarker(range, atStart) {
var markerId = "selectionBoundary_" + (+new Date()) + "_" + ("" + Math.random()).slice(2);
var markerEl;
var doc = dom.getDocument(range.startContainer);
// Clone the Range and collapse to the appropriate boundary point
var boundaryRange = range.cloneRange();
boundaryRange.collapse(atStart);
// Create the marker element containing a single invisible character using DOM methods and insert it
markerEl = doc.createElement("span");
markerEl.id = markerId;
markerEl.style.lineHeight = "0";
markerEl.style.display = "none";
markerEl.className = "rangySelectionBoundary";
markerEl.appendChild(doc.createTextNode(markerTextChar));
boundaryRange.insertNode(markerEl);
return markerEl;
}
function setRangeBoundary(doc, range, markerId, atStart) {
var markerEl = gEBI(markerId, doc);
if (markerEl) {
range[atStart ? "setStartBefore" : "setEndBefore"](markerEl);
removeNode(markerEl);
} else {
module.warn("Marker element has been removed. Cannot restore selection.");
}
}
function compareRanges(r1, r2) {
return r2.compareBoundaryPoints(r1.START_TO_START, r1);
}
function saveRange(range, direction) {
var startEl, endEl, doc = api.DomRange.getRangeDocument(range), text = range.toString();
var backward = isDirectionBackward(direction);
if (range.collapsed) {
endEl = insertRangeBoundaryMarker(range, false);
return {
document: doc,
markerId: endEl.id,
collapsed: true
};
} else {
endEl = insertRangeBoundaryMarker(range, false);
startEl = insertRangeBoundaryMarker(range, true);
return {
document: doc,
startMarkerId: startEl.id,
endMarkerId: endEl.id,
collapsed: false,
backward: backward,
toString: function() {
return "original text: '" + text + "', new text: '" + range.toString() + "'";
}
};
}
}
function restoreRange(rangeInfo, normalize) {
var doc = rangeInfo.document;
if (typeof normalize == "undefined") {
normalize = true;
}
var range = api.createRange(doc);
if (rangeInfo.collapsed) {
var markerEl = gEBI(rangeInfo.markerId, doc);
if (markerEl) {
markerEl.style.display = "inline";
var previousNode = markerEl.previousSibling;
// Workaround for issue 17
if (previousNode && previousNode.nodeType == 3) {
removeNode(markerEl);
range.collapseToPoint(previousNode, previousNode.length);
} else {
range.collapseBefore(markerEl);
removeNode(markerEl);
}
} else {
module.warn("Marker element has been removed. Cannot restore selection.");
}
} else {
setRangeBoundary(doc, range, rangeInfo.startMarkerId, true);
setRangeBoundary(doc, range, rangeInfo.endMarkerId, false);
}
if (normalize) {
range.normalizeBoundaries();
}
return range;
}
function saveRanges(ranges, direction) {
var rangeInfos = [], range, doc;
var backward = isDirectionBackward(direction);
// Order the ranges by position within the DOM, latest first, cloning the array to leave the original untouched
ranges = ranges.slice(0);
ranges.sort(compareRanges);
for (var i = 0, len = ranges.length; i < len; ++i) {
rangeInfos[i] = saveRange(ranges[i], backward);
}
// Now that all the markers are in place and DOM manipulation over, adjust each range's boundaries to lie
// between its markers
for (i = len - 1; i >= 0; --i) {
range = ranges[i];
doc = api.DomRange.getRangeDocument(range);
if (range.collapsed) {
range.collapseAfter(gEBI(rangeInfos[i].markerId, doc));
} else {
range.setEndBefore(gEBI(rangeInfos[i].endMarkerId, doc));
range.setStartAfter(gEBI(rangeInfos[i].startMarkerId, doc));
}
}
return rangeInfos;
}
function saveSelection(win) {
if (!api.isSelectionValid(win)) {
module.warn("Cannot save selection. This usually happens when the selection is collapsed and the selection document has lost focus.");
return null;
}
var sel = api.getSelection(win);
var ranges = sel.getAllRanges();
var backward = (ranges.length == 1 && sel.isBackward());
var rangeInfos = saveRanges(ranges, backward);
// Ensure current selection is unaffected
if (backward) {
sel.setSingleRange(ranges[0], backward);
} else {
sel.setRanges(ranges);
}
return {
win: win,
rangeInfos: rangeInfos,
restored: false
};
}
function restoreRanges(rangeInfos) {
var ranges = [];
// Ranges are in reverse order of appearance in the DOM. We want to restore earliest first to avoid
// normalization affecting previously restored ranges.
var rangeCount = rangeInfos.length;
for (var i = rangeCount - 1; i >= 0; i--) {
ranges[i] = restoreRange(rangeInfos[i], true);
}
return ranges;
}
function restoreSelection(savedSelection, preserveDirection) {
if (!savedSelection.restored) {
var rangeInfos = savedSelection.rangeInfos;
var sel = api.getSelection(savedSelection.win);
var ranges = restoreRanges(rangeInfos), rangeCount = rangeInfos.length;
if (rangeCount == 1 && preserveDirection && api.features.selectionHasExtend && rangeInfos[0].backward) {
sel.removeAllRanges();
sel.addRange(ranges[0], true);
} else {
sel.setRanges(ranges);
}
savedSelection.restored = true;
}
}
function removeMarkerElement(doc, markerId) {
var markerEl = gEBI(markerId, doc);
if (markerEl) {
removeNode(markerEl);
}
}
function removeMarkers(savedSelection) {
var rangeInfos = savedSelection.rangeInfos;
for (var i = 0, len = rangeInfos.length, rangeInfo; i < len; ++i) {
rangeInfo = rangeInfos[i];
if (rangeInfo.collapsed) {
removeMarkerElement(savedSelection.doc, rangeInfo.markerId);
} else {
removeMarkerElement(savedSelection.doc, rangeInfo.startMarkerId);
removeMarkerElement(savedSelection.doc, rangeInfo.endMarkerId);
}
}
}
api.util.extend(api, {
saveRange: saveRange,
restoreRange: restoreRange,
saveRanges: saveRanges,
restoreRanges: restoreRanges,
saveSelection: saveSelection,
restoreSelection: restoreSelection,
removeMarkerElement: removeMarkerElement,
removeMarkers: removeMarkers
});
});
return rangy;
}, this);
+306
View File
@@ -0,0 +1,306 @@
/**
* Serializer module for Rangy.
* Serializes Ranges and Selections. An example use would be to store a user's selection on a particular page in a
* cookie or local storage and restore it on the user's next visit to the same page.
*
* Part of Rangy, a cross-browser JavaScript range and selection library
* https://github.com/timdown/rangy
*
* Depends on Rangy core.
*
* Copyright 2022, Tim Down
* Licensed under the MIT license.
* Version: 1.3.1
* Build date: 17 August 2022
*/
(function(factory, root) {
// No AMD or CommonJS support so we use the rangy property of root (probably the global variable)
factory(root.rangy);
})(function(rangy) {
rangy.createModule("Serializer", ["WrappedSelection"], function(api, module) {
var UNDEF = "undefined";
var util = api.util;
// encodeURIComponent and decodeURIComponent are required for cookie handling
if (typeof encodeURIComponent == UNDEF || typeof decodeURIComponent == UNDEF) {
module.fail("encodeURIComponent and/or decodeURIComponent method is missing");
}
// Checksum for checking whether range can be serialized
var crc32 = (function() {
function utf8encode(str) {
var utf8CharCodes = [];
for (var i = 0, len = str.length, c; i < len; ++i) {
c = str.charCodeAt(i);
if (c < 128) {
utf8CharCodes.push(c);
} else if (c < 2048) {
utf8CharCodes.push((c >> 6) | 192, (c & 63) | 128);
} else {
utf8CharCodes.push((c >> 12) | 224, ((c >> 6) & 63) | 128, (c & 63) | 128);
}
}
return utf8CharCodes;
}
var cachedCrcTable = null;
function buildCRCTable() {
var table = [];
for (var i = 0, j, crc; i < 256; ++i) {
crc = i;
j = 8;
while (j--) {
if ((crc & 1) == 1) {
crc = (crc >>> 1) ^ 0xEDB88320;
} else {
crc >>>= 1;
}
}
table[i] = crc >>> 0;
}
return table;
}
function getCrcTable() {
if (!cachedCrcTable) {
cachedCrcTable = buildCRCTable();
}
return cachedCrcTable;
}
return function(str) {
var utf8CharCodes = utf8encode(str), crc = -1, crcTable = getCrcTable();
for (var i = 0, len = utf8CharCodes.length, y; i < len; ++i) {
y = (crc ^ utf8CharCodes[i]) & 0xFF;
crc = (crc >>> 8) ^ crcTable[y];
}
return (crc ^ -1) >>> 0;
};
})();
var dom = api.dom;
function escapeTextForHtml(str) {
return str.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function nodeToInfoString(node, infoParts) {
infoParts = infoParts || [];
var nodeType = node.nodeType, children = node.childNodes, childCount = children.length;
var nodeInfo = [nodeType, node.nodeName, childCount].join(":");
var start = "", end = "";
switch (nodeType) {
case 3: // Text node
start = escapeTextForHtml(node.nodeValue);
break;
case 8: // Comment
start = "<!--" + escapeTextForHtml(node.nodeValue) + "-->";
break;
default:
start = "<" + nodeInfo + ">";
end = "</>";
break;
}
if (start) {
infoParts.push(start);
}
for (var i = 0; i < childCount; ++i) {
nodeToInfoString(children[i], infoParts);
}
if (end) {
infoParts.push(end);
}
return infoParts;
}
// Creates a string representation of the specified element's contents that is similar to innerHTML but omits all
// attributes and comments and includes child node counts. This is done instead of using innerHTML to work around
// IE <= 8's policy of including element properties in attributes, which ruins things by changing an element's
// innerHTML whenever the user changes an input within the element.
function getElementChecksum(el) {
var info = nodeToInfoString(el).join("");
return crc32(info).toString(16);
}
function serializePosition(node, offset, rootNode) {
var pathParts = [], n = node;
rootNode = rootNode || dom.getDocument(node).documentElement;
while (n && n != rootNode) {
pathParts.push(dom.getNodeIndex(n, true));
n = n.parentNode;
}
return pathParts.join("/") + ":" + offset;
}
function deserializePosition(serialized, rootNode, doc) {
if (!rootNode) {
rootNode = (doc || document).documentElement;
}
var parts = serialized.split(":");
var node = rootNode;
var nodeIndices = parts[0] ? parts[0].split("/") : [], i = nodeIndices.length, nodeIndex;
while (i--) {
nodeIndex = parseInt(nodeIndices[i], 10);
if (nodeIndex < node.childNodes.length) {
node = node.childNodes[nodeIndex];
} else {
throw module.createError("deserializePosition() failed: node " + dom.inspectNode(node) +
" has no child with index " + nodeIndex + ", " + i);
}
}
return new dom.DomPosition(node, parseInt(parts[1], 10));
}
function serializeRange(range, omitChecksum, rootNode) {
rootNode = rootNode || api.DomRange.getRangeDocument(range).documentElement;
if (!dom.isOrIsAncestorOf(rootNode, range.commonAncestorContainer)) {
throw module.createError("serializeRange(): range " + range.inspect() +
" is not wholly contained within specified root node " + dom.inspectNode(rootNode));
}
var serialized = serializePosition(range.startContainer, range.startOffset, rootNode) + "," +
serializePosition(range.endContainer, range.endOffset, rootNode);
if (!omitChecksum) {
serialized += "{" + getElementChecksum(rootNode) + "}";
}
return serialized;
}
var deserializeRegex = /^([^,]+),([^,\{]+)(\{([^}]+)\})?$/;
function deserializeRange(serialized, rootNode, doc) {
if (rootNode) {
doc = doc || dom.getDocument(rootNode);
} else {
doc = doc || document;
rootNode = doc.documentElement;
}
var result = deserializeRegex.exec(serialized);
var checksum = result[4];
if (checksum) {
var rootNodeChecksum = getElementChecksum(rootNode);
if (checksum !== rootNodeChecksum) {
throw module.createError("deserializeRange(): checksums of serialized range root node (" + checksum +
") and target root node (" + rootNodeChecksum + ") do not match");
}
}
var start = deserializePosition(result[1], rootNode, doc), end = deserializePosition(result[2], rootNode, doc);
var range = api.createRange(doc);
range.setStartAndEnd(start.node, start.offset, end.node, end.offset);
return range;
}
function canDeserializeRange(serialized, rootNode, doc) {
if (!rootNode) {
rootNode = (doc || document).documentElement;
}
var result = deserializeRegex.exec(serialized);
var checksum = result[3];
return !checksum || checksum === getElementChecksum(rootNode);
}
function serializeSelection(selection, omitChecksum, rootNode) {
selection = api.getSelection(selection);
var ranges = selection.getAllRanges(), serializedRanges = [];
for (var i = 0, len = ranges.length; i < len; ++i) {
serializedRanges[i] = serializeRange(ranges[i], omitChecksum, rootNode);
}
return serializedRanges.join("|");
}
function deserializeSelection(serialized, rootNode, win) {
if (rootNode) {
win = win || dom.getWindow(rootNode);
} else {
win = win || window;
rootNode = win.document.documentElement;
}
var serializedRanges = serialized.split("|");
var sel = api.getSelection(win);
var ranges = [];
for (var i = 0, len = serializedRanges.length; i < len; ++i) {
ranges[i] = deserializeRange(serializedRanges[i], rootNode, win.document);
}
sel.setRanges(ranges);
return sel;
}
function canDeserializeSelection(serialized, rootNode, win) {
var doc;
if (rootNode) {
doc = win ? win.document : dom.getDocument(rootNode);
} else {
win = win || window;
rootNode = win.document.documentElement;
}
var serializedRanges = serialized.split("|");
for (var i = 0, len = serializedRanges.length; i < len; ++i) {
if (!canDeserializeRange(serializedRanges[i], rootNode, doc)) {
return false;
}
}
return true;
}
var cookieName = "rangySerializedSelection";
function getSerializedSelectionFromCookie(cookie) {
var parts = cookie.split(/[;,]/);
for (var i = 0, len = parts.length, nameVal, val; i < len; ++i) {
nameVal = parts[i].split("=");
if (nameVal[0].replace(/^\s+/, "") == cookieName) {
val = nameVal[1];
if (val) {
return decodeURIComponent(val.replace(/\s+$/, ""));
}
}
}
return null;
}
function restoreSelectionFromCookie(win) {
win = win || window;
var serialized = getSerializedSelectionFromCookie(win.document.cookie);
if (serialized) {
deserializeSelection(serialized, win.doc);
}
}
function saveSelectionCookie(win, props) {
win = win || window;
props = (typeof props == "object") ? props : {};
var expires = props.expires ? ";expires=" + props.expires.toUTCString() : "";
var path = props.path ? ";path=" + props.path : "";
var domain = props.domain ? ";domain=" + props.domain : "";
var secure = props.secure ? ";secure" : "";
var serialized = serializeSelection(api.getSelection(win));
win.document.cookie = encodeURIComponent(cookieName) + "=" + encodeURIComponent(serialized) + expires + path + domain + secure;
}
util.extend(api, {
serializePosition: serializePosition,
deserializePosition: deserializePosition,
serializeRange: serializeRange,
deserializeRange: deserializeRange,
canDeserializeRange: canDeserializeRange,
serializeSelection: serializeSelection,
deserializeSelection: deserializeSelection,
canDeserializeSelection: canDeserializeSelection,
restoreSelectionFromCookie: restoreSelectionFromCookie,
saveSelectionCookie: saveSelectionCookie,
getElementChecksum: getElementChecksum,
nodeToInfoString: nodeToInfoString
});
util.crc32 = crc32;
});
return rangy;
}, this);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
{
"moodle-editor_atto-rangy": {
"requires": [ ]
}
}