textangular
Version:
A radically powerful Text-Editor/Wysiwyg editor for Angular.js
1,300 lines (1,240 loc) • 189 kB
JavaScript
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module unless amdModuleId is set
define('textAngular', ["rangy","rangy/lib/rangy-selectionsaverestore"], function (a0,b1) {
return (root['textAngular.name'] = factory(a0,b1));
});
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(require("rangy"),require("rangy/lib/rangy-selectionsaverestore"));
} else {
root['textAngular'] = factory(rangy);
}
}(this, function (rangy) {
// tests against the current jqLite/jquery implementation if this can be an element
function validElementString(string){
try{
return angular.element(string).length !== 0;
}catch(any){
return false;
}
}
// setup the global contstant functions for setting up the toolbar
// all tool definitions
var taTools = {};
/*
A tool definition is an object with the following key/value parameters:
action: [function(deferred, restoreSelection)]
a function that is executed on clicking on the button - this will allways be executed using ng-click and will
overwrite any ng-click value in the display attribute.
The function is passed a deferred object ($q.defer()), if this is wanted to be used `return false;` from the action and
manually call `deferred.resolve();` elsewhere to notify the editor that the action has finished.
restoreSelection is only defined if the rangy library is included and it can be called as `restoreSelection()` to restore the users
selection in the WYSIWYG editor.
display: [string]?
Optional, an HTML element to be displayed as the button. The `scope` of the button is the tool definition object with some additional functions
If set this will cause buttontext and iconclass to be ignored
class: [string]?
Optional, if set will override the taOptions.classes.toolbarButton class.
buttontext: [string]?
if this is defined it will replace the contents of the element contained in the `display` element
iconclass: [string]?
if this is defined an icon (<i>) will be appended to the `display` element with this string as it's class
tooltiptext: [string]?
Optional, a plain text description of the action, used for the title attribute of the action button in the toolbar by default.
activestate: [function(commonElement)]?
this function is called on every caret movement, if it returns true then the class taOptions.classes.toolbarButtonActive
will be applied to the `display` element, else the class will be removed
disabled: [function()]?
if this function returns true then the tool will have the class taOptions.classes.disabled applied to it, else it will be removed
Other functions available on the scope are:
name: [string]
the name of the tool, this is the first parameter passed into taRegisterTool
isDisabled: [function()]
returns true if the tool is disabled, false if it isn't
displayActiveToolClass: [function(boolean)]
returns true if the tool is 'active' in the currently focussed toolbar
onElementSelect: [Object]
This object contains the following key/value pairs and is used to trigger the ta-element-select event
element: [String]
an element name, will only trigger the onElementSelect action if the tagName of the element matches this string
filter: [function(element)]?
an optional filter that returns a boolean, if true it will trigger the onElementSelect.
action: [function(event, element, editorScope)]
the action that should be executed if the onElementSelect function runs
*/
// name and toolDefinition to add into the tools available to be added on the toolbar
function registerTextAngularTool(name, toolDefinition){
if(!name || name === '' || taTools.hasOwnProperty(name)) throw('textAngular Error: A unique name is required for a Tool Definition');
if(
(toolDefinition.display && (toolDefinition.display === '' || !validElementString(toolDefinition.display))) ||
(!toolDefinition.display && !toolDefinition.buttontext && !toolDefinition.iconclass)
)
throw('textAngular Error: Tool Definition for "' + name + '" does not have a valid display/iconclass/buttontext value');
taTools[name] = toolDefinition;
}
angular.module('textAngularSetup', [])
.constant('taRegisterTool', registerTextAngularTool)
.value('taTools', taTools)
// Here we set up the global display defaults, to set your own use a angular $provider#decorator.
.value('taOptions', {
//////////////////////////////////////////////////////////////////////////////////////
// forceTextAngularSanitize
// set false to allow the textAngular-sanitize provider to be replaced
// with angular-sanitize or a custom provider.
forceTextAngularSanitize: true,
///////////////////////////////////////////////////////////////////////////////////////
// keyMappings
// allow customizable keyMappings for specialized key boards or languages
//
// keyMappings provides key mappings that are attached to a given commandKeyCode.
// To modify a specific keyboard binding, simply provide function which returns true
// for the event you wish to map to.
// Or to disable a specific keyboard binding, provide a function which returns false.
// Note: 'RedoKey' and 'UndoKey' are internally bound to the redo and undo functionality.
// At present, the following commandKeyCodes are in use:
// 98, 'TabKey', 'ShiftTabKey', 105, 117, 'UndoKey', 'RedoKey'
//
// To map to an new commandKeyCode, add a new key mapping such as:
// {commandKeyCode: 'CustomKey', testForKey: function (event) {
// if (event.keyCode=57 && event.ctrlKey && !event.shiftKey && !event.altKey) return true;
// } }
// to the keyMappings. This example maps ctrl+9 to 'CustomKey'
// Then where taRegisterTool(...) is called, add a commandKeyCode: 'CustomKey' and your
// tool will be bound to ctrl+9.
//
// To disble one of the already bound commandKeyCodes such as 'RedoKey' or 'UndoKey' add:
// {commandKeyCode: 'RedoKey', testForKey: function (event) { return false; } },
// {commandKeyCode: 'UndoKey', testForKey: function (event) { return false; } },
// to disable them.
//
keyMappings : [],
toolbar: [
['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'pre', 'quote'],
['bold', 'italics', 'underline', 'strikeThrough', 'ul', 'ol', 'redo', 'undo', 'clear'],
['justifyLeft','justifyCenter','justifyRight','justifyFull','indent','outdent'],
['html', 'insertImage', 'insertLink', 'insertVideo', 'wordcount', 'charcount']
],
classes: {
focussed: "focussed",
toolbar: "btn-toolbar",
toolbarGroup: "btn-group",
toolbarButton: "btn btn-default",
toolbarButtonActive: "active",
disabled: "disabled",
textEditor: 'form-control',
htmlEditor: 'form-control'
},
defaultTagAttributes : {
a: {target:""}
},
setup: {
// wysiwyg mode
textEditorSetup: function($element){ /* Do some processing here */ },
// raw html
htmlEditorSetup: function($element){ /* Do some processing here */ }
},
defaultFileDropHandler:
/* istanbul ignore next: untestable image processing */
function(file, insertAction){
var reader = new FileReader();
if(file.type.substring(0, 5) === 'image'){
reader.onload = function() {
if(reader.result !== '') insertAction('insertImage', reader.result, true);
};
reader.readAsDataURL(file);
// NOTE: For async procedures return a promise and resolve it when the editor should update the model.
return true;
}
return false;
}
})
// This is the element selector string that is used to catch click events within a taBind, prevents the default and $emits a 'ta-element-select' event
// these are individually used in an angular.element().find() call. What can go here depends on whether you have full jQuery loaded or just jQLite with angularjs.
// div is only used as div.ta-insert-video caught in filter.
.value('taSelectableElements', ['a','img'])
// This is an array of objects with the following options:
// selector: <string> a jqLite or jQuery selector string
// customAttribute: <string> an attribute to search for
// renderLogic: <function(element)>
// Both or one of selector and customAttribute must be defined.
.value('taCustomRenderers', [
{
// Parse back out: '<div class="ta-insert-video" ta-insert-video src="' + urlLink + '" allowfullscreen="true" width="300" frameborder="0" height="250"></div>'
// To correct video element. For now only support youtube
selector: 'img',
customAttribute: 'ta-insert-video',
renderLogic: function(element){
var iframe = angular.element('<iframe></iframe>');
var attributes = element.prop("attributes");
// loop through element attributes and apply them on iframe
angular.forEach(attributes, function(attr) {
iframe.attr(attr.name, attr.value);
});
iframe.attr('src', iframe.attr('ta-insert-video'));
element.replaceWith(iframe);
}
}
])
.value('taTranslations', {
// moved to sub-elements
//toggleHTML: "Toggle HTML",
//insertImage: "Please enter a image URL to insert",
//insertLink: "Please enter a URL to insert",
//insertVideo: "Please enter a youtube URL to embed",
html: {
tooltip: 'Toggle html / Rich Text'
},
// tooltip for heading - might be worth splitting
heading: {
tooltip: 'Heading '
},
p: {
tooltip: 'Paragraph'
},
pre: {
tooltip: 'Preformatted text'
},
ul: {
tooltip: 'Unordered List'
},
ol: {
tooltip: 'Ordered List'
},
quote: {
tooltip: 'Quote/unquote selection or paragraph'
},
undo: {
tooltip: 'Undo'
},
redo: {
tooltip: 'Redo'
},
bold: {
tooltip: 'Bold'
},
italic: {
tooltip: 'Italic'
},
underline: {
tooltip: 'Underline'
},
strikeThrough:{
tooltip: 'Strikethrough'
},
justifyLeft: {
tooltip: 'Align text left'
},
justifyRight: {
tooltip: 'Align text right'
},
justifyFull: {
tooltip: 'Justify text'
},
justifyCenter: {
tooltip: 'Center'
},
indent: {
tooltip: 'Increase indent'
},
outdent: {
tooltip: 'Decrease indent'
},
clear: {
tooltip: 'Clear formatting'
},
insertImage: {
dialogPrompt: 'Please enter an image URL to insert',
tooltip: 'Insert image',
hotkey: 'the - possibly language dependent hotkey ... for some future implementation'
},
insertVideo: {
tooltip: 'Insert video',
dialogPrompt: 'Please enter a youtube URL to embed'
},
insertLink: {
tooltip: 'Insert / edit link',
dialogPrompt: "Please enter a URL to insert"
},
editLink: {
reLinkButton: {
tooltip: "Relink"
},
unLinkButton: {
tooltip: "Unlink"
},
targetToggle: {
buttontext: "Open in New Window"
}
},
wordcount: {
tooltip: 'Display words Count'
},
charcount: {
tooltip: 'Display characters Count'
}
})
.factory('taToolFunctions', ['$window','taTranslations', function($window, taTranslations) {
return {
imgOnSelectAction: function(event, $element, editorScope){
// setup the editor toolbar
// Credit to the work at http://hackerwins.github.io/summernote/ for this editbar logic/display
var finishEdit = function(){
editorScope.updateTaBindtaTextElement();
editorScope.hidePopover();
};
event.preventDefault();
editorScope.displayElements.popover.css('width', '375px');
var container = editorScope.displayElements.popoverContainer;
container.empty();
var buttonGroup = angular.element('<div class="btn-group" style="padding-right: 6px;">');
var fullButton = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1">100% </button>');
fullButton.on('click', function(event){
event.preventDefault();
$element.css({
'width': '100%',
'height': ''
});
finishEdit();
});
var halfButton = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1">50% </button>');
halfButton.on('click', function(event){
event.preventDefault();
$element.css({
'width': '50%',
'height': ''
});
finishEdit();
});
var quartButton = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1">25% </button>');
quartButton.on('click', function(event){
event.preventDefault();
$element.css({
'width': '25%',
'height': ''
});
finishEdit();
});
var resetButton = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1">Reset</button>');
resetButton.on('click', function(event){
event.preventDefault();
$element.css({
width: '',
height: ''
});
finishEdit();
});
buttonGroup.append(fullButton);
buttonGroup.append(halfButton);
buttonGroup.append(quartButton);
buttonGroup.append(resetButton);
container.append(buttonGroup);
buttonGroup = angular.element('<div class="btn-group" style="padding-right: 6px;">');
var floatLeft = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1"><i class="fa fa-align-left"></i></button>');
floatLeft.on('click', function(event){
event.preventDefault();
// webkit
$element.css('float', 'left');
// firefox
$element.css('cssFloat', 'left');
// IE < 8
$element.css('styleFloat', 'left');
finishEdit();
});
var floatRight = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1"><i class="fa fa-align-right"></i></button>');
floatRight.on('click', function(event){
event.preventDefault();
// webkit
$element.css('float', 'right');
// firefox
$element.css('cssFloat', 'right');
// IE < 8
$element.css('styleFloat', 'right');
finishEdit();
});
var floatNone = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1"><i class="fa fa-align-justify"></i></button>');
floatNone.on('click', function(event){
event.preventDefault();
// webkit
$element.css('float', '');
// firefox
$element.css('cssFloat', '');
// IE < 8
$element.css('styleFloat', '');
finishEdit();
});
buttonGroup.append(floatLeft);
buttonGroup.append(floatNone);
buttonGroup.append(floatRight);
container.append(buttonGroup);
buttonGroup = angular.element('<div class="btn-group">');
var remove = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" unselectable="on" tabindex="-1"><i class="fa fa-trash-o"></i></button>');
remove.on('click', function(event){
event.preventDefault();
$element.remove();
finishEdit();
});
buttonGroup.append(remove);
container.append(buttonGroup);
editorScope.showPopover($element);
editorScope.showResizeOverlay($element);
},
aOnSelectAction: function(event, $element, editorScope){
// setup the editor toolbar
// Credit to the work at http://hackerwins.github.io/summernote/ for this editbar logic
event.preventDefault();
editorScope.displayElements.popover.css('width', '436px');
var container = editorScope.displayElements.popoverContainer;
container.empty();
container.css('line-height', '28px');
var link = angular.element('<a href="' + $element.attr('href') + '" target="_blank">' + $element.attr('href') + '</a>');
link.css({
'display': 'inline-block',
'max-width': '200px',
'overflow': 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap',
'vertical-align': 'middle'
});
container.append(link);
var buttonGroup = angular.element('<div class="btn-group pull-right">');
var reLinkButton = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" tabindex="-1" unselectable="on" title="' + taTranslations.editLink.reLinkButton.tooltip + '"><i class="fa fa-edit icon-edit"></i></button>');
reLinkButton.on('click', function(event){
event.preventDefault();
var urlLink = $window.prompt(taTranslations.insertLink.dialogPrompt, $element.attr('href'));
if(urlLink && urlLink !== '' && urlLink !== 'http://'){
$element.attr('href', urlLink);
editorScope.updateTaBindtaTextElement();
}
editorScope.hidePopover();
});
buttonGroup.append(reLinkButton);
var unLinkButton = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" tabindex="-1" unselectable="on" title="' + taTranslations.editLink.unLinkButton.tooltip + '"><i class="fa fa-unlink icon-unlink"></i></button>');
// directly before this click event is fired a digest is fired off whereby the reference to $element is orphaned off
unLinkButton.on('click', function(event){
event.preventDefault();
$element.replaceWith($element.contents());
editorScope.updateTaBindtaTextElement();
editorScope.hidePopover();
});
buttonGroup.append(unLinkButton);
var targetToggle = angular.element('<button type="button" class="btn btn-default btn-sm btn-small" tabindex="-1" unselectable="on">' + taTranslations.editLink.targetToggle.buttontext + '</button>');
if($element.attr('target') === '_blank'){
targetToggle.addClass('active');
}
targetToggle.on('click', function(event){
event.preventDefault();
$element.attr('target', ($element.attr('target') === '_blank') ? '' : '_blank');
targetToggle.toggleClass('active');
editorScope.updateTaBindtaTextElement();
});
buttonGroup.append(targetToggle);
container.append(buttonGroup);
editorScope.showPopover($element);
},
extractYoutubeVideoId: function(url) {
var re = /(?:youtube(?:-nocookie)?\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/i;
var match = url.match(re);
return (match && match[1]) || null;
}
};
}])
.run(['taRegisterTool', '$window', 'taTranslations', 'taSelection', 'taToolFunctions', '$sanitize', 'taOptions', '$log',
function(taRegisterTool, $window, taTranslations, taSelection, taToolFunctions, $sanitize, taOptions, $log){
// test for the version of $sanitize that is in use
// You can disable this check by setting taOptions.textAngularSanitize == false
var gv = {}; $sanitize('', gv);
/* istanbul ignore next, throws error */
if ((taOptions.forceTextAngularSanitize===true) && (gv.version !== 'taSanitize')) {
throw angular.$$minErr('textAngular')("textAngularSetup", "The textAngular-sanitize provider has been replaced by another -- have you included angular-sanitize by mistake?");
}
taRegisterTool("html", {
iconclass: 'fa fa-code',
tooltiptext: taTranslations.html.tooltip,
action: function(){
this.$editor().switchView();
},
activeState: function(){
return this.$editor().showHtml;
}
});
// add the Header tools
// convenience functions so that the loop works correctly
var _retActiveStateFunction = function(q){
return function(){ return this.$editor().queryFormatBlockState(q); };
};
var headerAction = function(){
return this.$editor().wrapSelection("formatBlock", "<" + this.name.toUpperCase() +">");
};
angular.forEach(['h1','h2','h3','h4','h5','h6'], function(h){
taRegisterTool(h.toLowerCase(), {
buttontext: h.toUpperCase(),
tooltiptext: taTranslations.heading.tooltip + h.charAt(1),
action: headerAction,
activeState: _retActiveStateFunction(h.toLowerCase())
});
});
taRegisterTool('p', {
buttontext: 'P',
tooltiptext: taTranslations.p.tooltip,
action: function(){
return this.$editor().wrapSelection("formatBlock", "<P>");
},
activeState: function(){ return this.$editor().queryFormatBlockState('p'); }
});
// key: pre -> taTranslations[key].tooltip, taTranslations[key].buttontext
taRegisterTool('pre', {
buttontext: 'pre',
tooltiptext: taTranslations.pre.tooltip,
action: function(){
return this.$editor().wrapSelection("formatBlock", "<PRE>");
},
activeState: function(){ return this.$editor().queryFormatBlockState('pre'); }
});
taRegisterTool('ul', {
iconclass: 'fa fa-list-ul',
tooltiptext: taTranslations.ul.tooltip,
action: function(){
return this.$editor().wrapSelection("insertUnorderedList", null);
},
activeState: function(){ return this.$editor().queryCommandState('insertUnorderedList'); }
});
taRegisterTool('ol', {
iconclass: 'fa fa-list-ol',
tooltiptext: taTranslations.ol.tooltip,
action: function(){
return this.$editor().wrapSelection("insertOrderedList", null);
},
activeState: function(){ return this.$editor().queryCommandState('insertOrderedList'); }
});
taRegisterTool('quote', {
iconclass: 'fa fa-quote-right',
tooltiptext: taTranslations.quote.tooltip,
action: function(){
return this.$editor().wrapSelection("formatBlock", "<BLOCKQUOTE>");
},
activeState: function(){ return this.$editor().queryFormatBlockState('blockquote'); }
});
taRegisterTool('undo', {
iconclass: 'fa fa-undo',
tooltiptext: taTranslations.undo.tooltip,
action: function(){
return this.$editor().wrapSelection("undo", null);
}
});
taRegisterTool('redo', {
iconclass: 'fa fa-repeat',
tooltiptext: taTranslations.redo.tooltip,
action: function(){
return this.$editor().wrapSelection("redo", null);
}
});
taRegisterTool('bold', {
iconclass: 'fa fa-bold',
tooltiptext: taTranslations.bold.tooltip,
action: function(){
return this.$editor().wrapSelection("bold", null);
},
activeState: function(){
return this.$editor().queryCommandState('bold');
},
commandKeyCode: 98
});
taRegisterTool('justifyLeft', {
iconclass: 'fa fa-align-left',
tooltiptext: taTranslations.justifyLeft.tooltip,
action: function(){
return this.$editor().wrapSelection("justifyLeft", null);
},
activeState: function(commonElement){
/* istanbul ignore next: */
if (commonElement && commonElement.nodeName === '#document') return false;
var result = false;
if (commonElement)
result =
commonElement.css('text-align') === 'left' ||
commonElement.attr('align') === 'left' ||
(
commonElement.css('text-align') !== 'right' &&
commonElement.css('text-align') !== 'center' &&
commonElement.css('text-align') !== 'justify' && !this.$editor().queryCommandState('justifyRight') && !this.$editor().queryCommandState('justifyCenter')
) && !this.$editor().queryCommandState('justifyFull');
result = result || this.$editor().queryCommandState('justifyLeft');
return result;
}
});
taRegisterTool('justifyRight', {
iconclass: 'fa fa-align-right',
tooltiptext: taTranslations.justifyRight.tooltip,
action: function(){
return this.$editor().wrapSelection("justifyRight", null);
},
activeState: function(commonElement){
/* istanbul ignore next: */
if (commonElement && commonElement.nodeName === '#document') return false;
var result = false;
if(commonElement) result = commonElement.css('text-align') === 'right';
result = result || this.$editor().queryCommandState('justifyRight');
return result;
}
});
taRegisterTool('justifyFull', {
iconclass: 'fa fa-align-justify',
tooltiptext: taTranslations.justifyFull.tooltip,
action: function(){
return this.$editor().wrapSelection("justifyFull", null);
},
activeState: function(commonElement){
var result = false;
if(commonElement) result = commonElement.css('text-align') === 'justify';
result = result || this.$editor().queryCommandState('justifyFull');
return result;
}
});
taRegisterTool('justifyCenter', {
iconclass: 'fa fa-align-center',
tooltiptext: taTranslations.justifyCenter.tooltip,
action: function(){
return this.$editor().wrapSelection("justifyCenter", null);
},
activeState: function(commonElement){
/* istanbul ignore next: */
if (commonElement && commonElement.nodeName === '#document') return false;
var result = false;
if(commonElement) result = commonElement.css('text-align') === 'center';
result = result || this.$editor().queryCommandState('justifyCenter');
return result;
}
});
taRegisterTool('indent', {
iconclass: 'fa fa-indent',
tooltiptext: taTranslations.indent.tooltip,
action: function(){
return this.$editor().wrapSelection("indent", null);
},
activeState: function(){
return this.$editor().queryFormatBlockState('blockquote');
},
commandKeyCode: 'TabKey'
});
taRegisterTool('outdent', {
iconclass: 'fa fa-outdent',
tooltiptext: taTranslations.outdent.tooltip,
action: function(){
return this.$editor().wrapSelection("outdent", null);
},
activeState: function(){
return false;
},
commandKeyCode: 'ShiftTabKey'
});
taRegisterTool('italics', {
iconclass: 'fa fa-italic',
tooltiptext: taTranslations.italic.tooltip,
action: function(){
return this.$editor().wrapSelection("italic", null);
},
activeState: function(){
return this.$editor().queryCommandState('italic');
},
commandKeyCode: 105
});
taRegisterTool('underline', {
iconclass: 'fa fa-underline',
tooltiptext: taTranslations.underline.tooltip,
action: function(){
return this.$editor().wrapSelection("underline", null);
},
activeState: function(){
return this.$editor().queryCommandState('underline');
},
commandKeyCode: 117
});
taRegisterTool('strikeThrough', {
iconclass: 'fa fa-strikethrough',
tooltiptext: taTranslations.strikeThrough.tooltip,
action: function(){
return this.$editor().wrapSelection("strikeThrough", null);
},
activeState: function(){
return document.queryCommandState('strikeThrough');
}
});
taRegisterTool('clear', {
iconclass: 'fa fa-ban',
tooltiptext: taTranslations.clear.tooltip,
action: function(deferred, restoreSelection){
var i, selectedElements, elementsSeen;
this.$editor().wrapSelection("removeFormat", null);
var possibleNodes = angular.element(taSelection.getSelectionElement());
selectedElements = taSelection.getAllSelectedElements();
//$log.log('selectedElements:', selectedElements);
// remove lists
var removeListElements = function(list){
list = angular.element(list);
var prevElement = list;
angular.forEach(list.children(), function(liElem){
var newElem = angular.element('<p></p>');
newElem.html(angular.element(liElem).html());
prevElement.after(newElem);
prevElement = newElem;
});
list.remove();
};
angular.forEach(selectedElements, function(element) {
if (element.nodeName.toLowerCase() === 'ul' ||
element.nodeName.toLowerCase() === 'ol') {
//console.log('removeListElements', element);
removeListElements(element);
}
});
angular.forEach(possibleNodes.find("ul"), removeListElements);
angular.forEach(possibleNodes.find("ol"), removeListElements);
// clear out all class attributes. These do not seem to be cleared via removeFormat
var $editor = this.$editor();
var recursiveRemoveClass = function(node){
node = angular.element(node);
/* istanbul ignore next: this is not triggered in tests any longer since we now never select the whole displayELement */
if(node[0] !== $editor.displayElements.text[0]) {
node.removeAttr('class');
}
angular.forEach(node.children(), recursiveRemoveClass);
};
angular.forEach(possibleNodes, recursiveRemoveClass);
// check if in list. If not in list then use formatBlock option
if(possibleNodes[0] && possibleNodes[0].tagName.toLowerCase() !== 'li' &&
possibleNodes[0].tagName.toLowerCase() !== 'ol' &&
possibleNodes[0].tagName.toLowerCase() !== 'ul') {
this.$editor().wrapSelection("formatBlock", "default");
}
restoreSelection();
}
});
/* jshint -W099 */
/****************************
// we don't use this code - since the previous way CLEAR is expected to work does not clear partially selected <li>
var removeListElement = function(listE){
console.log(listE);
var _list = listE.parentNode.childNodes;
console.log('_list', _list);
var _preLis = [], _postLis = [], _found = false;
for (i = 0; i < _list.length; i++) {
if (_list[i] === listE) {
_found = true;
} else if (!_found) _preLis.push(_list[i]);
else _postLis.push(_list[i]);
}
var _parent = angular.element(listE.parentNode);
var newElem = angular.element('<p></p>');
newElem.html(angular.element(listE).html());
if (_preLis.length === 0 || _postLis.length === 0) {
if (_postLis.length === 0) _parent.after(newElem);
else _parent[0].parentNode.insertBefore(newElem[0], _parent[0]);
if (_preLis.length === 0 && _postLis.length === 0) _parent.remove();
else angular.element(listE).remove();
} else {
var _firstList = angular.element('<' + _parent[0].tagName + '></' + _parent[0].tagName + '>');
var _secondList = angular.element('<' + _parent[0].tagName + '></' + _parent[0].tagName + '>');
for (i = 0; i < _preLis.length; i++) _firstList.append(angular.element(_preLis[i]));
for (i = 0; i < _postLis.length; i++) _secondList.append(angular.element(_postLis[i]));
_parent.after(_secondList);
_parent.after(newElem);
_parent.after(_firstList);
_parent.remove();
}
taSelection.setSelectionToElementEnd(newElem[0]);
};
elementsSeen = [];
if (selectedElements.length !==0) console.log(selectedElements);
angular.forEach(selectedElements, function (element) {
if (elementsSeen.indexOf(element) !== -1 || elementsSeen.indexOf(element.parentElement) !== -1) {
return;
}
elementsSeen.push(element);
if (element.nodeName.toLowerCase() === 'li') {
console.log('removeListElement', element);
removeListElement(element);
}
else if (element.parentElement && element.parentElement.nodeName.toLowerCase() === 'li') {
console.log('removeListElement', element.parentElement);
elementsSeen.push(element.parentElement);
removeListElement(element.parentElement);
}
});
**********************/
/**********************
if(possibleNodes[0].tagName.toLowerCase() === 'li'){
var _list = possibleNodes[0].parentNode.childNodes;
var _preLis = [], _postLis = [], _found = false;
for(i = 0; i < _list.length; i++){
if(_list[i] === possibleNodes[0]){
_found = true;
}else if(!_found) _preLis.push(_list[i]);
else _postLis.push(_list[i]);
}
var _parent = angular.element(possibleNodes[0].parentNode);
var newElem = angular.element('<p></p>');
newElem.html(angular.element(possibleNodes[0]).html());
if(_preLis.length === 0 || _postLis.length === 0){
if(_postLis.length === 0) _parent.after(newElem);
else _parent[0].parentNode.insertBefore(newElem[0], _parent[0]);
if(_preLis.length === 0 && _postLis.length === 0) _parent.remove();
else angular.element(possibleNodes[0]).remove();
}else{
var _firstList = angular.element('<'+_parent[0].tagName+'></'+_parent[0].tagName+'>');
var _secondList = angular.element('<'+_parent[0].tagName+'></'+_parent[0].tagName+'>');
for(i = 0; i < _preLis.length; i++) _firstList.append(angular.element(_preLis[i]));
for(i = 0; i < _postLis.length; i++) _secondList.append(angular.element(_postLis[i]));
_parent.after(_secondList);
_parent.after(newElem);
_parent.after(_firstList);
_parent.remove();
}
taSelection.setSelectionToElementEnd(newElem[0]);
}
*******************/
/* istanbul ignore next: if it's javascript don't worry - though probably should show some kind of error message */
var blockJavascript = function (link) {
if (link.toLowerCase().indexOf('javascript')!==-1) {
return true;
}
return false;
};
taRegisterTool('insertImage', {
iconclass: 'fa fa-picture-o',
tooltiptext: taTranslations.insertImage.tooltip,
action: function(){
var imageLink;
imageLink = $window.prompt(taTranslations.insertImage.dialogPrompt, 'http://');
if(imageLink && imageLink !== '' && imageLink !== 'http://'){
/* istanbul ignore next: don't know how to test this... since it needs a dialogPrompt */
// block javascript here
if (!blockJavascript(imageLink)) {
if (taSelection.getSelectionElement().tagName.toLowerCase() === 'a') {
// due to differences in implementation between FireFox and Chrome, we must move the
// insertion point past the <a> element, otherwise FireFox inserts inside the <a>
// With this change, both FireFox and Chrome behave the same way!
taSelection.setSelectionAfterElement(taSelection.getSelectionElement());
}
// In the past we used the simple statement:
//return this.$editor().wrapSelection('insertImage', imageLink, true);
//
// However on Firefox only, when the content is empty this is a problem
// See Issue #1201
// Investigation reveals that Firefox only inserts a <p> only!!!!
// So now we use insertHTML here and all is fine.
// NOTE: this is what 'insertImage' is supposed to do anyway!
var embed = '<img src="' + imageLink + '">';
return this.$editor().wrapSelection('insertHTML', embed, true);
}
}
},
onElementSelect: {
element: 'img',
action: taToolFunctions.imgOnSelectAction
}
});
taRegisterTool('insertVideo', {
iconclass: 'fa fa-youtube-play',
tooltiptext: taTranslations.insertVideo.tooltip,
action: function(){
var urlPrompt;
urlPrompt = $window.prompt(taTranslations.insertVideo.dialogPrompt, 'https://');
// block javascript here
/* istanbul ignore else: if it's javascript don't worry - though probably should show some kind of error message */
if (!blockJavascript(urlPrompt)) {
if (urlPrompt && urlPrompt !== '' && urlPrompt !== 'https://') {
videoId = taToolFunctions.extractYoutubeVideoId(urlPrompt);
/* istanbul ignore else: if it's invalid don't worry - though probably should show some kind of error message */
if (videoId) {
// create the embed link
var urlLink = "https://www.youtube.com/embed/" + videoId;
// create the HTML
// for all options see: http://stackoverflow.com/questions/2068344/how-do-i-get-a-youtube-video-thumbnail-from-the-youtube-api
// maxresdefault.jpg seems to be undefined on some.
var embed = '<img class="ta-insert-video" src="https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg" ta-insert-video="' + urlLink + '" contenteditable="false" allowfullscreen="true" frameborder="0" />';
/* istanbul ignore next: don't know how to test this... since it needs a dialogPrompt */
if (taSelection.getSelectionElement().tagName.toLowerCase() === 'a') {
// due to differences in implementation between FireFox and Chrome, we must move the
// insertion point past the <a> element, otherwise FireFox inserts inside the <a>
// With this change, both FireFox and Chrome behave the same way!
taSelection.setSelectionAfterElement(taSelection.getSelectionElement());
}
// insert
return this.$editor().wrapSelection('insertHTML', embed, true);
}
}
}
},
onElementSelect: {
element: 'img',
onlyWithAttrs: ['ta-insert-video'],
action: taToolFunctions.imgOnSelectAction
}
});
taRegisterTool('insertLink', {
tooltiptext: taTranslations.insertLink.tooltip,
iconclass: 'fa fa-link',
action: function(){
var urlLink;
// if this link has already been set, we need to just edit the existing link
/* istanbul ignore if: we do not test this */
if (taSelection.getSelectionElement().tagName.toLowerCase() === 'a') {
urlLink = $window.prompt(taTranslations.insertLink.dialogPrompt, taSelection.getSelectionElement().href);
} else {
urlLink = $window.prompt(taTranslations.insertLink.dialogPrompt, 'http://');
}
if(urlLink && urlLink !== '' && urlLink !== 'http://'){
// block javascript here
/* istanbul ignore else: if it's javascript don't worry - though probably should show some kind of error message */
if (!blockJavascript(urlLink)) {
return this.$editor().wrapSelection('createLink', urlLink, true);
}
}
},
activeState: function(commonElement){
if(commonElement) return commonElement[0].tagName === 'A';
return false;
},
onElementSelect: {
element: 'a',
action: taToolFunctions.aOnSelectAction
}
});
taRegisterTool('wordcount', {
display: '<div id="toolbarWC" style="display:block; min-width:100px;">Words: <span ng-bind="wordcount"></span></div>',
disabled: true,
wordcount: 0,
activeState: function(){ // this fires on keyup
var textElement = this.$editor().displayElements.text;
/* istanbul ignore next: will default to '' when undefined */
var workingHTML = textElement[0].innerHTML || '';
var noOfWords = 0;
/* istanbul ignore if: will default to '' when undefined */
if (workingHTML.replace(/\s*<[^>]*?>\s*/g, '') !== '') {
noOfWords = workingHTML.replace(/<\/?(b|i|em|strong|span|u|strikethrough|a|img|small|sub|sup|label)( [^>*?])?>/gi, '') // remove inline tags without adding spaces
.replace(/(<[^>]*?>\s*<[^>]*?>)/ig, ' ') // replace adjacent tags with possible space between with a space
.replace(/(<[^>]*?>)/ig, '') // remove any singular tags
.replace(/\s+/ig, ' ') // condense spacing
.match(/\S+/g).length; // count remaining non-space strings
}
//Set current scope
this.wordcount = noOfWords;
//Set editor scope
this.$editor().wordcount = noOfWords;
return false;
}
});
taRegisterTool('charcount', {
display: '<div id="toolbarCC" style="display:block; min-width:120px;">Characters: <span ng-bind="charcount"></span></div>',
disabled: true,
charcount: 0,
activeState: function(){ // this fires on keyup
var textElement = this.$editor().displayElements.text;
var sourceText = textElement[0].innerText || textElement[0].textContent; // to cover the non-jquery use case.
// Caculate number of chars
var noOfChars = sourceText.replace(/(\r\n|\n|\r)/gm,"").replace(/^\s+/g,' ').replace(/\s+$/g, ' ').length;
//Set current scope
this.charcount = noOfChars;
//Set editor scope
this.$editor().charcount = noOfChars;
return false;
}
});
}]);
/*
@license textAngular
Author : Austin Anderson
License : 2013 MIT
Version 1.5.8
See README.md or https://github.com/fraywing/textAngular/wiki for requirements and use.
*/
/*
Commonjs package manager support (eg componentjs).
*/
"use strict";// NOTE: textAngularVersion must match the Gruntfile.js 'setVersion' task.... and have format v/d+./d+./d+
var textAngularVersion = 'v1.5.8'; // This is automatically updated during the build process to the current release!
// IE version detection - http://stackoverflow.com/questions/4169160/javascript-ie-detection-why-not-use-simple-conditional-comments
// We need this as IE sometimes plays funny tricks with the contenteditable.
// ----------------------------------------------------------
// If you're not in IE (or IE version is less than 5) then:
// ie === undefined
// If you're in IE (>=5) then you can determine which version:
// ie === 7; // IE7
// Thus, to detect IE:
// if (ie) {}
// And to detect the version:
// ie === 6 // IE6
// ie > 7 // IE8, IE9, IE10 ...
// ie < 9 // Anything less than IE9
// ----------------------------------------------------------
/* istanbul ignore next: untestable browser check */
var _browserDetect = {
ie: (function(){
var undef,
v = 3,
div = document.createElement('div'),
all = div.getElementsByTagName('i');
while (
div.innerHTML = '<!--[if gt IE ' + (++v) + ']><i></i><![endif]-->',
all[0]
);
return v > 4 ? v : undef;
}()),
webkit: /AppleWebKit\/([\d.]+)/i.test(navigator.userAgent),
isFirefox: navigator.userAgent.toLowerCase().indexOf('firefox') > -1
};
// Global to textAngular to measure performance where needed
var performance = performance || {};
performance.now = (function() {
return performance.now ||
performance.mozNow ||
performance.msNow ||
performance.oNow ||
performance.webkitNow ||
function() { return new Date().getTime(); };
})();
// usage is:
// var t0 = performance.now();
// doSomething();
// var t1 = performance.now();
// console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to do something!');
//
// turn html into pure text that shows visiblity
function stripHtmlToText(html)
{
var tmp = document.createElement("DIV");
tmp.innerHTML = html;
var res = tmp.textContent || tmp.innerText || '';
res.replace('\u200B', ''); // zero width space
res = res.trim();
return res;
}
// get html
function getDomFromHtml(html)
{
var tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp;
}
// Global to textAngular REGEXP vars for block and list elements.
var BLOCKELEMENTS = /^(address|article|aside|audio|blockquote|canvas|center|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)$/i;
var LISTELEMENTS = /^(ul|li|ol)$/i;
var VALIDELEMENTS = /^(address|article|aside|audio|blockquote|canvas|center|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|li)$/i;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim#Compatibility
/* istanbul ignore next: trim shim for older browsers */
if (!String.prototype.trim) {
String.prototype.trim = function () {
return this.replace(/^\s+|\s+$/g, '');
};
}
/*
Custom stylesheet for the placeholders rules.
Credit to: http://davidwalsh.name/add-rules-stylesheets
*/
var sheet, addCSSRule, removeCSSRule, _addCSSRule, _removeCSSRule, _getRuleIndex;
/* istanbul ignore else: IE <8 test*/
if(_browserDetect.ie > 8 || _browserDetect.ie === undefined){
var _sheets = document.styleSheets;
/* istanbul ignore next: preference for stylesheet loaded externally */
for(var i = 0; i < _sheets.length; i++){
if(_sheets[i].media.length === 0 || _sheets[i].media.mediaText.match(/(all|screen)/ig)){
if(_sheets[i].href){
if(_sheets[i].href.match(/textangular\.(min\.|)css/ig)){
sheet = _sheets[i];
break;
}
}
}
}
/* istanbul ignore next: preference for stylesheet loaded externally */
if(!sheet){
// this sheet is used for the placeholders later on.
sheet = (function() {
// Create the <style> tag
var style = document.createElement("style");
/* istanbul ignore else : WebKit hack :( */
if(_browserDetect.webkit) style.appendChild(document.createTextNode(""));
// Add the <style> element to the page, add as first so the styles can be overridden by custom stylesheets
document.getElementsByTagName('head')[0].appendChild(style);
return style.sheet;
})();
}
// use as: addCSSRule("header", "float: left");
addCSSRule = function(selector, rules) {
return _addCSSRule(sheet, selector, rules);
};
_addCSSRule = function(_sheet, selector, rules){
var insertIndex;
var insertedRule;
// This order is important as IE 11 has both cssRules and rules but they have different lengths - cssRules is correct, rules gives an error in IE 11
/* istanbul ignore next: browser catches */
if(_sheet.cssRules) insertIndex = Math.max(_sheet.cssRules.length - 1, 0);
else if(_sheet.rules) insertIndex = Math.max(_sheet.rules.length - 1, 0);
/* istanbul ignore else: untestable IE option */
if(_sheet.insertRule) {
_sheet.insertRule(selector + "{" + rules + "}", insertIndex);
}
else {
_sheet.addRule(selector, rules, insertIndex);
}
/* istanbul ignore next: browser catches */
if(sheet.rules) insertedRule = sheet.rules[insertIndex];
else if(sheet.cssRules) insertedRule = sheet.cssRules[insertIndex];
// return the inserted stylesheet rule
return insertedRule;
};
_getRuleIndex = function(rule, rules) {
var i, ruleIndex;
for (i=0; i < rules.length; i++) {
/* istanbul ignore else: check for correct rule */
if (rules[i].cssText === rule.cssText) {
ruleIndex = i;
break;
}
}
return ruleIndex;
};
removeCSSRule = function(rule){
_removeCSSRule(sheet, rule);
};
/* istanbul ignore next: tests are browser specific */
_removeCSSRule = function(sheet, rule){
var rules = sheet.cssRules || sheet.rules;
if(!rules || rules.length === 0) return;
var ruleIndex = _getRuleIndex(rule, rules);
if(sheet.removeRule){
sheet.removeRule(ruleIndex);
}else{
sheet.deleteRule(ruleIndex);
}
};
}
angular.module('textAngular.factories', [])
.factory('taBrowserTag', [function(){
return function(tag){
/* istanbul ignore next: ie specific test */
if(!tag) return (_browserDetect.ie <= 8)? 'P' : 'p';
else if(tag === '') return (_browserDetect.ie === undefined)? 'div' : (_browserDetect.ie <= 8)? 'P' : 'p';
else return (_browserDetect.ie <= 8)? tag.toUpperCase() : tag;
};
}]).factory('taApplyCustomRenderers', ['taCustomRenderers', 'taDOM', function(taCustomRenderers, taDOM){
return function(val){
var element = angular.element('<div></div>');
element[0].innerHTML = val;
angular.forEach(taCustomRenderers, function(renderer){
var elements = [];
// get elements based on what is defined. If both defined do secondary filter in the forEach after using selector string
if(renderer.selector && renderer.selector !== '')
elements = element.find(renderer.selector);
/* istanbul ignore else: shouldn't fire, if it does we're ignoring everything */
else if(renderer.customAttribute && renderer.customAttribute !== '')
elements = taDOM.getByAttribute(element, renderer.customAttribute);
// process elements if any found
angular.forEach(elements, function(_element){
_element = angular.element(_element);
if(renderer.selector && renderer.selector !== '' && renderer.customAttribute && renderer.customAttribute !== ''){
if(_element.attr(renderer.customAttribute) !== undefined) renderer.renderLogic(_element);
} else renderer.renderLogic(_element);
});
});
return element[0].innerHTML;
};
}]).factory('taFixChrome', function(){
// get whaterever rubbish is inserted in chrome
// should be passed an html string, returns an html string
var taFixChrome = function(html){
if(!html || !angular.isString(html) || html.length <= 0) return html;
// grab all elements with a style attibute
var spanMatch = /<([^>\/]+?)style=("([^\"]+)"|'([^']+)')([^>]*)>/ig;
var appleConvertedSpaceMatch = /<span class="Apple-converted-space">([^<]+)<\/span>/ig;
var match, styleVal, appleSpaceVal, newTag, finalHtml = '', lastIndex = 0;
// remove all the Apple-converted-space spans and replace with the content of the span
/* istanbul ignore next: apple-contereted-space span match */
while(match = appleConvertedSpaceMatch.exec(html)){
appleSpaceVal = match[1];
appleSpaceVal = appleSpaceVal.replace(/ /ig, ' ');
finalHtml += html.substring(lastIndex, match.index) + appleSpaceVal;
lastIndex = match.index + match[0].length;
}
/* istanbul ignore next: apple-contereted-space span has matched */
if (lastIndex) {
// modified....
finalHtml += html.substring(lastIndex);
html=finalHtml;
finalHtml='';
lastIndex=0;
}
while(match = spanMatch.exec(html)){
// one of the quoted values ' or "
/* istanbul ignore next: quotations match */
styleVal = match[3] || match[4];
// test for chrome inserted junk
if(styleVal && styleVal.match(/line-height: 1.[0-9]{3,12};|color: inherit; line-height: 1.1;|color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);|background-color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);/i)){
// replace original tag with new tag
styleVal = styleVal.replace(/( |)font-family: inherit;|( |)line-height: 1.[0-9]{3,12};|( |)color: inherit;|( |)color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);|( |)background-color: rgb\(\d{1,3}, \d{1,3}, \d{1,3}\);/ig, '');
newTag = '<' + match[1].trim();
if(styleVal.trim().length > 0) newTag += ' style=' + match[2].substring(0,1) + styleVal + match[2].substring(0,1);
newTag += match[5].trim() + ">";
finalHtml += html.substring(lastIndex, match.index) + newTag;
lastIndex = match.index + match[0].length;
}
}
finalHtml += html.substring(lastIndex);
// only replace when something has changed, else we get focus problems on inserting lists
if(lastIndex > 0){
// replace all empty strings
return finalHtml.replace(/<span\s?>(.*?)<\/span>(<br(\/|)>|)/ig, '$1');
} else return html;
};
return taFixChrome;
}).factory('taSanitize', ['$sanitize', function taSanitizeFactory($sanitize){
var convert_infos = [
{
property: 'font-weight',
values: [ 'bold' ],
tag: 'b'
},
{
property: 'font-style',
values: [ 'italic' ],
tag: 'i'
}
];
var styleMatch = [];
for(var i = 0; i < convert_infos.length; i++){
var _partialStyle = '(' + convert_infos[i].property + ':\\s*(';
for(var j = 0; j < convert_infos[i].values.length; j++){
/* istanbul ignore next: not needed to be tested yet */
if(j > 0) _partialStyle += '|';
_partialStyle += convert_infos[i].values[j];
}
_partialStyle += ');)';
styleMatch.push(_partialStyle);
}
var styleRegexString = '(' + styleMatch.join('|') + ')';
function wrapNested(html, wrapTag) {
var depth = 0;
var lastIndex = 0;
var match;
var tagRegex = /<[^>]*>/ig;
while(match = tagRegex.exec(html)){
lastIndex = match.index;
if(match[0].substr(1, 1) === '/'){
if(depth === 0) break;
else depth--;
}else depth++;
}
return wrapTag +
html.substring(0, lastIndex) +
// get the start tags reversed - this is safe as we construct the strings with no content except the tags
angular.element(wrapTag)[0].outerHTML.substring(wrapTag.length) +
html.substring(lastIndex);
}
function transformLegacyStyles(html){
if(!html || !angular.isString(html) || html.length <= 0) return html;
var i;
var styleElementMatch = /<([^>\/]+?)style=("([^"]+)"|'([^']+)')([^>]*)>/ig;
var match, subMatch, styleVal, newTag, lastNewTag = '', newHtml, finalHtml = '', last