/* The MIT License (MIT) Copyright (c) 2014 Jeff Collins Changes: 1. 2017-05-10 Edmond Meng Add if condition in getTextPrecedingCurrentSelection function to support non-contenteditable elements in contenteditable element; Add space button to trigger selection; 2. 2017-05-17 Edmond Meng Remove operation to add \xA0 after replaced text. We do not need the character. 3. 2017-06-02 Edmond Meng Modify $watch('isVisible()') to make sure mentio-menu is hidden once mentio input init. 4. 2017-06-06 Edmond Meng Modify $log.error to $log.debug to avoid error output while changing mention-input from editable to non-editable. 5. 2017-07-13 Edmond Meng Modify completely, to support new feature. Maybe refactor is required if time is enough. */ 'use strict'; angular.module('mentio', []) .directive('mentio', ['mentioUtil', '$document', '$compile', '$log', '$timeout', function (mentioUtil, $document, $compile, $log, $timeout) { return { restrict: 'A', scope: { macros: '=mentioMacros', search: '&mentioSearch', select: '&mentioSelect', items: '=mentioItems', autoSearch: '&mentioAutoSearch', autoSelect: '&mentioAutoSelect', autoItems: '=mentioAutoItems', typedTerm: '=mentioTypedTerm', rangeInfo: '=mentioRangeInfo', altId: '=mentioId', iframeElement: '=mentioIframeElement', requireLeadingSpace: '=mentioRequireLeadingSpace', selectNotFound: '=mentioSelectNotFound', trimTerm: '=mentioTrimTerm', ngModel: '=' }, controller: ["$scope", "$timeout", "$attrs", "enums", function ($scope, $timeout, $attrs, enums) { $scope.query = function (triggerChar, triggerText) { var remoteScope = $scope.triggerCharMap[triggerChar]; if ($scope.trimTerm === undefined || $scope.trimTerm) { triggerText = triggerText.trim(); } remoteScope.showMenu(); remoteScope.search({ term: triggerText }); $scope.typedTerm = triggerText; }; $scope.autoTriggerQuery = function (triggerText) { var remoteScope = $scope.autoTriggerMenu; if ($scope.trimTerm === undefined || $scope.trimTerm) { triggerText = triggerText.trim(); } remoteScope.showMenu(); var query = remoteScope.search({ term: triggerText }); if (typeof query.then === 'function') { /* query is a promise, at least our best guess */ query.then(function (result) { if (!result || result.length === 0) { $scope.typedTerm = ''; } else { $scope.typedTerm = triggerText; } }); } else { $scope.typedTerm = triggerText; } }; $scope.defaultSearch = function(locals) { var results = []; angular.forEach($scope.items, function(item) { if (item.label.toUpperCase().indexOf(locals.term.toUpperCase()) >= 0) { results.push(item); } }); $scope.localItems = results; }; $scope.bridgeSearch = function(termString) { var searchFn = $attrs.mentioSearch ? $scope.search : $scope.defaultSearch; return searchFn({ term: termString }); }; $scope.bridgeAutoTriggerSearch = function (termString) { return $scope.autoSearch({ term: termString }); }; $scope.defaultSelect = function(locals) { return $scope.defaultTriggerChar + locals.item.label; }; $scope.bridgeSelect = function(itemVar) { var selectFn = $attrs.mentioSelect ? $scope.select : $scope.defaultSelect; return selectFn({ item: itemVar }); }; $scope.bridgeAutoTriggerSelect = function (itemVar) { return $scope.autoSelect({ item: itemVar }); }; $scope.setTriggerText = function(text) { if ($scope.syncTriggerText) { $scope.typedTerm = ($scope.trimTerm === undefined || $scope.trimTerm) ? text.trim() : text; } }; $scope.context = function() { if ($scope.iframeElement) { return {iframe: $scope.iframeElement}; } }; $scope.replaceText = function (text, hasTrailingSpace) { $scope.hideAll(); if ($scope.currentMentionTriggerChar) { mentioUtil.replaceTriggerText($scope.context(), $scope.targetElement, $scope.targetElementPath, $scope.targetElementSelectedOffset, $scope.triggerCharSet, text, $scope.requireLeadingSpace, hasTrailingSpace); if (!hasTrailingSpace) { $scope.setTriggerText(''); angular.element($scope.targetElement).triggerHandler('change'); if ($scope.isContentEditable()) { $scope.contentEditableMenuPasted = true; var timer = $timeout(function () { $scope.contentEditableMenuPasted = false; }, 200); $scope.$on('$destroy', function () { $timeout.cancel(timer); }); } } } else { mentioUtil.replaceAutoTriggerText($scope.targetElement, $scope.targetElementPath, $scope.targetElementSelectedOffset, $scope.triggerCharSet, text); } mentioUtil.clearEmptyTextNode($scope.targetElement); $scope.rangeInfo = mentioUtil.getContentEditableSelectedPath($scope.context()); }; $scope.hideAll = function () { for (var key in $scope.triggerCharMap) { if ($scope.triggerCharMap.hasOwnProperty(key)) { $scope.triggerCharMap[key].hideMenu(); } } if ($scope.autoTriggerMenu) { $scope.autoTriggerMenu.hideMenu(); } }; $scope.getActiveMenuScope = function () { for (var key in $scope.triggerCharMap) { if ($scope.triggerCharMap.hasOwnProperty(key)) { if ($scope.triggerCharMap[key].visible) { return $scope.triggerCharMap[key]; } } } if ($scope.autoTriggerMenu && $scope.autoTriggerMenu.visible) { return $scope.autoTriggerMenu; } return null; }; $scope.selectActive = function () { for (var key in $scope.triggerCharMap) { if ($scope.triggerCharMap.hasOwnProperty(key)) { if ($scope.triggerCharMap[key].visible) { $scope.triggerCharMap[key].selectActive(); } } } if ($scope.autoTriggerMenu && $scope.autoTriggerMenu.visible) { $scope.autoTriggerMenu.selectActive(); } }; $scope.isActive = function () { for (var key in $scope.triggerCharMap) { if ($scope.triggerCharMap.hasOwnProperty(key)) { if ($scope.triggerCharMap[key].visible) { return true; } } } if ($scope.autoTriggerMenu && $scope.autoTriggerMenu.visible) { return true; } return false; }; $scope.isContentEditable = function() { return ($scope.targetElement.nodeName !== 'INPUT' && $scope.targetElement.nodeName !== 'TEXTAREA'); }; $scope.replaceMacro = function(macro, hasTrailingSpace) { if (!hasTrailingSpace) { $scope.replacingMacro = true; $scope.timer = $timeout(function() { mentioUtil.replaceMacroText($scope.context(), $scope.targetElement, $scope.targetElementPath, $scope.targetElementSelectedOffset, $scope.macros, $scope.macros[macro]); angular.element($scope.targetElement).triggerHandler('change'); $scope.replacingMacro = false; }, 300); $scope.$on('$destroy', function() { $timeout.cancel($scope.timer); }); } else { mentioUtil.replaceMacroText($scope.context(), $scope.targetElement, $scope.targetElementPath, $scope.targetElementSelectedOffset, $scope.macros, $scope.macros[macro]); } mentioUtil.clearEmptyTextNode($scope.targetElement); $scope.rangeInfo = mentioUtil.getContentEditableSelectedPath($scope.context()); }; $scope.addMenu = function(menuScope) { if (menuScope.parentScope && (menuScope.triggerChar && $scope.triggerCharMap.hasOwnProperty(menuScope.triggerChar) || !menuScope.triggerChar && $scope.autoTriggerMenu)) { return; } if (menuScope.triggerChar) { $scope.triggerCharMap[menuScope.triggerChar] = menuScope; if ($scope.triggerCharSet === undefined) { $scope.triggerCharSet = []; } $scope.triggerCharSet.push(menuScope.triggerChar); } else { $scope.autoTriggerMenu = menuScope; } menuScope.setParent($scope); }; $scope.$on( 'menuCreated', function (event, data) { if ( $attrs.id !== undefined || $attrs.mentioId !== undefined ) { if ( $attrs.id === data.targetElement || ( $attrs.mentioId !== undefined && $scope.altId === data.targetElement ) ) { $scope.addMenu(data.scope); } } } ); $document.on( 'click', function () { if ($scope.isActive()) { $scope.$apply(function () { $scope.hideAll(); }); } } ); $document.on( 'keydown keypress paste', function (event) { var activeMenuScope = $scope.getActiveMenuScope(); if (activeMenuScope) { // Edmond Meng: Add Space button to trigger selection if (event.which === enums.keyCode.tab || event.which === enums.keyCode.enter || event.which === enums.keyCode.space) { event.preventDefault(); activeMenuScope.selectActive(); } if (event.which === enums.keyCode.esc) { event.preventDefault(); activeMenuScope.$apply(function () { activeMenuScope.hideMenu(); }); } if (event.which === enums.keyCode.down) { // Since both key down and '(' are in code 40, handle this scenario if (!event.originalEvent || event.originalEvent.key !== '(') { event.preventDefault(); activeMenuScope.$apply(function () { activeMenuScope.activateNextItem(); }); activeMenuScope.adjustScroll(1); } else { event.preventDefault(); activeMenuScope.selectActive(); } } if (event.which === enums.keyCode.select) { if (event.originalEvent && event.originalEvent.key === ')') { event.preventDefault(); activeMenuScope.selectActive(); } } if (event.which === enums.keyCode.up) { // Since both key up and '&' are in code 38, handle this scenario if (!event.originalEvent || event.originalEvent.key !== '&') { event.preventDefault(); activeMenuScope.$apply(function () { activeMenuScope.activatePreviousItem(); }); activeMenuScope.adjustScroll(-1); } } if (event.which === enums.keyCode.left || event.which === enums.keyCode.right) { // Both key left and '%' are in code 37; Both key right and '\'' are in code 39 if (!event.originalEvent || event.originalEvent.key !== '%' && event.originalEvent.key !== '\'') { event.preventDefault(); } } } } ); }], link: function (scope, element, attrs) { scope.triggerCharMap = {}; scope.autoTriggerMenu; scope.rangeInfo; scope.targetElement = element; attrs.$set('autocomplete', 'off'); if (attrs.mentioItems) { scope.localItems = []; scope.parentScope = scope; var itemsRef = attrs.mentioSearch ? ' mentio-items="items"' : ' mentio-items="localItems"'; scope.defaultTriggerChar = attrs.mentioTriggerChar ? scope.$eval(attrs.mentioTriggerChar) : '@'; var htmlComm = '<mentio-menu' + ' mentio-search="bridgeSearch(term)"' + ' mentio-select="bridgeSelect(item)"' + itemsRef; if (attrs.mentioTemplateUrl) { htmlComm = htmlComm + ' mentio-template-url="' + attrs.mentioTemplateUrl + '"'; } htmlComm = htmlComm + ' mentio-trigger-char="\'' + scope.defaultTriggerChar + '\'"' + ' mentio-parent-scope="parentScope"' + '/>'; var linkFnComm = $compile(htmlComm); var elComm = linkFnComm(scope); var htmlAuto = '<mentio-menu' + ' mentio-search="bridgeAutoTriggerSearch(term)"' + ' mentio-select="bridgeAutoTriggerSelect(item)"' + ' mentio-items="autoItems"'; if (attrs.mentioTemplateUrl) { htmlAuto = htmlAuto + ' mentio-template-url="' + attrs.mentioTemplateUrl + '"'; } htmlAuto = htmlAuto + ' mentio-avoid-trigger-char="\'' + scope.defaultTriggerChar + '\'"' + ' mentio-parent-scope="parentScope"' + '/>'; var linkFnAuto = $compile(htmlAuto); var elAuto = linkFnAuto(scope); element.parent().append(elComm); element.parent().append(elAuto); scope.$on('$destroy', function() { elComm.remove(); elAuto.remove(); }); } if (attrs.mentioTypedTerm) { scope.syncTriggerText = true; } function keyHandler(event) { function stopEvent(event) { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); } var activeMenuScope = scope.getActiveMenuScope(); if (activeMenuScope) { if (event.which === enums.keyCode.tab || event.which === enums.keyCode.enter || event.which === enums.keyCode.space) { stopEvent(event); activeMenuScope.selectActive(); return false; } if (event.which === enums.keyCode.esc) { stopEvent(event); activeMenuScope.$apply(function () { activeMenuScope.hideMenu(); }); return false; } if (event.which === enums.keyCode.down) { // Since both key down and '(' are in code 40, handle this scenario if (!event.originalEvent || event.originalEvent.key !== '(') { stopEvent(event); activeMenuScope.$apply(function () { activeMenuScope.activateNextItem(); }); activeMenuScope.adjustScroll(1); } else { stopEvent(event); activeMenuScope.selectActive(); } return false; } if (event.which === enums.keyCode.select) { if (event.originalEvent && event.originalEvent.key === ')') { stopEvent(event); activeMenuScope.selectActive(); return false; } } if (event.which === enums.keyCode.up) { // Since both key up and '&' are in code 38, handle this scenario if (!event.originalEvent || event.originalEvent.key !== '&') { stopEvent(event); activeMenuScope.$apply(function () { activeMenuScope.activatePreviousItem(); }); activeMenuScope.adjustScroll(-1); return false; } } if (event.which === enums.keyCode.left || event.which === enums.keyCode.right) { // Both key left and '%' are in code 37; Both key right and '\'' are in code 39 if (!event.originalEvent || event.originalEvent.key !== '%' && event.originalEvent.key !== '\'') { stopEvent(event); return false; } } } } scope.$watch( 'iframeElement', function(newValue) { if (newValue) { var iframeDocument = newValue.contentWindow.document; iframeDocument.addEventListener('click', function () { if (scope.isActive()) { scope.$apply(function () { scope.hideAll(); }); } } ); iframeDocument.addEventListener('keydown', keyHandler, true /*capture*/); scope.$on ( '$destroy', function() { iframeDocument.removeEventListener ( 'keydown', keyHandler ); }); } } ); scope.$watch( 'ngModel', function (newValue) { /*jshint maxcomplexity:14 */ /*jshint maxstatements:39 */ // yes this function needs refactoring if ((!newValue || newValue === '') && !scope.isActive()) { // ignore while setting up return; } if (scope.triggerCharSet === undefined) { $log.debug('No mentio-items attribute was provided, ' + 'and no separate mentio-menus were specified. Nothing to do.'); return; } if (scope.contentEditableMenuPasted) { // don't respond to changes from insertion of the menu content scope.contentEditableMenuPasted = false; return; } if (scope.replacingMacro) { $timeout.cancel(scope.timer); scope.replacingMacro = false; } var isActive = scope.isActive(); var isContentEditable = scope.isContentEditable(); var mentionInfo = mentioUtil.getTriggerInfo(scope.context(), scope.triggerCharSet, scope.requireLeadingSpace, isActive); var autoMentionInfo = mentioUtil.getAutoTriggerInfo(scope.triggerCharSet, isActive); if (mentionInfo !== undefined && mentionInfo.mentionSelectedPath.length <= 1 && ( !isActive || (isActive && ( /* content editable selection changes to local nodes which modifies the start position of the selection over time, just consider triggerchar changes which will have the odd effect that deleting a trigger char pops the menu for a previous trigger char sequence if one exists in a content editable */ (isContentEditable && mentionInfo.mentionTriggerChar === scope.currentMentionTriggerChar) || (!isContentEditable && mentionInfo.mentionPosition === scope.currentMentionPosition) ) ) ) ) { /** save selection info about the target control for later re-selection */ if (mentionInfo.mentionSelectedElement) { scope.targetElement = mentionInfo.mentionSelectedElement; scope.targetElementPath = mentionInfo.mentionSelectedPath; scope.targetElementSelectedOffset = mentionInfo.mentionSelectedOffset; } /* publish to external ngModel */ scope.setTriggerText(mentionInfo.mentionText); /* remember current position */ scope.currentMentionPosition = mentionInfo.mentionPosition; scope.currentMentionTriggerChar = mentionInfo.mentionTriggerChar; /* perform query */ scope.query(mentionInfo.mentionTriggerChar, mentionInfo.mentionText); } else if (autoMentionInfo !== undefined && autoMentionInfo.mentionSelectedPath.length <= 1 && isContentEditable //( // !isActive || // (isActive && // ( // autoTriggerChars.indexOf(autoMentionInfo.mentionTriggerChar) >= 0 // ) // ) //) ) { /** save selection info about the target control for later re-selection */ if (autoMentionInfo.mentionSelectedElement) { scope.targetElement = autoMentionInfo.mentionSelectedElement; scope.targetElementPath = autoMentionInfo.mentionSelectedPath; scope.targetElementSelectedOffset = autoMentionInfo.mentionSelectedOffset; } /* publish to external ngModel */ scope.setTriggerText(autoMentionInfo.mentionText); /* remember current position */ scope.currentMentionPosition = autoMentionInfo.mentionPosition; scope.currentMentionTriggerChar = autoMentionInfo.mentionTriggerChar; /* perform query */ scope.autoTriggerQuery(autoMentionInfo.mentionText); } else { var currentTypedTerm = scope.typedTerm; scope.setTriggerText(''); scope.hideAll(); var macroMatchInfo = mentioUtil.getMacroMatch(scope.context(), scope.macros); if (macroMatchInfo !== undefined) { scope.targetElement = macroMatchInfo.macroSelectedElement; scope.targetElementPath = macroMatchInfo.macroSelectedPath; scope.targetElementSelectedOffset = macroMatchInfo.macroSelectedOffset; scope.replaceMacro(macroMatchInfo.macroText, macroMatchInfo.macroHasTrailingSpace); } else if (scope.selectNotFound && currentTypedTerm && currentTypedTerm !== '') { var lastScope; if (scope.currentMentionTriggerChar === undefined || scope.currentMentionTriggerChar === null) { lastScope = scope.autoTriggerMenu; } else { lastScope = scope.triggerCharMap[scope.currentMentionTriggerChar]; } if (lastScope) { // just came out of typeahead state var text = lastScope.select({ item: {label: currentTypedTerm} }); //if (typeof text.then === 'function') { // /* text is a promise, at least our best guess */ // text.then(scope.replaceText); //} else { // scope.replaceText(text, true); //} } } } } ); } }; }]) .directive('mentioMenu', ['mentioUtil', '$rootScope', '$log', '$window', '$document', function (mentioUtil, $rootScope, $log, $window, $document) { return { restrict: 'E', scope: { search: '&mentioSearch', select: '&mentioSelect', items: '=mentioItems', triggerChar: '=mentioTriggerChar', avoidTriggerChar: '=mentioAvoidTriggerChar', forElem: '=mentioFor', parentScope: '=mentioParentScope' }, templateUrl: function(tElement, tAttrs) { return tAttrs.mentioTemplateUrl !== undefined ? tAttrs.mentioTemplateUrl : 'mentio-menu.tpl.html'; }, controller: ["$scope", function ($scope) { $scope.visible = false; // callable both with controller (menuItem) and without controller (local) this.activate = $scope.activate = function (item) { $scope.activeItem = item; }; // callable both with controller (menuItem) and without controller (local) this.isActive = $scope.isActive = function (item) { return $scope.activeItem === item; }; // callable both with controller (menuItem) and without controller (local) this.selectItem = $scope.selectItem = function (item) { var text = $scope.select({ item: item }); if (typeof text.then === 'function') { /* text is a promise, at least our best guess */ text.then($scope.parentMentio.replaceText); } else { $scope.parentMentio.replaceText(text); } }; $scope.activateNextItem = function () { var length = $scope.items.length > 10 ? 10 : $scope.items.length; var index = $scope.items.indexOf($scope.activeItem); this.activate($scope.items[(index + 1) % length]); //this.activate($scope.items[(index + 1) % $scope.items.length]); }; $scope.activatePreviousItem = function () { var length = $scope.items.length > 10 ? 10 : $scope.items.length; var index = $scope.items.indexOf($scope.activeItem); this.activate($scope.items[index === 0 ? length - 1 : index - 1]); //this.activate($scope.items[index === 0 ? $scope.items.length - 1 : index - 1]); }; $scope.isFirstItemActive = function () { var index = $scope.items.indexOf($scope.activeItem); return index === 0; }; $scope.isLastItemActive = function () { var index = $scope.items.indexOf($scope.activeItem); return index === ($scope.items.length - 1); }; $scope.selectActive = function () { $scope.selectItem($scope.activeItem); }; $scope.isVisible = function () { return $scope.visible; }; $scope.showMenu = function () { if (!$scope.visible) { $scope.requestVisiblePendingSearch = true; } }; $scope.setParent = function (scope) { $scope.parentMentio = scope; $scope.targetElement = scope.targetElement; }; }], link: function (scope, element) { element[0].parentNode.removeChild(element[0]); $document[0].body.appendChild(element[0]); scope.menuElement = element; // for testing if (scope.parentScope) { scope.parentScope.addMenu(scope); } else { if (!scope.forElem) { $log.error('mentio-menu requires a target element in tbe mentio-for attribute'); return; } if (!scope.triggerChar) { $log.debug('Auto trigger menu.'); } else { $log.debug(scope.triggerChar + ' trigger menu.'); } // send own scope to mentio directive so that the menu // becomes attached $rootScope.$broadcast('menuCreated', { targetElement : scope.forElem, scope : scope }); } angular.element($window).bind( 'resize', function () { if (scope.isVisible()) { if (!scope.triggerChar) { var avoidTriggerCharSet = []; if (scope.avoidTriggerChar !== undefined && scope.avoidTriggerChar !== null) { avoidTriggerCharSet.push(scope.avoidTriggerChar); } mentioUtil.popAutoTriggerUnderMention(avoidTriggerCharSet, element); } else { var triggerCharSet = []; triggerCharSet.push(scope.triggerChar); mentioUtil.popUnderMention(scope.parentMentio.context(), triggerCharSet, element, scope.requireLeadingSpace); } } } ); scope.$watch('items', function (items) { if (items && items.length > 0) { scope.activate(items[0]); if (!scope.visible && scope.requestVisiblePendingSearch) { scope.visible = true; scope.requestVisiblePendingSearch = false; } } else { scope.hideMenu(); } }); scope.$watch('isVisible()', function (visible) { // wait for the watch notification to show the menu if (visible) { if (!scope.triggerChar) { var avoidTriggerCharSet = []; if (scope.avoidTriggerChar !== undefined && scope.avoidTriggerChar !== null) { avoidTriggerCharSet.push(scope.avoidTriggerChar); } mentioUtil.popAutoTriggerUnderMention(avoidTriggerCharSet, element); } else { var triggerCharSet = []; triggerCharSet.push(scope.triggerChar); mentioUtil.popUnderMention(scope.parentMentio.context(), triggerCharSet, element, scope.requireLeadingSpace); } } // Edmond Meng: Add to make sure mentio-menu is hidden once mentio input init. else if (element.css('display') !== 'none') { element.css('display', 'none'); } }); scope.parentMentio.$on('$destroy', function () { element.remove(); }); scope.hideMenu = function () { scope.visible = false; scope.$parent.typedTerm = ''; element.css('display', 'none'); }; scope.adjustScroll = function (direction) { var menuEl = element[0]; var menuItemsList = menuEl.querySelector('ul'); var menuItem = (menuEl.querySelector('[mentio-menu-item].active') || menuEl.querySelector('[data-mentio-menu-item].active')); if (scope.isFirstItemActive()) { return menuItemsList.scrollTop = 0; } else if(scope.isLastItemActive()) { return menuItemsList.scrollTop = menuItemsList.scrollHeight; } if (menuItem) { if (direction === 1) { menuItemsList.scrollTop += menuItem.offsetHeight; } else { menuItemsList.scrollTop -= menuItem.offsetHeight; } } }; } }; }]) .directive('mentioMenuItem', function () { return { restrict: 'A', scope: { item: '=mentioMenuItem' }, require: '^mentioMenu', link: function (scope, element, attrs, controller) { scope.$watch(function () { return controller.isActive(scope.item); }, function (active) { if (active) { element.addClass('active'); } else { element.removeClass('active'); } }); element.bind('mouseenter', function () { scope.$apply(function () { controller.activate(scope.item); }); }); element.bind('click', function () { controller.selectItem(scope.item); return false; }); } }; }) .filter('unsafe', ["$sce", function($sce) { return function (val) { return $sce.trustAsHtml(val); }; }]) .filter('mentioHighlight', function() { function escapeRegexp (queryToEscape) { return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); } return function (matchItem, query, hightlightClass) { if (query) { var replaceText = hightlightClass ? '<span class="' + hightlightClass + '">$&</span>' : '<strong>$&</strong>'; return ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), replaceText); } else { return matchItem; } }; }); 'use strict'; angular.module('mentio') .factory('mentioUtil', ["$window", "$location", "$anchorScroll", "$timeout", "enums", function ($window, $location, $anchorScroll, $timeout, enums) { // public function popUnderMention (ctx, triggerCharSet, selectionEl, requireLeadingSpace) { var coordinates; var mentionInfo = getTriggerInfo(ctx, triggerCharSet, requireLeadingSpace, false); if (mentionInfo !== undefined) { if (selectedElementIsTextAreaOrInput(ctx)) { coordinates = getTextAreaOrInputUnderlinePosition(ctx, getDocument(ctx).activeElement, mentionInfo.mentionPosition); } else { coordinates = getContentEditableCaretPosition(ctx, mentionInfo.mentionPosition); } // Move the button into place. selectionEl.css({ top: coordinates.top + 'px', left: coordinates.left + 'px', position: 'absolute', zIndex: 10000, display: 'block' }); $timeout(function(){ scrollIntoView(ctx, selectionEl); },0); } else { selectionEl.css({ display: 'none' }); } } // Auto trigger popup function for contenteditable function popAutoTriggerUnderMention (triggerCharSet, selectionEl) { var coordinates; var mentionInfo = getAutoTriggerInfo(triggerCharSet, false); if (mentionInfo !== undefined) { coordinates = getContentEditableCaretPosition(undefined, mentionInfo.mentionPosition); // Move the button into place. selectionEl.css({ top: coordinates.top + 'px', left: coordinates.left + 'px', position: 'absolute', zIndex: 10000, display: 'block' }); $timeout(function () { scrollIntoView(undefined, selectionEl); }, 0); } else { selectionEl.css({ display: 'none' }); } } function scrollIntoView(ctx, elem) { // cheap hack in px - need to check styles relative to the element var reasonableBuffer = 20; var maxScrollDisplacement = 100; var clientRect; var e = elem[0]; while (clientRect === undefined || clientRect.height === 0) { clientRect = e.getBoundingClientRect(); if (clientRect.height === 0) { e = e.childNodes[0]; if (e === undefined || !e.getBoundingClientRect) { return; } } } var elemTop = clientRect.top; var elemBottom = elemTop + clientRect.height; if(elemTop < 0) { $window.scrollTo(0, $window.pageYOffset + clientRect.top - reasonableBuffer); } else if (elemBottom > $window.innerHeight) { var maxY = $window.pageYOffset + clientRect.top - reasonableBuffer; if (maxY - $window.pageYOffset > maxScrollDisplacement) { maxY = $window.pageYOffset + maxScrollDisplacement; } var targetY = $window.pageYOffset - ($window.innerHeight - elemBottom); if (targetY > maxY) { targetY = maxY; } $window.scrollTo(0, targetY); } } function selectedElementIsTextAreaOrInput (ctx) { var element = getDocument(ctx).activeElement; if (element !== null) { var nodeName = element.nodeName; var type = element.getAttribute('type'); return (nodeName === 'INPUT' && type === 'text') || nodeName === 'TEXTAREA'; } return false; } function selectElement (ctx, targetElement, path, offset) { var elem = targetElement; if (path) { for (var i = 0; i < path.length; i++) { elem = elem.childNodes[path[i]]; if (elem === undefined) { return; } while (elem.length < offset) { offset -= elem.length; elem = elem.nextSibling; if (!elem) { return; } } if (elem.childNodes.length === 0 && !elem.length) { elem = elem.previousSibling; if (!elem) { return; } } } } moveCaretTo(ctx, elem, offset); targetElement.focus(); } function moveCaretTo(ctx, elem, offset) { var sel = getWindowSelection(ctx); var range = getDocument(ctx).createRange(); range.setStart(elem, offset); range.setEnd(elem, offset); range.collapse(true); try { sel.removeAllRanges(); } catch (error) { } sel.addRange(range); } function selectAutoTriggerElement(targetElement, path, offset) { var elem = targetElement; if (path) { for (var i = 0; i < path.length; i++) { elem = elem.childNodes[path[i]]; if (elem === undefined) { return; } while (elem.length < offset) { offset -= elem.length; elem = elem.nextSibling; if (!elem) { return; } } if (elem.childNodes.length === 0 && !elem.length) { elem = elem.previousSibling; if (!elem) { return; } } } } if (elem.nodeType !== enums.domNodeType.text) { offset = elem.childNodes.length; } moveCaretTo(undefined, elem, offset); targetElement.focus(); } function pasteHtml (ctx, html, startPos, endPos) { var range, sel; sel = getWindowSelection(ctx); range = getDocument(ctx).createRange(); range.setStart(sel.anchorNode, startPos); range.setEnd(sel.anchorNode, endPos); range.deleteContents(); var el = getDocument(ctx).createElement('div'); el.innerHTML = html; var frag = getDocument(ctx).createDocumentFragment(), node, lastNode; while ((node = el.firstChild)) { lastNode = frag.appendChild(node); } range.insertNode(frag); // Preserve the selection if (lastNode) { range = range.cloneRange(); range.setStartAfter(lastNode); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); return lastNode; } return null; } function resetSelection (ctx, targetElement, path, offset) { var nodeName = targetElement.nodeName; if (nodeName === 'INPUT' || nodeName === 'TEXTAREA') { if (targetElement !== getDocument(ctx).activeElement) { targetElement.focus(); } } else { selectElement(ctx, targetElement, path, offset); } } // public function replaceMacroText (ctx, targetElement, path, offset, macros, text) { resetSelection(ctx, targetElement, path, offset); var macroMatchInfo = getMacroMatch(ctx, macros); if (macroMatchInfo.macroHasTrailingSpace) { macroMatchInfo.macroText = macroMatchInfo.macroText + '\xA0'; text = text + '\xA0'; } if (macroMatchInfo !== undefined) { var element = getDocument(ctx).activeElement; if (selectedElementIsTextAreaOrInput(ctx)) { var startPos = macroMatchInfo.macroPosition; var endPos = macroMatchInfo.macroPosition + macroMatchInfo.macroText.length; element.value = element.value.substring(0, startPos) + text + element.value.substring(endPos, element.value.length); element.selectionStart = startPos + text.length; element.selectionEnd = startPos + text.length; } else { pasteHtml(ctx, text, macroMatchInfo.macroPosition, macroMatchInfo.macroPosition + macroMatchInfo.macroText.length); } } } // public function replaceTriggerText (ctx, targetElement, path, offset, triggerCharSet, text, requireLeadingSpace, hasTrailingSpace) { resetSelection(ctx, targetElement, path, offset); var mentionInfo = getTriggerInfo(ctx, triggerCharSet, requireLeadingSpace, true, hasTrailingSpace); if (mentionInfo !== undefined) { if (selectedElementIsTextAreaOrInput()) { var myField = getDocument(ctx).activeElement; text = text + ' '; var startPos = mentionInfo.mentionPosition; var endPos = mentionInfo.mentionPosition + mentionInfo.mentionText.length + 1; myField.value = myField.value.substring(0, startPos) + text + myField.value.substring(endPos, myField.value.length); myField.selectionStart = startPos + text.length; myField.selectionEnd = startPos + text.length; } else { // Remove operation to add \xA0. We do not need the character. //// add a space to the end of the pasted text //text = text + '\xA0'; pasteHtml(ctx, text, mentionInfo.mentionPosition, mentionInfo.mentionPosition + mentionInfo.mentionText.length + 1); } } } // public function replaceAutoTriggerText(targetElement, path, offset, triggerCharSet, text) { selectAutoTriggerElement(targetElement, path, offset); //resetSelection(undefined, targetElement, path, offset); var mentionInfo = getAutoTriggerInfo(triggerCharSet, true); if (mentionInfo !== undefined) { // Set caret between () added after the formula name var lastNode = pasteHtml(undefined, text, mentionInfo.mentionPosition, mentionInfo.mentionPosition + mentionInfo.mentionText.length); while (lastNode.nodeType !== enums.domNodeType.text) { lastNode = lastNode.childNodes[lastNode.childNodes.length - 1]; } moveCaretTo(undefined, lastNode, lastNode.textContent.length - 1); } } function getNodePositionInParent (ctx, elem) { if (elem.parentNode === null) { return 0; } for (var i = 0; i < elem.parentNode.childNodes.length; i++) { var node = elem.parentNode.childNodes[i]; if (node === elem) { return i; } } } // public function getMacroMatch (ctx, macros) { var selected, path = [], offset; if (selectedElementIsTextAreaOrInput(ctx)) { selected = getDocument(ctx).activeElement; } else { // content editable var selectionInfo = getContentEditableSelectedPath(ctx); if (selectionInfo) { selected = selectionInfo.selected; path = selectionInfo.path; offset = selectionInfo.offset; } } var effectiveRange = getTextPrecedingCurrentSelection(ctx); if (effectiveRange !== undefined && effectiveRange !== null) { var matchInfo; var hasTrailingSpace = false; if (effectiveRange.length > 0 && (effectiveRange.charAt(effectiveRange.length - 1) === '\xA0' || effectiveRange.charAt(effectiveRange.length - 1) === ' ')) { hasTrailingSpace = true; // strip space effectiveRange = effectiveRange.substring(0, effectiveRange.length-1); } angular.forEach(macros, function (macro, c) { var idx = effectiveRange.toUpperCase().lastIndexOf(c.toUpperCase()); if (idx >= 0 && c.length + idx === effectiveRange.length) { var prevCharPos = idx - 1; if (idx === 0 || effectiveRange.charAt(prevCharPos) === '\xA0' || effectiveRange.charAt(prevCharPos) === ' ' ) { matchInfo = { macroPosition: idx, macroText: c, macroSelectedElement: selected, macroSelectedPath: path, macroSelectedOffset: offset, macroHasTrailingSpace: hasTrailingSpace }; } } }); if (matchInfo) { return matchInfo; } } } function getContentEditableSelectedPath(ctx) { // content editable var sel = getWindowSelection(ctx); var selected = sel.anchorNode; var path = []; var offset; if (selected != null) { var i; var ce = selected.contentEditable; while (selected !== null && ce !== 'true') { i = getNodePositionInParent(ctx, selected); path.push(i); selected = selected.parentNode; if (selected !== null) { ce = selected.contentEditable; } } path.reverse(); // getRangeAt may not exist, need alternative offset = sel.getRangeAt(0).startOffset; return { selected: selected, path: path, offset: offset }; } } // public function getTriggerInfo (ctx, triggerCharSet, requireLeadingSpace, menuAlreadyActive, hasTrailingSpace) { /*jshint maxcomplexity:11 */ // yes this function needs refactoring var selected, path, offset; if (selectedElementIsTextAreaOrInput(ctx)) { selected = getDocument(ctx).activeElement; } else { // content editable var selectionInfo = getContentEditableSelectedPath(ctx); if (selectionInfo) { selected = selectionInfo.selected; path = selectionInfo.path; offset = selectionInfo.offset; } } var effectiveRange = getTextPrecedingCurrentSelection(ctx); if (effectiveRange !== undefined && effectiveRange !== null) { var mostRecentTriggerCharPos = -1; var triggerChar; triggerCharSet.forEach(function(c) { var idx = effectiveRange.lastIndexOf(c); if (idx > mostRecentTriggerCharPos) { mostRecentTriggerCharPos = idx; triggerChar = c; } }); if (mostRecentTriggerCharPos >= 0 && ( mostRecentTriggerCharPos === 0 || !requireLeadingSpace || /[\xA0\s]/g.test ( effectiveRange.substring( mostRecentTriggerCharPos - 1, mostRecentTriggerCharPos) ) ) ) { var currentTriggerSnippet = effectiveRange.substring(mostRecentTriggerCharPos + 1, effectiveRange.length); triggerChar = effectiveRange.substring(mostRecentTriggerCharPos, mostRecentTriggerCharPos+1); var firstSnippetChar = currentTriggerSnippet.substring(0,1); var leadingSpace = currentTriggerSnippet.length > 0 && ( firstSnippetChar === ' ' || firstSnippetChar === '\xA0' ); if (hasTrailingSpace) { currentTriggerSnippet = currentTriggerSnippet.trim(); } if (!leadingSpace && (menuAlreadyActive || !(/[\xA0\s]/g.test(currentTriggerSnippet)))) { return { mentionPosition: mostRecentTriggerCharPos, mentionText: currentTriggerSnippet, mentionSelectedElement: selected, mentionSelectedPath: path, mentionSelectedOffset: offset, mentionTriggerChar: triggerChar }; } } } } // public // Auto trigger function for contenteditable function getAutoTriggerInfo (triggerCharSet, menuAlreadyActive) { var selected, path, offset; var autoTriggerCharSet = ['+', '-', '*', '/', '\xA0', ' ']; var selectionInfo = getContentEditableSelectedPath(); if (selectionInfo) { selected = selectionInfo.selected; path = selectionInfo.path; offset = selectionInfo.offset; } var effectiveRange = getTextPrecedingCurrentSelection(); if (effectiveRange !== undefined && effectiveRange !== null) { var mostRecentTriggerCharPos = -1; var triggerChar; autoTriggerCharSet.forEach(function (c) { var idx = effectiveRange.lastIndexOf(c); if (idx > mostRecentTriggerCharPos) { mostRecentTriggerCharPos = idx; triggerChar = c; } }); // If only trigger char exists without snippet, do not show menu if (mostRecentTriggerCharPos + 1 >= effectiveRange.length) { return; } var currentTriggerSnippet = effectiveRange.substring(mostRecentTriggerCharPos + 1, effectiveRange.length); triggerChar = mostRecentTriggerCharPos >= 0 ? effectiveRange.substring(mostRecentTriggerCharPos, mostRecentTriggerCharPos + 1) : null; var invalidSnippet = false; triggerCharSet.forEach(function (c) { if (currentTriggerSnippet.indexOf(c) > 0) { invalidSnippet = true; } }); if (menuAlreadyActive || !(/[\xA0\s]/g.test(currentTriggerSnippet)) && !invalidSnippet) { return { mentionPosition: mostRecentTriggerCharPos + 1, mentionText: currentTriggerSnippet, mentionSelectedElement: selected, mentionSelectedPath: path, mentionSelectedOffset: offset, mentionTriggerChar: triggerChar }; } } } // public // Clear empty text node in contenteditable. function clearEmptyTextNode(elem) { var toDeleteNodes = []; for (var i = 0; i < elem.childNodes.length; i++) { if (elem.childNodes[i].nodeType === enums.domNodeType.text && elem.childNodes[i].nodeValue === '') { toDeleteNodes.push(elem.childNodes[i]); } else { clearEmptyTextNode(elem.childNodes[i]); } } toDeleteNodes.forEach(function (n) { elem.removeChild(n); }); } function getWindowSelection(ctx) { if (!ctx) { return window.getSelection(); } else { return ctx.iframe.contentWindow.getSelection(); } } function getDocument(ctx) { if (!ctx) { return document; } else { return ctx.iframe.contentWindow.document; } } function getTextPrecedingCurrentSelection (ctx) { var text; if (selectedElementIsTextAreaOrInput(ctx)) { var textComponent = getDocument(ctx).activeElement; var startPos = textComponent.selectionStart; text = textComponent.value.substring(0, startPos); } else { var selectedElem = getWindowSelection(ctx).anchorNode; // Edmond Meng: Add if condition to support non-contenteditable elements // in contenteditable element. Only text nodes can trigger mentio. if (selectedElem != null && selectedElem.nodeType === enums.domNodeType.text) { var workingNodeContent = selectedElem.textContent; var selectStartOffset = getWindowSelection(ctx).getRangeAt(0).startOffset; if (selectStartOffset >= 0) { text = workingNodeContent.substring(0, selectStartOffset); } } } return text; } function getContentEditableCaretPosition (ctx, selectedNodePosition) { var markerTextChar = '\ufeff'; var markerEl, markerId = 'sel_' + new Date().getTime() + '_' + Math.random().toString().substr(2); var range; var sel = getWindowSelection(ctx); var prevRange = sel.getRangeAt(0); range = getDocument(ctx).createRange(); range.setStart(sel.anchorNode, selectedNodePosition); range.setEnd(sel.anchorNode, selectedNodePosition); range.collapse(false); // Create the marker element containing a single invisible character using DOM methods and insert it markerEl = getDocument(ctx).createElement('span'); markerEl.id = markerId; markerEl.appendChild(getDocument(ctx).createTextNode(markerTextChar)); range.insertNode(markerEl); sel.removeAllRanges(); sel.addRange(prevRange); var coordinates = { left: 0, top: markerEl.offsetHeight }; localToGlobalCoordinates(ctx, markerEl, coordinates); markerEl.parentNode.removeChild(markerEl); return coordinates; } function localToGlobalCoordinates(ctx, element, coordinates) { var obj = element; var iframe = ctx ? ctx.iframe : null; while(obj) { coordinates.left += obj.offsetLeft + obj.clientLeft; coordinates.top += obj.offsetTop + obj.clientTop; obj = obj.offsetParent; if (!obj && iframe) { obj = iframe; iframe = null; } } obj = element; iframe = ctx ? ctx.iframe : null; while(obj !== getDocument().body) { if (obj.scrollTop && obj.scrollTop > 0) { coordinates.top -= obj.scrollTop; } if (obj.scrollLeft && obj.scrollLeft > 0) { coordinates.left -= obj.scrollLeft; } obj = obj.parentNode; if (!obj && iframe) { obj = iframe; iframe = null; } } } function getTextAreaOrInputUnderlinePosition (ctx, element, position) { var properties = [ 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing' ]; var isFirefox = (window.mozInnerScreenX !== null); var div = getDocument(ctx).createElement('div'); div.id = 'input-textarea-caret-position-mirror-div'; getDocument(ctx).body.appendChild(div); var style = div.style; var computed = window.getComputedStyle ? getComputedStyle(element) : element.currentStyle; style.whiteSpace = 'pre-wrap'; if (element.nodeName !== 'INPUT') { style.wordWrap = 'break-word'; } // position off-screen style.position = 'absolute'; style.visibility = 'hidden'; // transfer the element's properties to the div properties.forEach(function (prop) { style[prop] = computed[prop]; }); if (isFirefox) { style.width = (parseInt(computed.width) - 2) + 'px'; if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll'; } else { style.overflow = 'hidden'; } div.textContent = element.value.substring(0, position); if (element.nodeName === 'INPUT') { div.textContent = div.textContent.replace(/\s/g, '\u00a0'); } var span = getDocument(ctx).createElement('span'); span.textContent = element.value.substring(position) || '.'; div.appendChild(span); var coordinates = { top: span.offsetTop + parseInt(computed.borderTopWidth) + parseInt(computed.fontSize), left: span.offsetLeft + parseInt(computed.borderLeftWidth) }; localToGlobalCoordinates(ctx, element, coordinates); getDocument(ctx).body.removeChild(div); return coordinates; } return { // public popUnderMention: popUnderMention, popAutoTriggerUnderMention: popAutoTriggerUnderMention, replaceMacroText: replaceMacroText, replaceTriggerText: replaceTriggerText, replaceAutoTriggerText: replaceAutoTriggerText, getMacroMatch: getMacroMatch, getTriggerInfo: getTriggerInfo, getAutoTriggerInfo: getAutoTriggerInfo, selectElement: selectElement, clearEmptyTextNode: clearEmptyTextNode, // private: for unit testing only getTextAreaOrInputUnderlinePosition: getTextAreaOrInputUnderlinePosition, getTextPrecedingCurrentSelection: getTextPrecedingCurrentSelection, getContentEditableSelectedPath: getContentEditableSelectedPath, getNodePositionInParent: getNodePositionInParent, getContentEditableCaretPosition: getContentEditableCaretPosition, pasteHtml: pasteHtml, resetSelection: resetSelection, scrollIntoView: scrollIntoView }; }]); angular.module("mentio").run(["$templateCache", function($templateCache) {$templateCache.put("mentio-menu.tpl.html","<style>\n.scrollable-menu {\n height: auto;\n max-height: 300px;\n overflow: auto;\n}\n\n.menu-highlighted {\n font-weight: bold;\n}\n</style>\n<ul class=\"dropdown-menu scrollable-menu\" style=\"display:block\">\n <li mentio-menu-item=\"item\" ng-repeat=\"item in items track by $index\">\n <a class=\"text-primary\" ng-bind-html=\"item.label | mentioHighlight:$parent.typedTerm:\'menu-highlighted\' | unsafe\"></a>\n </li>\n</ul>");}]);