chore: complete update
This commit is contained in:
36
resources/js/modules/scrollfx/ScrollEngine.js
Normal file
36
resources/js/modules/scrollfx/ScrollEngine.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// src/resources/js/scrollfx/ScrollEngine.js
|
||||
|
||||
class ScrollEngine {
|
||||
constructor() {
|
||||
this.triggers = new Set();
|
||||
this.viewportHeight = window.innerHeight;
|
||||
this._loop = this._loop.bind(this);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
this.viewportHeight = window.innerHeight;
|
||||
});
|
||||
|
||||
requestAnimationFrame(this._loop);
|
||||
}
|
||||
|
||||
register(trigger) {
|
||||
this.triggers.add(trigger);
|
||||
}
|
||||
|
||||
unregister(trigger) {
|
||||
this.triggers.delete(trigger);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.triggers.clear();
|
||||
}
|
||||
|
||||
_loop() {
|
||||
this.triggers.forEach(trigger => {
|
||||
trigger.update(this.viewportHeight);
|
||||
});
|
||||
requestAnimationFrame(this._loop);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ScrollEngine();
|
||||
69
resources/js/modules/scrollfx/ScrollTrigger.js
Normal file
69
resources/js/modules/scrollfx/ScrollTrigger.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/resources/js/scrollfx/ScrollTrigger.js
|
||||
|
||||
export default class ScrollTrigger {
|
||||
constructor(config) {
|
||||
this.element = this.resolveElement(config.element);
|
||||
this.target = config.target
|
||||
? this.element.querySelector(config.target)
|
||||
: this.element;
|
||||
|
||||
if (config.target && !this.target) {
|
||||
throw new Error(`Target selector '${config.target}' not found inside element '${config.element}'.`);
|
||||
}
|
||||
|
||||
this.start = config.start || 'top 80%';
|
||||
this.end = config.end || 'bottom 20%';
|
||||
this.scrub = config.scrub || false;
|
||||
this.onEnter = config.onEnter || null;
|
||||
this.onLeave = config.onLeave || null;
|
||||
this.onUpdate = config.onUpdate || null;
|
||||
|
||||
this._wasVisible = false;
|
||||
this._progress = 0;
|
||||
}
|
||||
|
||||
resolveElement(input) {
|
||||
if (typeof input === 'string') {
|
||||
const els = document.querySelectorAll(input);
|
||||
if (els.length === 1) return els[0];
|
||||
throw new Error(`Selector '${input}' matched ${els.length} elements, expected exactly 1.`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
getScrollProgress(viewportHeight) {
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const startPx = this.parsePosition(this.start, viewportHeight);
|
||||
const endPx = this.parsePosition(this.end, viewportHeight);
|
||||
const scrollRange = endPx - startPx;
|
||||
const current = rect.top - startPx;
|
||||
return 1 - Math.min(Math.max(current / scrollRange, 0), 1);
|
||||
}
|
||||
|
||||
parsePosition(pos, viewportHeight) {
|
||||
const [edge, value] = pos.split(' ');
|
||||
const edgeOffset = edge === 'top' ? 0 : viewportHeight;
|
||||
const percentage = parseFloat(value) / 100;
|
||||
return edgeOffset - viewportHeight * percentage;
|
||||
}
|
||||
|
||||
update(viewportHeight) {
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const inViewport = rect.bottom > 0 && rect.top < viewportHeight;
|
||||
|
||||
if (inViewport && !this._wasVisible) {
|
||||
this._wasVisible = true;
|
||||
if (this.onEnter) this.onEnter(this.target);
|
||||
}
|
||||
|
||||
if (!inViewport && this._wasVisible) {
|
||||
this._wasVisible = false;
|
||||
if (this.onLeave) this.onLeave(this.target);
|
||||
}
|
||||
|
||||
if (this.scrub && inViewport) {
|
||||
const progress = this.getScrollProgress(viewportHeight);
|
||||
if (this.onUpdate) this.onUpdate(this.target, progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
177
resources/js/modules/scrollfx/Tween.js
Normal file
177
resources/js/modules/scrollfx/Tween.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// resources/js/scrollfx/Tween.js
|
||||
|
||||
export const Easing = {
|
||||
linear: t => t,
|
||||
easeInQuad: t => t * t,
|
||||
easeOutQuad: t => t * (2 - t),
|
||||
easeInOutQuad: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
||||
};
|
||||
|
||||
function interpolate(start, end, t) {
|
||||
return start + (end - start) * t;
|
||||
}
|
||||
|
||||
function parseTransform(transform) {
|
||||
const result = {
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
rotate: 0
|
||||
};
|
||||
if (!transform || transform === 'none') return result;
|
||||
|
||||
const translateMatch = transform.match(/translateY\((-?\d+(?:\.\d+)?)px\)/);
|
||||
const scaleMatch = transform.match(/scale\((-?\d+(?:\.\d+)?)\)/);
|
||||
const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
|
||||
|
||||
if (translateMatch) result.translateY = parseFloat(translateMatch[1]);
|
||||
if (scaleMatch) result.scale = parseFloat(scaleMatch[1]);
|
||||
if (rotateMatch) result.rotate = parseFloat(rotateMatch[1]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function tweenTo(el, props = {}, duration = 300, easing = Easing.linear) {
|
||||
const start = performance.now();
|
||||
const initial = {};
|
||||
|
||||
const currentTransform = parseTransform(getComputedStyle(el).transform);
|
||||
|
||||
for (const key in props) {
|
||||
if (['translateY', 'scale', 'rotate'].includes(key)) {
|
||||
initial[key] = currentTransform[key];
|
||||
} else {
|
||||
const current = parseFloat(getComputedStyle(el)[key]) || 0;
|
||||
initial[key] = current;
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
const t = Math.min((now - start) / duration, 1);
|
||||
const eased = easing(t);
|
||||
|
||||
let transformParts = [];
|
||||
|
||||
for (const key in props) {
|
||||
const startValue = initial[key];
|
||||
const endValue = parseFloat(props[key]);
|
||||
const current = interpolate(startValue, endValue, eased);
|
||||
|
||||
if (key === 'translateY') transformParts.push(`translateY(${current}px)`);
|
||||
else if (key === 'scale') transformParts.push(`scale(${current})`);
|
||||
else if (key === 'rotate') transformParts.push(`rotate(${current}deg)`);
|
||||
else el.style[key] = current + (key === 'opacity' ? '' : 'px');
|
||||
}
|
||||
|
||||
if (transformParts.length > 0) {
|
||||
el.style.transform = transformParts.join(' ');
|
||||
}
|
||||
|
||||
if (t < 1) requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
export function timeline(steps = []) {
|
||||
let index = 0;
|
||||
|
||||
function runNext() {
|
||||
if (index >= steps.length) return;
|
||||
const { el, props, duration, easing = Easing.linear, delay = 0 } = steps[index++];
|
||||
setTimeout(() => {
|
||||
tweenTo(el, props, duration, easing);
|
||||
setTimeout(runNext, duration);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
runNext();
|
||||
}
|
||||
|
||||
export function tweenFromTo(el, from = {}, to = {}, duration = 300, easing = Easing.linear) {
|
||||
for (const key in from) {
|
||||
if (['translateY', 'scale', 'rotate'].includes(key)) {
|
||||
el.style.transform = `${key}(${from[key]}${key === 'rotate' ? 'deg' : key === 'translateY' ? 'px' : ''})`;
|
||||
} else {
|
||||
el.style[key] = from[key] + (key === 'opacity' ? '' : 'px');
|
||||
}
|
||||
}
|
||||
tweenTo(el, to, duration, easing);
|
||||
}
|
||||
|
||||
// === Utility Animations ===
|
||||
|
||||
export function fadeIn(el, duration = 400, easing = Easing.easeOutQuad) {
|
||||
el.classList.remove('fade-out');
|
||||
el.classList.add('fade-in');
|
||||
tweenTo(el, { opacity: 1 }, duration, easing);
|
||||
}
|
||||
|
||||
export function fadeOut(el, duration = 400, easing = Easing.easeInQuad) {
|
||||
el.classList.remove('fade-in');
|
||||
el.classList.add('fade-out');
|
||||
tweenTo(el, { opacity: 0 }, duration, easing);
|
||||
}
|
||||
|
||||
export function zoomIn(el, duration = 500, easing = Easing.easeOutQuad) {
|
||||
el.classList.add('zoom-in');
|
||||
tweenTo(el, { opacity: 1, scale: 1 }, duration, easing);
|
||||
}
|
||||
|
||||
export function zoomOut(el, duration = 500, easing = Easing.easeInQuad) {
|
||||
el.classList.remove('zoom-in');
|
||||
tweenTo(el, { opacity: 0, scale: 0.8 }, duration, easing);
|
||||
}
|
||||
|
||||
export function triggerCssAnimation(el, className, duration = 1000) {
|
||||
el.classList.add(className);
|
||||
setTimeout(() => {
|
||||
el.classList.remove(className);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// === ScrollTrigger Presets ===
|
||||
|
||||
import { createTrigger } from './index.js';
|
||||
|
||||
export function fadeScrollTrigger(selector, options = {}) {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach(el => {
|
||||
createTrigger({
|
||||
element: el,
|
||||
start: options.start || 'top 80%',
|
||||
end: options.end || 'bottom 20%',
|
||||
onEnter: () => fadeIn(el),
|
||||
onLeave: () => fadeOut(el)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function zoomScrollTrigger(selector, options = {}) {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach(el => {
|
||||
createTrigger({
|
||||
element: el,
|
||||
start: options.start || 'top 80%',
|
||||
end: options.end || 'bottom 20%',
|
||||
onEnter: () => zoomIn(el),
|
||||
onLeave: () => zoomOut(el)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function fixedZoomScrollTrigger(selector, options = {}) {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach(el => {
|
||||
el.style.willChange = 'transform, opacity';
|
||||
el.style.opacity = 0;
|
||||
el.style.transform = 'scale(0.8)';
|
||||
|
||||
createTrigger({
|
||||
element: el,
|
||||
start: options.start || 'top 100%',
|
||||
end: options.end || 'top 40%',
|
||||
onEnter: () => zoomIn(el, 600),
|
||||
onLeave: () => zoomOut(el, 400)
|
||||
});
|
||||
});
|
||||
}
|
||||
57
resources/js/modules/scrollfx/index.js
Normal file
57
resources/js/modules/scrollfx/index.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/resources/js/scrollfx/index.js
|
||||
|
||||
import ScrollEngine from './ScrollEngine.js';
|
||||
import ScrollTrigger from './ScrollTrigger.js';
|
||||
import {fadeScrollTrigger, fixedZoomScrollTrigger, zoomScrollTrigger} from "./Tween";
|
||||
|
||||
export function createTrigger(config) {
|
||||
const elements = typeof config.element === 'string'
|
||||
? document.querySelectorAll(config.element)
|
||||
: [config.element];
|
||||
|
||||
const triggers = [];
|
||||
|
||||
elements.forEach(el => {
|
||||
const trigger = new ScrollTrigger({ ...config, element: el });
|
||||
ScrollEngine.register(trigger);
|
||||
triggers.push(trigger);
|
||||
});
|
||||
|
||||
return triggers.length === 1 ? triggers[0] : triggers;
|
||||
}
|
||||
|
||||
export function init(config = {}) {
|
||||
const {
|
||||
selector = '.fade-in-on-scroll, .zoom-in, .fade-out, .fade',
|
||||
offset = 0.85, // z.B. 85 % Viewport-Höhe
|
||||
baseDelay = 0.05, // delay pro Element (stagger)
|
||||
once = true // nur 1× triggern?
|
||||
} = config;
|
||||
|
||||
const elements = Array.from(document.querySelectorAll(selector))
|
||||
.map(el => ({ el, triggered: false }));
|
||||
|
||||
function update() {
|
||||
const triggerY = window.innerHeight * offset;
|
||||
|
||||
elements.forEach((obj, index) => {
|
||||
if (obj.triggered && once) return;
|
||||
|
||||
const rect = obj.el.getBoundingClientRect();
|
||||
|
||||
if (rect.top < triggerY) {
|
||||
obj.el.style.transitionDelay = `${index * baseDelay}s`;
|
||||
obj.el.classList.add('visible', 'entered');
|
||||
obj.el.classList.remove('fade-out');
|
||||
obj.triggered = true;
|
||||
} else if (!once) {
|
||||
obj.el.classList.remove('visible', 'entered');
|
||||
obj.triggered = false;
|
||||
}
|
||||
});
|
||||
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
Reference in New Issue
Block a user