bauhausjs
Version:
A modular CMS for Node.js
1,070 lines (1,010 loc) • 48.7 kB
JavaScript
// 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(' ' + 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);
});
}
};
}
]);