UNPKG

textangular

Version:

A radically powerful Text-Editor/Wysiwyg editor for Angular.js

1,300 lines (1,240 loc) 189 kB
(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(/&nbsp;/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