/files/angular.textangular/1.2.2/textAngular.js
JavaScript | 1060 lines | 787 code | 61 blank | 212 comment | 180 complexity | 63f91081dc7b3a7864a541c70db04ca6 MD5 | raw file
- /*
- textAngular
- Author : Austin Anderson
- License : 2013 MIT
- Version 1.2.2
- See README.md or https://github.com/fraywing/textAngular/wiki for requirements and use.
- */
- (function(){ // encapsulate all variables so they don't become global vars
- "Use Strict";
- // fix a webkit bug, see: https://gist.github.com/shimondoodkin/1081133
- // this is set true when a blur occurs as the blur of the ta-bind triggers before the click
- var globalContentEditableBlur = false;
- /* istanbul ignore next: Browser Un-Focus fix for webkit */
- if(/AppleWebKit\/([\d.]+)/.exec(navigator.userAgent)) { // detect webkit
- document.addEventListener("click", function(){
- var curelement = window.event.target;
- if(globalContentEditableBlur && curelement !== null){
- var isEditable = false;
- var tempEl = curelement;
- while(tempEl !== null && tempEl.tagName.toLowerCase() !== 'html' && !isEditable){
- isEditable = tempEl.contentEditable === 'true';
- tempEl = tempEl.parentNode;
- }
- if(!isEditable){
- document.getElementById('textAngular-editableFix-010203040506070809').setSelectionRange(0, 0); // set caret focus to an element that handles caret focus correctly.
- curelement.focus(); // focus the wanted element.
- }
- }
- globalContentEditableBlur = false;
- }, false); // add global click handler
- angular.element(document).ready(function () {
- angular.element(document.body).append(angular.element('<input id="textAngular-editableFix-010203040506070809" style="width:1px;height:1px;border:none;margin:0;padding:0;position:absolute; top: -10000; left: -10000;" unselectable="on" tabIndex="-1">'));
- });
- }
- // 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 ie = (function(){
- var undef,rv = -1; // Return value assumes failure.
- var ua = window.navigator.userAgent;
- var msie = ua.indexOf('MSIE ');
- var trident = ua.indexOf('Trident/');
- if (msie > 0) {
- // IE 10 or older => return version number
- rv = parseInt(ua.substring(msie + 5, ua.indexOf('.', msie)), 10);
- } else if (trident > 0) {
- // IE 11 (or newer) => return version number
- var rvNum = ua.indexOf('rv:');
- rv = parseInt(ua.substring(rvNum + 3, ua.indexOf('.', rvNum)), 10);
- }
- return ((rv > -1) ? rv : undef);
- }());
- // Thanks to answer in http://stackoverflow.com/questions/2308134/trim-in-javascript-not-working-in-ie
- /* istanbul ignore next: trim shim for older browsers */
- if(typeof String.prototype.trim !== 'function') {
- String.prototype.trim = function() {
- return this.replace(/^\s\s*/, '').replace(/\s\s*$/, '');
- };
- }
- // 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;
- }
- }
- /*
- Custom stylesheet for the placeholders rules.
- Credit to: http://davidwalsh.name/add-rules-stylesheets
- */
- var sheet, addCSSRule, removeCSSRule, _addCSSRule, _removeCSSRule;
- /* istanbul ignore else: IE <8 test*/
- if(ie > 8 || ie === undefined){
- var topsheet = (function() {
- // Create the <style> tag
- var style = document.createElement("style");
- /* istanbul ignore else : WebKit hack :( */
- if(/AppleWebKit\/([\d.]+)/.exec(navigator.userAgent)) style.appendChild(document.createTextNode(""));
- // Add the <style> element to the page, add as first so the styles can be overridden by custom stylesheets
- document.head.insertBefore(style,document.head.firstChild);
- return style.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(/AppleWebKit\/([\d.]+)/.exec(navigator.userAgent)) style.appendChild(document.createTextNode(""));
- // Add the <style> element to the page, add as first so the styles can be overridden by custom stylesheets
- document.head.appendChild(style);
- return style.sheet;
- })();
- // use as: addCSSRule("header", "float: left");
- addCSSRule = function(selector, rules) {
- _addCSSRule(sheet, selector, rules);
- };
- _addCSSRule = function(sheet, selector, rules){
- var insertIndex;
- /* istanbul ignore else: firefox catch */
- if(sheet.rules) insertIndex = Math.max(sheet.rules.length - 1, 0);
- else if(sheet.cssRules) insertIndex = Math.max(sheet.cssRules.length - 1, 0);
- /* istanbul ignore else: untestable IE option */
- if(sheet.insertRule) {
- sheet.insertRule(selector + "{" + rules + "}", insertIndex);
- }
- else {
- sheet.addRule(selector, rules, insertIndex);
- }
- // return the index of the stylesheet rule
- return insertIndex;
- };
- removeCSSRule = function(index){
- _removeCSSRule(sheet, index);
- };
- _removeCSSRule = function(sheet, index){
- /* istanbul ignore else: untestable IE option */
- if(sheet.removeRule){
- sheet.removeRule(index);
- }else{
- sheet.deleteRule(index);
- }
- };
- // add generic styling for the editor
- _addCSSRule(topsheet, '.ta-scroll-window.form-control', "height: auto; min-height: 300px; overflow: auto; font-family: inherit; font-size: 100%; position: relative; padding: 0;");
- _addCSSRule(topsheet, '.ta-root.focussed .ta-scroll-window.form-control', 'border-color: #66afe9; outline: 0; -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);');
- _addCSSRule(topsheet, '.ta-editor.ta-html', "min-height: 300px; height: auto; overflow: auto; font-family: inherit; font-size: 100%;");
- _addCSSRule(topsheet, '.ta-scroll-window > .ta-bind', "height: auto; min-height: 300px; padding: 6px 12px;");
- // add the styling for the awesomness of the resizer
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay', 'z-index: 100; position: absolute; display: none;');
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay > .ta-resizer-handle-info', 'position: absolute; bottom: 16px; right: 16px; border: 1px solid black; background-color: #FFF; padding: 0 4px; opacity: 0.7;');
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay > .ta-resizer-handle-background', 'position: absolute; bottom: 5px; right: 5px; left: 5px; top: 5px; border: 1px solid black; background-color: rgba(0, 0, 0, 0.2);');
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay > .ta-resizer-handle-corner', 'width: 10px; height: 10px; position: absolute;');
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay > .ta-resizer-handle-corner-tl', 'top: 0; left: 0; border-left: 1px solid black; border-top: 1px solid black;');
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay > .ta-resizer-handle-corner-tr', 'top: 0; right: 0; border-right: 1px solid black; border-top: 1px solid black;');
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay > .ta-resizer-handle-corner-bl', 'bottom: 0; left: 0; border-left: 1px solid black; border-bottom: 1px solid black;');
- _addCSSRule(topsheet, '.ta-root .ta-resizer-handle-overlay > .ta-resizer-handle-corner-br', 'bottom: 0; right: 0; border: 1px solid black; cursor: se-resize; background-color: white;');
- }
- // recursive function that returns an array of angular.elements that have the passed attribute set on them
- function getByAttribute(element, attribute){
- var resultingElements = [];
- var childNodes = element.children();
- if(childNodes.length){
- angular.forEach(childNodes, function(child){
- resultingElements = resultingElements.concat(getByAttribute(angular.element(child), attribute));
- });
- }
- if(element.attr(attribute) !== undefined) resultingElements.push(element);
- return resultingElements;
- }
- // this global var is used to prevent multiple fires of the drop event. Needs to be global to the textAngular file.
- var dropFired = false;
- var textAngular = angular.module("textAngular", ['ngSanitize', 'textAngularSetup']); //This makes ngSanitize required
- // 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
- 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;
- }
- textAngular.constant('taRegisterTool', registerTextAngularTool);
- textAngular.value('taTools', taTools);
- textAngular.config([function(){
- // clear taTools variable. Just catches testing and any other time that this config may run multiple times...
- angular.forEach(taTools, function(value, key){ delete taTools[key]; });
- }]);
- textAngular.directive("textAngular", [
- '$compile', '$timeout', 'taOptions', 'taSelection', 'taExecCommand', 'textAngularManager', '$window', '$document', '$animate', '$log',
- function($compile, $timeout, taOptions, taSelection, taExecCommand, textAngularManager, $window, $document, $animate, $log){
- return {
- require: '?ngModel',
- scope: {},
- restrict: "EA",
- link: function(scope, element, attrs, ngModel){
- // all these vars should not be accessable outside this directive
- var _keydown, _keyup, _keypress, _mouseup, _mousedown, _focusin, _focusout,
- _originalContents, _toolbars,
- _serial = (attrs.serial) ? attrs.serial : Math.floor(Math.random() * 10000000000000000),
- _name = (attrs.name) ? attrs.name : 'textAngularEditor' + _serial,
- _taExecCommand;
- var oneEvent = function(_element, event, action){
- $timeout(function(){
- // shim the .one till fixed
- var _func = function(){
- _element.off(event, _func);
- action();
- };
- _element.on(event, _func);
- }, 100);
- };
- _taExecCommand = taExecCommand(attrs.taDefaultWrap);
- // get the settings from the defaults and add our specific functions that need to be on the scope
- angular.extend(scope, angular.copy(taOptions), {
- // wraps the selection in the provided tag / execCommand function. Should only be called in WYSIWYG mode.
- wrapSelection: function(command, opt, isSelectableElementTool){
- // catch errors like FF erroring when you try to force an undo with nothing done
- _taExecCommand(command, false, opt);
- if(isSelectableElementTool){
- // re-apply the selectable tool events
- scope['reApplyOnSelectorHandlerstaTextElement' + _serial]();
- }
- // refocus on the shown display element, this fixes a display bug when using :focus styles to outline the box.
- // You still have focus on the text/html input it just doesn't show up
- scope.displayElements.text[0].focus();
- },
- showHtml: false
- });
- // setup the options from the optional attributes
- if(attrs.taFocussedClass) scope.classes.focussed = attrs.taFocussedClass;
- if(attrs.taTextEditorClass) scope.classes.textEditor = attrs.taTextEditorClass;
- if(attrs.taHtmlEditorClass) scope.classes.htmlEditor = attrs.taHtmlEditorClass;
- // optional setup functions
- if(attrs.taTextEditorSetup) scope.setup.textEditorSetup = scope.$parent.$eval(attrs.taTextEditorSetup);
- if(attrs.taHtmlEditorSetup) scope.setup.htmlEditorSetup = scope.$parent.$eval(attrs.taHtmlEditorSetup);
- // optional fileDropHandler function
- if(attrs.taFileDrop) scope.fileDropHandler = scope.$parent.$eval(attrs.taFileDrop);
- else scope.fileDropHandler = scope.defaultFileDropHandler;
- _originalContents = element[0].innerHTML;
- // clear the original content
- element[0].innerHTML = '';
- // Setup the HTML elements as variable references for use later
- scope.displayElements = {
- // we still need the hidden input even with a textarea as the textarea may have invalid/old input in it,
- // wheras the input will ALLWAYS have the correct value.
- forminput: angular.element("<input type='hidden' tabindex='-1' style='display: none;'>"),
- html: angular.element("<textarea></textarea>"),
- text: angular.element("<div></div>"),
- // other toolbased elements
- scrollWindow: angular.element("<div class='ta-scroll-window'></div>"),
- popover: angular.element('<div class="popover fade bottom" style="max-width: none; width: 305px;"></div>'),
- popoverArrow: angular.element('<div class="arrow"></div>'),
- popoverContainer: angular.element('<div class="popover-content"></div>'),
- resize: {
- overlay: angular.element('<div class="ta-resizer-handle-overlay"></div>'),
- background: angular.element('<div class="ta-resizer-handle-background"></div>'),
- anchors: [
- angular.element('<div class="ta-resizer-handle-corner ta-resizer-handle-corner-tl"></div>'),
- angular.element('<div class="ta-resizer-handle-corner ta-resizer-handle-corner-tr"></div>'),
- angular.element('<div class="ta-resizer-handle-corner ta-resizer-handle-corner-bl"></div>'),
- angular.element('<div class="ta-resizer-handle-corner ta-resizer-handle-corner-br"></div>')
- ],
- info: angular.element('<div class="ta-resizer-handle-info"></div>')
- }
- };
- // Setup the popover
- scope.displayElements.popover.append(scope.displayElements.popoverArrow);
- scope.displayElements.popover.append(scope.displayElements.popoverContainer);
- scope.displayElements.scrollWindow.append(scope.displayElements.popover);
- scope.displayElements.popover.on('mousedown', function(e, eventData){
- /* istanbul ignore else: this is for catching the jqLite testing*/
- if(eventData) angular.extend(e, eventData);
- // this prevents focusout from firing on the editor when clicking anything in the popover
- e.preventDefault();
- return false;
- });
- // define the popover show and hide functions
- scope.showPopover = function(_el){
- scope.displayElements.popover.css('display', 'block');
- scope.reflowPopover(_el);
- $animate.addClass(scope.displayElements.popover, 'in');
- oneEvent(element, 'click keyup', function(){scope.hidePopover();});
- };
- scope.reflowPopover = function(_el){
- if(scope.displayElements.text[0].offsetHeight - 51 > _el[0].offsetTop){
- scope.displayElements.popover.css('top', _el[0].offsetTop + _el[0].offsetHeight + 'px');
- scope.displayElements.popover.removeClass('top').addClass('bottom');
- }else{
- scope.displayElements.popover.css('top', _el[0].offsetTop - 54 + 'px');
- scope.displayElements.popover.removeClass('bottom').addClass('top');
- }
- var _maxLeft = scope.displayElements.text[0].offsetWidth - scope.displayElements.popover[0].offsetWidth;
- var _targetLeft = _el[0].offsetLeft + (_el[0].offsetWidth / 2.0) - (scope.displayElements.popover[0].offsetWidth / 2.0);
- scope.displayElements.popover.css('left', Math.max(0, Math.min(_maxLeft, _targetLeft)) + 'px');
- scope.displayElements.popoverArrow.css('margin-left', (Math.min(_targetLeft, (Math.max(0, _targetLeft - _maxLeft))) - 11) + 'px');
- };
- scope.hidePopover = function(){
- $animate.removeClass(scope.displayElements.popover, 'in', /* istanbul ignore next: dosen't test with mocked animate */ function(){
- scope.displayElements.popover.css('display', '');
- scope.displayElements.popoverContainer.attr('style', '');
- scope.displayElements.popoverContainer.attr('class', 'popover-content');
- });
- };
- // setup the resize overlay
- scope.displayElements.resize.overlay.append(scope.displayElements.resize.background);
- angular.forEach(scope.displayElements.resize.anchors, function(anchor){ scope.displayElements.resize.overlay.append(anchor);});
- scope.displayElements.resize.overlay.append(scope.displayElements.resize.info);
- scope.displayElements.scrollWindow.append(scope.displayElements.resize.overlay);
- // define the show and hide events
- scope.reflowResizeOverlay = function(_el){
- _el = angular.element(_el)[0];
- scope.displayElements.resize.overlay.css({
- 'display': 'block',
- 'left': _el.offsetLeft - 5 + 'px',
- 'top': _el.offsetTop - 5 + 'px',
- 'width': _el.offsetWidth + 10 + 'px',
- 'height': _el.offsetHeight + 10 + 'px'
- });
- scope.displayElements.resize.info.text(_el.offsetWidth + ' x ' + _el.offsetHeight);
- };
- /* istanbul ignore next: pretty sure phantomjs won't test this */
- scope.showResizeOverlay = function(_el){
- var resizeMouseDown = function(event){
- var startPosition = {
- width: parseInt(_el.attr('width')),
- height: parseInt(_el.attr('height')),
- x: event.clientX,
- y: event.clientY
- };
- if(startPosition.width === undefined) startPosition.width = _el[0].offsetWidth;
- if(startPosition.height === undefined) startPosition.height = _el[0].offsetHeight;
- scope.hidePopover();
- var ratio = startPosition.height / startPosition.width;
- var mousemove = function(event){
- // calculate new size
- var pos = {
- x: Math.max(0, startPosition.width + (event.clientX - startPosition.x)),
- y: Math.max(0, startPosition.height + (event.clientY - startPosition.y))
- };
- var applyImageSafeCSS = function(_el, css){
- _el = angular.element(_el);
- if(_el[0].tagName.toLowerCase() === 'img'){
- if(css.height){
- _el.attr('height', css.height);
- delete css.height;
- }
- if(css.width){
- _el.attr('width', css.width);
- delete css.width;
- }
- }
- _el.css(css);
- };
- if(event.shiftKey){
- // keep ratio
- var newRatio = pos.y / pos.x;
- applyImageSafeCSS(_el, {
- width: ratio > newRatio ? pos.x : pos.y / ratio,
- height: ratio > newRatio ? pos.x * ratio : pos.y
- });
- }else{
- applyImageSafeCSS(_el, {
- width: pos.x,
- height: pos.y
- });
- }
- // reflow the popover tooltip
- scope.reflowResizeOverlay(_el);
- };
- $document.find('body').on('mousemove', mousemove);
- oneEvent(scope.displayElements.resize.overlay, 'mouseup', function(){
- $document.find('body').off('mousemove', mousemove);
- scope.showPopover(_el);
- });
- event.stopPropagation();
- event.preventDefault();
- };
- scope.displayElements.resize.anchors[3].on('mousedown', resizeMouseDown);
- scope.reflowResizeOverlay(_el);
- oneEvent(element, 'click', function(){scope.hideResizeOverlay();});
- };
- /* istanbul ignore next: pretty sure phantomjs won't test this */
- scope.hideResizeOverlay = function(){
- scope.displayElements.resize.overlay.css('display', '');
- };
- // allow for insertion of custom directives on the textarea and div
- scope.setup.htmlEditorSetup(scope.displayElements.html);
- scope.setup.textEditorSetup(scope.displayElements.text);
- scope.displayElements.html.attr({
- 'id': 'taHtmlElement' + _serial,
- 'ng-show': 'showHtml',
- 'ta-bind': 'ta-bind',
- 'ng-model': 'html'
- });
- scope.displayElements.text.attr({
- 'id': 'taTextElement' + _serial,
- 'contentEditable': 'true',
- 'ta-bind': 'ta-bind',
- 'ng-model': 'html'
- });
- scope.displayElements.scrollWindow.attr({'ng-hide': 'showHtml'});
- if(attrs.taDefaultWrap) scope.displayElements.text.attr('ta-default-wrap', attrs.taDefaultWrap);
-
- if(attrs.taUnsafeSanitizer){
- scope.displayElements.text.attr('ta-unsafe-sanitizer', attrs.taUnsafeSanitizer);
- scope.displayElements.html.attr('ta-unsafe-sanitizer', attrs.taUnsafeSanitizer);
- }
-
- // add the main elements to the origional element
- scope.displayElements.scrollWindow.append(scope.displayElements.text);
- element.append(scope.displayElements.scrollWindow);
- element.append(scope.displayElements.html);
- scope.displayElements.forminput.attr('name', _name);
- element.append(scope.displayElements.forminput);
- if(attrs.tabindex){
- element.removeAttr('tabindex');
- scope.displayElements.text.attr('tabindex', attrs.tabindex);
- scope.displayElements.html.attr('tabindex', attrs.tabindex);
- }
- if (attrs.placeholder) {
- scope.displayElements.text.attr('placeholder', attrs.placeholder);
- scope.displayElements.html.attr('placeholder', attrs.placeholder);
- }
- if(attrs.taDisabled){
- scope.displayElements.text.attr('ta-readonly', 'disabled');
- scope.displayElements.html.attr('ta-readonly', 'disabled');
- scope.disabled = scope.$parent.$eval(attrs.taDisabled);
- scope.$parent.$watch(attrs.taDisabled, function(newVal){
- scope.disabled = newVal;
- if(scope.disabled){
- element.addClass(scope.classes.disabled);
- }else{
- element.removeClass(scope.classes.disabled);
- }
- });
- }
- // compile the scope with the text and html elements only - if we do this with the main element it causes a compile loop
- $compile(scope.displayElements.scrollWindow)(scope);
- $compile(scope.displayElements.html)(scope);
- scope.updateTaBindtaTextElement = scope['updateTaBindtaTextElement' + _serial];
- scope.updateTaBindtaHtmlElement = scope['updateTaBindtaHtmlElement' + _serial];
- // add the classes manually last
- element.addClass("ta-root");
- scope.displayElements.scrollWindow.addClass("ta-text ta-editor " + scope.classes.textEditor);
- scope.displayElements.html.addClass("ta-html ta-editor " + scope.classes.htmlEditor);
- // used in the toolbar actions
- scope._actionRunning = false;
- var _savedSelection = false;
- scope.startAction = function(){
- scope._actionRunning = true;
- // if rangy library is loaded return a function to reload the current selection
- if($window.rangy && $window.rangy.saveSelection){
- _savedSelection = $window.rangy.saveSelection();
- return function(){
- if(_savedSelection) $window.rangy.restoreSelection(_savedSelection);
- };
- }
- };
- scope.endAction = function(){
- scope._actionRunning = false;
- if(_savedSelection) $window.rangy.removeMarkers(_savedSelection);
- _savedSelection = false;
- scope.updateSelectedStyles();
- // only update if in text or WYSIWYG mode
- if(!scope.showHtml) scope['updateTaBindtaTextElement' + _serial]();
- };
- // note that focusout > focusin is called everytime we click a button - except bad support: http://www.quirksmode.org/dom/events/blurfocus.html
- // cascades to displayElements.text and displayElements.html automatically.
- _focusin = function(){
- element.addClass(scope.classes.focussed);
- _toolbars.focus();
- };
- scope.displayElements.html.on('focus', _focusin);
- scope.displayElements.text.on('focus', _focusin);
- _focusout = function(e){
- // if we are NOT runnig an action and have NOT focussed again on the text etc then fire the blur events
- if(!scope._actionRunning && $document[0].activeElement !== scope.displayElements.html[0] && $document[0].activeElement !== scope.displayElements.text[0]){
- element.removeClass(scope.classes.focussed);
- _toolbars.unfocus();
- // to prevent multiple apply error defer to next seems to work.
- $timeout(function(){ element.triggerHandler('blur'); }, 0);
- }
- e.preventDefault();
- return false;
- };
- scope.displayElements.html.on('blur', _focusout);
- scope.displayElements.text.on('blur', _focusout);
- // Setup the default toolbar tools, this way allows the user to add new tools like plugins.
- // This is on the editor for future proofing if we find a better way to do this.
- scope.queryFormatBlockState = function(command){
- // $document[0].queryCommandValue('formatBlock') errors in Firefox if we call this when focussed on the textarea
- return !scope.showHtml && command.toLowerCase() === $document[0].queryCommandValue('formatBlock').toLowerCase();
- };
- scope.queryCommandState = function(command){
- // $document[0].queryCommandValue('formatBlock') errors in Firefox if we call this when focussed on the textarea
- return (!scope.showHtml) ? $document[0].queryCommandState(command) : '';
- };
- scope.switchView = function(){
- scope.showHtml = !scope.showHtml;
- //Show the HTML view
- if(scope.showHtml){
- //defer until the element is visible
- $timeout(function(){
- // [0] dereferences the DOM object from the angular.element
- return scope.displayElements.html[0].focus();
- }, 100);
- }else{
- //Show the WYSIWYG view
- //defer until the element is visible
- $timeout(function(){
- // [0] dereferences the DOM object from the angular.element
- return scope.displayElements.text[0].focus();
- }, 100);
- }
- };
- // changes to the model variable from outside the html/text inputs
- // if no ngModel, then the only input is from inside text-angular
- if(attrs.ngModel){
- var _firstRun = true;
- ngModel.$render = function(){
- if(_firstRun){
- // we need this firstRun to set the originalContents otherwise it gets overrided by the setting of ngModel to undefined from NaN
- _firstRun = false;
- // if view value is null or undefined initially and there was original content, set to the original content
- var _initialValue = scope.$parent.$eval(attrs.ngModel);
- if((_initialValue === undefined || _initialValue === null) && (_originalContents && _originalContents !== '')){
- // on passing through to taBind it will be sanitised
- ngModel.$setViewValue(_originalContents);
- }
- }
- scope.displayElements.forminput.val(ngModel.$viewValue);
- // if the editors aren't focused they need to be updated, otherwise they are doing the updating
- /* istanbul ignore else: don't care */
- if(!scope._elementSelectTriggered && $document[0].activeElement !== scope.displayElements.html[0] && $document[0].activeElement !== scope.displayElements.text[0]){
- // catch model being null or undefined
- scope.html = ngModel.$viewValue || '';
- }
- };
- // trigger the validation calls
- var _validity = function(value){
- if(attrs.required) ngModel.$setValidity('required', !(!value || value.trim() === ''));
- return value;
- };
- ngModel.$parsers.push(_validity);
- ngModel.$formatters.push(_validity);
- }else{
- // if no ngModel then update from the contents of the origional html.
- scope.displayElements.forminput.val(_originalContents);
- scope.html = _originalContents;
- }
- // changes from taBind back up to here
- scope.$watch('html', function(newValue, oldValue){
- if(newValue !== oldValue){
- if(attrs.ngModel && ngModel.$viewValue !== newValue) ngModel.$setViewValue(newValue);
- scope.displayElements.forminput.val(newValue);
- }
- });
- if(attrs.taTargetToolbars) _toolbars = textAngularManager.registerEditor(_name, scope, attrs.taTargetToolbars.split(','));
- else{
- var _toolbar = angular.element('<div text-angular-toolbar name="textAngularToolbar' + _serial + '">');
- // passthrough init of toolbar options
- if(attrs.taToolbar) _toolbar.attr('ta-toolbar', attrs.taToolbar);
- if(attrs.taToolbarClass) _toolbar.attr('ta-toolbar-class', attrs.taToolbarClass);
- if(attrs.taToolbarGroupClass) _toolbar.attr('ta-toolbar-group-class', attrs.taToolbarGroupClass);
- if(attrs.taToolbarButtonClass) _toolbar.attr('ta-toolbar-button-class', attrs.taToolbarButtonClass);
- if(attrs.taToolbarActiveButtonClass) _toolbar.attr('ta-toolbar-active-button-class', attrs.taToolbarActiveButtonClass);
- if(attrs.taFocussedClass) _toolbar.attr('ta-focussed-class', attrs.taFocussedClass);
- element.prepend(_toolbar);
- $compile(_toolbar)(scope.$parent);
- _toolbars = textAngularManager.registerEditor(_name, scope, ['textAngularToolbar' + _serial]);
- }
- scope.$on('$destroy', function(){
- textAngularManager.unregisterEditor(_name);
- });
- // catch element select event and pass to toolbar tools
- scope.$on('ta-element-select', function(event, element){
- _toolbars.triggerElementSelect(event, element);
- });
- scope.$on('ta-drop-event', function(event, element, dropEvent, dataTransfer){
- scope.displayElements.text[0].focus();
- if(dataTransfer && dataTransfer.files && dataTransfer.files.length > 0){
- angular.forEach(dataTransfer.files, function(file){
- // taking advantage of boolean execution, if the fileDropHandler returns true, nothing else after it is executed
- // If it is false then execute the defaultFileDropHandler if the fileDropHandler is NOT the default one
- try{
- return scope.fileDropHandler(file, scope.wrapSelection) ||
- (scope.fileDropHandler !== scope.defaultFileDropHandler &&
- scope.defaultFileDropHandler(file, scope.wrapSelection));
- }catch(error){
- $log.error(error);
- }
- });
- dropEvent.preventDefault();
- dropEvent.stopPropagation();
- }
- });
- // the following is for applying the active states to the tools that support it
- scope._bUpdateSelectedStyles = false;
- // loop through all the tools polling their activeState function if it exists
- scope.updateSelectedStyles = function(){
- var _selection;
- // test if the common element ISN'T the root ta-text node
- if((_selection = taSelection.getSelectionElement()) !== undefined && _selection.parentNode !== scope.displayElements.text[0]){
- _toolbars.updateSelectedStyles(angular.element(_selection));
- }else _toolbars.updateSelectedStyles();
- // used to update the active state when a key is held down, ie the left arrow
- if(scope._bUpdateSelectedStyles) $timeout(scope.updateSelectedStyles, 200);
- };
- // start updating on keydown
- _keydown = function(){
- /* istanbul ignore else: don't run if already running */
- if(!scope._bUpdateSelectedStyles){
- scope._bUpdateSelectedStyles = true;
- scope.$apply(function(){
- scope.updateSelectedStyles();
- });
- }
- };
- scope.displayElements.html.on('keydown', _keydown);
- scope.displayElements.text.on('keydown', _keydown);
- // stop updating on key up and update the display/model
- _keyup = function(){
- scope._bUpdateSelectedStyles = false;
- };
- scope.displayElements.html.on('keyup', _keyup);
- scope.displayElements.text.on('keyup', _keyup);
- // stop updating on key up and update the display/model
- _keypress = function(event, eventData){
- /* istanbul ignore else: this is for catching the jqLite testing*/
- if(eventData) angular.extend(event, eventData);
- scope.$apply(function(){
- if(_toolbars.sendKeyCommand(event)){
- /* istanbul ignore else: don't run if already running */
- if(!scope._bUpdateSelectedStyles){
- scope.updateSelectedStyles();
- }
- event.preventDefault();
- return false;
- }
- });
- };
- scope.displayElements.html.on('keypress', _keypress);
- scope.displayElements.text.on('keypress', _keypress);
- // update the toolbar active states when we click somewhere in the text/html boxed
- _mouseup = function(){
- // ensure only one execution of updateSelectedStyles()
- scope._bUpdateSelectedStyles = false;
- scope.$apply(function(){
- scope.updateSelectedStyles();
- });
- };
- scope.displayElements.html.on('mouseup', _mouseup);
- scope.displayElements.text.on('mouseup', _mouseup);
- }
- };
- }
- ]).factory('taBrowserTag', [function(){
- return function(tag){
- /* istanbul ignore next: ie specific test */
- if(!tag) return (ie <= 8)? 'P' : 'p';
- else if(tag === '') return (ie === undefined)? 'div' : (ie <= 8)? 'P' : 'p';
- else return (ie <= 8)? tag.toUpperCase() : tag;
- };
- }]).factory('taExecCommand', ['taSelection', 'taBrowserTag', '$document', function(taSelection, taBrowserTag, $document){
- var BLOCKELEMENTS = /^(address|article|aside|audio|blockquote|canvas|dd|div|dl|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|noscript|ol|output|p|pre|section|table|tfoot|ul|video)$/ig;
- var LISTELEMENTS = /^(ul|li|ol)$/ig;
- var listToDefault = function(listElement, defaultWrap){
- var $target, i;
- // if all selected then we should remove the list
- // grab all li elements and convert to taDefaultWrap tags
- var children = listElement.find('li');
- for(i = children.length - 1; i >= 0; i--){
- $target = angular.element('<' + defaultWrap + '>' + children[i].innerHTML + '</' + defaultWrap + '>');
- listElement.after($target);
- }
- listElement.remove();
- taSelection.setSelectionToElementEnd($target[0]);
- };
- var listToList = function(listElement, newListTag){
- var $target = angular.element('<' + newListTag + '>' + listElement[0].innerHTML + '</' + newListTag + '>');
- listElement.after($target);
- listElement.remove();
- taSelection.setSelectionToElementEnd($target.find('li')[0]);
- };
- var childElementsToList = function(elements, listElement, newListTag){
- var html = '';
- for(var i = 0; i < elements.length; i++){
- html += '<' + taBrowserTag('li') + '>' + elements[i].innerHTML + '</' + taBrowserTag('li') + '>';
- }
- var $target = angular.element('<' + newListTag + '>' + html + '</' + newListTag + '>');
- listElement.after($target);
- listElement.remove();
- taSelection.setSelectionToElementEnd($target.find('li')[0]);
- };
- return function(taDefaultWrap){
- taDefaultWrap = taBrowserTag(taDefaultWrap);
- return function(command, showUI, options){
- var i, $target, html, _nodes, next;
- var defaultWrapper = angular.element('<' + taDefaultWrap + '>');
- var selectedElement = taSelection.getSelectionElement();
- var $selected = angular.element(selectedElement);
- if(selectedElement !== undefined){
- var tagName = selectedElement.tagName.toLowerCase();
- if(command.toLowerCase() === 'insertorderedlist' || command.toLowerCase() === 'insertunorderedlist'){
- var selfTag = taBrowserTag((command.toLowerCase() === 'insertorderedlist')? 'ol' : 'ul');
- if(tagName === selfTag){
- // if all selected then we should remove the list
- // grab all li elements and convert to taDefaultWrap tags
- return listToDefault($selected, taDefaultWrap);
- }else if(tagName === 'li' && $selected.parent()[0].tagName.toLowerCase() === selfTag && $selected.parent().children().length === 1){
- // catch for the previous statement if only one li exists
- return listToDefault($selected.parent(), taDefaultWrap);
- }else if(tagName === 'li' && $selected.parent()[0].tagName.toLowerCase() !== selfTag && $selected.parent().children().length === 1){
- // catch for the previous statement if only one li exists
- return listToList($selected.parent(), selfTag);
- }else if(tagName.match(BLOCKELEMENTS) && !$selected.hasClass('ta-bind')){
- // if it's one of those block elements we have to change the contents
- // if it's a ol/ul we are changing from one to the other
- if(tagName === 'ol' || tagName === 'ul'){
- return listToList($selected, selfTag);
- }else{
- var childBlockElements = false;
- angular.forEach($selected.children(), function(elem){
- if(elem.tagName.match(BLOCKELEMENTS)) {
- childBlockElements = true;
- }
- });
- if(childBlockElements){
- return childElementsToList($selected.children(), $selected, selfTag);
- }else{
- return childElementsToList([angular.element('<div>' + selectedElement.innerHTML + '</div>')[0]], $selected, selfTag);
- }
- }
- }else if(tagName.match(BLOCKELEMENTS)){
- // if we get here then all the contents of the ta-bind are selected
- _nodes = taSelection.getOnlySelectedElements();
- if(_nodes.length === 1 && (_nodes[0].tagName.toLowerCase() === 'ol' || _nodes[0].tagName.toLowerCase() === 'ul')){
- if(_nodes[0].tagName.toLowerCase() === selfTag){
- // remove
- return listToDefault(angular.element(_nodes[0]), taDefaultWrap);
- }else{
- return listToList(angular.element(_nodes[0]), selfTag);
- }
- }else{
- html = '';
- var $nodes = [];
- for(i = 0; i < _nodes.length; i++){
- /* istanbul ignore else: catch for real-world can't make it occur in testing */
- if(_nodes[i].nodeType !== 3){
- var $n = angular.element(_nodes[i]);
- html += '<' + taBrowserTag('li') + '>' + $n[0].innerHTML + '</' + taBrowserTag('li') + '>';
- $nodes.unshift($n);
- }
- }
- $target = angular.element('<' + selfTag + '>' + html + '</' + selfTag + '>');
- $nodes.pop().replaceWith($target);
- angular.forEach($nodes, function($node){ $node.remove(); });
- }
- taSelection.setSelectionToElementEnd($target[0]);
- return;
- }
- }else if(command.toLowerCase() === 'formatblock'){
- var optionsTagName = options.toLowerCase().replace(/[<>]/ig, '');
- if(tagName === 'li') $target = $selected.parent();
- else $target = $selected;
- // find the first blockElement
- while(!$target[0].tagName.match(BLOCKELEMENTS)){
- $target = $target.parent();
- tagName = $target[0].tagName.toLowerCase();
- }
- if(tagName === optionsTagName){
- // $target is wrap element
- _nodes = $target.children();
- var hasBlock = false;
- for(i = 0; i < _nodes.length; i++){
- hasBlock = hasBlock || _nodes[i].tagName.match(BLOCKELEMENTS);
- }
- if(hasBlock){
- $target.after(_nodes);
- next = $target.next();
- $target.remove();
- $target = next;
- }else{
- defaultWrapper.append($target[0].childNodes);
- $target.after(defaultWrapper);
- $target.remove();
- $target = defaultWrapper;
- }
- }else if($target.parent()[0].tagName.toLowerCase() === optionsTagName && !$target.parent().hasClass('ta-bind')){
- //unwrap logic for parent
- var blockElement = $target.parent();
- var contents = blockElement.contents();
- for(i = 0; i < contents.length; i ++){
- /* istanbul ignore next: can't test - some wierd thing with how phantomjs works */
- if(blockElement.parent().hasClass('ta-bind') && contents[i].nodeType === 3){
- defaultWrapper = angular.element('<' + taDefaultWrap + '>');
- defaultWrapper[0].innerHTML = contents[i].outerHTML;
- contents[i] = defaultWrapper[0];
- }
- blockElement.parent()[0].insertBefore(contents[i], blockElement[0]);
- }
- blockElement.remove();
- }else if(tagName.match(LISTELEMENTS)){
- // wrapping a list element
- $target.wrap(options);
- }else{
- // default wrap behaviour
- _nodes = taSelection.getOnlySelectedElements();
- if(_nodes.length === 0) _nodes = [$target[0]];
- // find the parent block element if any of the nodes are inline or text
- var inlineNodePresent = false;
- angular.forEach(_nodes, function(node){
- if(node.nodeType === 3 || !node.tagName.match(BLOCKELEMENTS)){
- inlineNodePresent = true;
- }
- });
- if(inlineNodePresent){
- while(_nodes[0].nodeType === 3 || !_nodes[0].tagName.match(BLOCKELEMENTS)){
- _nodes = [_nodes[0].parentNode];
- }
- }
- if(angular.element(_nodes[0]).hasClass('ta-bind')){
- $target = angular.element(options);
- $target[0].innerHTML = _nodes[0].innerHTML;
- _nodes[0].innerHTML = $target[0].outerHTML;
- }else if(optionsTagName === 'blockquote'){
- // blockquotes wrap other block elements
- html = '';
- for(i = 0; i < _nodes.length; i++){
- html += _nodes[i].outerHTML;
- }
- $target = angular.element(options);
- $target[0].innerHTML = html;
- _nodes[0].parentNode.insertBefore($target[0],_nodes[0]);
- angular.forEach(_nodes, function(node){
- node.parentNode.removeChild(node);
- });
- }
- else {
- // regular block elements replace other block elements
- for(i = 0; i < _nodes.length; i++){
- $target = angular.element(options);
- $target[0].innerHTML = _nodes[i].innerHTML;
- _nodes[i].parentNode.insertBefore($target[0],_nodes[i]);
- _nodes[i].parentNode.removeChild(_nodes[i]);
- }
- }
- }
- taSelection.setSelectionToElementEnd($target[0]);
- return;
- }
- }
- try{
- $document[0].execCommand(command, showUI, options);
- }catch(e){}
- };
- };
- }]).directive('taBind', ['taSanitize', '$timeout', '$window', '$document', 'taFixChrome', 'taBrowserTag', 'taSelection', 'taSelectableElements', 'taApplyCustomRenderers', 'taOptions',
- function(taSanitize, $timeout, $window, $document, taFixChrome, taBrowserTag, taSelection, taSelectableElements, taApplyCustomRenderers, taOptions){
- // Uses for this are textarea or input with ng-model and ta-bind='text'
- // OR any non-form element with contenteditable="contenteditable" ta-bind="html|text" ng-model
- return {
- require: 'ngModel',
- scope: {},
- link: function(scope, element, attrs, ngModel){
- // the option to use taBind on an input or textarea is required as it will sanitize all input into it correctly.
- var _isContentEditable = element.attr('contenteditable') !== undefined && element.attr('contenteditable');
- var _isInputFriendly = _isContentEditable || element[0].tagName.toLowerCase() === 'textarea' || element[0].tagName.toLowerCase() === 'input';
- var _isReadonly = false;
- var _focussed = false;
- var _disableSanitizer = attrs.taUnsafeSanitizer || taOptions.disableSanitizer;
-
- // defaults to the paragraph element, but we need the line-break or it doesn't allow you to type into the empty element
- // non IE is '<p><br/></p>', ie is '<p></p>' as for once IE gets it correct...
- var _defaultVal, _defaultTest;
- // set the default to be a paragraph value
- if(attrs.taDefaultWrap === undefined) attrs.taDefaultWrap = 'p';
- /* istanbul ignore next: ie specific test */
- if(attrs.taDefaultWrap === ''){
- _defaultVal = '';
- _defaultTest = (ie === undefined)? '<div><br></div>' : (ie >= 11)? '<p><br></p>' : (ie <= 8)? '<P> </P>' : '<p> </p>';
- }else{
- _defaultVal = (ie === undefined || ie >= 11)?
- '<' + attrs.taDefaultWrap + '><br></' + attrs.taDefaultWrap + '>' :
- (ie <= 8)?
- '<' + attrs.taDefaultWrap.toUpperCase() + '></' + attrs.taDefaultWrap.toUpperCase() + '>' :
- '<' + attrs.taDefaultWrap + '></' + attrs.taDefaultWrap + '>';
- _defaultTest = (ie === undefined || ie >= 11)?
- '<' + attrs.taDefaultWrap + '><br></' + attrs.taDefaultWrap + '>' :
- (ie <= 8)?
- '<' + attrs.taDefaultWrap.toUpperCase() + '> </' + attrs.taDefaultWrap.toUpperCase() + '>' :
- '<' + attrs.taDefaultWrap + '> </' + attrs.taDefaultWrap + '>';
- }
- element.addClass('ta-bind');
- // in here we are undoing the converts used elsewhere to prevent the < > and & being displayed when they shouldn't in the code.
- var _compileHtml = function(){
- if(_isContentEditable) return element[0].innerHTML;
- if(_isInputFriendly) return element.val();
- throw ('textAngular Error: attempting to update non-editable taBind');
- };
-
- var _setViewValue = function(val){
- if(!val) val = _compileHtml();
- if(val === _defaultTest){
- // this avoids us from tripping the ng-pristine flag if we click in and out with out typing
- if(ngModel.$viewValue !== '') ngModel.$setViewValue('');
- }else{
- if(ngModel.$viewValue !== val) ngModel.$setViewValue(val);
- }
- };
-
- //used for updating when inserting wrapped elements
- scope.$parent['updateTaBind' + (attrs.id || '')] = function(){
- if(!_isReadonly) _setViewValue();
- };
-
- //this code is used to update the models when data is entered/deleted
- if(_isInputFriendly){
- if(!_isContentEditable){
- element.on('paste cut', function(){
- // timeout to next is needed as otherwise the paste/cut event has not finished actually changing the display
- if(!_isReadonly) $timeout(function(){
- ngModel.$setViewValue(_compileHtml());
- }, 0);
- });
- // if a textarea or input just add in change and blur handlers, everything else is done by angulars input directive
- element.on('change blur', function(){
- if(!_isReadonly) ngModel.$setViewValue(_compileHtml());
- });
- }else{
- element.on('cut', function(e){
- // timeout to next is needed as otherwise the paste/cut event has not finished actually changing the display
- if(!_isReadonly)
- $timeout(function(){
- _setViewValue();
- }, 0);
- else e.preventDefault();
- });
- element.on('paste', function(e, eventData){
- /* istanbul ignore else: this is for catching the jqLite testing*/
- if(eventData) angular.extend(e, eventData);
- var text;
- // for non-ie
- if(e.clipboardData || (e.originalEvent && e.originalEvent.clipboardData))
- text = (e.originalEvent || e).clipboardData.getData('text/plain');
- // for ie
- else if($window.clipboardData)
- text = $window.clipboardData.getData('Text');
- // if theres non text data and we aren't in read-only do default
- if(!text && !_isReadonly) return true;
- // prevent the default paste command
- e.preventDefault();
- if(!_isReadonly){
- var _working = angular.element('<div></div>');
- _working[0].innerHTML = text;
- // this strips out all HTML tags
- text = _working.text();
- if ($document[0].selection){
- var range = $document[0].selection.createRange();
- range.pasteHTML(text);
- }
- else{
- $document[0].execCommand('insertText', false, text);
- }
- _setViewValue();
- }
- });
- // all the code specific to cont