atropos-passive-listeners
Version:
Fork-passive-listerners.
609 lines (539 loc) • 20.3 kB
JavaScript
/**
* Atropos 2.0.2
* Touch-friendly 3D parallax hover effects
* https://atroposjs.com
*
* Copyright 2021-2023
*
* Released under the MIT License
*
* Released on: August 28, 2023
*/
(function (factory) {
typeof define === 'function' && define.amd ? define(factory) :
factory();
})((function () { 'use strict';
/* eslint-disable no-restricted-globals */
const $ = (el, sel) => el.querySelector(sel);
const $$ = (el, sel) => el.querySelectorAll(sel);
const removeUndefinedProps = (obj = {}) => {
const result = {};
Object.keys(obj).forEach((key) => {
if (typeof obj[key] !== 'undefined') result[key] = obj[key];
});
return result;
};
const defaults = {
alwaysActive: false,
activeOffset: 50,
shadowOffset: 50,
shadowScale: 1,
duration: 300,
rotate: true,
rotateTouch: true,
rotateXMax: 15,
rotateYMax: 15,
rotateXInvert: false,
rotateYInvert: false,
stretchX: 0,
stretchY: 0,
stretchZ: 0,
commonOrigin: true,
shadow: true,
highlight: true,
};
function Atropos(originalParams = {}) {
let { el, eventsEl } = originalParams;
const { isComponent } = originalParams;
let childrenRootEl;
const self = {
__atropos__: true,
params: {
...defaults,
onEnter: null,
onLeave: null,
onRotate: null,
...removeUndefinedProps(originalParams || {}),
},
destroyed: false,
isActive: false,
};
const { params } = self;
let rotateEl;
let scaleEl;
let innerEl;
let elBoundingClientRect;
let eventsElBoundingClientRect;
let shadowEl;
let highlightEl;
let isScrolling;
let clientXStart;
let clientYStart;
const queue = [];
let queueFrameId;
const purgeQueue = () => {
queueFrameId = requestAnimationFrame(() => {
queue.forEach((data) => {
if (typeof data === 'function') {
data();
} else {
const { element, prop, value } = data;
element.style[prop] = value;
}
});
queue.splice(0, queue.length);
purgeQueue();
});
};
purgeQueue();
const $setDuration = (element, value) => {
queue.push({ element, prop: 'transitionDuration', value });
};
const $setEasing = (element, value) => {
queue.push({ element, prop: 'transitionTimingFunction', value });
};
const $setTransform = (element, value) => {
queue.push({ element, prop: 'transform', value });
};
const $setOpacity = (element, value) => {
queue.push({ element, prop: 'opacity', value });
};
const $setOrigin = (element, value) => {
queue.push({ element, prop: 'transformOrigin', value });
};
const $on = (element, event, handler, props) => element.addEventListener(event, handler, props);
const $off = (element, event, handler, props) =>
element.removeEventListener(event, handler, props);
const createShadow = () => {
let created;
shadowEl = $(el, '.atropos-shadow');
if (!shadowEl) {
shadowEl = document.createElement('span');
shadowEl.classList.add('atropos-shadow');
created = true;
}
$setTransform(
shadowEl,
`translate3d(0,0,-${params.shadowOffset}px) scale(${params.shadowScale})`,
);
if (created) {
rotateEl.appendChild(shadowEl);
}
};
const createHighlight = () => {
let created;
highlightEl = $(el, '.atropos-highlight');
if (!highlightEl) {
highlightEl = document.createElement('span');
highlightEl.classList.add('atropos-highlight');
created = true;
}
$setTransform(highlightEl, `translate3d(0,0,0)`);
if (created) {
innerEl.appendChild(highlightEl);
}
};
const setChildrenOffset = ({
rotateXPercentage = 0,
rotateYPercentage = 0,
duration,
opacityOnly,
easeOut,
}) => {
const getOpacity = (element) => {
if (element.dataset.atroposOpacity && typeof element.dataset.atroposOpacity === 'string') {
return element.dataset.atroposOpacity.split(';').map((v) => parseFloat(v));
}
return undefined;
};
$$(childrenRootEl, '[data-atropos-offset], [data-atropos-opacity]').forEach((childEl) => {
$setDuration(childEl, duration);
$setEasing(childEl, easeOut ? 'ease-out' : '');
const elementOpacity = getOpacity(childEl);
if (rotateXPercentage === 0 && rotateYPercentage === 0) {
if (!opacityOnly) $setTransform(childEl, `translate3d(0, 0, 0)`);
if (elementOpacity) $setOpacity(childEl, elementOpacity[0]);
} else {
const childElOffset = parseFloat(childEl.dataset.atroposOffset) / 100;
if (!Number.isNaN(childElOffset) && !opacityOnly) {
$setTransform(
childEl,
`translate3d(${-rotateYPercentage * -childElOffset}%, ${
rotateXPercentage * -childElOffset
}%, 0)`,
);
}
if (elementOpacity) {
const [min, max] = elementOpacity;
const rotatePercentage = Math.max(
Math.abs(rotateXPercentage),
Math.abs(rotateYPercentage),
);
$setOpacity(childEl, min + ((max - min) * rotatePercentage) / 100);
}
}
});
};
const setElements = (clientX, clientY) => {
const isMultiple = el !== eventsEl;
if (!elBoundingClientRect) {
elBoundingClientRect = el.getBoundingClientRect();
}
if (isMultiple && !eventsElBoundingClientRect) {
eventsElBoundingClientRect = eventsEl.getBoundingClientRect();
}
if (typeof clientX === 'undefined' && typeof clientY === 'undefined') {
const rect = isMultiple ? eventsElBoundingClientRect : elBoundingClientRect;
clientX = rect.left + rect.width / 2;
clientY = rect.top + rect.height / 2;
}
let rotateX = 0;
let rotateY = 0;
const { top, left, width, height } = elBoundingClientRect;
let transformOrigin;
if (!isMultiple) {
const centerX = width / 2;
const centerY = height / 2;
const coordX = clientX - left;
const coordY = clientY - top;
rotateY = ((params.rotateYMax * (coordX - centerX)) / (width / 2)) * -1;
rotateX = (params.rotateXMax * (coordY - centerY)) / (height / 2);
} else {
const {
top: parentTop,
left: parentLeft,
width: parentWidth,
height: parentHeight,
} = eventsElBoundingClientRect;
const offsetLeft = left - parentLeft;
const offsetTop = top - parentTop;
const centerX = width / 2 + offsetLeft;
const centerY = height / 2 + offsetTop;
const coordX = clientX - parentLeft;
const coordY = clientY - parentTop;
rotateY = ((params.rotateYMax * (coordX - centerX)) / (parentWidth - width / 2)) * -1;
rotateX = (params.rotateXMax * (coordY - centerY)) / (parentHeight - height / 2);
transformOrigin = `${clientX - left}px ${clientY - top}px`;
}
rotateX = Math.min(Math.max(-rotateX, -params.rotateXMax), params.rotateXMax);
if (params.rotateXInvert) rotateX = -rotateX;
rotateY = Math.min(Math.max(-rotateY, -params.rotateYMax), params.rotateYMax);
if (params.rotateYInvert) rotateY = -rotateY;
const rotateXPercentage = (rotateX / params.rotateXMax) * 100;
const rotateYPercentage = (rotateY / params.rotateYMax) * 100;
const stretchX =
(isMultiple ? (rotateYPercentage / 100) * params.stretchX : 0) *
(params.rotateYInvert ? -1 : 1);
const stretchY =
(isMultiple ? (rotateXPercentage / 100) * params.stretchY : 0) *
(params.rotateXInvert ? -1 : 1);
const stretchZ = isMultiple
? (Math.max(Math.abs(rotateXPercentage), Math.abs(rotateYPercentage)) / 100) * params.stretchZ
: 0;
$setTransform(
rotateEl,
`translate3d(${stretchX}%, ${-stretchY}%, ${-stretchZ}px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`,
);
if (transformOrigin && params.commonOrigin) {
$setOrigin(rotateEl, transformOrigin);
}
if (highlightEl) {
$setDuration(highlightEl, `${params.duration}ms`);
$setEasing(highlightEl, 'ease-out');
$setTransform(
highlightEl,
`translate3d(${-rotateYPercentage * 0.25}%, ${rotateXPercentage * 0.25}%, 0)`,
);
$setOpacity(
highlightEl,
Math.max(Math.abs(rotateXPercentage), Math.abs(rotateYPercentage)) / 100,
);
}
setChildrenOffset({
rotateXPercentage,
rotateYPercentage,
duration: `${params.duration}ms`,
easeOut: true,
});
if (typeof params.onRotate === 'function') params.onRotate(rotateX, rotateY);
};
const activate = () => {
queue.push(() => el.classList.add('atropos-active'));
$setDuration(rotateEl, `${params.duration}ms`);
$setEasing(rotateEl, 'ease-out');
$setTransform(scaleEl, `translate3d(0,0, ${params.activeOffset}px)`);
$setDuration(scaleEl, `${params.duration}ms`);
$setEasing(scaleEl, 'ease-out');
if (shadowEl) {
$setDuration(shadowEl, `${params.duration}ms`);
$setEasing(shadowEl, 'ease-out');
}
self.isActive = true;
};
const onPointerEnter = (e) => {
isScrolling = undefined;
if (e.type === 'pointerdown' && e.pointerType === 'mouse') return;
if (e.type === 'pointerenter' && e.pointerType !== 'mouse') return;
if (e.type === 'pointerdown') {
e.preventDefault();
}
clientXStart = e.clientX;
clientYStart = e.clientY;
if (params.alwaysActive) {
elBoundingClientRect = undefined;
eventsElBoundingClientRect = undefined;
return;
}
activate();
if (typeof params.onEnter === 'function') params.onEnter();
};
const onTouchMove = (e) => {
if (isScrolling === false && e.cancelable) {
e.preventDefault();
}
};
const onPointerMove = (e) => {
if (!params.rotate || !self.isActive) return;
if (e.pointerType !== 'mouse') {
if (!params.rotateTouch) return;
e.preventDefault();
}
const { clientX, clientY } = e;
const diffX = clientX - clientXStart;
const diffY = clientY - clientYStart;
if (
typeof params.rotateTouch === 'string' &&
(diffX !== 0 || diffY !== 0) &&
typeof isScrolling === 'undefined'
) {
if (diffX * diffX + diffY * diffY >= 25) {
const touchAngle = (Math.atan2(Math.abs(diffY), Math.abs(diffX)) * 180) / Math.PI;
isScrolling = params.rotateTouch === 'scroll-y' ? touchAngle > 45 : 90 - touchAngle > 45;
}
if (isScrolling === false) {
el.classList.add('atropos-rotate-touch');
if (e.cancelable) {
e.preventDefault();
}
}
}
if (e.pointerType !== 'mouse' && isScrolling) {
return;
}
setElements(clientX, clientY);
};
const onPointerLeave = (e) => {
elBoundingClientRect = undefined;
eventsElBoundingClientRect = undefined;
if (!self.isActive) return;
if (e && e.type === 'pointerup' && e.pointerType === 'mouse') return;
if (e && e.type === 'pointerleave' && e.pointerType !== 'mouse') return;
if (typeof params.rotateTouch === 'string' && isScrolling) {
el.classList.remove('atropos-rotate-touch');
}
if (params.alwaysActive) {
setElements();
if (typeof params.onRotate === 'function') params.onRotate(0, 0);
if (typeof params.onLeave === 'function') params.onLeave();
return;
}
queue.push(() => el.classList.remove('atropos-active'));
$setDuration(scaleEl, `${params.duration}ms`);
$setEasing(scaleEl, '');
$setTransform(scaleEl, `translate3d(0,0, ${0}px)`);
if (shadowEl) {
$setDuration(shadowEl, `${params.duration}ms`);
$setEasing(shadowEl, '');
}
if (highlightEl) {
$setDuration(highlightEl, `${params.duration}ms`);
$setEasing(highlightEl, '');
$setTransform(highlightEl, `translate3d(0, 0, 0)`);
$setOpacity(highlightEl, 0);
}
$setDuration(rotateEl, `${params.duration}ms`);
$setEasing(rotateEl, '');
$setTransform(rotateEl, `translate3d(0,0,0) rotateX(0deg) rotateY(0deg)`);
setChildrenOffset({ duration: `${params.duration}ms` });
self.isActive = false;
if (typeof params.onRotate === 'function') params.onRotate(0, 0);
if (typeof params.onLeave === 'function') params.onLeave();
};
const onDocumentClick = (e) => {
const clickTarget = e.target;
if (!eventsEl.contains(clickTarget) && clickTarget !== eventsEl && self.isActive) {
onPointerLeave();
}
};
const initDOM = () => {
if (typeof el === 'string') {
el = $(document, el);
}
if (!el) return;
// eslint-disable-next-line
if (el.__atropos__) return;
if (typeof eventsEl !== 'undefined') {
if (typeof eventsEl === 'string') {
eventsEl = $(document, eventsEl);
}
} else {
eventsEl = el;
}
childrenRootEl = isComponent ? el.parentNode.host : el;
Object.assign(self, {
el,
});
rotateEl = $(el, '.atropos-rotate');
scaleEl = $(el, '.atropos-scale');
innerEl = $(el, '.atropos-inner');
// eslint-disable-next-line
el.__atropos__ = self;
};
const init = () => {
initDOM();
if (!el || !eventsEl) return;
if (params.shadow) {
createShadow();
}
if (params.highlight) {
createHighlight();
}
if (params.rotateTouch) {
if (typeof params.rotateTouch === 'string') {
el.classList.add(`atropos-rotate-touch-${params.rotateTouch}`);
} else {
el.classList.add('atropos-rotate-touch');
}
}
if ($(childrenRootEl, '[data-atropos-opacity]')) {
setChildrenOffset({ opacityOnly: true });
}
$on(document, 'click', onDocumentClick, {passive: true});
$on(eventsEl, 'pointerdown', onPointerEnter, {passive: true});
$on(eventsEl, 'pointerenter', onPointerEnter, {passive: true});
$on(eventsEl, 'pointermove', onPointerMove, {passive: true});
$on(eventsEl, 'touchmove', onTouchMove, {passive: true});
$on(eventsEl, 'pointerleave', onPointerLeave, {passive: true});
$on(eventsEl, 'pointerup', onPointerLeave, {passive: true});
$on(eventsEl, 'lostpointercapture', onPointerLeave, {passive: true});
if (params.alwaysActive) {
activate();
setElements();
}
};
const destroy = () => {
self.destroyed = true;
cancelAnimationFrame(queueFrameId);
$off(document, 'click', onDocumentClick);
$off(eventsEl, 'pointerdown', onPointerEnter);
$off(eventsEl, 'pointerenter', onPointerEnter);
$off(eventsEl, 'pointermove', onPointerMove);
$off(eventsEl, 'touchmove', onTouchMove);
$off(eventsEl, 'pointerleave', onPointerLeave);
$off(eventsEl, 'pointerup', onPointerLeave);
$off(eventsEl, 'lostpointercapture', onPointerLeave);
// eslint-disable-next-line
delete el.__atropos__;
};
self.destroy = destroy;
init();
// eslint-disable-next-line
return self;
}
/* eslint-disable no-restricted-globals */
// eslint-disable-next-line import/no-named-as-default
const styles = `.atropos{position:relative;display:block;perspective:1200px;transform:translate3d(0,0,0)}.atropos-rotate-scroll-x,.atropos-rotate-scroll-y,.atropos-rotate-touch{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;user-select:none}.atropos-rotate-touch-scroll-y{touch-action:pan-y}.atropos-rotate-touch-scroll-x{touch-action:pan-x}.atropos-rotate-touch{touch-action:none}.atropos-rotate,.atropos-scale{width:100%;height:100%;transform-style:preserve-3d;transition-property:transform;display:block}.atropos-highlight,.atropos-shadow{position:absolute;pointer-events:none;transition-property:transform,opacity;display:block;opacity:0}.atropos-shadow{z-index:-1;background:#000;left:0;top:0;width:100%;height:100%;filter:blur(30px)}.atropos-highlight{left:-50%;top:-50%;width:200%;height:200%;background-image:radial-gradient(circle at 50%,rgba(255,255,255,.25),transparent 50%);z-index:0}.atropos-rotate{position:relative}.atropos-inner{width:100%;height:100%;position:relative;overflow:hidden;transform-style:preserve-3d;transform:translate3d(0,0,0);display:block}.atropos-active{z-index:1}.atropos-active .atropos-shadow{opacity:1!important}::slotted([data-atropos-offset]),[data-atropos-offset]{transition-property:transform}[data-atropos-opacity]{transition-property:opacity}::slotted([data-atropos-offset][data-atropos-opacity]),[data-atropos-offset][data-atropos-opacity]{transition-property:transform,opacity}`;
class AtroposComponent extends HTMLElement {
// eslint-disable-next-line no-useless-constructor
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.init();
}
disconnectedCallback() {
this.destroy();
}
init() {
const defaultProps = {
...defaults,
};
const props = {};
Object.keys(defaultProps).forEach((key) => {
const attributeName = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
const attributeValue = this.getAttribute(attributeName);
if (attributeValue === null) {
props[key] = defaultProps[key];
} else {
switch (typeof defaultProps[key]) {
case 'boolean':
props[key] = attributeValue !== 'false';
break;
case 'number':
props[key] = isNaN(parseFloat(attributeValue, 10))
? defaultProps[key]
: parseFloat(attributeValue, 10);
break;
default:
props[key] = attributeValue;
}
}
});
const innerClass = this.cls('atropos-inner', props.innerClass);
// eslint-disable-next-line no-restricted-globals
const el = document.createElement('div');
el.classList.add('atropos');
el.innerHTML = `
<div class="atropos-scale" part="scale">
<div class="atropos-rotate" part="rotate">
<div class="${innerClass}" part="inner">
<slot></slot>
</div>
<slot name="rotate"></slot>
</div>
<slot name="scale"></slot>
</div>
<slot name="root"></slot>
`;
this.shadow.innerHTML = '';
if (typeof CSSStyleSheet !== 'undefined' && this.shadow.adoptedStyleSheets) {
// eslint-disable-next-line no-restricted-globals
const styleSheet = new CSSStyleSheet();
styleSheet.replaceSync(styles);
this.shadow.adoptedStyleSheets = [styleSheet];
} else {
const styleEl = document.createElement('style');
styleEl.rel = 'stylesheet';
styleEl.textContent = styles;
this.shadow.appendChild(styleEl);
}
this.shadow.appendChild(el);
this.atroposRef = Atropos({
el,
isComponent: true,
...props,
onEnter: () => {
this.dispatchEvent(new CustomEvent('enter'));
},
onLeave: () => {
this.dispatchEvent(new CustomEvent('leave'));
},
onRotate: (...args) => {
this.dispatchEvent(new CustomEvent('rotate', { detail: args }));
},
});
}
destroy() {
if (this.atroposInstance) {
this.atroposInstance.destroy();
this.atroposInstance = null;
}
}
// eslint-disable-next-line class-methods-use-this
cls(...args) {
return args.filter((c) => !!c).join(' ');
}
}
customElements.define('atropos-component', AtroposComponent);
}));