import angular from 'angular' import $ from 'jquery' import _ from 'lodash' import template from './first-time-motivator-transclude.html' import firstTimeMotivatorTemplate from './first-time-motivator.html' angular.module('app').directive('mflyFirstTimeMotivator', [ '$compile', '$timeout', '$window', 'translateFactory', ( $compile, $timeout, $window, translateFactory, ) => { return { controller: ['$scope', '$element', function( this: { setTargetVisibility: (newValue: boolean) => void }, $scope: ScopeKeyValuePair, $element: ng.IAugmentedJQuery ) { // initialize local elements for the ftm and the target let ftmElement let targetElement // also create an object to reference the ftm's size and position const ftmPosition = { height: 0, left: -800, // initial position absolute and off screen top: -800, width: 0 } // set translations $scope.closeText = translateFactory.instant('JSUI.CLOSE') $scope.nextText = translateFactory.instant('JSUI.NEXT') $scope.okText = translateFactory.instant('JSUI.OK_GOT_IT') // Template compilation and positioning is asnyc - create a compilation flag // To independently maintain when the ftm is ready to be positioned in view let ftmIsCompiled = false // assume we are positioned to the top if a position is not provided if (!$scope.position) { $scope.position = 'top' } this.setTargetVisibility = (newValue) => { if (newValue && isDisplayed()) { // we only need to reposition as we are already displayed positionFirstTimeMotivator() } else if (newValue && !isDisplayed()) { // we need toposition then display... positionFirstTimeMotivator() displayFtm() } else { // we are hiding the component hideFtm() } } function isDisplayed() { if (ftmElement) { return $(ftmElement).hasClass('is-visible') } else { return false } } function displayFtm() { if (ftmElement) { $(ftmElement).addClass('is-visible') } } function hideFtm() { if (ftmElement) { $(ftmElement).removeClass('is-visible') } } // In addition to the target's visibility the calling controller must set the // exposed isVisible attribute to show the ftm. // We watch changes in this variable to position the ftm $scope.$watch('isVisible', (newValue) => { if (newValue) { // If the isVisible is true then compile and show the ftm compileAndShowFirstTimeMotivator() } else { closeFtm() } }) // --------------------------------------------------------------------------- // Compilation functions // --------------------------------------------------------------------------- function compileElement(templateToCompile) { return $compile(angular.element(templateToCompile))($scope) } function compileAndShowFirstTimeMotivator() { // Showing the FTM can be difficult because heights and widths can be tricky on a // dispay:none element due to this the ftm is hidden by positioning it offscreen // with a top left css !important override. // That override is removed when isVisible and targetIsVisible are both truthy // at which point this funciton takes over and positions the element. if (!ftmIsCompiled) { // If we've show the ftmElement we need to compile it and add it to the dom ftmElement = compileElement(firstTimeMotivatorTemplate) $('body').append(ftmElement) $timeout(() => { // get the dimensions of the ftm and the targetElement // (must be after a timeout to ensure it has an accurate size) targetElement = $element[0].children[0] ftmPosition.width = ftmElement[0].offsetWidth ftmPosition.height = ftmElement[0].offsetHeight // Once we've set the ftm's width and height we can calculate and apply // the final position calculatePosition() ftmIsCompiled = true // the timeout wrapping this compilation is necessary but as a by product it can squash the listeners in // the target element. broadcast to the child to let it know a display was attempted and is now compiled $scope.$broadcast('compileComplete') }) } else { calculatePosition() } } // --------------------------------------------------------------------------- // Positioning functions // --------------------------------------------------------------------------- function calculatePosition() { // get position of the target element const parentRectangle = targetElement.getBoundingClientRect() // center horizontally for top or bottom position if ($scope.position === 'top' || $scope.position === 'bottom') { const horizCenterOfParent = (parentRectangle.width / 2) + parentRectangle.left ftmPosition.left = horizCenterOfParent - (ftmPosition.width / 2) // if top, position above the targetElement if ($scope.position === 'top') { ftmPosition.top = parentRectangle.top - ftmPosition.height - 5 } else { // if bottom, position below... ftmPosition.top = parentRectangle.top + parentRectangle.height + 5 } } // center vertically for left or right position if ($scope.position === 'left' || $scope.position === 'right') { const verticalCenterOfParent = (parentRectangle.height / 2) + parentRectangle.top ftmPosition.top = verticalCenterOfParent - (ftmPosition.height / 2) // if left, position to the left of the targetElement if ($scope.position === 'left') { ftmPosition.left = parentRectangle.left - ftmPosition.width - 10 } else { // if right, position right... ftmPosition.left = parentRectangle.left + parentRectangle.width + 10 } } // Account for potential scrolling ftmPosition.left += $window.pageXOffset ftmPosition.top += $window.pageYOffset // If manaual offsets were included, add those now if (angular.isNumber($scope.offsetX)) { ftmPosition.left += $scope.offsetX } if (angular.isNumber($scope.offsetY)) { ftmPosition.top += $scope.offsetY } // If manual arrow offset is included add those now if (parseInt($scope.arrowOffset, 10)) { offsetArrow() } // Set the correct position for the ftm $(ftmElement) .css('left', ftmPosition.left + 'px') .css('top', ftmPosition.top + 'px') } function positionFirstTimeMotivator() { if (ftmIsCompiled) { // if we compiled the ftm and located the target then position calculatePosition() } } function offsetArrow() { // Offset for a pseudo element cannot be a simple dynamic attr change // The Pseudo is not actually part of the DOM and can't have it's styles directly edited // Instead we will create a dynamic temp class, apply it to the element, and append it to the document // This function is called during positioning which can happen several times yet our style // only needs to be applied once so if we already have an arrow-offset class get out if (_.includes($(ftmElement).attr('class'), 'arrow-offset-')) { return } const attr = ($scope.position === 'top' || $scope.position === 'bottom') ? 'left' : 'top' const beforeOffset = parseInt($scope.arrowOffset, 10) - 17 const afterOffset = parseInt($scope.arrowOffset, 10) - 15 let beforeOffsetStr let afterOffsetStr // Syntax adjusting for calc() css function if (Number.isFinite(beforeOffset) && beforeOffset < 0) { beforeOffsetStr = '- ' + _.trimStart(beforeOffset.toString(), '-') } else { beforeOffsetStr = '+ ' + beforeOffset.toString() } if (Number.isFinite(afterOffset) && afterOffset < 0) { afterOffsetStr = '- ' + _.trimStart(afterOffset.toString(), '-') } else { afterOffsetStr = '+ ' + afterOffset.toString() } // create a temp class just for this instance const tempClass = 'arrow-offset-' + hashTitle() const tempStyle = '' // add style to document $(tempStyle).appendTo('head') // add class to element $(ftmElement).addClass(tempClass) } function hashTitle() { let hash = 0 let i let chr if ($scope.ftmTitle.length === 0) { return hash } for (i = 0; i < $scope.ftmTitle.length; i++) { chr = $scope.ftmTitle.charCodeAt(i) // tslint:disable-next-line:no-bitwise hash = ((hash << 5) - hash) + chr // tslint:disable-next-line:no-bitwise hash |= 0 // Convert to 32bit integer } return hash } // ---------------------------------------------------- // Cleanup and Scope functions // ---------------------------------------------------- // Removes FTM from the DOM function closeFtm() { if (!!ftmElement) { ftmElement.remove() ftmIsCompiled = false } } // Called when the close link is used in the UI $scope.close = () => { // return the step to the callback $scope.onClose({step: $scope.step}) // hide the ftm (removes from the DOM) $scope.isVisible = false } // called with the ok button is clicked in the UI $scope.complete = () => { $scope.onComplete() // hide the ftm (removes from the DOM) $scope.isVisible = false } // cleanup $scope.$on('$destroy', () => { // remove the ftm when the scope is destroyed closeFtm() }) }], replace: true, restrict: 'E', scope: { arrowOffset: '@', count: '@', ftmText: '@', ftmTitle: '@', group: '@', isVisible: '=', offsetX: '=', offsetY: '=', onClose: '&', onComplete: '&', position: '@', step: '@', zIndexLevel: '@' }, template, transclude: true, } // tslint:disable-next-line:max-file-line-count }])