Is there a way to detect when an element's getBoundingClientRect() rectangle has changed without actually calculating getBoundingClientRect()? Something like a "dirty flag"? Naively, I assume that there must be such a mechanism somewhere in the internal workings of browsers, but I haven't been able to find this thing exposed in the DOM API. Maybe there is a way to do this with MutationObservers?
My application is a web component that turns DOM elements into nodes of a graph, and draws the edges onto a full screen canvas. See here.
Right now, I'm calling getBoundingClientRect() for every element, one time per animation frame frame, even when nothing is changing. It's feeling expensive. I'm usually getting %15-%50 CPU usage on a decently powerful computer at 60 fps.
Does anyone know of such a thing? Do you think it's reasonable to expect something like this? Is this kind of thing feasible? Has it ever been proposed before?
As mentioned in the comments above. The APIs you're looking for are: ResizeObserver and IntersectionObserver. However, there are a few things to note:
ResizeObserver will only fire when the observed element changes size. And it will essentially only give you correct values for width and height.
Both ResizeObserver and IntersectionObserver are supposed to not block paint
ResizeObserver will trigger after layout but before paint, which essentially makes it feel synchronous.
IntersectionObserver fires asynchronously.
What if you need position change tracking
This is what IntersectionObserver is made for. It can often be used for visibility detection. The problem here is that IntersectionObserver only fires when the ratio of intersection changes. This means that if a small child moves around within a larger container div, and you track intersection between the parent and the child, you won't get any events except when the child is entering or exiting the parent.
You can still track when an element moves at all. This is how:
Start by measuring the position of the element you want to track using getBoundingClientRect.
Insert a div as an absolutely positioned direct child of body which is positioned exactly where the tracked element is.
Start tracking the intersection between this div and the original element.
The intersection should start at 1. Whenever it changes to something else:
Remeasure the element using getBoundingClientRect.
Fire the position/size changed event
update the styles of the custom div to the new position of the element.
the observer should fire again with the intersection ratio at 1 again, this value can be ignored.
NOTE: this technique can also be used for more efficient polypill for ResizeObserver which is a newer feature than IntersectionObserver. The commonly available polyfills rely on MutationObserver which is considerably less efficient.
I have had mediocre success with a combination of 3 observers:
An event listener listens for scroll events on any of the scrollable ancestors of the observed element. For this, a capture scroll event listener can be registered on the document, which checks if the event target is an ancestor of the observed element.
A ResizeObserver detects resizes of the observed element.
An IntersectionObserver detects position changes of the observed element. To achieve this, an invisible child element is added to the observed element, 2×2 pixels in size, positioned using position: fixed. The invisible element does not have any top or left coordinates, causing it to be rendered inside the observed element, but it is moved using a negative margin-left and margin-top to an absolute position of -1,-1 in the top left corner of the viewport. With this positioning, 1 of the 4 pixels is visible in the viewport (at position 0,0 of the viewport), while the other 3 pixels are invisible (at position -1,-1, -1,0 and 0,-1 of the viewport). As soon as the observed element moves, its invisible child moves with it, causing 0 or more than 1 pixel to be visible and the IntersectionObserver to fire, leading us to emit a change event and repositioning the invisible element.
function observeScroll(element: HTMLElement, callback: () => void): () => void {
const listener = (e: Event) => {
if ((e.target as HTMLElement).contains(element)) {
callback();
}
};
document.addEventListener('scroll', listener, { capture: true });
return () => {
document.removeEventListener('scroll', listener, { capture: true });
};
}
function observeSize(element: HTMLElement, callback: () => void): () => void {
const resizeObserver = new ResizeObserver(() => {
callback();
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}
function observePosition(element: HTMLElement, callback: () => void): () => void {
const positionObserver = document.createElement('div');
Object.assign(positionObserver.style, {
position: 'fixed',
pointerEvents: 'none',
width: '2px',
height: '2px'
});
element.appendChild(positionObserver);
const reposition = () => {
const rect = positionObserver.getBoundingClientRect();
Object.assign(positionObserver.style, {
marginLeft: `${parseFloat(positionObserver.style.marginLeft || '0') - rect.left - 1}px`,
marginTop: `${parseFloat(positionObserver.style.marginTop || '0') - rect.top - 1}px`
});
};
reposition();
const intersectionObserver = new IntersectionObserver((entries) => {
const visiblePixels = Math.round(entries[0].intersectionRatio * 4);
if (visiblePixels !== 1) {
reposition();
callback();
}
}, {
threshold: [0.125, 0.375, 0.625, 0.875]
});
intersectionObserver.observe(positionObserver);
return () => {
intersectionObserver.disconnect();
positionObserver.remove();
};
}
export function observeBounds(element: HTMLElement, callback: () => void): () => void {
const destroyScroll = observeScroll(element, callback);
const destroySize = observeSize(element, callback);
const destroyPosition = observePosition(element, callback);
return () => {
destroyScroll();
destroySize();
destroyPosition();
};
}
When using this code, keep in mind that the callback is called synchronously and will block whatever event is calling it. The callback should call whatever actions asynchronously (for example using setTimeout()).
Note: There are some situations where this will not work:
When the observed element has an ancestor that acts as a containing block for fixed elements (transform, perspective, filter or will-change: transform is set, see MDN under fixed) AND has overflow set to something else than visible, fixed descendants will not be able to escape the containing block. This will cause permanent invisibility of all 4 pixels of the position detector element, so the IntersectionObserver will not fire on position changes. I am looking for a solution here.
When the observer is used inside an iframe and the top left corner of the iframe is out of view, the IntersectionObserver will also report 0 pixels of visibility.
Related
I have to create a UI where the user can click buttons to move a DIV (the gray box) in preset increments by clicking the 4 buttons. Further, i need to detect when they have moved it completely "inside" the other DIV (dotted red line).
Moving the div seems straightforward enough, but I am confused as to the best way to detect if the gray box is fully inside the dotted box.
I can do this ad HTML or as an SVG, but all my research has shown conflicting results and although detecting an intersection seems simple, I have found nothing on detecting being fully contained in the bounds of a different rect.
Suggestions on an approach appreciated.
You need to use the Intersection Observer API
const options = {
root: document.querySelector('CONTAINER SELECTOR'),
rootMargin: '0px',
threshold: 1.0
}
const targetToWatch = document.querySelector('INSIDE ITEM SELECTOR');
let callback = (entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
};
const observer = new IntersectionObserver(callback, options);
observer.observe(targetToWatch);
You need then to check various entry. parameters to know if the element is inside.
Right now I manage lazy loading of images when an element at the bottom of the screen becomes visible, like so:
let options = { rootMargin: '0px', threshold: 0.1 }
let callback = (entries, observer) => {
console.log(entries[0]);
var bar = entries[0];
if (entries.filter(entry => entry.isIntersecting).length) {
// load images
}
};
let observer = new IntersectionObserver(callback, options);
observer.observe(document.querySelector('#bottombar'))
This however means that the new images get loaded only when the bottom is reached, which gives a hiccup-y feel to the interaction.
Is it possible to fire the event when - for example - the element is a full screen height (or less) below the bottom of the screen? I guess it could be done with scroll events - but IntersectionObserver seems cleaner (and fires less often).
Yes you can.
Check this out: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer
You can create an IntersectionObserver and provide a custom root property which corresponds to an element bigger than the viewport (e.g. height: 200vh).
Also: if you provide a threshold of 0 you shouldn't have the hiccups you describe.
Another thing you can do is set a rootMargin of 100% to indicate that the margin is a whole new viewport.
After a page loads, I’d like to observe an element (addonCard) using the Intersection Observer API to know if that element is fully visible or not. If the element is invisible or partially visible, I want the element to be scrolled into full visibility. If it’s already fully visible, I want the element to stop being observed. From my understanding, you can check for full visibility by setting the threshold property to 1. However, my implementation below doesn’t work (the element is scrolled regardless of whether it's fully visible or not):
let addonCard = this.querySelector(`[addon-id="${param}"]`);
let observer = new IntersectionObserver(
(entries, observer) => {
entries.forEach(entry => {
if (entry.intersectionRatio != 1) {
let stickyHeaderHeight = document.querySelector(
"#page-header > .sticky-container"
).offsetHeight;
let topOfTarget = entry.target.offsetTop - stickyHeaderHeight;
window.scrollTo({
top: topOfTarget,
behavior: "smooth"
});
} else {
observer.unobserve(entry.target);
}
});
}, {
threshold: 1,
}
);
observer.observe(addonCard);
Can someone explain why this implementation doesn't work and how I can make it work? Why does entry.intersectionRatio never change from 0?
Expected behavior: addonCard should scroll into full visibility if not fully visible. If it's already fully visible, no scrolling should be done.
Actual behavior: Scrolling occurs regardless of whether addonCard is fully visible or not.
If I write this codes in separate HTML, CSS and Javascript files and open it with a browser, sticky sharebar appears when the target observed in middle of viewport height, but in codepen appears when the target observed in bottom of viewport height. What is the reason?
{
class StickyShareBar {
constructor(element) {
this.element = element;
this.contentTarget = document.getElementsByClassName('js-sticky-sharebar-target');
this.showClass = 'sticky-sharebar--on-target';
this.threshold = '50%';
this.initShareBar();
}
initShareBar() {
if(this.contentTarget.length < 1) {
this.element.addClass( this.showClass);
return;
}
if(intersectionObserverSupported) {
this.initObserver();
} else {
this.element.addClass(this.showClass);
}
}
initObserver() {
const self = this;
var observer = new IntersectionObserver(
function(entries, observer) {
self.element.classList.toggle( self.showClass, entries[0].isIntersecting);
},
{
rootMargin: "0px 0px -"+this.threshold+" 0px"}
);
observer.observe(this.contentTarget[0]);
}
}
const stickyShareBar = document.getElementsByClassName('js-sticky-sharebar'),
intersectionObserverSupported = ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype);
new StickyShareBar(stickyShareBar[0]);
}
It might be a problem with rootMargin and the fact that you are using an iframe
https://w3c.github.io/IntersectionObserver/#dom-intersectionobserver-rootmargin
https://github.com/w3c/IntersectionObserver/issues/372
It's because of the targeted element. When the srcollbar can reach the targeted element ( js-sticky-sharebar-target ) then the event is fired. When the content container width is smaller the scroll wheel can't reach the targeted element. For this reason it's not showing on browser or small screens. I have changed the targeted element and placed it on the top. Now it's working as you expected.
Changed HTML:
<div class="container new-js-sticky-sharebar-target">
Changed JS:
this.contentTarget = document.getElementsByClassName('new-js-sticky-sharebar-target');
See Demo
The IntersectionObserver spec has been expanded to allow passing a Document as an argument to root. So if you pass the Document of the iframe as the argument for root it triggers a special case where it will consider the iframe's window as the viewport and hence it will work as expected. In something like codepen you probably have no control over this but outside of that it will fix your problem.
See https://github.com/w3c/IntersectionObserver/issues/372
I've had this exact same issue many times when using Intersection Observer in CodePen. Like others have said, it's because CodePen renders your work in an iframe and the rootMargin doesn't work the way you might expect because of that.
I have tried pretty much every solution that has been described in other threads and this is the only one I have gotten to work: https://codepen.io/nickcil/pen/MWbqOaJ
The solution is to wrap your HTML in a full width and height element that you set to position: fixed and overflow: auto. Then set that element as the root for your observer. rootMargin will now work as expected in your pen.
Is there a way to stop Google Translate Bar from moving my content down? I have a static background image, and a header image that corresponds with the background image, so when the Google Translate Bar is fixed to the top of my screen, it moves my top content down and out of the background image.
Is there a way to make it just statically over my content or fixed in such a way it won't move my content down?
Or Can I detect is Translation is taking place, then move my background accordingly? I tried to use this but it doesn't revert back if I remove the Translation Bar:
document.addEventListener('DOMSubtreeModified', function (e) {
if(e.target.tagName === 'HTML' && window.google) {
if(e.target.className.match('translated')) {
document.body.style.backgroundPosition="0px 40px";
} else {
document.body.style.backgroundPosition="0px 0px";
}
}
}, true);
It's a bit difficult without a code example, but the easiest solution would be to set position: fixed; and top: 0 on the translate bar, however, this means it will always remain at the top of the page once you scroll down.
If the translate bar is near the top of your document, which it sounds like it is, you can set the position to absolute instead, keeping the top: 0 declaration. This should make it appear at the top of the closest positioned ancestor, i.e. an element with position set to relative, absolute, fixed, or sticky. If this doesn't exist, it'll be positioned according to the root tag, i.e. <html> in a well-formed document. Here, you could set position: relative on your <body>, for example.
Both fixed and sticky takes the element entirely out of the document flow, so they will do exactly what you're requesting here: appear on top of other content.
The addEventListener('DOMSubtreeModified') is outdated. What you want is to use DOM MutationObserver Events to apply the change. This DOM API is available on all major browser since 2012 I think.
I use this on to lower the google translator bar, so maybe moving the bar down also solves your problem. If not, just change the callback function and variables for your need.
The google translator creates an iframe element like this:
<iframe id=":1.container" class="goog-te-banner-frame skiptranslate" frameborder="0" src="javascript:''" style="visibility: visible; top: calc(100% - 40px);">...</iframe>
So the MutationObserver code to move that element down is as follow:
//Observer for Google translator bar creation and action to move to bottom
// Select the nodetree that will be observed for mutations
var nodetree = document.getElementsByTagName("body")[0];
// Select the target node atributes (CSS selector)
var targetNode = "iframe.goog-te-banner-frame";
// Options for the observer (which mutations to observe)
var config = { attributes: false, childList: true };
// Callback function to execute when mutations of DOM tree are observed
var lowerGoogleTranslateBar = function(mutations_on_DOMtree) {
for(var mutation of mutations_on_DOMtree) {
if (mutation.type == 'childList') {
console.log(mutation);
if (document.querySelector(targetNode) != null) {
//40px is the height of the bar
document.querySelector(targetNode).style.setProperty("top", "calc(100% - 40px)");
//after action is done, disconnect the observer from the nodetree
observerGoogleTranslator.disconnect();
}
}
}
};
// Create an observer instance linked to the callback function
var observerGoogleTranslator = new MutationObserver(lowerGoogleTranslateBar);
// Start observing the target node for configured mutations
observerGoogleTranslator.observe(nodetree, config);
You can learn more about this here: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver