diff --git a/dist/index.cjs b/dist/index.cjs new file mode 100644 index 0000000..9756b62 --- /dev/null +++ b/dist/index.cjs @@ -0,0 +1,369 @@ +'use strict'; + +/** + * Clamps a value between limits. + * + * @param {number} min lower limit + * @param {number} max upper limit + * @param {number} value value to clamp + * @return {number} clamped value + * + * @example + * const x = clamp(0, 1, 1.5); // returns 1 + */ +function clamp (min, max, value) { + return Math.min(Math.max(min, value), max); +} + +/** + * Throttle a function to trigger once per animation frame. + * Keeps the arguments from last call, even if that call gets ignored. + * + * @param {function} fn function to throttle + * @return {(function(): void)} + */ +function frameThrottle (fn) { + let throttled = false; + + return function () { + if (!throttled) { + throttled = true; + + window.requestAnimationFrame(() => { + throttled = false; + fn(); + }); + } + }; +} + +function getRect (element) { + let el = element; + let left = 0; + let top = 0; + + if (el.offsetParent) { + do { + left += el.offsetLeft; + top += el.offsetTop; + el = el.offsetParent; + } while (el); + } + + return { + left, + top, + width: element.offsetWidth, + height: element.offsetHeight + }; +} + +/** + * Return new progress for {x, y} for the farthest-side formula ("cover"). + * + * @param {Object} target + * @param {number} target.left + * @param {number} target.top + * @param {number} target.width + * @param {number} target.height + * @param {Object} root + * @param {number} root.width + * @param {number} root.height + * @returns {{x: (x: number) => number, y: (y: number) => number}} + */ +function centerToTargetFactory (target, root) { + // we store reference to the arguments and do all calculation on the fly + // so that target dims, scroll position, and root dims are always up-to-date + return { + x (x1) { + const layerCenterX = target.left - scrollPosition.x + target.width / 2; + const isXStartFarthest = layerCenterX >= root.width / 2; + + const xDuration = (isXStartFarthest ? layerCenterX : root.width - layerCenterX) * 2; + const x0 = isXStartFarthest ? 0 : layerCenterX - xDuration / 2; + + return (x1 - x0) / xDuration; + }, + y (y1) { + const layerCenterY = target.top - scrollPosition.y + target.height / 2; + const isYStartFarthest = layerCenterY >= root.height / 2; + + const yDuration = (isYStartFarthest ? layerCenterY : root.height - layerCenterY) * 2; + const y0 = isYStartFarthest ? 0 : layerCenterY - yDuration / 2; + + return (y1 - y0) / yDuration; + } + }; +} + +const scrollPosition = {x: 0, y: 0}; + +/** + * Updates scroll position on scrollend. + * Used when root is entire viewport and centeredOnTarget=true. + */ +function scrollend (tick, lastProgress) { + scrollPosition.x = window.scrollX; + scrollPosition.y = window.scrollY; + + requestAnimationFrame(() => tick && tick(lastProgress)); +} + +/** + * Update root rect when root is entire viewport. + * + * @param {PointerConfig} config + */ +function windowResize (config) { + config.rect.width = window.visualViewport.width; + config.rect.height = window.visualViewport.height; +} + +/** + * Observe and update root rect when root is an element. + * + * @param {PointerConfig} config + * @returns {ResizeObserver} + */ +function observeRootResize (config) { + const observer = new ResizeObserver((entries) => { + entries.forEach((entry) => { + config.rect.width = entry.borderBoxSize[0].inlineSize; + config.rect.height = entry.borderBoxSize[0].blockSize; + }); + }); + + observer.observe(config.root, { box: 'border-box' }); + + return observer; +} + +/** + * Initialize and return a pointer controller. + * + * @private + * @param {PointerConfig} config + * @return {{tick: function, destroy: function}} + */ +function getController (config) { + let hasCenteredToTarget = false; + let lastProgress = {x: config.rect.width / 2, y: config.rect.height / 2, vx: 0, vy: 0}; + let tick, resizeObserver, windowResizeHandler, scrollendHandler; + + /* + * Prepare scenes data. + */ + config.scenes.forEach((scene) => { + if (scene.target && scene.centeredToTarget) { + scene.transform = centerToTargetFactory(getRect(scene.target), config.rect); + + hasCenteredToTarget = true; + } + + if (config.root) { + resizeObserver = observeRootResize(config); + } + else { + windowResizeHandler = windowResize.bind(null, config); + window.addEventListener('resize', windowResizeHandler); + } + }); + + /** + * Updates progress in all scene effects. + * + * @private + * @param {Object} progress + * @param {number} progress.x + * @param {number} progress.y + * @param {number} progress.vx + * @param {number} progress.vy + */ + tick = function (progress) { + for (let scene of config.scenes) { + // get scene's progress + const x = +clamp(0, 1, scene.transform?.x(progress.x) || progress.x / config.rect.width).toPrecision(4); + const y = +clamp(0, 1, scene.transform?.y(progress.y) || progress.y / config.rect.height).toPrecision(4); + + const velocity = {x: progress.vx, y: progress.vy}; + + // run effect + scene.effect(scene, {x, y}, velocity); + } + + Object.assign(lastProgress, progress); + }; + + if (hasCenteredToTarget) { + scrollendHandler = scrollend.bind(null, tick, lastProgress); + document.addEventListener('scrollend', scrollendHandler); + } + + /** + * Removes all side effects and deletes all objects. + */ + function destroy () { + document.removeEventListener('scrollend', scrollendHandler); + + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + else { + window.removeEventListener('resize', windowResizeHandler); + windowResizeHandler = null; + } + + tick = null; + lastProgress = null; + } + + /** + * Mouse controller. + */ + return { + tick, + destroy + }; +} + +/** + * @class Pointer + * @param {PointerConfig} config + * + * @example + * import { Pointer } from 'kuliso'; + * + * const pointer = new Pointer({ + * scenes: [...] + * }); + * + * pointer.start(); + */ +class Pointer { + constructor (config = {}) { + this.config = { ...config }; + + this.effect = null; + + const trigger = frameThrottle(() => { + this.tick(); + }); + + // in no root then use the viewport's size + this.config.rect = this.config.root + ? { + width: this.config.root.offsetWidth, + height: this.config.root.offsetHeight + } + : { + width: window.visualViewport.width, + height: window.visualViewport.height + }; + + + this.progress = { + x: this.config.rect.width / 2, + y: this.config.rect.height / 2, + vx: 0, + vy: 0 + }; + + this._measure = (event) => { + this.progress.x = this.config.root ? event.offsetX : event.x; + this.progress.y = this.config.root ? event.offsetY : event.y; + this.progress.vx = event.movementX; + this.progress.vy = event.movementY; + trigger(); + }; + } + + /** + * Setup event and effect, and reset progress and frame. + */ + start () { + this.setupEffect(); + this.setupEvent(); + } + + /** + * Removes event listener. + */ + pause () { + this.removeEvent(); + } + + /** + * Handle animation frame work. + */ + tick () { + // update effect + this.effect.tick(this.progress); + } + + /** + * Stop the event and effect, and remove all DOM side-effects. + */ + destroy () { + this.pause(); + this.removeEffect(); + } + + /** + * Register to pointermove for triggering update. + */ + setupEvent () { + this.removeEvent(); + const element = this.config.root || window; + element.addEventListener('pointermove', this._measure, {passive: true}); + } + + /** + * Remove pointermove handler. + */ + removeEvent () { + const element = this.config.root || window; + element.removeEventListener('pointermove', this._measure); + } + + /** + * Reset registered effect. + */ + setupEffect () { + this.removeEffect(); + this.effect = getController(this.config); + } + + /** + * Remove registered effect. + */ + removeEffect () { + this.effect && this.effect.destroy(); + this.effect = null; + } +} + +/** + * @typedef {object} PointerConfig + * @property {PointerScene[]} scenes list of effect scenes to perform during pointermove. + * @property {HTMLElement} [root] element to use as hit area for pointermove events. Defaults to entire viewport. + * @property {{width: number, height: number}} [rect] created automatically on Pointer construction. + */ + +/** + * @typedef {Object} PointerScene + * @desc A configuration object for a scene. Must be provided an effect function. + * @example { effects: (scene, p) => { animation.currentTime = p.x; } } + * @property {EffectCallback} effect the effect to perform. + * @property {boolean} [centeredToTarget] whether this scene's progress is centered on the target's center. + * @property {HTMLElement} [target] target element for the effect. + */ + +/** + * @typedef {function(scene: PointerScene, progress: {x: number, y: number}, velocity: {x: number, y: number}): void} EffectCallback + * @param {PointerScene} scene + * @param {{x: number, y: number}} progress + * @param {{x: number, y: number}} velocity + */ + +exports.Pointer = Pointer; diff --git a/documentation.yml b/documentation.yml index 48316e3..5235482 100644 --- a/documentation.yml +++ b/documentation.yml @@ -1,14 +1,5 @@ toc: - - Kuliso - - mouseConfig - - MouseScene + - Pointer + - PointerConfig + - PointerScene - EffectCallback - - RangeOffset - - RangeName - - CSSUnitValue - - AbsoluteOffsetContext - - name: utilities - children: - - lerp - - defaultTo - - frameThrottle diff --git a/index.js b/index.js new file mode 100644 index 0000000..ce35d4b --- /dev/null +++ b/index.js @@ -0,0 +1 @@ +export { Pointer } from './src/Pointer.js'; diff --git a/package.json b/package.json index c92ffa3..32b6d6e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "rollup -c", "test": "c8 ava test/*.spec.js -s", "test:debug": "ava test/*.spec.js -s", - "docs": "documentation build src/Kuliso.js -f html -o docs/reference -c documentation.yml", + "docs": "documentation build src/Pointer.js -f html -o docs/reference -c documentation.yml", "rtfm": "npm run docs && http-server ./docs/reference" }, "repository": { diff --git a/rollup.config.js b/rollup.config.js index 1708a8c..6b63bea 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,7 +2,7 @@ import progress from 'rollup-plugin-progress'; import filesize from 'rollup-plugin-filesize'; export default { - input: 'index.js', + input: 'src/Pointer.js', output: { file: 'dist/index.cjs', format: 'cjs' diff --git a/src/Kuliso.js b/src/Kuliso.js deleted file mode 100644 index 4d8bee0..0000000 --- a/src/Kuliso.js +++ /dev/null @@ -1,241 +0,0 @@ -import { getController } from './controller.js'; -import { defaultTo, frameThrottle, lerp } from './utilities.js'; - -/** - * @private - */ -const DEFAULTS = { - transitionActive: false, - transitionFriction: 0.9, - transitionEpsilon: 1, - velocityActive: false, - velocityMax: 1 -}; - -/** - * @class Kuliso - * @param {mouseConfig} config - * - * @example - * import { Kuliso } from 'kuliso'; - * - * const kuliso = new Kuliso({ - * scenes: [...] - * }); - * kuliso.start(); - */ -export class Kuliso { - constructor (config = {}) { - this.config = defaultTo(config, DEFAULTS); - - this.progress = { - p: 0, - prevP: 0, - vp: 0 - }; - this.currentProgress = { - p: 0, - prevP: 0, - vp: 0 - }; - - this._lerpFrameId = 0; - this.effect = null; - // if no root or root is document.body then use window - this.config.root = (!this.config.root || this.config.root === window.document.body) ? window : this.config.root; - this.config.resetProgress = this.config.resetProgress || this.resetProgress.bind(this); - - this._measure = this.config.measure || (() => { - const root = this.config.root; - // get current scroll position from window or element - this.progress.p = this.config.horizontal - ? root.scrollX || root.scrollLeft || 0 - : root.scrollY || root.scrollTop || 0; - }); - - this._trigger = frameThrottle(() => { - this._measure?.(); - this.tick(true); - }); - } - - /** - * Setup event and effect, and reset progress and frame. - */ - start () { - this.setupEffect(); - this.setupEvent(); - this.resetProgress(); - this.tick(); - } - - /** - * Removes event listener. - */ - pause () { - this.removeEvent(); - } - - /** - * Reset progress in the DOM and inner state to given x and y. - * - * @param {Object} [pointerPosition] - * @param {number} [pointerPosition.x] - * @param {number} [pointerPosition.y] - */ - resetProgress (pointerPosition = {}) { - // get current pointer position (support window, element) - const root = this.config.root; - const x = pointerPosition.x || pointerPosition.x === 0 ? pointerPosition.x : root.scrollX || root.scrollLeft || 0; - const y = pointerPosition.y || pointerPosition.y === 0 ? pointerPosition.y : root.scrollY || root.scrollTop || 0; - this.progress.x = x; - this.progress.y = y; - this.progress.prevX = x; - this.progress.prevY = y; - this.progress.vx = 0; - this.progress.vy = 0; - - if (pointerPosition) { - this.config.root.scrollTo(x, y); - } - } - - /** - * Handle animation frame work. - * - * @param {boolean} [clearLerpFrame] whether to cancel an existing lerp frame - */ - tick (clearLerpFrame) { - const hasLerp = this.config.transitionActive; - - // if transition is active interpolate to next point - if (hasLerp) { - this.lerp(); - } - - // choose the object we iterate on - const progress = hasLerp ? this.currentProgress : this.progress; - - if (this.config.velocityActive) { - const dp = progress.p - progress.prevP; - const factorP = dp < 0 ? -1 : 1; - progress.vp = Math.min(this.config.velocityMax, Math.abs(dp)) / this.config.velocityMax * factorP; - } - - // update effect - this.effect.tick(progress); - - if (hasLerp && (progress.p !== this.progress.p)) { - if (clearLerpFrame && this._lerpFrameId) { - window.cancelAnimationFrame(this._lerpFrameId); - } - - this._lerpFrameId = window.requestAnimationFrame(() => this.tick()); - } - - progress.prevP = progress.p; - } - - /** - * Calculate current progress. - */ - lerp () { - this.currentProgress.p = lerp(this.currentProgress.p, this.progress.p, +(1 - this.config.transitionFriction).toFixed(3), this.config.transitionEpsilon); - } - - /** - * Stop the event and effect, and remove all DOM side-effects. - */ - destroy () { - this.pause(); - this.removeEffect(); - } - - /** - * Register to pointermove for triggering update. - */ - setupEvent () { - this.removeEvent(); - this.config.root.addEventListener('pointermove', this._trigger); - } - - /** - * Remove pointermove handler. - */ - removeEvent () { - this.config.root.removeEventListener('pointermove', this._trigger); - } - - /** - * Reset registered effect. - */ - setupEffect () { - this.removeEffect(); - this.effect = getController(this.config); - } - - /** - * Remove registered effect. - */ - removeEffect () { - this.effect && this.effect.destroy(); - this.effect = null; - } -} - -/** - * @typedef {object} scrollConfig - * @property {ScrollScene[]} scenes list of effect scenes to perform during scroll. - * @property {boolean} [horizontal] whether to use the horizontal axis. Defaults to `false`. - * @property {boolean} [transitionActive] whether to animate effect progress. - * @property {number} [transitionFriction] between 0 to 1, amount of friction effect in the transition. 1 being no movement and 0 as no friction. Defaults to 0.4. - * @property {boolean} [velocityActive] whether to calculate velocity with progress. - * @property {number} [velocityMax] max possible value for velocity. Velocity value will be normalized according to this number, so it is kept between 0 and 1. Defaults to 1. - * @property {boolean} [observeViewportEntry] whether to observe entry/exit of scenes into viewport for disabling/enabling them. Defaults to `true`. - * @property {boolean} [viewportRootMargin] `rootMargin` option to be used for viewport observation. Defaults to `'7% 7%'`. - * @property {boolean} [observeViewportResize] whether to observe resize of the visual viewport. Defaults to `false`. - * @property {boolean} [observeSourcesResize] whether to observe resize of view-timeline source elements. Defaults to `false`. - * @property {Element|Window} [root] the scrollable element, defaults to window. - */ - -/** - * @typedef {Object} ScrollScene - * @desc A configuration object for a scene. Must be provided an effect function, and either a start and end, a start and duration, or a duration as RangeName. - * @example { effects: (scene, p) => { animation.currentTime = p; }, duration: 'contain' } - * @property {EffectCallback} effect the effect to perform. - * @property {number|RangeOffset} start scroll position in pixels where effect starts. - * @property {number|RangeName} [duration] duration of effect in pixels. Defaults to end - start. - * @property {number|RangeOffset} [end] scroll position in pixels where effect ends. Defaults to start + duration. - * @property {boolean} [disabled] whether to perform updates on the scene. Defaults to false. - * @property {Element} [viewSource] an element to be used for observing intersection with viewport for disabling/enabling the scene, or the source of a ViewTimeline if scene start/end are provided as ranges. - */ - -/** - * @typedef {function(scene: ScrollScene, progress: number, velocity: number): void} EffectCallback - * @param {ScrollScene} scene - * @param {number} progress - * @param {number} velocity - */ - -/** - * @typedef {'entry' | 'contain' | 'exit' | 'cover'} RangeName - */ - -/** - * @typedef {Object} RangeOffset - * @property {RangeName} name - * @property {number} offset - * @property {CSSUnitValue} [add] - */ - -/** - * @typedef {Object} CSSUnitValue - * @property {number} value - * @property {'px'|'vh'|'vw'} unit - */ - -/** - * @typedef {Object} AbsoluteOffsetContext - * @property {number} viewportWidth - * @property {number} viewportHeight - */ diff --git a/src/Pointer.js b/src/Pointer.js new file mode 100644 index 0000000..0fa84e2 --- /dev/null +++ b/src/Pointer.js @@ -0,0 +1,141 @@ +import { getController } from './controller.js'; +import { frameThrottle } from './utilities.js'; + +/** + * @class Pointer + * @param {PointerConfig} config + * + * @example + * import { Pointer } from 'kuliso'; + * + * const pointer = new Pointer({ + * scenes: [...] + * }); + * + * pointer.start(); + */ +export class Pointer { + constructor (config = {}) { + this.config = { ...config }; + + this.effect = null; + + const trigger = frameThrottle(() => { + this.tick(); + }); + + // in no root then use the viewport's size + this.config.rect = this.config.root + ? { + width: this.config.root.offsetWidth, + height: this.config.root.offsetHeight + } + : { + width: window.visualViewport.width, + height: window.visualViewport.height + }; + + + this.progress = { + x: this.config.rect.width / 2, + y: this.config.rect.height / 2, + vx: 0, + vy: 0 + }; + + this._measure = (event) => { + this.progress.x = this.config.root ? event.offsetX : event.x; + this.progress.y = this.config.root ? event.offsetY : event.y; + this.progress.vx = event.movementX; + this.progress.vy = event.movementY; + trigger(); + }; + } + + /** + * Setup event and effect, and reset progress and frame. + */ + start () { + this.setupEffect(); + this.setupEvent(); + } + + /** + * Removes event listener. + */ + pause () { + this.removeEvent(); + } + + /** + * Handle animation frame work. + */ + tick () { + // update effect + this.effect.tick(this.progress); + } + + /** + * Stop the event and effect, and remove all DOM side-effects. + */ + destroy () { + this.pause(); + this.removeEffect(); + } + + /** + * Register to pointermove for triggering update. + */ + setupEvent () { + this.removeEvent(); + const element = this.config.root || window; + element.addEventListener('pointermove', this._measure, {passive: true}); + } + + /** + * Remove pointermove handler. + */ + removeEvent () { + const element = this.config.root || window; + element.removeEventListener('pointermove', this._measure); + } + + /** + * Reset registered effect. + */ + setupEffect () { + this.removeEffect(); + this.effect = getController(this.config); + } + + /** + * Remove registered effect. + */ + removeEffect () { + this.effect && this.effect.destroy(); + this.effect = null; + } +} + +/** + * @typedef {object} PointerConfig + * @property {PointerScene[]} scenes list of effect scenes to perform during pointermove. + * @property {HTMLElement} [root] element to use as hit area for pointermove events. Defaults to entire viewport. + * @property {{width: number, height: number}} [rect] created automatically on Pointer construction. + */ + +/** + * @typedef {Object} PointerScene + * @desc A configuration object for a scene. Must be provided an effect function. + * @example { effects: (scene, p) => { animation.currentTime = p.x; } } + * @property {EffectCallback} effect the effect to perform. + * @property {boolean} [centeredToTarget] whether this scene's progress is centered on the target's center. + * @property {HTMLElement} [target] target element for the effect. + */ + +/** + * @typedef {function(scene: PointerScene, progress: {x: number, y: number}, velocity: {x: number, y: number}): void} EffectCallback + * @param {PointerScene} scene + * @param {{x: number, y: number}} progress + * @param {{x: number, y: number}} velocity + */ diff --git a/src/controller.js b/src/controller.js new file mode 100644 index 0000000..8254a43 --- /dev/null +++ b/src/controller.js @@ -0,0 +1,170 @@ +import { getRect, clamp } from './utilities.js'; + +/** + * Return new progress for {x, y} for the farthest-side formula ("cover"). + * + * @param {Object} target + * @param {number} target.left + * @param {number} target.top + * @param {number} target.width + * @param {number} target.height + * @param {Object} root + * @param {number} root.width + * @param {number} root.height + * @returns {{x: (x: number) => number, y: (y: number) => number}} + */ +function centerToTargetFactory (target, root) { + // we store reference to the arguments and do all calculation on the fly + // so that target dims, scroll position, and root dims are always up-to-date + return { + x (x1) { + const layerCenterX = target.left - scrollPosition.x + target.width / 2; + const isXStartFarthest = layerCenterX >= root.width / 2; + + const xDuration = (isXStartFarthest ? layerCenterX : root.width - layerCenterX) * 2; + const x0 = isXStartFarthest ? 0 : layerCenterX - xDuration / 2; + + return (x1 - x0) / xDuration; + }, + y (y1) { + const layerCenterY = target.top - scrollPosition.y + target.height / 2; + const isYStartFarthest = layerCenterY >= root.height / 2; + + const yDuration = (isYStartFarthest ? layerCenterY : root.height - layerCenterY) * 2; + const y0 = isYStartFarthest ? 0 : layerCenterY - yDuration / 2; + + return (y1 - y0) / yDuration; + } + }; +} + +const scrollPosition = {x: 0, y: 0}; + +/** + * Updates scroll position on scrollend. + * Used when root is entire viewport and centeredOnTarget=true. + */ +function scrollend (tick, lastProgress) { + scrollPosition.x = window.scrollX; + scrollPosition.y = window.scrollY; + + requestAnimationFrame(() => tick && tick(lastProgress)); +} + +/** + * Update root rect when root is entire viewport. + * + * @param {PointerConfig} config + */ +function windowResize (config) { + config.rect.width = window.visualViewport.width; + config.rect.height = window.visualViewport.height; +} + +/** + * Observe and update root rect when root is an element. + * + * @param {PointerConfig} config + * @returns {ResizeObserver} + */ +function observeRootResize (config) { + const observer = new ResizeObserver((entries) => { + entries.forEach((entry) => { + config.rect.width = entry.borderBoxSize[0].inlineSize; + config.rect.height = entry.borderBoxSize[0].blockSize; + }); + }); + + observer.observe(config.root, { box: 'border-box' }); + + return observer; +} + +/** + * Initialize and return a pointer controller. + * + * @private + * @param {PointerConfig} config + * @return {{tick: function, destroy: function}} + */ +export function getController (config) { + let hasCenteredToTarget = false; + let lastProgress = {x: config.rect.width / 2, y: config.rect.height / 2, vx: 0, vy: 0}; + let tick, resizeObserver, windowResizeHandler, scrollendHandler; + + /* + * Prepare scenes data. + */ + config.scenes.forEach((scene) => { + if (scene.target && scene.centeredToTarget) { + scene.transform = centerToTargetFactory(getRect(scene.target), config.rect); + + hasCenteredToTarget = true; + } + + if (config.root) { + resizeObserver = observeRootResize(config); + } + else { + windowResizeHandler = windowResize.bind(null, config); + window.addEventListener('resize', windowResizeHandler); + } + }); + + /** + * Updates progress in all scene effects. + * + * @private + * @param {Object} progress + * @param {number} progress.x + * @param {number} progress.y + * @param {number} progress.vx + * @param {number} progress.vy + */ + tick = function (progress) { + for (let scene of config.scenes) { + // get scene's progress + const x = +clamp(0, 1, scene.transform?.x(progress.x) || progress.x / config.rect.width).toPrecision(4); + const y = +clamp(0, 1, scene.transform?.y(progress.y) || progress.y / config.rect.height).toPrecision(4); + + const velocity = {x: progress.vx, y: progress.vy}; + + // run effect + scene.effect(scene, {x, y}, velocity); + } + + Object.assign(lastProgress, progress); + } + + if (hasCenteredToTarget) { + scrollendHandler = scrollend.bind(null, tick, lastProgress) + document.addEventListener('scrollend', scrollendHandler); + } + + /** + * Removes all side effects and deletes all objects. + */ + function destroy () { + document.removeEventListener('scrollend', scrollendHandler); + + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + else { + window.removeEventListener('resize', windowResizeHandler); + windowResizeHandler = null; + } + + tick = null; + lastProgress = null; + } + + /** + * Mouse controller. + */ + return { + tick, + destroy + }; +} diff --git a/src/hover.js b/src/hover.js deleted file mode 100644 index 2116117..0000000 --- a/src/hover.js +++ /dev/null @@ -1,49 +0,0 @@ -import { clamp } from './utilities.js'; - -export function getHandler ({target, progress, callback}) { - let rect; - - if (target && target !== window) { - rect = { ...target.getBoundingClientRect().toJSON() }; - } - else { - target = window; - rect = { - left: 0, - top: 0, - width: window.innerWidth, - height: window.innerHeight - }; - } - - const {width, height, left, top} = rect; - - function handler (event) { - const {clientX, clientY} = event; - - // percentage of position progress - const x = clamp(0, 1, (clientX - left) / width); - const y = clamp(0, 1, (clientY - top) / height); - - progress.x = +x.toPrecision(4); - progress.y = +y.toPrecision(4); - progress.h = height; - progress.w = width; - - callback(); - } - - function on (config) { - target.addEventListener('mousemove', handler, config || false); - } - - function off (config) { - target.removeEventListener('mousemove', handler, config || false); - } - - return { - on, - off, - handler - }; -} diff --git a/src/utilities.js b/src/utilities.js index 1cd19b7..354a7ac 100644 --- a/src/utilities.js +++ b/src/utilities.js @@ -13,41 +13,6 @@ function clamp (min, max, value) { return Math.min(Math.max(min, value), max); } -/** - * Returns a new Object with the properties of the first argument - * assigned to it, and the second argument as its prototype, so - * its properties are served as defaults. - * - * @param {Object} obj properties to assign - * @param {Object|null} defaults - * @return {Object} - */ -function defaultTo (obj, defaults) { - return Object.assign(Object.create(defaults), obj); -} - -/** - * Copies all given objects into a new Object. - * - * @param {...Object} objects - * @return {Object} - */ -function clone (...objects) { - return Object.assign({}, ...objects); -} - -/** - * Interpolate from a to b by the factor t. - * - * @param {number} a start point - * @param {number} b end point - * @param {number} t interpolation factor - * @return {number} - */ -function lerp (a, b, t) { - return a * (1 - t) + b * t; -} - /** * Throttle a function to trigger once per animation frame. * Keeps the arguments from last call, even if that call gets ignored. @@ -70,15 +35,29 @@ function frameThrottle (fn) { }; } -function map (x, a, b, c, d) { - return (x - a) * (d - c) / (b - a) + c; +function getRect (element) { + let el = element; + let left = 0; + let top = 0; + + if (el.offsetParent) { + do { + left += el.offsetLeft; + top += el.offsetTop; + el = el.offsetParent; + } while (el); + } + + return { + left, + top, + width: element.offsetWidth, + height: element.offsetHeight + }; } export { - map, + getRect, clamp, - clone, - defaultTo, - lerp, frameThrottle }; diff --git a/test/Pointer.spec.js b/test/Pointer.spec.js new file mode 100644 index 0000000..52c8fbc --- /dev/null +++ b/test/Pointer.spec.js @@ -0,0 +1,249 @@ +import test from 'ava'; +import { Pointer } from '../src/Pointer.js'; + +class ResizeObserver { + constructor (callback) { + this.callback = callback; + global.resizeObserver = this; + } + observe (element) { + this.target = element; + } + disconnect () {} + trigger ({width, height}) { + this.target.offsetWidth = width; + this.target.offsetHeight = height; + + const entries = [{ + borderBoxSize: [ + { + inlineSize: width, + blockSize: height, + } + ] + }]; + + this.callback(entries); + } +} + +function generateElement ({width, height}) { + return { + offsetWidth: width, + offsetHeight: height, + addEventListener (type, handler) {}, + removeEventListener () {} + }; +} + +test.beforeEach(() => { + let resizeHandler, scrollendHandler; + + global.window = { + visualViewport: { + width: 400, + height: 200 + }, + addEventListener (type, handler) { + if (type === 'resize') { + resizeHandler = handler; + } + }, + removeEventListener () {}, + resize (width, height) { + this.visualViewport.width = width; + this.visualViewport.height = height; + resizeHandler?.(); + }, + scrollTo (x, y) { + this.scrollY = y; + this.scrollX = x; + scrollendHandler?.(); + } + }; + global.document = { + addEventListener (type, handler) { + if (type === 'scrollend') { + scrollendHandler = handler; + } + }, + removeEventListener () {} + }; + global.ResizeObserver = ResizeObserver; + global.requestAnimationFrame = function () {}; +}) + +test('Pointer.constructor :: sanity test', t => { + const pointer = new Pointer({ + scenes: [ + { effect: () => {} } + ] + }); + + t.is(typeof pointer._measure, 'function'); +}); + +test('Pointer.start() :: sanity test', t => { + const pointer = new Pointer({ + scenes: [ + { effect: () => {} } + ] + }); + + pointer.start(); + + t.is(typeof pointer.effect.tick, 'function'); +}); + +test('Pointer.destroy() :: sanity test', t => { + const pointer = new Pointer({ + scenes: [ + { effect: () => {} } + ] + }); + + pointer.start(); + pointer.destroy(); + + t.is(pointer.effect, null); +}); + +test('Pointer.tick() :: sanity test :: viewport as root', t => { + let x = 0; + let y = 0; + const pointer = new Pointer({ + scenes: [ + { + effect(scene, progress) { + x = progress.x; + y = progress.y; + } + } + ] + }); + + pointer.start(); + pointer.progress.x = 100; + pointer.progress.y = 100; + pointer.tick(); + + t.is(x, 0.25); + t.is(y, 0.5); +}); + +test('Pointer.tick() :: sanity test :: element as root', t => { + let x = 0; + let y = 0; + const root = generateElement({width: 100, height: 100}); + const pointer = new Pointer({ + root, + scenes: [ + { + effect(scene, progress) { + x = progress.x; + y = progress.y; + } + } + ] + }); + + pointer.start(); + pointer.progress.x = 20; + pointer.progress.y = 50; + pointer.tick(); + + t.is(x, 20 / 100); + t.is(y, 50 / 100); +}); + +test('Pointer.tick() :: update on resize :: element as root', t => { + let x = 0; + let y = 0; + const root = generateElement({width: 100, height: 100}); + const pointer = new Pointer({ + root, + scenes: [ + { + effect(scene, progress) { + x = progress.x; + y = progress.y; + } + } + ] + }); + + pointer.start(); + pointer.progress.x = 20; + pointer.progress.y = 50; + pointer.tick(); + + t.is(x, 20 / 100); + t.is(y, 50 / 100); + + global.resizeObserver.trigger({width: 200, height: 200}); + pointer.tick(); + + t.is(x, 20 / 200); + t.is(y, 50 / 200); +}); + +test('Pointer.tick() :: update on window.resize', t => { + let x = 0; + let y = 0; + const pointer = new Pointer({ + scenes: [ + { + effect(scene, progress) { + x = progress.x; + y = progress.y; + } + } + ] + }); + + pointer.start(); + pointer.progress.x = 100; + pointer.progress.y = 100; + pointer.tick(); + + t.is(x, 0.25); + t.is(y, 0.5); + + window.resize(600, 400); + pointer.tick(); + + t.is(x, +(100 / 600).toPrecision(4)); + t.is(y, 100 / 400); +}); + +test('Pointer.tick() :: centeredToTarget=true :: update on window.scrollend', t => { + let x = 0; + let y = 0; + const target = generateElement({width: 100, height: 100}); + const pointer = new Pointer({ + scenes: [ + { + target, + centeredToTarget: true, + effect(scene, progress) { + x = progress.x; + y = progress.y; + } + } + ] + }); + + pointer.start(); + pointer.progress.x = 50; + pointer.progress.y = 50; + pointer.tick(); + + t.is(x, 0.5); + t.is(y, 0.5); + + global.window.scrollTo(0, 50); + pointer.tick(); + + t.is(x, 0.5); + t.is(y, 250 / 400); +}); diff --git a/types.d.ts b/types.d.ts index 077ba67..3cdf236 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,23 +1,12 @@ -declare type RangeName = 'entry' | 'exit' | 'contain' | 'cover'; - -declare type RangeOffset = { - name?: RangeName; - offset?: number; -}; - -declare type mouseConfig = { - scenes: MouseScene; - transitionActive?: boolean; - transitionFriction?: number; +declare type PointerConfig = { + scenes: PointerScene; + root?: HTMLElement; } -declare type MouseScene = { - effect: (scene: MouseScene, progress: number) => void; - start?: RangeOffset; - duration?: number | RangeName; - end?: RangeOffset; - disabled?: boolean; - viewSource?: HTMLElement; +declare type PointerScene = { + effect: (scene: PointerScene, progress: {x: number, y: number}, velocity: {x: number, y: number}) => void; + centeredToTarget?: boolean; + target?: HTMLElement; } declare module "kuliso";