1

In a browser automation application, I have some criteria (say xpath) that lets me locate an element that I want to click. I do not want to deliver the click via javascript (element.click() method), but rather by an external automator using X11. That's because the click method has proved unreliable for me. So I need to know a screen location on the element that is directly clickable - i.e. the click will neither go to a child of the element, nor to some other element that overlaps and hides the element. I can do this in brute force by searching over each pixel of the element, like this:

function findClickPosition(el) {
  const rect = el.getBoundingClientRect();
  for (let y = rect.y; y <= rect.y + rect.height; y++) {
    for (let x = rect.x; x <= rect.x + rect.width; x++) {
      if (el === document.elementFromPoint(x, y)) {
        return {
          x,
          y
        };
      }
    }
  }
  return null;
}

However I wondered if there might not be a way I can optimise that by, for example, checking if the entire element is hidden by another element first. Is that possible and if so, how?

see sharper
  • 11,505
  • 8
  • 46
  • 65
  • Event delegation leverages event bubbling, and the Event Object's properties to narrow down precisely what element was clicked, what element listens to an event, what functions trigger, etc. By using if/else statements you control what, when and how. The actual dimensions are not as important as the element's position in the DOM tree hierarchy and it's characteristics. – zer00ne Jun 01 '21 at 04:20
  • No there is no other way, even if you could check that an element covers it entirely, and receives pointer-events, there could be a hole in it from a clip-path that you could not be able to access (e.g external svg). So checking `elementFromPoint()` is indeed the only bullet-proof solution. However, I concur with the previous comment, that you have to resort on this is a very bad sign. Triple check why you think you need to do this that way. – Kaiido Jun 01 '21 at 04:28
  • Oh, and since you are only looking for a single point where it would be clickable, instead of linearly looping over all pixels, I guess a random search would be more performant for cases where there is such a place, or some kind of binary search, but in the whole rect. – Kaiido Jun 01 '21 at 04:32
  • does this help at all? https://stackoverflow.com/questions/49751396/determine-if-element-is-behind-another – Shrey Joshi Jun 01 '21 at 05:30
  • 1
    @ShreyJoshi Yes I saw that after posting - it tends to confirm the other comments that there is no alternative. – see sharper Jun 01 '21 at 05:43

2 Answers2

1

There is no other bullet-proof way, no.

To give one case that would discard any other method, think of the CSS clip-path rule, which is able to dig real holes inside an element through which click events would go.
If this clip-path is defined by an external SVG, it may be impossible for your script to read its path definition and thus to compute where the hole is.

div {
  position: absolute;
  width: 300px;
  height: 150px;
}
.target {
  background: red;
  cursor: pointer;
  left: 50px;
  top: 25px;
}
.over {
  background: green;
  --rect-size: 75px;
  clip-path: polygon( evenodd,
    /* outer rect */
    0 0, /* top - left */
    100% 0, /* top - right */
    100% 100%, /* bottom - right */
    0% 100%, /* bottom - left */
    0 0, /* and top - left again */
    /* do the same with inner rect */
    calc(50% - var(--rect-size) / 2) calc(50% - var(--rect-size) / 2),
    calc(50% + var(--rect-size) / 2) calc(50% - var(--rect-size) / 2),
    calc(50% + var(--rect-size) / 2) calc(50% + var(--rect-size) / 2),
    calc(50% - var(--rect-size) / 2) calc(50% + var(--rect-size) / 2),
    calc(50% - var(--rect-size) / 2) calc(50% - var(--rect-size) / 2)
  );
}
<div class="target"></div>
<div class="over"></div>

However you could improve a bit your algorithm.

First, check that the element you are trying to click is in the viewport, if it isn't, elementFromPoint() won't find it, and I doubt your click method will be able to reach it either.

Then, instead of linearly walking over all the coordinates in the element, it should be more performant in most of the cases to search randomly in these coordinates. The assumption here is that if an element is over a pixel, there are big chances it will also cover its neighbors. So better search in an other place altogether.

A final point which reaches back the first one is to not check all the coordinates of the target element, but only the ones visible in the viewport, to do this, we can simply calculate the intersection rectangle between our element's BBox and the viewport's.

document.querySelectorAll( "div:not(.cursor)" ).forEach( (el) => {
  el.style.cssText = `
    width: ${ (Math.random() * 100) + 50 }px;
    height: ${ (Math.random() * 100) + 50 }px;
    left: ${ (Math.random() * 250) }px;
    top: ${ (Math.random() * 250) }px;
  `;
} );

const target = document.querySelector(".target");
target.onclick = (evt) => {
  console.log( "clicked" );
}

const point = findClickPosition( target );
if( !point ) {
  console.log( "no clickable point found" );
}
else {
  const cursor = document.querySelector(".cursor");
  cursor.style.cssText = `
    left: ${ point.x }px;
    top: ${ point.y }px;
  `;
}

function findClickPosition( el ) {
  let i = 0; // for metrics only
  const el_rect = el.getBoundingClientRect();
  const win_rect = { right: window.innerWidth, bottom: window.innerHeight };
  const { top, left, width, height } = intersectRect( el_rect, win_rect );
  
  if( width <= 0 || height <= 0 ) {
    console.log( "early exit" ); // for metrics only
    return null; // out of screen
  }

  // build an Array of all the points
  const coords = Array.from( { length: width * height }, (_,i) => {
    const float = i / width;
    const y = Math.floor( float );
    const x = Math.round( (float - y) * width );
    return { x: x + left | 0, y: y + top | 0 };
  } );
  while( coords.length ) {
    // grab a random point in the list
    const [{ x, y }] = coords.splice( Math.random() * coords.length, 1 );
    i++; // for metrics only
    if (el === document.elementFromPoint(x, y)) {
      console.log( "looped", i ); // for metrics only
      return { x, y };
    }
  }
  console.log( "looped", i ); // for metrics only
  return null;
}
function intersectRect( rect1, rect2 ) {
  const top = Math.max( rect1.top || 0, rect2.top || 0 );
  const left = Math.max( rect1.left || 0, rect2.left || 0 );
  const bottom = Math.min( rect1.bottom || 0, rect2.bottom || 0 );
  const right = Math.min( rect1.right || 0, rect2.right || 0 );
  const width = right - left;
  const height = bottom - top;
  return { width, height, top, left, bottom, right };
}
.viewport > div {
  position: absolute;
  background: rgba(0,255,0,.2);
}
.viewport > .target { background: rgba(255,0,0,1); }
.viewport > .cursor {
  pointer-events: none;
  background: transparent;
  border: 1px solid;
  width: 10px;
  height: 10px;
  transform: translate(-5px, -5px);
  border-radius: 50%;
}
/* stacksnippet's console */
.as-console {
  pointer-events: none;
  opacity: 0.5;
}
<div class="viewport">
  <div class="target"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="over"></div>
  <div class="cursor"></div>
</div>

We could probably still further improve it by using a smarter algorithm than randomness, but I'll leave it as an exercise for whoever wants to do so.


Now, I feel obliged to note that the fact you have to resort on this sort of checks is a code smell. In your position, I would triple check the reasons that made me believe I needed this.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Thank you - nice suggestions. The brute force approach was not what I was using in reality, though what I had was also provisional code until I worked out something better. If it turned out it was not possible to pre-check obscuring elements, then my plan was a type of 2d binary search. But this is great. – see sharper Jun 01 '21 at 05:57
  • 1
    As for the code smell, I would love to use the JS click method, but in practice I have found it fails unpredictably in some scenarios and I was unable to work out why - not the obvious one of timing and attaching the onclick handler. I posted another question on that which failed to get any responses. – see sharper Jun 01 '21 at 05:57
-2

Ideally you would want to have some attributes on the button that specifies it's state. Like disabled. The way that user events work within the DOM is that a click event will be "bubbled" to the container that has the click listener (which would be the button). Otherwise you can check if an element is clickable by doing if(element.getAttribute('onclick')!=null){

Daniel Duong
  • 1,084
  • 4
  • 11