Capturing Document-Click Events With AngularJS
As I have been trying to learn AngularJS, one of the biggest stumbling blocks [for me] is the extreme separation of the Controller layer from DOM (Document Object Model) manipulation. In an AngularJS application, none of the Controllers are supposed to touch the document in anyway; all mutation should be done through the two-way data binding. If you do need to touch the DOM, you need to use (or create) an AngularJS Directive. As a learning experiment, I wanted to see if I could come up with a way to listen for document-level click events.
Out of the box, AngularJS comes with a bunch of mouse-related directives like "ngClick", "ngMousedown", and "ngMouseleave" to facilitate DOM interactions. Unfortunately, all of these need to be applied to an HTML Element. But, what if I wanted to listen for mouse events on the Document node? Since the document node isn't represented by an HTML Element, the out-of-the-box directives didn't seem like they would help.
In order to have my Controller react to document-level mouse events, I had to create a custom AngularJS directive that would "glue" the Document node to the Controller's $scope methods. In the following demo, I've created the "bnDocumentClick" attribute directive (although I am not explicitly restricting it to an attribute):
bn-document-click="handleClick( $event )"
This directive evaluates the given expression in the context of the $scope object of the current Controller. In my demo, the $event object is the underlying jQuery event object; I wasn't sure how to create an AngularJS event based on the jQuery event but, I assume they are completely different beasts.
Ok, let's take a look at the demo - when the user click on the page, the X/Y coordinates of the mouse are output within the View:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>Capturing Document-Clicks In AngularJS</title>
</head>
<body
ng-controller="DemoController"
bn-document-click="handleClick( $event )">
<h1>
Capturing Document-Clicks In AngularJS
</h1>
<p>
Click anywhere to trigger an event.
</p>
<p>
<strong>Click X</strong>: {{ mouseX }}
</p>
<p>
<strong>Click Y</strong>: {{ mouseY }}
</p>
<!--
Load jQuery and AngularJS from the CDN. In order for
AngularJS to use the "full" jQuery (as opposed to its own
jQLite), we need to load jQuery first.
-->
<script
type="text/javascript"
src="//ajax.googleapis.com/ajax/libs/jquery/1.8.1/jquery.min.js">
</script>
<script
type="text/javascript"
src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.2/angular.min.js">
</script>
<script type="text/javascript">
// Create an application module for our demo.
var Demo = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// Define our document-click directive. This will evaluate the
// given expression on the containing scope when the click
// event is triggered.
Demo.directive(
"bnDocumentClick",
function( $document, $parse ){
// I connect the Angular context to the DOM events.
var linkFunction = function( $scope, $element, $attributes ){
// Get the expression we want to evaluate on the
// scope when the document is clicked.
var scopeExpression = $attributes.bnDocumentClick;
// Compile the scope expression so that we can
// explicitly invoke it with a map of local
// variables. We need this to pass-through the
// click event.
//
// NOTE: I ** think ** this is similar to
// JavaScript's apply() method, except using a
// set of named variables instead of an array.
var invoker = $parse( scopeExpression );
// Bind to the document click event.
$document.on(
"click",
function( event ){
// When the click event is fired, we need
// to invoke the AngularJS context again.
// As such, let's use the $apply() to make
// sure the $digest() method is called
// behind the scenes.
$scope.$apply(
function(){
// Invoke the handler on the scope,
// mapping the jQuery event to the
// $event object.
invoker(
$scope,
{
$event: event
}
);
}
);
}
);
// TODO: Listen for "$destroy" event to remove
// the event binding when the parent controller
// is removed from the rendered document.
};
// Return the linking function.
return( linkFunction );
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I am the controller for the Body tag.
Demo.controller(
"DemoController",
function( $scope ) {
// Set the initial X/Y values.
$scope.mouseX = "N/A";
$scope.mouseY = "N/A";
// When the document is clicked, it will invoke
// this method, passing-through the jQuery event.
$scope.handleClick = function( event ){
$scope.mouseX = event.pageX;
$scope.mouseY = event.pageY;
};
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
</script>
</body>
</html>
The Controller here is super basic. It simply uses the mouesX and mouseY $scope properties to render the View. The complexity here lies completely within the bnDocumentClick directive.
The "link" function is used to glue the DOM to the $scope of the runtime Controller. In this case, the link function binds to the Document node and triggers the given handler when the mouse is clicked. Since our jQuery event binding is outside of the AngularJS context, we have to use the $apply() function to make sure that all digest cycles are processed after our click handler has executed.
The trickiest part was using the $parse() method to create an invoker function that would allow use to pass the jQuery event through to the $scope's handleClick() event. The $parse() method compiles the AngularJS expression:
var invoker = $parse( scopeExpression );
... into a function that can be invoked with a given context and a set of local variables:
invoker( context, valueMap )
To me, this seems like the AngularJS equivalent to the core JavaScript function, apply():
apply( context, arguments )
... and is what allows us to pass the underlying jQuery event through to the handleClick() function where it can be used to update the $scope properties - mouseX and mouseY.
This took a lot of trial and error and a good bit of Googling; but, it seems to work pretty well. That said, I think passing the $event object through to the Controller might be an anti-pattern in AngularJS since it exposes the DOM (in a way) to a layer of the application that is supposed to be DOM-agnostic. Perhaps, I'll try this again, using a shared Mouse Service that is used to keep track of the mouse position on a per-event basis.
Want to use code from this post? Check out the license.
Reader Comments
Why don't you just create a micro directive and capture the click with angulars bind method?
I would use something like this:
and add the directive to the body element like this:
It works fine for me.
Best Fritz
@Fritz,
I think we're basically doing the same thing? I have a directive that binds to the click event on the document; and then calls the Scope method in response. Unless I am missing what you are saying?
@Ben,
Yes basically it's the same. :) I just wanted to point out that you don't need $document and $parse. And when u rely on angulars internal bind method you also don't need to manually digest.
Nevertheless I guess this only makes minor differences in performance.
Best Fritz
@Fritz,
I gotcha. Yeah, I'm definitely still learning about when things are or are not necessary. For example, I refactored this demo to use a shared mouse service:
www.bennadel.com/blog/2423-Exposing-A-Mouse-Service-For-Click-Events-In-AngularJS.htm
... this exposes the mouse location data through a shared service.
BUT, when I was putting it together, I first tried to use both $eval() and $apply():
... then, with some trial and error, I realized I could pass an expression directly into $apply():
... and it would run properly and call the digest in the background.
Since I am so new to all of this, I am going off of what I see in a lot of demos and people typically pass a function to $apply(); so I thought that was necessary. Slowly trying to separate out what I see people do vs. what is actually necessary :D
Getting there slowly.
You don't need a directive at all to achieve this. Simply add jquery click event into the controller, update the $scope.mouseX and $scope.mouseY from the jquery event values then call $scope.$apply(). Like this...
var Demo = angular.module( "Demo", [] );
Demo.controller("DemoController",
function( $scope ) {
// Set the initial X/Y values.
$scope.mouseX = "N/A";
$scope.mouseY = "N/A";
$(document).click(function(event) {
$scope.mouseX = event.pageX;
$scope.mouseY = event.pageY;
$scope.$apply();
});
}
);
@Nick,
While you can do this, one of the fundamental philosophies of AngularJS is to keep DOM manipulation out of the "Controller" instances. This is one of the biggest mental shifts to make when moving to an AngualrJS application. Instead, we need to start using Directives to "pipe" UI interactions into $scope-based behaviors.
It's still something I'm working at really getting a good handle on!
This is very informative. You provided enough screenshots and it makes it easier to follow. I hope I'll have the same effect when I try doing this. Thank you for sharing.
Hi Ben, this was quite a useful article, however i was trying to make it work on the dom within an iFrame but could not get it to work. any ideas how it would be best to make it work with iFrame ?
I had a similar issue and your article helped me to understand how to solve my problem. After some additional playing around, I believe a found a more efficient approach.
You can view my solution at; http://embed.plnkr.co/EcSTWJ82VQ7vF0mboFVf/preview
Have you considered adding something to un-bind the event on $destroy?
I'm thinking of cases where you have multiple views (or nested partials or directives) and only need the document click handler in some views.
In which case it might not be added to the body tag, but perhaps to a tag inside a view or directive, and the event handler should be unbound when the view or directive is destroyed (eg when replaced by a different view).
It *sort of* seems wrong to put the directive attribute anywhere other than the body tag, but in cases where it is only required when particular views are displayed, it also sort of makes sense.
I'm currently trying to work out whether the best design is to go with my above suggestion of putting the attribute in non-body tags, or to put it at body-level (app level), which means it's always bound, and then pass non-dom events (using $rootScope.$broadcast) from the app level controller down to whatever controllers want to subscribe to it. eg:
$document.on('click',function(){
$rootScope.$broadcast('document-click');
});
Can you also return the element ID
@JP - the "event" object that is passed into the event handler is a standard event object. Meaning it has the typical "target" and "currentTarget" properties, etc - these are DOM elements from which you can get their IDs (although I would assume you want the ID to get the element, so there might not be any need for this as you have the element as "target" already).
The event object is already passed as an argument in Ben's example. In mine you could just pass the event object through by changing it to:
$document.on('click',function(event){
$rootScope.$broadcast('document-click', event);
});
This was very informative. Thanks for sharing.
IFrames are also a specific case, as they don't bubble up click events into their containing document. Would be particularly useful to see a solution for this without using jQuery but just jqLite.
To observe clicking on iFrames I just register the same click handler into the iFrame contents:
var iFrameChildren = $document.find( 'iframe' ).contents();
iFrameChildren.on ( 'click', clickEventHandler );
scope.$on ( '$destroy', function() {
iFrameChildren.off ( 'click', clickEventHandler );
});