mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Merge branch 'main' into feat/antigravity
This commit is contained in:
419
src/content/Animations/PixelTrail/PixelTrail.vue
Normal file
419
src/content/Animations/PixelTrail/PixelTrail.vue
Normal file
@@ -0,0 +1,419 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface GooeyFilterConfig {
|
||||
id: string;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
interface PixelTrailProps {
|
||||
gridSize?: number;
|
||||
trailSize?: number;
|
||||
maxAge?: number;
|
||||
interpolate?: number;
|
||||
color?: string;
|
||||
gooeyFilter?: GooeyFilterConfig;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PixelTrailProps>(), {
|
||||
gridSize: 40,
|
||||
trailSize: 0.1,
|
||||
maxAge: 250,
|
||||
interpolate: 5,
|
||||
color: '#ffffff',
|
||||
gooeyFilter: undefined,
|
||||
className: ''
|
||||
});
|
||||
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null;
|
||||
let scene: THREE.Scene | null = null;
|
||||
let camera: THREE.OrthographicCamera | null = null;
|
||||
let mesh: THREE.Mesh | null = null;
|
||||
let animationFrameId: number | null = null;
|
||||
let lastTime = 0;
|
||||
let containerWidth = 0;
|
||||
let containerHeight = 0;
|
||||
|
||||
// Trail texture system (matching React's useTrailTexture config)
|
||||
const TEXTURE_SIZE = 512; // React uses size: 512
|
||||
const INTENSITY = 0.2;
|
||||
const MIN_FORCE = 0.3;
|
||||
const SMOOTHING = 0;
|
||||
|
||||
interface TrailPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
age: number;
|
||||
force: number;
|
||||
}
|
||||
|
||||
let trailCanvas: HTMLCanvasElement | null = null;
|
||||
let trailCtx: CanvasRenderingContext2D | null = null;
|
||||
let trailTexture: THREE.CanvasTexture | null = null;
|
||||
let trail: TrailPoint[] = [];
|
||||
let force = 0;
|
||||
|
||||
// Smooth average for force calculation (from drei)
|
||||
function smoothAverage(current: number, measurement: number, smoothing: number = 0.9): number {
|
||||
return measurement * smoothing + current * (1.0 - smoothing);
|
||||
}
|
||||
|
||||
// Vertex shader
|
||||
const vertexShader = `
|
||||
void main() {
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Fragment shader for pixel grid (identical to React)
|
||||
const fragmentShader = `
|
||||
uniform vec2 resolution;
|
||||
uniform sampler2D mouseTrail;
|
||||
uniform float gridSize;
|
||||
uniform vec3 pixelColor;
|
||||
|
||||
vec2 coverUv(vec2 uv) {
|
||||
vec2 s = resolution.xy / max(resolution.x, resolution.y);
|
||||
vec2 newUv = (uv - 0.5) * s + 0.5;
|
||||
return clamp(newUv, 0.0, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 screenUv = gl_FragCoord.xy / resolution;
|
||||
vec2 uv = coverUv(screenUv);
|
||||
|
||||
vec2 gridUv = fract(uv * gridSize);
|
||||
vec2 gridUvCenter = (floor(uv * gridSize) + 0.5) / gridSize;
|
||||
|
||||
float trail = texture2D(mouseTrail, gridUvCenter).r;
|
||||
|
||||
gl_FragColor = vec4(pixelColor, trail);
|
||||
}
|
||||
`;
|
||||
|
||||
function hexToRgb(hex: string): THREE.Color {
|
||||
return new THREE.Color(hex);
|
||||
}
|
||||
|
||||
// Apply coverUv transformation to convert screen coords to texture coords
|
||||
// Must match the shader's coverUv EXACTLY
|
||||
function screenToTextureUv(screenX: number, screenY: number): { x: number; y: number } {
|
||||
// Match shader: vec2 s = resolution.xy / max(resolution.x, resolution.y);
|
||||
const maxDim = Math.max(containerWidth, containerHeight);
|
||||
const sx = containerWidth / maxDim;
|
||||
const sy = containerHeight / maxDim;
|
||||
|
||||
// Match shader: vec2 newUv = (uv - 0.5) * s + 0.5;
|
||||
const x = (screenX - 0.5) * sx + 0.5;
|
||||
const y = (screenY - 0.5) * sy + 0.5;
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, x)),
|
||||
y: Math.max(0, Math.min(1, y))
|
||||
};
|
||||
}
|
||||
|
||||
function initTrailTexture() {
|
||||
trailCanvas = document.createElement('canvas');
|
||||
trailCanvas.width = trailCanvas.height = TEXTURE_SIZE;
|
||||
trailCtx = trailCanvas.getContext('2d')!;
|
||||
trailCtx.fillStyle = 'black';
|
||||
trailCtx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
|
||||
|
||||
trailTexture = new THREE.CanvasTexture(trailCanvas);
|
||||
trailTexture.minFilter = THREE.NearestFilter;
|
||||
trailTexture.magFilter = THREE.NearestFilter;
|
||||
trailTexture.wrapS = THREE.ClampToEdgeWrapping;
|
||||
trailTexture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
}
|
||||
|
||||
function clearTrail() {
|
||||
if (!trailCtx) return;
|
||||
trailCtx.globalCompositeOperation = 'source-over';
|
||||
trailCtx.fillStyle = 'black';
|
||||
trailCtx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
|
||||
}
|
||||
|
||||
function addTouch(point: { x: number; y: number }) {
|
||||
const last = trail[trail.length - 1];
|
||||
|
||||
if (last) {
|
||||
const dx = last.x - point.x;
|
||||
const dy = last.y - point.y;
|
||||
const dd = dx * dx + dy * dy;
|
||||
|
||||
const newForce = Math.max(MIN_FORCE, Math.min(dd * 10000, 1));
|
||||
force = smoothAverage(newForce, force, SMOOTHING);
|
||||
|
||||
// Interpolation (matching drei's logic)
|
||||
if (props.interpolate > 0) {
|
||||
const lines = Math.ceil(dd / Math.pow((props.trailSize * 0.5) / props.interpolate, 2));
|
||||
|
||||
if (lines > 1) {
|
||||
for (let i = 1; i < lines; i++) {
|
||||
trail.push({
|
||||
x: last.x - (dx / lines) * i,
|
||||
y: last.y - (dy / lines) * i,
|
||||
age: 0,
|
||||
force: newForce
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trail.push({ x: point.x, y: point.y, age: 0, force });
|
||||
}
|
||||
|
||||
function drawTouch(point: TrailPoint) {
|
||||
if (!trailCtx) return;
|
||||
|
||||
const pos = {
|
||||
x: point.x * TEXTURE_SIZE,
|
||||
y: (1 - point.y) * TEXTURE_SIZE
|
||||
};
|
||||
|
||||
// Calculate intensity based on age (matching drei's logic)
|
||||
// React uses linear easing: ease = (x) => x (identity function)
|
||||
let intensity = 1;
|
||||
if (point.age < props.maxAge * 0.3) {
|
||||
// Fade in phase (0 to 30% of maxAge)
|
||||
intensity = point.age / (props.maxAge * 0.3);
|
||||
} else {
|
||||
// Fade out phase (30% to 100% of maxAge)
|
||||
intensity = 1 - (point.age - props.maxAge * 0.3) / (props.maxAge * 0.7);
|
||||
}
|
||||
|
||||
intensity *= point.force;
|
||||
|
||||
// Apply blending
|
||||
trailCtx.globalCompositeOperation = 'screen';
|
||||
|
||||
const radius = TEXTURE_SIZE * props.trailSize * intensity;
|
||||
|
||||
if (radius <= 0) return;
|
||||
|
||||
const grd = trailCtx.createRadialGradient(
|
||||
pos.x,
|
||||
pos.y,
|
||||
Math.max(0, radius * 0.25),
|
||||
pos.x,
|
||||
pos.y,
|
||||
Math.max(0, radius)
|
||||
);
|
||||
grd.addColorStop(0, `rgba(255, 255, 255, ${INTENSITY})`);
|
||||
grd.addColorStop(1, 'rgba(0, 0, 0, 0.0)');
|
||||
|
||||
trailCtx.beginPath();
|
||||
trailCtx.fillStyle = grd;
|
||||
trailCtx.arc(pos.x, pos.y, Math.max(0, radius), 0, Math.PI * 2);
|
||||
trailCtx.fill();
|
||||
}
|
||||
|
||||
function updateTrailTexture(delta: number) {
|
||||
if (!trailCtx || !trailTexture) return;
|
||||
|
||||
clearTrail();
|
||||
|
||||
// Age points and remove old ones
|
||||
trail = trail.filter(point => {
|
||||
point.age += delta * 1000;
|
||||
return point.age <= props.maxAge;
|
||||
});
|
||||
|
||||
// Reset force when empty
|
||||
if (!trail.length) force = 0;
|
||||
|
||||
// Draw all points
|
||||
trail.forEach(point => drawTouch(point));
|
||||
|
||||
trailTexture.needsUpdate = true;
|
||||
}
|
||||
|
||||
function setupScene() {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
containerWidth = width;
|
||||
containerHeight = height;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
// Initialize trail texture
|
||||
initTrailTexture();
|
||||
|
||||
// Main renderer
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(dpr);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Main scene
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
|
||||
camera.position.z = 1;
|
||||
|
||||
// Main mesh with pixel shader
|
||||
const pixelColor = hexToRgb(props.color);
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
resolution: { value: new THREE.Vector2(width * dpr, height * dpr) },
|
||||
mouseTrail: { value: trailTexture },
|
||||
gridSize: { value: props.gridSize },
|
||||
pixelColor: { value: new THREE.Vector3(pixelColor.r, pixelColor.g, pixelColor.b) }
|
||||
},
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true
|
||||
});
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(2, 2);
|
||||
mesh = new THREE.Mesh(geometry, material);
|
||||
scene.add(mesh);
|
||||
|
||||
// Event listeners
|
||||
container.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Start animation
|
||||
trail = [];
|
||||
force = 0;
|
||||
lastTime = performance.now();
|
||||
animate();
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const container = containerRef.value;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const screenX = (event.clientX - rect.left) / rect.width;
|
||||
const screenY = 1 - (event.clientY - rect.top) / rect.height;
|
||||
|
||||
// Convert screen coordinates to texture UV space (apply coverUv transformation)
|
||||
const uv = screenToTextureUv(screenX, screenY);
|
||||
|
||||
// Add touch point
|
||||
addTouch(uv);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
const container = containerRef.value;
|
||||
if (!container || !renderer || !mesh) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
containerWidth = width;
|
||||
containerHeight = height;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
renderer.setSize(width, height);
|
||||
|
||||
const material = mesh.material as THREE.ShaderMaterial;
|
||||
material.uniforms.resolution.value.set(width * dpr, height * dpr);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
if (!renderer || !scene || !camera || !mesh) return;
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
|
||||
// Calculate delta time
|
||||
const currentTime = performance.now();
|
||||
const delta = (currentTime - lastTime) / 1000;
|
||||
lastTime = currentTime;
|
||||
|
||||
// Update trail texture with delta time
|
||||
updateTrailTexture(delta);
|
||||
|
||||
// Render
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
const container = containerRef.value;
|
||||
if (container) {
|
||||
container.removeEventListener('pointermove', handlePointerMove);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
|
||||
// Clear trail data
|
||||
trail = [];
|
||||
force = 0;
|
||||
|
||||
if (renderer) {
|
||||
if (container && container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
renderer.dispose();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
if (trailTexture) {
|
||||
trailTexture.dispose();
|
||||
trailTexture = null;
|
||||
}
|
||||
|
||||
if (mesh) {
|
||||
(mesh.material as THREE.ShaderMaterial).dispose();
|
||||
mesh.geometry.dispose();
|
||||
mesh = null;
|
||||
}
|
||||
|
||||
trailCanvas = null;
|
||||
trailCtx = null;
|
||||
scene = null;
|
||||
camera = null;
|
||||
}
|
||||
|
||||
onMounted(setupScene);
|
||||
onUnmounted(cleanup);
|
||||
|
||||
watch(
|
||||
() => [props.gridSize, props.trailSize, props.maxAge, props.interpolate, props.color],
|
||||
() => {
|
||||
cleanup();
|
||||
setupScene();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full">
|
||||
<svg v-if="props.gooeyFilter" class="absolute overflow-hidden z-[1]">
|
||||
<defs>
|
||||
<filter :id="props.gooeyFilter.id">
|
||||
<feGaussianBlur in="SourceGraphic" :stdDeviation="props.gooeyFilter.strength" result="blur" />
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9"
|
||||
result="goo"
|
||||
/>
|
||||
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="['absolute z-[1] w-full h-full', props.className]"
|
||||
:style="props.gooeyFilter ? { filter: `url(#${props.gooeyFilter.id})` } : undefined"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ type InfiniteMenuItem = {
|
||||
|
||||
type InfiniteMenuProps = {
|
||||
items?: InfiniteMenuItem[];
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_ITEMS: InfiniteMenuItem[] = [
|
||||
@@ -22,7 +23,9 @@ const DEFAULT_ITEMS: InfiniteMenuItem[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const props = defineProps<InfiniteMenuProps>();
|
||||
const props = withDefaults(defineProps<InfiniteMenuProps>(), {
|
||||
scale: 1.0
|
||||
});
|
||||
|
||||
// Refs
|
||||
const canvasRef = ref<HTMLCanvasElement>();
|
||||
@@ -699,8 +702,11 @@ class InfiniteGridMenu {
|
||||
private items: InfiniteMenuItem[],
|
||||
private onActiveItemChange: (index: number) => void,
|
||||
private onMovementChange: (isMoving: boolean) => void,
|
||||
private onInit?: (menu: InfiniteGridMenu) => void
|
||||
private onInit?: (menu: InfiniteGridMenu) => void,
|
||||
scale: number = 3.0
|
||||
) {
|
||||
this.scaleFactor = scale;
|
||||
this.camera.position[2] = scale;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -1127,6 +1133,26 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.scale,
|
||||
() => {
|
||||
if (infiniteMenu && canvasRef.value) {
|
||||
infiniteMenu.destroy();
|
||||
infiniteMenu = new InfiniteGridMenu(
|
||||
canvasRef.value,
|
||||
resolvedItems.value,
|
||||
handleActiveItem,
|
||||
moving => {
|
||||
isMoving.value = moving;
|
||||
},
|
||||
menu => menu.run(),
|
||||
|
||||
props.scale
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
412
src/content/TextAnimations/Shuffle/Shuffle.vue
Normal file
412
src/content/TextAnimations/Shuffle/Shuffle.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tag"
|
||||
ref="textRef"
|
||||
:class="computedClasses"
|
||||
:style="computedStyle"
|
||||
>
|
||||
{{ text }}
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
|
||||
|
||||
export interface ShuffleProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
style?: Record<string, any>;
|
||||
shuffleDirection?: 'left' | 'right';
|
||||
duration?: number;
|
||||
maxDelay?: number;
|
||||
ease?: string | ((t: number) => number);
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justify';
|
||||
onShuffleComplete?: () => void;
|
||||
shuffleTimes?: number;
|
||||
animationMode?: 'random' | 'evenodd';
|
||||
loop?: boolean;
|
||||
loopDelay?: number;
|
||||
stagger?: number;
|
||||
scrambleCharset?: string;
|
||||
colorFrom?: string;
|
||||
colorTo?: string;
|
||||
triggerOnce?: boolean;
|
||||
respectReducedMotion?: boolean;
|
||||
triggerOnHover?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ShuffleProps>(), {
|
||||
className: '',
|
||||
shuffleDirection: 'right',
|
||||
duration: 0.35,
|
||||
maxDelay: 0,
|
||||
ease: 'power3.out',
|
||||
threshold: 0.1,
|
||||
rootMargin: '-100px',
|
||||
tag: 'p',
|
||||
textAlign: 'center',
|
||||
shuffleTimes: 1,
|
||||
animationMode: 'evenodd',
|
||||
loop: false,
|
||||
loopDelay: 0,
|
||||
stagger: 0.03,
|
||||
scrambleCharset: '',
|
||||
colorFrom: undefined,
|
||||
colorTo: undefined,
|
||||
triggerOnce: true,
|
||||
respectReducedMotion: true,
|
||||
triggerOnHover: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'shuffle-complete': [];
|
||||
}>();
|
||||
|
||||
const textRef = useTemplateRef<HTMLElement>('textRef');
|
||||
const fontsLoaded = ref(false);
|
||||
const ready = ref(false);
|
||||
|
||||
const splitRef = ref<GSAPSplitText | null>(null);
|
||||
const wrappersRef = ref<HTMLElement[]>([]);
|
||||
const tlRef = ref<gsap.core.Timeline | null>(null);
|
||||
const playingRef = ref(false);
|
||||
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
|
||||
let hoverHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
const scrollTriggerStart = computed(() => {
|
||||
const startPct = (1 - props.threshold) * 100;
|
||||
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || '');
|
||||
const mv = mm ? parseFloat(mm[1]) : 0;
|
||||
const mu = mm ? mm[2] || 'px' : 'px';
|
||||
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
|
||||
return `top ${startPct}%${sign}`;
|
||||
});
|
||||
|
||||
const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-6xl leading-none';
|
||||
|
||||
const userHasFont = computed(() => props.className && /font[-[]/i.test(props.className));
|
||||
|
||||
const fallbackFont = computed(() =>
|
||||
userHasFont.value ? {} : { fontFamily: `'Press Start 2P', sans-serif` }
|
||||
);
|
||||
|
||||
const computedStyle = computed(() => ({
|
||||
textAlign: props.textAlign,
|
||||
...fallbackFont.value,
|
||||
...props.style
|
||||
}));
|
||||
|
||||
const computedClasses = computed(() =>
|
||||
`${baseTw} ${ready.value ? 'visible' : 'invisible'} ${props.className}`.trim()
|
||||
);
|
||||
|
||||
const removeHover = () => {
|
||||
if (hoverHandler && textRef.value) {
|
||||
textRef.value.removeEventListener('mouseenter', hoverHandler);
|
||||
hoverHandler = null;
|
||||
}
|
||||
};
|
||||
|
||||
const teardown = () => {
|
||||
if (tlRef.value) {
|
||||
tlRef.value.kill();
|
||||
tlRef.value = null;
|
||||
}
|
||||
if (wrappersRef.value.length) {
|
||||
wrappersRef.value.forEach(wrap => {
|
||||
const inner = wrap.firstElementChild as HTMLElement | null;
|
||||
const orig = inner?.querySelector('[data-orig="1"]') as HTMLElement | null;
|
||||
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
|
||||
});
|
||||
wrappersRef.value = [];
|
||||
}
|
||||
try {
|
||||
splitRef.value?.revert();
|
||||
} catch {}
|
||||
splitRef.value = null;
|
||||
playingRef.value = false;
|
||||
};
|
||||
|
||||
const build = () => {
|
||||
if (!textRef.value) return;
|
||||
teardown();
|
||||
|
||||
const el = textRef.value;
|
||||
const computedFont = getComputedStyle(el).fontFamily;
|
||||
|
||||
splitRef.value = new GSAPSplitText(el, {
|
||||
type: 'chars',
|
||||
charsClass: 'shuffle-char',
|
||||
wordsClass: 'shuffle-word',
|
||||
linesClass: 'shuffle-line',
|
||||
reduceWhiteSpace: false
|
||||
});
|
||||
|
||||
const chars = (splitRef.value.chars || []) as HTMLElement[];
|
||||
wrappersRef.value = [];
|
||||
|
||||
const rolls = Math.max(1, Math.floor(props.shuffleTimes));
|
||||
const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';
|
||||
|
||||
chars.forEach(ch => {
|
||||
const parent = ch.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const w = ch.getBoundingClientRect().width;
|
||||
if (!w) return;
|
||||
|
||||
const wrap = document.createElement('span');
|
||||
wrap.className = 'inline-block overflow-hidden align-baseline text-left';
|
||||
Object.assign(wrap.style, { width: w + 'px' });
|
||||
|
||||
const inner = document.createElement('span');
|
||||
inner.className = 'inline-block whitespace-nowrap will-change-transform origin-left transform-gpu';
|
||||
|
||||
parent.insertBefore(wrap, ch);
|
||||
wrap.appendChild(inner);
|
||||
|
||||
const firstOrig = ch.cloneNode(true) as HTMLElement;
|
||||
firstOrig.className = 'inline-block text-left';
|
||||
Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });
|
||||
|
||||
ch.setAttribute('data-orig', '1');
|
||||
ch.className = 'inline-block text-left';
|
||||
Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });
|
||||
|
||||
inner.appendChild(firstOrig);
|
||||
for (let k = 0; k < rolls; k++) {
|
||||
const c = ch.cloneNode(true) as HTMLElement;
|
||||
if (props.scrambleCharset) c.textContent = rand(props.scrambleCharset);
|
||||
c.className = 'inline-block text-left';
|
||||
Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });
|
||||
inner.appendChild(c);
|
||||
}
|
||||
inner.appendChild(ch);
|
||||
|
||||
const steps = rolls + 1;
|
||||
let startX = 0;
|
||||
let finalX = -steps * w;
|
||||
if (props.shuffleDirection === 'right') {
|
||||
const firstCopy = inner.firstElementChild as HTMLElement | null;
|
||||
const real = inner.lastElementChild as HTMLElement | null;
|
||||
if (real) inner.insertBefore(real, inner.firstChild);
|
||||
if (firstCopy) inner.appendChild(firstCopy);
|
||||
startX = -steps * w;
|
||||
finalX = 0;
|
||||
}
|
||||
|
||||
gsap.set(inner, { x: startX, force3D: true });
|
||||
if (props.colorFrom) (inner.style as any).color = props.colorFrom;
|
||||
|
||||
inner.setAttribute('data-final-x', String(finalX));
|
||||
inner.setAttribute('data-start-x', String(startX));
|
||||
|
||||
wrappersRef.value.push(wrap);
|
||||
});
|
||||
};
|
||||
|
||||
const getInners = () => wrappersRef.value.map(w => w.firstElementChild as HTMLElement);
|
||||
|
||||
const randomizeScrambles = () => {
|
||||
if (!props.scrambleCharset) return;
|
||||
wrappersRef.value.forEach(w => {
|
||||
const strip = w.firstElementChild as HTMLElement;
|
||||
if (!strip) return;
|
||||
const kids = Array.from(strip.children) as HTMLElement[];
|
||||
for (let i = 1; i < kids.length - 1; i++) {
|
||||
kids[i].textContent = props.scrambleCharset.charAt(Math.floor(Math.random() * props.scrambleCharset.length));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cleanupToStill = () => {
|
||||
wrappersRef.value.forEach(w => {
|
||||
const strip = w.firstElementChild as HTMLElement;
|
||||
if (!strip) return;
|
||||
const real = strip.querySelector('[data-orig="1"]') as HTMLElement | null;
|
||||
if (!real) return;
|
||||
strip.replaceChildren(real);
|
||||
strip.style.transform = 'none';
|
||||
strip.style.willChange = 'auto';
|
||||
});
|
||||
};
|
||||
|
||||
const armHover = () => {
|
||||
if (!props.triggerOnHover || !textRef.value) return;
|
||||
removeHover();
|
||||
const handler = () => {
|
||||
if (playingRef.value) return;
|
||||
build();
|
||||
if (props.scrambleCharset) randomizeScrambles();
|
||||
play();
|
||||
};
|
||||
hoverHandler = handler;
|
||||
textRef.value.addEventListener('mouseenter', handler);
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
const strips = getInners();
|
||||
if (!strips.length) return;
|
||||
|
||||
playingRef.value = true;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
smoothChildTiming: true,
|
||||
repeat: props.loop ? -1 : 0,
|
||||
repeatDelay: props.loop ? props.loopDelay : 0,
|
||||
onRepeat: () => {
|
||||
if (props.scrambleCharset) randomizeScrambles();
|
||||
gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });
|
||||
emit('shuffle-complete');
|
||||
props.onShuffleComplete?.();
|
||||
},
|
||||
onComplete: () => {
|
||||
playingRef.value = false;
|
||||
if (!props.loop) {
|
||||
cleanupToStill();
|
||||
if (props.colorTo) gsap.set(strips, { color: props.colorTo });
|
||||
emit('shuffle-complete');
|
||||
props.onShuffleComplete?.();
|
||||
armHover();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addTween = (targets: HTMLElement[], at: number) => {
|
||||
tl.to(
|
||||
targets,
|
||||
{
|
||||
x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0'),
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
force3D: true,
|
||||
stagger: props.animationMode === 'evenodd' ? props.stagger : 0
|
||||
},
|
||||
at
|
||||
);
|
||||
if (props.colorFrom && props.colorTo) tl.to(targets, { color: props.colorTo, duration: props.duration, ease: props.ease }, at);
|
||||
};
|
||||
|
||||
if (props.animationMode === 'evenodd') {
|
||||
const odd = strips.filter((_, i) => i % 2 === 1);
|
||||
const even = strips.filter((_, i) => i % 2 === 0);
|
||||
const oddTotal = props.duration + Math.max(0, odd.length - 1) * props.stagger;
|
||||
const evenStart = odd.length ? oddTotal * 0.7 : 0;
|
||||
if (odd.length) addTween(odd, 0);
|
||||
if (even.length) addTween(even, evenStart);
|
||||
} else {
|
||||
strips.forEach(strip => {
|
||||
const d = Math.random() * props.maxDelay;
|
||||
tl.to(
|
||||
strip,
|
||||
{
|
||||
x: parseFloat(strip.getAttribute('data-final-x') || '0'),
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
force3D: true
|
||||
},
|
||||
d
|
||||
);
|
||||
if (props.colorFrom && props.colorTo) tl.fromTo(strip, { color: props.colorFrom }, { color: props.colorTo, duration: props.duration, ease: props.ease }, d);
|
||||
});
|
||||
}
|
||||
|
||||
tlRef.value = tl;
|
||||
};
|
||||
|
||||
const create = () => {
|
||||
build();
|
||||
if (props.scrambleCharset) randomizeScrambles();
|
||||
play();
|
||||
armHover();
|
||||
ready.value = true;
|
||||
};
|
||||
|
||||
const initializeAnimation = async () => {
|
||||
if (typeof window === 'undefined' || !textRef.value || !props.text || !fontsLoaded.value) return;
|
||||
|
||||
if (props.respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
ready.value = true;
|
||||
emit('shuffle-complete');
|
||||
props.onShuffleComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const el = textRef.value;
|
||||
const start = scrollTriggerStart.value;
|
||||
|
||||
const st = ScrollTrigger.create({
|
||||
trigger: el,
|
||||
start,
|
||||
once: props.triggerOnce,
|
||||
onEnter: create
|
||||
});
|
||||
|
||||
scrollTriggerRef.value = st;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (scrollTriggerRef.value) {
|
||||
scrollTriggerRef.value.kill();
|
||||
scrollTriggerRef.value = null;
|
||||
}
|
||||
removeHover();
|
||||
teardown();
|
||||
ready.value = false;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if ('fonts' in document) {
|
||||
if (document.fonts.status === 'loaded') {
|
||||
fontsLoaded.value = true;
|
||||
} else {
|
||||
await document.fonts.ready;
|
||||
fontsLoaded.value = true;
|
||||
}
|
||||
} else {
|
||||
fontsLoaded.value = true;
|
||||
}
|
||||
|
||||
initializeAnimation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.text,
|
||||
() => props.duration,
|
||||
() => props.maxDelay,
|
||||
() => props.ease,
|
||||
() => props.shuffleDirection,
|
||||
() => props.shuffleTimes,
|
||||
() => props.animationMode,
|
||||
() => props.loop,
|
||||
() => props.loopDelay,
|
||||
() => props.stagger,
|
||||
() => props.scrambleCharset,
|
||||
() => props.colorFrom,
|
||||
() => props.colorTo,
|
||||
() => props.triggerOnce,
|
||||
() => props.respectReducedMotion,
|
||||
() => props.triggerOnHover,
|
||||
() => fontsLoaded.value
|
||||
],
|
||||
() => {
|
||||
cleanup();
|
||||
initializeAnimation();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
Reference in New Issue
Block a user