I need a filter for AngularJS that takes a string and finds the anchor tags and replaces the href with an:
The input comes from a third party API, is of variable length and could have zero or a thousand instances of tags in.
Sample input would be:
<p ng-html-bind="someScopedVariable | replaceAnchor"></p>
The filter would be:
angular.module('imApp').filter('replaceAnchor', function () {
return function(string) {
if (string) {
/* Sudo code would be:
1. find all <a> in string;
2. get the value of the href attribute and assign to variable hrefHolder
3. replace all href attributes with ng-click="(hrefHolder)";
4. return replaced content; */
} else {
return '';
}
}
})
ng-click="aFunction('original href value in here')";
JQuery is loaded in full (as opposed AngularJS jQLite) so happy if this is JQuery based. Iv tried combinations of $.each, $.find and $.replacewith with no joy so far.
Okay, there's A LOT of work to something like this. Simply replacing the href with "ng-click" will not actually make the "ng-click" magically work. ng-bind-html simply includes the raw html and does not compile it. Using our filter, we can make angular compile the html as well, making it work as expected.
First, here's a working plunkr.
Here's the code:
HTML:
<p ng-bind-html="someScopedVariable | replaceAnchor:'sampleFunction':this"></p>
We're passing some extra variables to the filter. The first one 'sampleFunction' will be the name of the scope function you want to call with the anchors. The second function is the scope you want to compile the html with. this refers to the current scope.
JS:
angular.module('imApp', [])
.config(function($sceProvider) {
$sceProvider.enabled(false);
})
.run(function($rootScope) {
$rootScope.someScopedVariable = 'Google';
$rootScope.sampleFunction = function(href) {
event && event.preventDefault && event.preventDefault();
alert(href);
};
})
.filter('replaceAnchor', function ($rootScope, $compile) {
return function(str, fn, scope) {
scope = scope || $rootScope;
if (str && fn) {
// Create a temporary container that we can use to search for anchor tags
var tempContainer = $('<div/>');
tempContainer.html(str);
// 1. find all <a> in string.
var anchorTags = tempContainer.find('a');
anchorTags.each(function() {
// 2. get the value of the href attribute and assign to variable hrefHolder
var hrefHolder = $(this).attr('href');
// 3. replace all href attributes with ng-click="(hrefHolder)";
$(this).attr('ng-click', fn + '(\'' + hrefHolder + '\')');
});
// 4. return replaced content
return $compile(tempContainer.html())($rootScope);
} else {
return str;
}
}
});
There's some boilerplate there, but I'm leaving it in because it's crucial to understanding what's happening. Let's look at the filter...
Our filter function that we're returning takes three arguments: the original html, the function name, and the scope. If the scope isn't passed, $rootScope will be used. First I create a temporary container to keep things clean and so that we only have one root element. Then I append the passed in original html to that temp container. Then we can use jQuery just like we normally would to loop through all anchor tags, grab their href, and create a new ng-click attribute that calls the function name we passed in with the href as it's only argument. Lastly, we return the new html that has been compiled to make the ng-clicks actually work.
You could always hardcode the function name into the filter, but I like to keep things reuseable.
Related
I use several javascript global constants to communicate state across controllers on my page. Maybe that's a bad idea, but it works for me to cut down on typing errors and centralizes the place where I invent these names to one place in my code.
Different parts of my page in different controllers are meant to display or be hidden depending on these global states. So I have one state which is defined
const DISPLAY_STATE_CHART = "showChart";
and the parent scope of several interested controllers can query a map maintained by this parent scope. The map can be queried by a key which, based on these state constants, sort of like:
state = $scope.$parent.displayStateMap.get(DISPLAY_STATE_CHART);
state is a boolean which is used to determine whether a div should be displayed or not.
So on my page, I want to display a div if the 'state' is true,
I include an ng-if:
<div ng-if="getDisplayState(DISPLAY_STATE_CHART)">some content</div>
In my controller I have a function defined like:
$scope.getDisplayState(when_display_state) {
return $scope.$parent.displayStateMap(when_display_state);
}
However the constant name encoded in the HTML is not getting through somehow, and when_display_state is coming through as "undefined".
If I change the html to use the string value, e.g.
<div ng-if="getDisplayState('showChart')">some content</div>
it works as expected, so it seems clear that the problem is that whatever part of Angular is interpreting the html string attached to ng-if is somehow unaware of these global constants.
Is there a solution to this?
Thanks.
You cannot use variables defined with const inside an ng-if. Inside an ng-if you can only use variables which are defined in the $scope of the particular template.
Refer to this SO answer, which is a response to an issue similar to yours.
But I can suggest you a workaround if you don't like moving the value of the particular const value into a scope variable, in case you don't mind setting your DOM elements via javascript.
Modify this line: <div ng-if="getDisplayState(DISPLAY_STATE_CHART)">some content</div> as follows: <div id="displayState"></div>.
And inside your javascript, run a function onload of the browser window which would check for the DISPLAY_STATE_CHART using the $scope.getDisplayState() function. Just the way you would display the div content based on its value, just set div value inside the javascript itself when the condition is satisfied, something like:
function run() {
if ($scope.getDisplayState(DISPLAY_STATE_CHART)) {
document.getElementById("displayState").innerHTML = "some content";
}
}
window.onload = function() {
run();
}
I've created a runnable script(just with sample values). Just for some better understanding.
var app = angular.module('constApp', []);
app.controller('constCtrl', function($scope) {
const DISPLAY_STATE_CHART = true;
$scope.getDisplayState = function(dsc) {
return dsc;
}
function run() {
if ($scope.getDisplayState(DISPLAY_STATE_CHART)) {
document.getElementById("displayState").innerHTML = "some content";
}
}
window.onload = function() {
run();
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.7.5/angular.min.js"></script>
<div ng-app="constApp" ng-controller="constCtrl">
<div id="displayState"></div>
</div>
I have angular on the front-end of an application with html characters being interpolated and rendered. The data is coming from a backend CMS.
Almost all of the anchor values are linking to the value of their inner text.
For example:
http://google.com
Instead of repeatedly entering this same pattern I'd like to extend the a tag with a directive:
app.directive('a', function(){
return{
link: function(scope, element, attrs){
var value = $(element)[0].innerText;
if(!attrs.href){
attrs.href = value;
}
if(!attrs.target){
attrs.target = '_blank';
}
}
}
})
My data is coming into angular through bindings like this:
<div class="issue-article-abstact">
<h6 class="main-section" realign>Abstract</h6>
<p ng-bind-html="article.abstract | to_trusted"></p>
</div>
"article.abstract" would be a json object containing <a>http://google.com</a>
This currently only picks up anchor tags that are not rendered on the page through interpolation. Is it possible to create a directive that will see values on the page from bindings and extend their functionality through a directive like this?
Angular doesn't compile html that is inserted using ng-bind-html.
There are third party modules you can use to do it, however you could also do the conversion in a service, controller, custom filter or httpInterceptor before data gets inserted.
Following uses jQuery since it seems you are including it in the page
Simple example:
function parseLinks(html) {
// create container and append html
var $div = $('<div>').append(html),
$links = $div.find('a');
// modify html
$links.not('[href]').attr('href', function(i, oldHref) {
return $(this).text();
});
$links.not('[target]').attr('target', '_blank');
// return innerHtml string
return $div.html();
}
$http.get('/api/items').then(function(resp){
var data = resp.data;
data.forEach(function(item){
item.abstract = parseLinks(item.abstract);
});
return data;
});
This will be more efficient than having to compile all of this html in the dom using directive also
I have a directive which is using $observe to watch when the value of one of the attributes changes. When this fires, I need to be able to retrieve the unevaluated value of the attribute not the evaluated value.
So my HTML would look like this:
<div my-attrib="{{scopeVar}}"></div>
Then the link function in my directive:
attrib.$observe('myAttrib', function(val) {
// Both val and attrib.myAttrib contain "ABC"
// I would like the uncompiled value instead
var evaluatedValue = attrib.myAttrib;
});
If the controller had done this:
$scope.myAttrib = "ABC";
When $observe first, evalutedValue returns "ABC". I actually need it to return "{{scopeVar}}".
EDIT: Per the comment below from François Wahl I ended up moving this into a ng-repeat element which is bound to an array of one item. Then I just remove/add the new item in the controller which updates $scope. This eliminates the need to retrieve the uncompiled attribute value and actually cleans things up quite a bit. It's definitely odd when looking at the view since it's not immediately clear as to why it's in a repeater, but it's worth it since it cleans up the code quite a bit.
You have to grab it in the compile: function of the directive link this:
.directive('myAttrib', function() {
return {
compile: function(tElement, tAttrs) {
var unevaluatedValue = tAttrs.myAttrib;
return function postLink(scope, element, attrs) {
attrs.$observe('myAttrib', function(val) {
// Both val and attrib.myAttrib contain "ABC"
// I would like the uncompiled value instead
var evaluatedValue = attrs.myAttrib;
console.log(unevaluatedValue);
console.log(evaluatedValue);
});
}
}
}
})
Example Plunker: http://plnkr.co/edit/bHDmxJvnENqz8Mpg1qpd?p=preview
Hope this helps.
My goal is to create a directive that can be applied to a given element, so that when the element is clicked, a modal is created. I would like to have the modal created and appended to the body node, which is outside of my ng-app element. Due to requirements of more than one app on a page, I can't put ng-app on the <html> or <body> tags. Yet for proper z positioning, I would to place the modal element as high up in the body as I can.
My directive looks like this:
var module = angular.module('Test', ['ngAnimate']);
module.directive('modal', function($compile, $animate) {
function link(scope, element, attr) {
element.on('click', function () {
var modal = $compile('<div class="modal"></div>')(scope);
scope.$apply(function () {
$animate.enter(modal, angular.element(document.body));
});
});
}
return {
link: link,
scope: {}
};
});
When I use $animate.enter to append the modal to the body, it is appended but the animation does not run. My HTML looks like this:
<body>
<div ng-app="Test">
<button modal>Open Modal</button>
</div>
</body>
If I move the ng-app from the div to the body, then the animation works. But I can't do this because I need to have the option of placing more than one ng-app on a given page.
Is it possible?
Working (or not-working) example:
http://plnkr.co/edit/vUi2PmLjea36nrJ9i3R2?p=preview
The short answer is: No you can't.
(At least Angular seems not to be designed to allow it).
The somewhat longer answer is: No, you can't. Here is why:
The reason is how $animate is currently implemented:
In your case, it adds the elements= to the DOM (to document.body in particular) and then checks to see if it should proceed with the animation specific stuff (or if animations are "disabled").
According to the source code, the function that checks if animations are "disabled" is:
function animationsDisabled(element, parentElement) {
if (rootAnimateState.disabled) return true;
if(isMatchingElement(element, $rootElement)) {
return rootAnimateState.disabled || rootAnimateState.running;
}
do {
//the element did not reach the root element which means that it
//is not apart of the DOM. Therefore there is no reason to do
//any animations on it
if(parentElement.length === 0) break;
var isRoot = isMatchingElement(parentElement, $rootElement);
var state = isRoot ? rootAnimateState : parentElement.data(NG_ANIMATE_STATE);
var result = state && (!!state.disabled || state.running || state.totalActive > 0);
if(isRoot || result) {
return result;
}
if(isRoot) return true;
}
while(parentElement = parentElement.parent());
return true;
}
As you can see, since your modal is not a child (but a sibling) of the $rootElement, isRoot will always be false and the do-while loop will run until there is no parent-element. At that point if(parentElement.length === 0) break; will break the loop and the function will return true, thus cancelling the animation.
The longest answer is: Depends (on how badly you need it).
The available options (I can think of) are:
Accept your fate and find some other way (besides $animate) to perform your animations.
Create your own fork of angular-animate and change one line of code, so that animations are not cancelled even if the target-element is not a child of the $rootElement.
If you like to live dangerously: What if we "temporarily swapped the $rootElement" ?
I tried various approaches (which outside of the scope of this answer) and the only one I could make work(?), was swapping the HTML element associated with the jqLite object (i.e. $rootElement[0]).
I wrapped the functionality in a service (along with some convenience features, e.g. restoring the original element after a set period of time):
module.factory('myRootElement', function ($rootElement, $timeout) {
/* Save the original element for reference */
var original = $rootElement[0];
/* Fake the $rootElement for the specified
* period of time (in milliseconds) */
function fakeForMillis(millis) {
$rootElement[0] = document.body;
$timeout(function () {
$rootElement[0] = original;
}, millis || 0);
}
/* Return the Service object */
return {
fakeForMillis: fakeForMillis
};
});
Finally, you only need to temporarily swap the $rootElement fo the animation to take place. (Unfortunately, you have to specify the time required for the animation, but I bet there are better ways to find it out programmatically - again outside the scope of this answer.)
myRootElement.fakeForMillis(1000);
$animate.enter(modal, angular.element(document.body));
See, also, this short demo.
I have no idea what I am talking about and I have by no means investigated the consequences of this approach, so use at your own risk and don't be surprised if strange things come your way.
Let's say I've got a directive that looks like this:
directive('attachment', function() {
return {
restrict: 'E',
controller: 'AttachmentCtrl'
}
})
That means I can write a list of 'attachment' elements like this:
<attachment ng-repeat="a in note.getAttachments()">
<p>Attachment ID: {{a.id}}</p>
</attachment>
In the above snippet, let's assume that note.getAttachments() returns a set of simple javascript object hashes.
Since I set a controller for the directive, I can include calls to that controller's scope functions inline.
Here's the controller:
function AttachmentCtrl($scope) {
$scope.getFilename = function() {
return 'image.jpg';
}
}
And here is the modified HTML for when we include a call to that $scope.getFilename function inline (the new 2nd paragraph):
<attachment ng-repeat="a in note.getAttachments()">
<p>Attachment ID: {{a.id}}</p>
<p>Attachment file name: {{getFilename()}}
</attachment>
However, this isn't useful. This will just print the same string, "image.jpg", as the file name for each attachment.
In actuality, the file name for the attachments is based on attachment ID. So an attachment with ID of "2" would have the file name of "image-2.jpg".
So our getFilename function needs to be modified. Let's fix it:
function AttachmentCtrl($scope) {
$scope.getFilename = function() {
return 'image-' + a.id + '.jpg';
}
}
But wait — this won't work. There is no variable a in the scope. We can use the variable a inline thanks to the ng-repeat, but that a variable isn't available to the scope bound to the directive.
So the question is, how do I make that a available to the scope?
Please note: I realize that in this particular example, I could just print image-{{a.id}}.jpg inline. But that does not answer the question. This is just an extremely simplified example. In reality, the getFilename function would be something too complex to print inline.
Edit: Yes, getFilename can accept an argument, and that would work. However, that also does not answer the question. I still want to know, without workarounds, whether you can get a into the scope without using it inline.
For example, maybe there is a way to inject it directly into the controller so it would be written as:
function AttachmentCtrl($scope, a) { ... }
But where would I pass it in from? Is there something I can add to the directive declaration? Maybe an ng-* attribute I can add next to the ng-repeat? I just want to know if it's possible.
But wait — this won't work. There is no variable "a" in the scope. We can use the variable a inline thanks to the
ng-repeat, but that a variable isn't available to the scope bound to
the directive.
Actually variable a is in the scope associated with the directive controller. Each controller created by the directive gets the child scope created by the ng-repeat iteration. So this works (note $scope.a.id):
function AttachmentCtrl($scope) {
$scope.getFilename = function() {
return 'image-' + $scope.a.id + '.jpg';
}
Here's a fiddle that shows the controller scope, directive scopes, and ngRepeat scopes.
"If multiple directives on the same element request new scope, only one new scope is created. " -- Directive docs, section "Directive Definition Object"
In your example, ng-repeat is creating a new scope, so all directives on that same element get that same new (child) scope.
Also, if you do ever come across a case where you need to get a variable into a controller, using attributes would be better than using ng-init.
Another way would be to use ng-init and set a model property for child scope. See this fiddle
Relevant code would be
<div ng-app='myApp' ng-controller='MyCtrl'>
<attachment ng-repeat="a in attachments" ng-init='model=a'>
<p>Attachment ID: {{model.id}}</p>
<p>Attachment file name: {{getFilename()}}</p>
</attachment>
</div>
and
function AttachmentCtrl($scope) {
$scope.getFilename = function () {
return 'image-' + $scope.model.id + '.jpg';
}
}
Just pass it into your function.
View:
<attachment ng-repeat="a in note.getAttachments()">
<p>Attachment ID: {{ a.id }}</p>
<p>Attachment file name: {{ getFilename(a) }}
</attachment>
Controller:
function AttachmentCtrl ($scope) {
$scope.getFilename = function (a) {
return 'image-' + a.id + '.jpg';
}
}