diff --git a/docs/error.md b/docs/error.md index e57e4da..49ba92c 100644 --- a/docs/error.md +++ b/docs/error.md @@ -18,10 +18,6 @@ Example: }); ``` -## Error 3 -Description: Sizes must be of type `Array` unless breakpoints have been specified - - ## Error 2 Description: The Plugin or Network has not been included in your bundle. Please manually include the script tag associated with this plugin or network. @@ -42,6 +38,10 @@ Example: ``` +## Error 3 +Description: Sizes must be of type `Array` unless breakpoints have been specified + + ## Error 4 Description: An ad must be passed into the GenericPlugin class. If your Plugin inherits from GenericPlugin and overrides the constructor make sure you are calling "super" and that you are passing in an diff --git a/docs/lazy-load-plugin.md b/docs/lazy-load-plugin.md index 077533c..067baf0 100644 --- a/docs/lazy-load-plugin.md +++ b/docs/lazy-load-plugin.md @@ -14,6 +14,10 @@ In order to avoid the penalties imposed by loading slow creatives at load time, The AdJS Auto Render plugin delays the loading of the creative until the creative is in the viewport. This ensures that the creatives only load when the visitor is ready to see them. +## External Dependencies +This Plugin utilizes IntersectionObserver which is only fully supported in modern browsers. +If you require support for older browsers, please be sure to include a polyfill in your application. + ## Installation Depending on your method of implementation, AdJS packages may be installed via different methods. Please follow the directions for your relevant method. diff --git a/docs/refresh-plugin.md b/docs/refresh-plugin.md index 1e783ff..e92b7d3 100644 --- a/docs/refresh-plugin.md +++ b/docs/refresh-plugin.md @@ -3,6 +3,10 @@ Viewability is a binary metric. Your creative will either generate a "viewable i The AdJS refresh plugin helps you maximize your impressions per page by refreshing/fetching a new creative after your Ad has been considered as "viewed". By default the AutoRefresh Plugin will only refresh an Ad after 30 seconds of view time. +## External Dependencies +This Plugin utilizes IntersectionObserver which is only fully supported in modern browsers. +If you require support for older browsers, please be sure to include a polyfill in your application. + ## Installation Depending on your method of implementation, AdJS packages may be installed via different methods. Please follow the directions for your relevant method. diff --git a/package.json b/package.json index d2ab0c7..6fe4231 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "adjs", - "version": "2.0.0-beta.25", + "version": "2.0.0-beta.26", "description": "Ad Library to simplify and optimize integration with ad networks such as DFP", "main": "./core.js", "types": "./types.d.ts", diff --git a/src/plugins/AutoRefresh.ts b/src/plugins/AutoRefresh.ts index 0515926..a3c5240 100644 --- a/src/plugins/AutoRefresh.ts +++ b/src/plugins/AutoRefresh.ts @@ -1,6 +1,6 @@ import { LOG_LEVELS } from '../types'; import dispatchEvent from '../utils/dispatchEvent'; -import ScrollMonitor from '../utils/scrollMonitor'; +import ITXObserver from '../utils/intersectionObserver'; import GenericPlugin from './GenericPlugin'; const ONE_SECOND = 1000; @@ -19,18 +19,18 @@ class AutoRefresh extends GenericPlugin { } public beforeClear() { - ScrollMonitor.unsubscribe(this.ad.el.id); + ITXObserver.unsubscribe(this.ad.el.id); dispatchEvent(this.ad.id, LOG_LEVELS.INFO, 'AutoRefresh Plugin', 'Ad viewability monitor has been removed.'); } public beforeDestroy() { - ScrollMonitor.unsubscribe(this.ad.el.id); + ITXObserver.unsubscribe(this.ad.el.id); dispatchEvent(this.ad.id, LOG_LEVELS.INFO, 'AutoRefresh Plugin', 'Ad viewability monitor has been removed.'); } private startMonitoringViewability(): void { const { container, configuration: { offset = 0 }, el } = this.ad; - ScrollMonitor.subscribe( + ITXObserver.subscribe( el.id, container, offset, diff --git a/src/plugins/AutoRender.ts b/src/plugins/AutoRender.ts index 495971b..17e99f2 100644 --- a/src/plugins/AutoRender.ts +++ b/src/plugins/AutoRender.ts @@ -1,6 +1,6 @@ import { LOG_LEVELS } from '../types'; import dispatchEvent from '../utils/dispatchEvent'; -import ScrollMonitor from '../utils/scrollMonitor'; +import ITXObserver from '../utils/intersectionObserver'; import GenericPlugin from './GenericPlugin'; class AutoRender extends GenericPlugin { @@ -16,7 +16,7 @@ class AutoRender extends GenericPlugin { const finalOffset = renderOffset || offset || 0; - ScrollMonitor.subscribe( + ITXObserver.subscribe( id, container, finalOffset, diff --git a/src/types.ts b/src/types.ts index 7b86c7d..446a585 100644 --- a/src/types.ts +++ b/src/types.ts @@ -181,14 +181,45 @@ export interface IAdConfiguration { targeting?: IAdTargeting; } -export interface IScrollMonitorRegisteredAd { +export interface IIntersectionObserverRegisteredAd { element: HTMLElement; offset: number; inView: boolean; fullyInView: boolean; enableByScroll?: boolean; - hasViewBeenScrolled: boolean; onEnterViewport: any[]; onFullyEnterViewport: any[]; onExitViewport: any[]; } + +interface IBounds { + readonly height: number; + readonly width: number; + readonly top: number; + readonly left: number; + readonly right: number; + readonly bottom: number; +} + +export interface IIntersectionObserverEntry { + readonly time: number; + readonly rootBounds: IBounds; + readonly boundingClientRect: IBounds; + readonly intersectionRect: IBounds; + readonly intersectionRatio: number; + readonly target: Element; +} + +export type IntersectionObserverCallback = (entries: IntersectionObserverEntry[]) => void; + +export declare class IIntersectionObserver { + public readonly root: Element | null; + public readonly rootMargin: string; + public readonly thresholds?: any; + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit); + + public observe(target: Element): void; + public unobserve(target: Element): void; + public disconnect(): void; + public takeRecords(): IntersectionObserverEntry[]; +} diff --git a/src/utils/intersectionObserver.ts b/src/utils/intersectionObserver.ts new file mode 100644 index 0000000..3f4298e --- /dev/null +++ b/src/utils/intersectionObserver.ts @@ -0,0 +1,119 @@ +import { IIntersectionObserver, IIntersectionObserverEntry, IIntersectionObserverRegisteredAd } from '../types'; + +class ITXObserver { + public static observer: IIntersectionObserver; + public static registeredAds: { [key: string]: IIntersectionObserverRegisteredAd } = {}; + public static adCount: number = 0; + public static hasViewBeenScrolled: boolean = false; + + public static monitorViewport() { + if (ITXObserver.observer) { + return; + } + + window.addEventListener('scroll', ITXObserver.onFirstScroll, false); + + ITXObserver.observer = new IntersectionObserver((entries: any) => { + ITXObserver.handleIntersect(entries); + }, { threshold: [0, 0.25, 1] }); + } + + public static handleIntersect = (entries: IIntersectionObserverEntry[]) => { + entries.forEach((event: IIntersectionObserverEntry) => { + const ad = ITXObserver.registeredAds[event.target.id]; + + if (ad.enableByScroll && !ITXObserver.hasViewBeenScrolled) { + return; + } + + const ratio = event.intersectionRatio; + const fullyInView = ratio === 1; + const inView = ratio > 0; + + if (fullyInView && !ad.fullyInView) { + ad.onFullyEnterViewport.forEach((fn) => fn()); + } + + if (inView && !ad.inView) { + ad.onEnterViewport.forEach((fn) => fn()); + } + + if (!inView && ad.inView) { + ad.onExitViewport.forEach((fn) => fn()); + } + + ad.inView = inView; + ad.fullyInView = fullyInView; + }); + } + + public static subscribe = ( + id: string, + element: HTMLElement, + offset: number = 0, + onEnterViewport?: () => any, + onFullyEnterViewport?: () => any, + onExitViewport?: () => any, + enableByScroll?: boolean, + ) => { + ITXObserver.monitorViewport(); + + const existingAd = ITXObserver.getAdIfExists(id); + + if (existingAd) { + if (onEnterViewport) { + existingAd.onEnterViewport.push(onEnterViewport); + } + + if (onFullyEnterViewport) { + existingAd.onFullyEnterViewport.push(onFullyEnterViewport); + } + + if (onExitViewport) { + existingAd.onExitViewport.push(onExitViewport); + } + + return; + } + + const ad: IIntersectionObserverRegisteredAd = { + element, + offset, + inView: false, + fullyInView: false, + enableByScroll, + onEnterViewport: onEnterViewport ? [onEnterViewport] : [], + onFullyEnterViewport: onFullyEnterViewport ? [onFullyEnterViewport] : [], + onExitViewport: onExitViewport ? [onExitViewport] : [], + }; + + ad.element.id = id; + ITXObserver.registeredAds[id] = ad; + ITXObserver.observer.observe(ad.element); + ++ITXObserver.adCount; + } + + public static unsubscribe = (id: string) => { + const ad = ITXObserver.registeredAds[id]; + + if (!ad) { + return; + } + + ITXObserver.observer.unobserve(ad.element); + + delete ITXObserver.registeredAds[id]; + --ITXObserver.adCount; + } + + private static getAdIfExists = (id: string) => { + return ITXObserver.registeredAds[id]; + } + + private static onFirstScroll = () => { + ITXObserver.hasViewBeenScrolled = true; + window.removeEventListener('scroll', ITXObserver.onFirstScroll, false); + } +} + +export default ITXObserver; diff --git a/src/utils/scrollMonitor.ts b/src/utils/scrollMonitor.ts deleted file mode 100644 index 8f95788..0000000 --- a/src/utils/scrollMonitor.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { IScrollMonitorRegisteredAd } from '../types'; -import throttle from './throttle'; - -const MONITORING = Symbol('MONITORING'); -const ON_SCROLL = Symbol('ON_SCROLL'); - -class ScrollMonitor { - public static throttleDuration: number = 100; - public static registeredAds: { [key: string]: IScrollMonitorRegisteredAd } = {}; - public static adCount: number = 0; - - public static monitorScroll() { - if (ScrollMonitor[MONITORING]) { - return; - } - - window.addEventListener('scroll', ScrollMonitor[ON_SCROLL], false); - ScrollMonitor[MONITORING] = true; - } - - public static subscribe = ( - id: string, - element: HTMLElement, - offset: number = 0, - onEnterViewport?: () => any, - onFullyEnterViewport?: () => any, - onExitViewport?: () => any, - enableByScroll?: boolean, - ) => { - ScrollMonitor.monitorScroll(); - - const existingAd = ScrollMonitor.getAdIfExists(id); - - if (existingAd) { - if (onEnterViewport) { - existingAd.onEnterViewport.push(onEnterViewport); - } - - if (onFullyEnterViewport) { - existingAd.onFullyEnterViewport.push(onFullyEnterViewport); - } - - if (onExitViewport) { - existingAd.onExitViewport.push(onExitViewport); - } - - return; - } - - const ad: IScrollMonitorRegisteredAd = { - element, - offset, - inView: false, - fullyInView: false, - enableByScroll, - hasViewBeenScrolled: false, - onEnterViewport: onEnterViewport ? [onEnterViewport] : [], - onFullyEnterViewport: onFullyEnterViewport ? [onFullyEnterViewport] : [], - onExitViewport: onExitViewport ? [onExitViewport] : [], - }; - - ScrollMonitor.registeredAds[id] = ad; - ScrollMonitor.evaulateCurrentViewability(ScrollMonitor.registeredAds[id], window.innerHeight); - ++ScrollMonitor.adCount; - } - - public static unsubscribe = (id: string) => { - if (!ScrollMonitor.registeredAds[id]) { - return; - } - - delete ScrollMonitor.registeredAds[id]; - --ScrollMonitor.adCount; - } - - private static [MONITORING]: boolean = false; - - private static [ON_SCROLL] = () => throttle(() => { - if (!ScrollMonitor.adCount) { - return; - } - - const windowHeight = window.innerHeight; - - Object.entries(ScrollMonitor.registeredAds).forEach(([key, ad]) => { - ad.hasViewBeenScrolled = true; - ScrollMonitor.evaulateCurrentViewability(ad, windowHeight); - }); - - }, ScrollMonitor.throttleDuration) - - private static evaulateCurrentViewability = (ad: IScrollMonitorRegisteredAd, windowHeight: number) => { - if (ad.enableByScroll && !ad.hasViewBeenScrolled) { - return; - } - - const bounding = ad.element.getBoundingClientRect(); - - const inView = (bounding.top - ad.offset) <= windowHeight && (bounding.top + bounding.height) >= 0; - const fullyInView = bounding.top >= 0 && bounding.bottom <= windowHeight; - - if (fullyInView && !ad.fullyInView) { - ad.onFullyEnterViewport.forEach((fn) => fn()); - } - - if (inView && !ad.inView) { - ad.onEnterViewport.forEach((fn) => fn()); - } - - if (!inView && ad.inView) { - ad.onExitViewport.forEach((fn) => fn()); - } - - ad.inView = inView; - ad.fullyInView = fullyInView; - } - - private static getAdIfExists = (id: string) => { - return ScrollMonitor.registeredAds[id]; - } -} - -export default ScrollMonitor; diff --git a/tests/utils/intersectionObserver.test.ts b/tests/utils/intersectionObserver.test.ts new file mode 100644 index 0000000..0f01759 --- /dev/null +++ b/tests/utils/intersectionObserver.test.ts @@ -0,0 +1,75 @@ +import ITXObserver from '../../src/utils/intersectionObserver'; + +describe('.ITXObserver Static Class', async () => { + beforeEach(() => { + const bounds = { + height: 1, + width: 1, + top: 1, + left: 1, + right: 1, + bottom: 1, + }; + + const rootElm = document.createElement('div'); + + ITXObserver.observer = { + root: rootElm, + rootMargin: '', + observe: (target: Element) => undefined, + unobserve: (target: Element) => undefined, + disconnect: () => undefined, + takeRecords: () => [{ + time: 12, + rootBounds: bounds, + boundingClientRect: bounds, + intersectionRect: bounds, + intersectionRatio: 12, + isIntersecting: false, + target: rootElm, + }, + ], + }; + }); + + describe('.subscribe', () => { + it('adds cbs to existing ad if another plugin has already subscribed it', () => { + const element = document.createElement('div'); + const cb = () => { }; + + ITXObserver.subscribe('ad1', element, 5, cb); + ITXObserver.subscribe('ad1', element, 5, cb, cb); + ITXObserver.subscribe('ad1', element, 5, cb, cb, cb); + + expect(ITXObserver.adCount).toBe(1); + expect(ITXObserver.registeredAds.ad1.onEnterViewport.length).toBe(3); + expect(ITXObserver.registeredAds.ad1.onFullyEnterViewport.length).toBe(2); + expect(ITXObserver.registeredAds.ad1.onExitViewport.length).toBe(1); + }); + + it('adds a new ad to the dict when appropriate', () => { + const element = document.createElement('div'); + const cb = () => { }; + + ITXObserver.subscribe('ad1', element, 5, cb); + ITXObserver.subscribe('ad1', element, 5, cb, cb); + + ITXObserver.subscribe('ad2', element, 5, cb, cb, cb); + + expect(ITXObserver.adCount).toBe(2); + }); + }); + + describe('.unsubscribe', () => { + it('removes the correct ad from the dict', () => { + ITXObserver.unsubscribe('ad1'); + expect(ITXObserver.adCount).toBe(1); + expect(ITXObserver.registeredAds.ad1).toBe(undefined); + expect(!!ITXObserver.registeredAds.ad2).toBe(true); + + ITXObserver.unsubscribe('ad2'); + expect(ITXObserver.adCount).toBe(0); + expect(ITXObserver.registeredAds.ad2).toBe(undefined); + }); + }); +}); diff --git a/tests/utils/scrollMonitor.test.ts b/tests/utils/scrollMonitor.test.ts deleted file mode 100644 index 6e6e173..0000000 --- a/tests/utils/scrollMonitor.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import ScrollMonitor from '../../src/utils/scrollMonitor'; - -describe('.ScrollMonitor Static Class', async () => { - describe('.subscribe', () => { - it('adds cbs to existing ad if another plugin has already subscribed it', () => { - const element = document.createElement('div'); - const cb = () => { }; - - ScrollMonitor.subscribe('ad1', element, 5, cb); - ScrollMonitor.subscribe('ad1', element, 5, cb, cb); - ScrollMonitor.subscribe('ad1', element, 5, cb, cb, cb); - - expect(ScrollMonitor.adCount).toBe(1); - expect(ScrollMonitor.registeredAds.ad1.onEnterViewport.length).toBe(3); - expect(ScrollMonitor.registeredAds.ad1.onFullyEnterViewport.length).toBe(2); - expect(ScrollMonitor.registeredAds.ad1.onExitViewport.length).toBe(1); - }); - - it('adds a new ad to the dict when appropriate', () => { - const element = document.createElement('div'); - const cb = () => { }; - - ScrollMonitor.subscribe('ad1', element, 5, cb); - ScrollMonitor.subscribe('ad1', element, 5, cb, cb); - - ScrollMonitor.subscribe('ad2', element, 5, cb, cb, cb); - - expect(ScrollMonitor.adCount).toBe(2); - }); - }); - - describe('.unsubscribe', () => { - it('removes the correct ad from the dict', () => { - ScrollMonitor.unsubscribe('ad1'); - expect(ScrollMonitor.adCount).toBe(1); - expect(ScrollMonitor.registeredAds.ad1).toBe(undefined); - expect(!!ScrollMonitor.registeredAds.ad2).toBe(true); - - ScrollMonitor.unsubscribe('ad2'); - expect(ScrollMonitor.adCount).toBe(0); - expect(ScrollMonitor.registeredAds.ad2).toBe(undefined); - }); - }); -});