Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New API: watchElement for classes with no mixin #185

Merged
merged 14 commits into from
Apr 23, 2019
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
module.exports = {
root: true,
parser: 'babel-eslint',
parserOptions: {
ecmaVersion: 2017,
ecmaVersion: 2018,
sourceType: 'module'
},
plugins: [
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ We utilize pooling techniques to reuse Intersection Observers and rAF observers
## Demo or examples
- Dummy app (`ember serve`): https://github.com/DockYard/ember-in-viewport/tree/master/tests/dummy
- Use with Ember [Modifiers](#modifiers) and [@ember/render-modifiers](https://github.com/emberjs/ember-render-modifiers)
- Use with [Native Classes](#classes)
- [ember-infinity](https://github.com/ember-infinity/ember-infinity)
- [ember-light-table](https://github.com/offirgolan/ember-light-table)
- Tracking advertisement impressions
Expand Down Expand Up @@ -276,6 +277,41 @@ export default Component.extend(InViewportMixin, {
</div>
```

### Classes

This allows you to absolve yourself from using a mixin in native classes!

```js
import Component from '@ember/component';
import { tagName } from '@ember-decorators/component';
import { inject as service } from '@ember/service'; // with polyfill

@tagName('')
export default class MyClass extends Component {
@service inViewport

didInsertElement() {
const loader = document.getElementById('loader');
const viewportTolerance = { bottom: 200 };
const { onEnter, onExit } = this.inViewport.watchElement(loader, { viewportTolerance });
onEnter(this.didEnterViewport.bind(this));
}

didEnterViewport() {
// do some other stuff
this.infinityLoad();
},

willDestroy() {
// need to manage cache yourself if you don't use the mixin
const loader = document.getElementById('loader');
this.inViewport.stopWatching(loader);
}
}
```

Options as the second argument to `inViewport.watchElement` include `intersectionThreshold`, `scrollableArea`, `viewportSpy` && `viewportTolerance`

## [**IntersectionObserver**'s Browser Support](https://platform-status.mozilla.org/)

### Out of the box
Expand Down
12 changes: 10 additions & 2 deletions addon/-private/observer-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,23 @@ export default class ObserverAdmin {
*/
add(element, observerOptions, enterCallback, exitCallback) {
if (enterCallback) {
this.instance.addEnterCallback(element, enterCallback);
this.addEnterCallback(element, enterCallback);
}
if (exitCallback) {
this.instance.addExitCallback(element, exitCallback);
this.addExitCallback(element, exitCallback);
}

return this.instance.observe(element, observerOptions);
}

addEnterCallback(element, enterCallback) {
this.instance.addEnterCallback(element, enterCallback);
}

addExitCallback(element, exitCallback) {
this.instance.addExitCallback(element, exitCallback);
}

/**
* This method takes a target element, observerOptions and a the scrollable area.
* The latter two act as unique identifiers to figure out which intersection observer instance
Expand Down
129 changes: 129 additions & 0 deletions addon/-private/raf-admin.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import RafPool from 'raf-pool';
import isInViewport from 'ember-in-viewport/utils/is-in-viewport';

/**
* ensure use on requestAnimationFrame, no matter how many components
Expand All @@ -10,6 +11,7 @@ export default class RAFAdmin {
/** @private **/
constructor() {
this._rafPool = new RafPool();
this.elementRegistry = new WeakMap();
}

add(...args) {
Expand All @@ -27,4 +29,131 @@ export default class RAFAdmin {
reset(...args) {
this._rafPool.reset(...args);
}

/**
* We provide our own element registry to add callbacks the user creates
*
* @method addEnterCallback
* @param {HTMLElement} element
* @param {Function} enterCallback
*/
addEnterCallback(element, enterCallback) {
this.elementRegistry.set(
element,
Object.assign({}, this.elementRegistry.get(element), { enterCallback })
);
}

/**
* We provide our own element registry to add callbacks the user creates
*
* @method addExitCallback
* @param {HTMLElement} element
* @param {Function} exitCallback
*/
addExitCallback(element, exitCallback) {
this.elementRegistry.set(
element,
Object.assign({}, this.elementRegistry.get(element), { exitCallback })
);
}

getCallbacks(element) {
return this.elementRegistry.get(element);
}
}

/**
* This is a recursive function that adds itself to raf-pool to be executed on a set schedule
*
* @method startRAF
* @param {HTMLElement} element
* @param {Object} configurationOptions
* @param {Function} enterCallback
* @param {Function} exitCallback
* @param {Function} addRAF
* @param {Function} removeRAF
*/
export function startRAF(
element,
{
scrollableArea,
viewportTolerance,
viewportSpy = false
},
enterCallback,
exitCallback,
addRAF, // bound function from service to add elementId to raf pool
removeRAF // bound function from service to remove elementId to raf pool
) {
const domScrollableArea = scrollableArea ? document.querySelector(scrollableArea) : undefined;

const height = domScrollableArea
? domScrollableArea.offsetHeight + domScrollableArea.getBoundingClientRect().top
: window.innerHeight;
const width = scrollableArea
? domScrollableArea.offsetWidth + domScrollableArea.getBoundingClientRect().left
: window.innerWidth;
const boundingClientRect = element.getBoundingClientRect();

if (boundingClientRect) {
const viewportEntered = element.getAttribute('data-in-viewport-entered');

triggerDidEnterViewport(
element,
isInViewport(
boundingClientRect,
height,
width,
viewportTolerance
),
viewportSpy,
enterCallback,
exitCallback,
viewportEntered
);

if (viewportSpy || viewportEntered !== 'true') {
// recursive
// add to pool of requestAnimationFrame listeners and executed on set schedule
addRAF(
startRAF.bind(
this,
element,
{ scrollableArea, viewportTolerance, viewportSpy },
enterCallback,
exitCallback,
addRAF
)
);
} else {
removeRAF()
}
}
}

function triggerDidEnterViewport(
element,
hasEnteredViewport,
viewportSpy,
enterCallback,
exitCallback,
viewportEntered = false
) {
const didEnter = (!viewportEntered || viewportEntered === 'false') && hasEnteredViewport;
const didLeave = viewportEntered === 'true' && !hasEnteredViewport;

if (didEnter) {
element.setAttribute('data-in-viewport-entered', true);
enterCallback();
}

if (didLeave) {
exitCallback();

// reset so we can call again
if (viewportSpy) {
element.setAttribute('data-in-viewport-entered', false);
}
}
}
23 changes: 7 additions & 16 deletions addon/mixins/in-viewport.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,21 +120,12 @@ export default Mixin.create({
if (get(this, 'viewportUseIntersectionObserver')) {
return scheduleOnce('afterRender', this, () => {
const scrollableArea = get(this, 'scrollableArea');
const domScrollableArea = scrollableArea ? document.querySelector(scrollableArea) : undefined;

// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
// IntersectionObserver takes either a Document Element or null for `root`
const { top = 0, left = 0, bottom = 0, right = 0 } = get(this, 'viewportTolerance');
const observerOptions = {
root: domScrollableArea,
rootMargin: `${top}px ${right}px ${bottom}px ${left}px`,
threshold: get(this, 'intersectionThreshold')
};

// create IntersectionObserver instance or add to existing
get(this, 'inViewport').setupIntersectionObserver(
const viewportTolerance = get(this, 'viewportTolerance');
const intersectionThreshold = get(this, 'intersectionThreshold');

get(this, 'inViewport').watchElement(
element,
observerOptions,
{ intersectionThreshold, viewportTolerance, scrollableArea },
bind(this, this._onEnterIntersection),
bind(this, this._onExitIntersection)
);
Expand Down Expand Up @@ -245,6 +236,7 @@ export default Mixin.create({
/**
* @method _triggerDidAccessViewport
* @param hasEnteredViewport
* @param viewportEntered
*/
_triggerDidAccessViewport(hasEnteredViewport = false, viewportEntered) {
const isTearingDown = this.isDestroyed || this.isDestroying;
Expand All @@ -270,7 +262,6 @@ export default Mixin.create({

if (triggeredEventName) {
this.trigger(triggeredEventName);
// get(this, 'inViewport').triggerEvent(triggeredEventName);
}
},

Expand All @@ -282,7 +273,7 @@ export default Mixin.create({
_unbindIfEntered(element) {
if (get(this, 'viewportEntered')) {
this._unbindListeners(element);
this.removeObserver('viewportEntered', this, this._unbindIfEntered);
this.removeObserver('viewportEntered', this, '_unbindIfEntered');
set(this, 'viewportEntered', false);
}
},
Expand Down
Loading