How to get the MouseEvent coordinates for an element that has CSS3 Transform?
I want to detect where a MouseEvent
has occurred, in coordinates relative to the clicked element. Why? Because I want to add an absolutely positioned child element at the clicked location.
I know how to detect it when no CSS3 transformations exist (see description below). However, when I add a CSS3 Transform, then my algorithm breaks, and I don't know how to fix it.
I'm not using any JavaScript library, and I want to understand how things work in plain JavaScript. So, please, don't answer with "just use jQuery".
By the way, I want a solution that works for all MouseEvents, not just "click". Not that it matters, because I believe all mouse events share the same properties, thus the same solution should work for all of them.
Background information
According to DOM Level 2 specification, a MouseEvent
has few properties related to getting the event coordinates:
-
screenX
andscreenY
return the screen coordinates (the origin is the top-left corner of user's monitor) -
clientX
andclientY
return the coordinates relative the document viewport.
Thus, in order to find the position of the MouseEvent
relative to the clicked element content, I must do this math:
ev.clientX - this.getBoundingClientRect().left - this.clientLeft + this.scrollLeft
-
ev.clientX
is the coordinate relative to the document viewport -
this.getBoundingClientRect().left
is the position of the element relative to the document viewport -
this.clientLeft
is the amount of border (and scrollbar) between the element boundary and the inner coordinates -
this.scrollLeft
is the amount of scrolling inside the element
getBoundingClientRect()
, clientLeft
and scrollLeft
are specified at CSSOM View Module.
Experiment without CSS Transform (it works)
Confusing? Try the following piece of JavaScript and HTML. Upon clicking, a red dot should appear exactly where the click has happened. This version is "quite simple" and works as expected.
function click_handler(ev) {
var rect = this.getBoundingClientRect();
var left = ev.clientX - rect.left - this.clientLeft + this.scrollLeft;
var top = ev.clientY - rect.top - this.clientTop + this.scrollTop;
var dot = document.createElement('div');
dot.setAttribute('style', 'position:absolute; width: 2px; height: 2px; top: '+top+'px; left: '+left+'px; background: red;');
this.appendChild(dot);
}
document.getElementById("experiment").addEventListener('click', click_handler, false);
<div id="experiment" style="border: 5px inset #AAA; background: #CCC; height: 400px; position: relative; overflow: auto;">
<div style="width: 900px; height: 2px;"></div>
<div style="height: 900px; width: 2px;"></div>
</div>
Experiment adding a CSS Transform (it fails)
Now, try adding a CSS transform
:
#experiment {
transform: scale(0.5);
-moz-transform: scale(0.5);
-o-transform: scale(0.5);
-webkit-transform: scale(0.5);
/* Note that this is a very simple transformation. */
/* Remember to also think about more complex ones, as described below. */
}
The algorithm doesn't know about the transformations, and thus calculates a wrong position. What's more, the results are different between Firefox 3.6 and Chrome 12. Opera 11.50 behaves just like Chrome.
In this example, the only transformation was scaling, so I could multiply the scaling factor to calculate the correct coordinate. However, if we think about arbitrary transformations (scale, rotate, skew, translate, matrix), and even nested transformations (a transformed element inside another transformed element), then we really need a better way to calculate the coordinates.
The behaviour you are experiencing is correct, and your algorithm isn't breaking. Firstly CSS3 Transforms are designed not to interfere with the box model.
To try and explain...
When you apply a CSS3 Transform on an element. the Element assumes a kind of relative positioning. In that the surrounding elements are not effected by the transformed element.
e.g. imagine three div's in a horizontal row. If you apply a scale transform to decrease the size of the centre div. The surrounding div's will not move inwards to occupy the space that was once occupied the transformed element.
example: http://jsfiddle.net/AshMokhberi/bWwkC/
So in the box model, the element does not actually change size. Only it's rendered size changes.
You also have to keep in mind that you are applying a scale Transform, so your elements "real" size is actually the same as it's original size. You are only changing it's perceived size.
To explain..
Imagine you create a div with a width of 1000px and scale it down to 1/2 the size. The internal size of the div is still 1000px, not 500px.
So the position of your dots are correct relative to the div's "real" size.
I modified your example to illustrate.
Instructions
- Click the div and keep you mouse in the same position.
- Find the dot in the wrong position.
- Press Q, the div will become the correct size.
- Move your mouse to find the dot in the correct position to where you clicked.
http://jsfiddle.net/AshMokhberi/EwQLX/
So in order to make the mouse clicks co-ordinates match the visible location on the div, you need to understand that the mouse is giving back co-ordinates based on the window, and your div offsets are also based on its "real" size.
As your object size is relative to the window the only solution is to scale the offset co-ordinates by the same scale value as your div.
However this can get tricky based on where you set the Transform-origin property of your div. As that is going to effect the offsets.
See here.
http://jsfiddle.net/AshMokhberi/KmDxj/
Hope this helps.
if element is container and positioned absolute or relative, you can place inside of it element, position it relative to parent and width = 1px, height = 1px, and move to inside of container, and after each move use document.elementFromPoint(event.clientX, event.clientY) =))))
You can use binary search to make it faster. looks terrible, but it works
http://jsfiddle.net/3VT5N/3/ - demo
another way is place 3 divs in corners of that element, than find transform matrix ... but is also works only for positioned containerable elements – 4esn0k
demo: http://jsfiddle.net/dAwfF/3/
Also, for Webkit webkitConvertPointFromPageToNode method can be used:
var div = document.createElement('div'), scale, point;
div.style.cssText = 'position:absolute;left:-1000px;top:-1000px';
document.body.appendChild(div);
scale = webkitConvertPointFromNodeToPage(div, new WebKitPoint(0, 0));
div.parentNode.removeChild(div);
scale.x = -scale.x / 1000;
scale.y = -scale.y / 1000;
point = webkitConvertPointFromPageToNode(element, new WebKitPoint(event.pageX * scale.x, event.pageY * scale.y));
point.x = point.x / scale.x;
point.y = point.y / scale.x;
BY FAR the fastest. The accepted answer takes about 40-70 ms on my 3d transforms site, this usually takes less than 20 (fiddle):
function getOffset(event,elt){
var st=new Date().getTime();
var iterations=0;
//if we have webkit, then use webkitConvertPointFromPageToNode instead
if(webkitConvertPointFromPageToNode){
var webkitPoint=webkitConvertPointFromPageToNode(elt,new WebKitPoint(event.clientX,event.clientY));
//if it is off-element, return null
if(webkitPoint.x<0||webkitPoint.y<0)
return null;
return {
x: webkitPoint.x,
y: webkitPoint.y,
time: new Date().getTime()-st
}
}
//make full-size element on top of specified element
var cover=document.createElement('div');
//add styling
cover.style.cssText='height:100%;width:100%;opacity:0;position:absolute;z-index:5000;';
//and add it to the document
elt.appendChild(cover);
//make sure the event is in the element given
if(document.elementFromPoint(event.clientX,event.clientY)!==cover){
//remove the cover
cover.parentNode.removeChild(cover);
//we've got nothing to show, so return null
return null;
}
//array of all places for rects
var rectPlaces=['topleft','topcenter','topright','centerleft','centercenter','centerright','bottomleft','bottomcenter','bottomright'];
//function that adds 9 rects to element
function addChildren(elt){
iterations++;
//loop through all places for rects
rectPlaces.forEach(function(curRect){
//create the element for this rect
var curElt=document.createElement('div');
//add class and id
curElt.setAttribute('class','offsetrect');
curElt.setAttribute('id',curRect+'offset');
//add it to element
elt.appendChild(curElt);
});
//get the element form point and its styling
var eltFromPoint=document.elementFromPoint(event.clientX,event.clientY);
var eltFromPointStyle=getComputedStyle(eltFromPoint);
//Either return the element smaller than 1 pixel that the event was in, or recurse until we do find it, and return the result of the recursement
return Math.max(parseFloat(eltFromPointStyle.getPropertyValue('height')),parseFloat(eltFromPointStyle.getPropertyValue('width')))<=1?eltFromPoint:addChildren(eltFromPoint);
}
//this is the innermost element
var correctElt=addChildren(cover);
//find the element's top and left value by going through all of its parents and adding up the values, as top and left are relative to the parent but we want relative to teh wall
for(var curElt=correctElt,correctTop=0,correctLeft=0;curElt!==cover;curElt=curElt.parentNode){
//get the style for the current element
var curEltStyle=getComputedStyle(curElt);
//add the top and left for the current element to the total
correctTop+=parseFloat(curEltStyle.getPropertyValue('top'));
correctLeft+=parseFloat(curEltStyle.getPropertyValue('left'));
}
//remove all of the elements used for testing
cover.parentNode.removeChild(cover);
//the returned object
var returnObj={
x: correctLeft,
y: correctTop,
time: new Date().getTime()-st,
iterations: iterations
}
return returnObj;
}
and also include the following CSS in the same page:
.offsetrect{
position: absolute;
opacity: 0;
height: 33.333%;
width: 33.333%;
}
#topleftoffset{
top: 0;
left: 0;
}
#topcenteroffset{
top: 0;
left: 33.333%;
}
#toprightoffset{
top: 0;
left: 66.666%;
}
#centerleftoffset{
top: 33.333%;
left: 0;
}
#centercenteroffset{
top: 33.333%;
left: 33.333%;
}
#centerrightoffset{
top: 33.333%;
left: 66.666%;
}
#bottomleftoffset{
top: 66.666%;
left: 0;
}
#bottomcenteroffset{
top: 66.666%;
left: 33.333%;
}
#bottomrightoffset{
top: 66.666%;
left: 66.666%;
}
It essentially splits the element into 9 squares, determines which one the click was in via document.elementFromPoint
. It then splits that into 9 smaller squares, etc until it is accurate to within a pixel. I know I over-commented it. The accepted answer is several times slower than this.
EDIT: It is now even faster, and if the user is in Chrome or Safari it will use a native function designed for this instead of the 9 sectors thingy and can do it consistently in LESS THAN 2 MILLISECONDS!