UNPKG

bauhausjs

Version:
1,070 lines (1,010 loc) 48.7 kB
// 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', 'textAngular.factories', 'textAngular.DOM', 'textAngular.validators', 'textAngular.taBind']); //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 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; } 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.run([function(){ /* istanbul ignore next: not sure how to test this */ // Require Rangy and rangy savedSelection module. if(!window.rangy){ throw("rangy-core.js and rangy-selectionsaverestore.js are required for textAngular to work correctly, rangy-core is not yet loaded."); }else{ window.rangy.init(); if(!window.rangy.saveSelection){ throw("rangy-selectionsaverestore.js is required for textAngular to work correctly."); } } }]); textAngular.directive("textAngular", [ '$compile', '$timeout', 'taOptions', 'taSelection', 'taExecCommand', 'textAngularManager', '$window', '$document', '$animate', '$log', '$q', '$parse', function($compile, $timeout, taOptions, taSelection, taExecCommand, textAngularManager, $window, $document, $animate, $log, $q, $parse){ 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, _focusin, _focusout, _originalContents, _toolbars, _serial = (attrs.serial) ? attrs.serial : Math.floor(Math.random() * 10000000000000000), _taExecCommand, _resizeMouseDown; scope._name = (attrs.name) ? attrs.name : 'textAngularEditor' + _serial; var oneEvent = function(_element, event, action){ $timeout(function(){ // shim the .one till fixed var _func = function(){ _element.off(event, _func); action.apply(this, arguments); }; _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){ if(command.toLowerCase() === "undo"){ scope['$undoTaBindtaTextElement' + _serial](); }else if(command.toLowerCase() === "redo"){ scope['$redoTaBindtaTextElement' + _serial](); }else{ // 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: scope.$eval(attrs.taShowHtml) || 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($document.find('body'), 'click keyup', function(){scope.hidePopover();}); }; scope.reflowPopover = function(_el){ /* istanbul ignore if: catches only if near bottom of editor */ if(scope.displayElements.text[0].offsetHeight - 51 > _el[0].offsetTop){ scope.displayElements.popover.css('top', _el[0].offsetTop + _el[0].offsetHeight + scope.displayElements.scrollWindow[0].scrollTop + 'px'); scope.displayElements.popover.removeClass('top').addClass('bottom'); }else{ scope.displayElements.popover.css('top', _el[0].offsetTop - 54 + scope.displayElements.scrollWindow[0].scrollTop + '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(){ /* istanbul ignore next: dosen't test with mocked animate */ var doneCb = function(){ scope.displayElements.popover.css('display', ''); scope.displayElements.popoverContainer.attr('style', ''); scope.displayElements.popoverContainer.attr('class', 'popover-content'); }; $q.when($animate.removeClass(scope.displayElements.popover, 'in', doneCb)).then(doneCb); }; // 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 _body = $document.find('body'); _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 || isNaN(startPosition.width)) startPosition.width = _el[0].offsetWidth; if(startPosition.height === undefined || isNaN(startPosition.height)) 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)) }; if(event.shiftKey){ // keep ratio var newRatio = pos.y / pos.x; pos.x = ratio > newRatio ? pos.x : pos.y / ratio; pos.y = ratio > newRatio ? pos.x * ratio : pos.y; } el = angular.element(_el); el.attr('height', Math.max(0, pos.y)); el.attr('width', Math.max(0, pos.x)); // reflow the popover tooltip scope.reflowResizeOverlay(_el); }; _body.on('mousemove', mousemove); oneEvent(_body, 'mouseup', function(event){ event.preventDefault(); event.stopPropagation(); _body.off('mousemove', mousemove); scope.showPopover(_el); }); event.stopPropagation(); event.preventDefault(); }; scope.displayElements.resize.anchors[3].on('mousedown', _resizeMouseDown); scope.reflowResizeOverlay(_el); oneEvent(_body, 'click', function(){scope.hideResizeOverlay();}); }; /* istanbul ignore next: pretty sure phantomjs won't test this */ scope.hideResizeOverlay = function(){ scope.displayElements.resize.anchors[3].off('mousedown', _resizeMouseDown); 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', scope._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); } }); } if(attrs.taPaste){ scope._pasteHandler = function(_html){ return $parse(attrs.taPaste)(scope.$parent, {$html: _html}); }; scope.displayElements.text.attr('ta-paste', '_pasteHandler($html)'); } // 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 _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(){ scope.focussed = true; element.addClass(scope.classes.focussed); _toolbars.focus(); element.triggerHandler('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(){ scope._bUpdateSelectedStyles = false; element.triggerHandler('blur'); scope.focussed = false; }, 0); } e.preventDefault(); return false; }; scope.displayElements.html.on('blur', _focusout); scope.displayElements.text.on('blur', _focusout); scope.displayElements.text.on('paste', function(event){ element.triggerHandler('paste', event); }); // 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; $animate.enabled(false, scope.displayElements.html); $animate.enabled(false, scope.displayElements.text); //Show the HTML view if(scope.showHtml){ //defer until the element is visible $timeout(function(){ $animate.enabled(true, scope.displayElements.html); $animate.enabled(true, scope.displayElements.text); // [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(){ $animate.enabled(true, scope.displayElements.html); $animate.enabled(true, scope.displayElements.text); // [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){ // 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(scope._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(scope._name, scope, ['textAngularToolbar' + _serial]); } scope.$on('$destroy', function(){ textAngularManager.unregisterEditor(scope._name); }); // catch element select event and pass to toolbar tools scope.$on('ta-element-select', function(event, element){ if(_toolbars.triggerElementSelect(event, element)){ scope['reApplyOnSelectorHandlerstaTextElement' + _serial](); } }); 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 // Once one of these has been executed wrap the result as a promise, if undefined or variable update the taBind, else we should wait for the promise try{ $q.when(scope.fileDropHandler(file, scope.wrapSelection) || (scope.fileDropHandler !== scope.defaultFileDropHandler && $q.when(scope.defaultFileDropHandler(file, scope.wrapSelection)))).then(function(){ scope['updateTaBindtaTextElement' + _serial](); }); }catch(error){ $log.error(error); } }); dropEvent.preventDefault(); dropEvent.stopPropagation(); /* istanbul ignore else, the updates if moved text */ }else{ $timeout(function(){ scope['updateTaBindtaTextElement' + _serial](); }, 0); } }); // the following is for applying the active states to the tools that support it scope._bUpdateSelectedStyles = false; /* istanbul ignore next: browser window/tab leave check */ angular.element(window).on('blur', function(){ scope._bUpdateSelectedStyles = false; scope.focussed = 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 /* istanbul ignore else: browser only check */ if(scope._bUpdateSelectedStyles) $timeout(scope.updateSelectedStyles, 200); }; // start updating on keydown _keydown = function(){ /* istanbul ignore next: ie catch */ if(!scope.focussed){ scope._bUpdateSelectedStyles = false; return; } /* 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); } }; } ]); textAngular.service('textAngularManager', ['taToolExecuteAction', 'taTools', 'taRegisterTool', function(taToolExecuteAction, taTools, taRegisterTool){ // this service is used to manage all textAngular editors and toolbars. // All publicly published functions that modify/need to access the toolbar or editor scopes should be in here // these contain references to all the editors and toolbars that have been initialised in this app var toolbars = {}, editors = {}; // when we focus into a toolbar, we need to set the TOOLBAR's $parent to be the toolbars it's linked to. // We also need to set the tools to be updated to be the toolbars... return { // register an editor and the toolbars that it is affected by registerEditor: function(name, scope, targetToolbars){ // targetToolbars are optional, we don't require a toolbar to function if(!name || name === '') throw('textAngular Error: An editor requires a name'); if(!scope) throw('textAngular Error: An editor requires a scope'); if(editors[name]) throw('textAngular Error: An Editor with name "' + name + '" already exists'); // _toolbars is an ARRAY of toolbar scopes var _toolbars = []; angular.forEach(targetToolbars, function(_name){ if(toolbars[_name]) _toolbars.push(toolbars[_name]); // if it doesn't exist it may not have been compiled yet and it will be added later }); editors[name] = { scope: scope, toolbars: targetToolbars, _registerToolbar: function(toolbarScope){ // add to the list late if(this.toolbars.indexOf(toolbarScope.name) >= 0) _toolbars.push(toolbarScope); }, // this is a suite of functions the editor should use to update all it's linked toolbars editorFunctions: { disable: function(){ // disable all linked toolbars angular.forEach(_toolbars, function(toolbarScope){ toolbarScope.disabled = true; }); }, enable: function(){ // enable all linked toolbars angular.forEach(_toolbars, function(toolbarScope){ toolbarScope.disabled = false; }); }, focus: function(){ // this should be called when the editor is focussed angular.forEach(_toolbars, function(toolbarScope){ toolbarScope._parent = scope; toolbarScope.disabled = false; toolbarScope.focussed = true; scope.focussed = true; }); }, unfocus: function(){ // this should be called when the editor becomes unfocussed angular.forEach(_toolbars, function(toolbarScope){ toolbarScope.disabled = true; toolbarScope.focussed = false; }); scope.focussed = false; }, updateSelectedStyles: function(selectedElement){ // update the active state of all buttons on liked toolbars angular.forEach(_toolbars, function(toolbarScope){ angular.forEach(toolbarScope.tools, function(toolScope){ if(toolScope.activeState){ toolbarScope._parent = scope; toolScope.active = toolScope.activeState(selectedElement); } }); }); }, sendKeyCommand: function(event){ // we return true if we applied an action, false otherwise var result = false; if(event.ctrlKey || event.metaKey) angular.forEach(taTools, function(tool, name){ if(tool.commandKeyCode && tool.commandKeyCode === event.which){ for(var _t = 0; _t < _toolbars.length; _t++){ if(_toolbars[_t].tools[name] !== undefined){ taToolExecuteAction.call(_toolbars[_t].tools[name], scope); result = true; break; } } } }); return result; }, triggerElementSelect: function(event, element){ // search through the taTools to see if a match for the tag is made. // if there is, see if the tool is on a registered toolbar and not disabled. // NOTE: This can trigger on MULTIPLE tools simultaneously. var elementHasAttrs = function(_element, attrs){ var result = true; for(var i = 0; i < attrs.length; i++) result = result && _element.attr(attrs[i]); return result; }; var workerTools = []; var unfilteredTools = {}; var result = false; element = angular.element(element); // get all valid tools by element name, keep track if one matches the var onlyWithAttrsFilter = false; angular.forEach(taTools, function(tool, name){ if( tool.onElementSelect && tool.onElementSelect.element && tool.onElementSelect.element.toLowerCase() === element[0].tagName.toLowerCase() && (!tool.onElementSelect.filter || tool.onElementSelect.filter(element)) ){ // this should only end up true if the element matches the only attributes onlyWithAttrsFilter = onlyWithAttrsFilter || (angular.isArray(tool.onElementSelect.onlyWithAttrs) && elementHasAttrs(element, tool.onElementSelect.onlyWithAttrs)); if(!tool.onElementSelect.onlyWithAttrs || elementHasAttrs(element, tool.onElementSelect.onlyWithAttrs)) unfilteredTools[name] = tool; } }); // if we matched attributes to filter on, then filter, else continue if(onlyWithAttrsFilter){ angular.forEach(unfilteredTools, function(tool, name){ if(tool.onElementSelect.onlyWithAttrs && elementHasAttrs(element, tool.onElementSelect.onlyWithAttrs)) workerTools.push({'name': name, 'tool': tool}); }); // sort most specific (most attrs to find) first workerTools.sort(function(a,b){ return b.tool.onElementSelect.onlyWithAttrs.length - a.tool.onElementSelect.onlyWithAttrs.length; }); }else{ angular.forEach(unfilteredTools, function(tool, name){ workerTools.push({'name': name, 'tool': tool}); }); } // Run the actions on the first visible filtered tool only if(workerTools.length > 0){ for(var _i = 0; _i < workerTools.length; _i++){ var tool = workerTools[_i].tool; var name = workerTools[_i].name; for(var _t = 0; _t < _toolbars.length; _t++){ if(_toolbars[_t].tools[name] !== undefined){ tool.onElementSelect.action.call(_toolbars[_t].tools[name], event, element, scope); result = true; break; } } if(result) break; } } return result; } } }; return editors[name].editorFunctions; }, // retrieve editor by name, largely used by testing suites only retrieveEditor: function(name){ return editors[name]; }, unregisterEditor: function(name){ delete editors[name]; }, // registers a toolbar such that it can be linked to editors registerToolbar: function(scope){ if(!scope) throw('textAngular Error: A toolbar requires a scope'); if(!scope.name || scope.name === '') throw('textAngular Error: A toolbar requires a name'); if(toolbars[scope.name]) throw('textAngular Error: A toolbar with name "' + scope.name + '" already exists'); toolbars[scope.name] = scope; angular.forEach(editors, function(_editor){ _editor._registerToolbar(scope); }); }, // retrieve toolbar by name, largely used by testing suites only retrieveToolbar: function(name){ return toolbars[name]; }, // retrieve toolbars by editor name, largely used by testing suites only retrieveToolbarsViaEditor: function(name){ var result = [], _this = this; angular.forEach(this.retrieveEditor(name).toolbars, function(name){ result.push(_this.retrieveToolbar(name)); }); return result; }, unregisterToolbar: function(name){ delete toolbars[name]; }, // functions for updating the toolbar buttons display updateToolsDisplay: function(newTaTools){ // pass a partial struct of the taTools, this allows us to update the tools on the fly, will not change the defaults. var _this = this; angular.forEach(newTaTools, function(_newTool, key){ _this.updateToolDisplay(key, _newTool); }); }, // this function resets all toolbars to their default tool definitions resetToolsDisplay: function(){ var _this = this; angular.forEach(taTools, function(_newTool, key){ _this.resetToolDisplay(key); }); }, // update a tool on all toolbars updateToolDisplay: function(toolKey, _newTool){ var _this = this; angular.forEach(toolbars, function(toolbarScope, toolbarKey){ _this.updateToolbarToolDisplay(toolbarKey, toolKey, _newTool); }); }, // resets a tool to the default/starting state on all toolbars resetToolDisplay: function(toolKey){ var _this = this; angular.forEach(toolbars, function(toolbarScope, toolbarKey){ _this.resetToolbarToolDisplay(toolbarKey, toolKey); }); }, // update a tool on a specific toolbar updateToolbarToolDisplay: function(toolbarKey, toolKey, _newTool){ if(toolbars[toolbarKey]) toolbars[toolbarKey].updateToolDisplay(toolKey, _newTool); else throw('textAngular Error: No Toolbar with name "' + toolbarKey + '" exists'); }, // reset a tool on a specific toolbar to it's default starting value resetToolbarToolDisplay: function(toolbarKey, toolKey){ if(toolbars[toolbarKey]) toolbars[toolbarKey].updateToolDisplay(toolKey, taTools[toolKey], true); else throw('textAngular Error: No Toolbar with name "' + toolbarKey + '" exists'); }, // removes a tool from all toolbars and it's definition removeTool: function(toolKey){ delete taTools[toolKey]; angular.forEach(toolbars, function(toolbarScope){ delete toolbarScope.tools[toolKey]; for(var i = 0; i < toolbarScope.toolbar.length; i++){ var toolbarIndex; for(var j = 0; j < toolbarScope.toolbar[i].length; j++){ if(toolbarScope.toolbar[i][j] === toolKey){ toolbarIndex = { group: i, index: j }; break; } if(toolbarIndex !== undefined) break; } if(toolbarIndex !== undefined){ toolbarScope.toolbar[toolbarIndex.group].slice(toolbarIndex.index, 1); toolbarScope._$element.children().eq(toolbarIndex.group).children().eq(toolbarIndex.index).remove(); } } }); }, // toolkey, toolDefinition are required. If group is not specified will pick the last group, if index isnt defined will append to group addTool: function(toolKey, toolDefinition, group, index){ taRegisterTool(toolKey, toolDefinition); angular.forEach(toolbars, function(toolbarScope){ toolbarScope.addTool(toolKey, toolDefinition, group, index); }); }, // adds a Tool but only to one toolbar not all addToolToToolbar: function(toolKey, toolDefinition, toolbarKey, group, index){ taRegisterTool(toolKey, toolDefinition); toolbars[toolbarKey].addTool(toolKey, toolDefinition, group, index); }, // this is used when externally the html of an editor has been changed and textAngular needs to be notified to update the model. // this will call a $digest if not already happening refreshEditor: function(name){ if(editors[name]){ editors[name].scope.updateTaBindtaTextElement(); /* istanbul ignore else: phase catch */ if(!editors[name].scope.$$phase) editors[name].scope.$digest(); }else throw('textAngular Error: No Editor with name "' + name + '" exists'); } }; }]); textAngular.directive('textAngularToolbar', [ '$compile', 'textAngularManager', 'taOptions', 'taTools', 'taToolExecuteAction', '$window', function($compile, textAngularManager, taOptions, taTools, taToolExecuteAction, $window){ return { scope: { name: '@' // a name IS required }, restrict: "EA", link: function(scope, element, attrs){ if(!scope.name || scope.name === '') throw('textAngular Error: A toolbar requires a name'); angular.extend(scope, angular.copy(taOptions)); if(attrs.taToolbar) scope.toolbar = scope.$parent.$eval(attrs.taToolbar); if(attrs.taToolbarClass) scope.classes.toolbar = attrs.taToolbarClass; if(attrs.taToolbarGroupClass) scope.classes.toolbarGroup = attrs.taToolbarGroupClass; if(attrs.taToolbarButtonClass) scope.classes.toolbarButton = attrs.taToolbarButtonClass; if(attrs.taToolbarActiveButtonClass) scope.classes.toolbarButtonActive = attrs.taToolbarActiveButtonClass; if(attrs.taFocussedClass) scope.classes.focussed = attrs.taFocussedClass; scope.disabled = true; scope.focussed = false; scope._$element = element; element[0].innerHTML = ''; element.addClass("ta-toolbar " + scope.classes.toolbar); scope.$watch('focussed', function(){ if(scope.focussed) element.addClass(scope.classes.focussed); else element.removeClass(scope.classes.focussed); }); var setupToolElement = function(toolDefinition, toolScope){ var toolElement; if(toolDefinition && toolDefinition.display){ toolElement = angular.element(toolDefinition.display); } else toolElement = angular.element("<button type='button'>"); if(toolDefinition && toolDefinition["class"]) toolElement.addClass(toolDefinition["class"]); else toolElement.addClass(scope.classes.toolbarButton); toolElement.attr('name', toolScope.name); // important to not take focus from the main text/html entry toolElement.attr('unselectable', 'on'); toolElement.attr('ng-disabled', 'isDisabled()'); toolElement.attr('tabindex', '-1'); toolElement.attr('ng-click', 'executeAction()'); toolElement.attr('ng-class', 'displayActiveToolClass(active)'); if (toolDefinition && toolDefinition.tooltiptext) { toolElement.attr('title', toolDefinition.tooltiptext); } toolElement.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 toolbar buttons e.preventDefault(); return false; }); if(toolDefinition && !toolDefinition.display && !toolScope._display){ // first clear out the current contents if any toolElement[0].innerHTML = ''; // add the buttonText if(toolDefinition.buttontext) toolElement[0].innerHTML = toolDefinition.buttontext; // add the icon to the front of the button if there is content if(toolDefinition.iconclass){ var icon = angular.element('<i>'), content = toolElement[0].innerHTML; icon.addClass(toolDefinition.iconclass); toolElement[0].innerHTML = ''; toolElement.append(icon); if(content && content !== '') toolElement.append('&nbsp;' + content); } } toolScope._lastToolDefinition = angular.copy(toolDefinition); return $compile(toolElement)(toolScope); }; // Keep a reference for updating the active states later scope.tools = {}; // create the tools in the toolbar // default functions and values to prevent errors in testing and on init scope._parent = { disabled: true, showHtml: false, queryFormatBlockState: function(){ return false; }, queryCommandState: function(){ return false; } }; var defaultChildScope = { $window: $window, $editor: function(){ // dynamically gets the editor as it is set return scope._parent; }, isDisabled: function(){ // to set your own disabled logic set a function or boolean on the tool called 'disabled' return ( // this bracket is important as without it it just returns the first bracket and ignores the rest // when the button's disabled function/value evaluates to true (typeof this.$eval('disabled') !== 'function' && this.$eval('disabled')) || this.$eval('disabled()') || // all buttons except the HTML Switch button should be disabled in the showHtml (RAW html) mode (this.name !== 'html' && this.$editor().showHtml) || // if the toolbar is disabled this.$parent.disabled || // if the current editor is disabled this.$editor().disabled ); }, displayActiveToolClass: function(active){ return (active)? scope.classes.toolbarButtonActive : ''; }, executeAction: taToolExecuteAction }; angular.forEach(scope.toolbar, function(group){ // setup the toolbar group var groupElement = angular.element("<div>"); groupElement.addClass(scope.classes.toolbarGroup); angular.forEach(group, function(tool){ // init and add the tools to the group // a tool name (key name from taTools struct) //creates a child scope of the main angularText scope and then extends the childScope with the functions of this particular tool // reference to the scope and element kept scope.tools[tool] = angular.extend(scope.$new(true), taTools[tool], defaultChildScope, {name: tool}); scope.tools[tool].$element = setupToolElement(taTools[tool], scope.tools[tool]); // append the tool compiled with the childScope to the group element groupElement.append(scope.tools[tool].$element); }); // append the group to the toolbar element.append(groupElement); }); // update a tool // if a value is set to null, remove from the display // when forceNew is set to true it will ignore all previous settings, used to reset to taTools definition // to reset to defaults pass in taTools[key] as _newTool and forceNew as true, ie `updateToolDisplay(key, taTools[key], true);` scope.updateToolDisplay = function(key, _newTool, forceNew){ var toolInstance = scope.tools[key]; if(toolInstance){ // get the last toolDefinition, then override with the new definition if(toolInstance._lastToolDefinition && !forceNew) _newTool = angular.extend({}, toolInstance._lastToolDefinition, _newTool); if(_newTool.buttontext === null && _newTool.iconclass === null && _newTool.display === null) throw('textAngular Error: Tool Definition for updating "' + key + '" does not have a valid display/iconclass/buttontext value'); // if tool is defined on this toolbar, update/redo the tool if(_newTool.buttontext === null){ delete _newTool.buttontext; } if(_newTool.iconclass === null){ delete _newTool.iconclass; } if(_newTool.display === null){ delete _newTool.display; } var toolElement = setupToolElement(_newTool, toolInstance); toolInstance.$element.replaceWith(toolElement); toolInstance.$element = toolElement; } }; // we assume here that all values passed are valid and correct scope.addTool = function(key, _newTool, groupIndex, index){ scope.tools[key] = angular.extend(scope.$new(true), taTools[key], defaultChildScope, {name: key}); scope.tools[key].$element = setupToolElement(taTools[key], scope.tools[key]); var group; if(groupIndex === undefined) groupIndex = scope.toolbar.length - 1; group = angular.element(element.children()[groupIndex]); if(index === undefined){ group.append(scope.tools[key].$element); scope.toolbar[groupIndex][scope.toolbar[groupIndex].length - 1] = key; }else{ group.children().eq(index).after(scope.tools[key].$element); scope.toolbar[groupIndex][index] = key; } }; textAngularManager.registerToolbar(scope); scope.$on('$destroy', function(){ textAngularManager.unregisterToolbar(scope.name); }); } }; } ]);