12 changed files with 486 additions and 9 deletions
@ -0,0 +1,249 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { computed, onBeforeUnmount, onMounted, useSlots, useTemplateRef, watch } from 'vue'; |
||||
|
|
||||
|
interface FuzzyTextProps { |
||||
|
fontSize?: number | string; |
||||
|
fontWeight?: string | number; |
||||
|
fontFamily?: string; |
||||
|
color?: string; |
||||
|
enableHover?: boolean; |
||||
|
baseIntensity?: number; |
||||
|
hoverIntensity?: number; |
||||
|
fuzzRange?: number; |
||||
|
fps?: number; |
||||
|
direction?: 'horizontal' | 'vertical' | 'both'; |
||||
|
transitionDuration?: number; |
||||
|
clickEffect?: boolean; |
||||
|
glitchMode?: boolean; |
||||
|
glitchInterval?: number; |
||||
|
glitchDuration?: number; |
||||
|
gradient?: string[] | null; |
||||
|
letterSpacing?: number; |
||||
|
className?: string; |
||||
|
} |
||||
|
|
||||
|
const props = withDefaults(defineProps<FuzzyTextProps>(), { |
||||
|
fontSize: 'clamp(2rem, 8vw, 8rem)', |
||||
|
fontWeight: 900, |
||||
|
fontFamily: 'inherit', |
||||
|
color: '#fff', |
||||
|
enableHover: true, |
||||
|
baseIntensity: 0.18, |
||||
|
hoverIntensity: 0.5, |
||||
|
fuzzRange: 30, |
||||
|
fps: 60, |
||||
|
direction: 'horizontal', |
||||
|
transitionDuration: 0, |
||||
|
clickEffect: false, |
||||
|
glitchMode: false, |
||||
|
glitchInterval: 2000, |
||||
|
glitchDuration: 200, |
||||
|
gradient: null, |
||||
|
letterSpacing: 0, |
||||
|
className: '' |
||||
|
}); |
||||
|
|
||||
|
const canvasRef = useTemplateRef<HTMLCanvasElement & { cleanupFuzzyText?: () => void }>('canvasRef'); |
||||
|
const slots = useSlots(); |
||||
|
|
||||
|
let animationFrameId: number; |
||||
|
let glitchTimeoutId: ReturnType<typeof setTimeout>; |
||||
|
let glitchEndTimeoutId: ReturnType<typeof setTimeout>; |
||||
|
let clickTimeoutId: ReturnType<typeof setTimeout>; |
||||
|
let cancelled = false; |
||||
|
|
||||
|
const text = computed(() => (slots.default?.() ?? []).map(v => v.children).join('')); |
||||
|
|
||||
|
const init = async () => { |
||||
|
const canvas = canvasRef.value; |
||||
|
if (!canvas) return; |
||||
|
|
||||
|
const ctx = canvas.getContext('2d'); |
||||
|
if (!ctx) return; |
||||
|
|
||||
|
const computedFontFamily = |
||||
|
props.fontFamily === 'inherit' ? window.getComputedStyle(canvas).fontFamily || 'sans-serif' : props.fontFamily; |
||||
|
|
||||
|
const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize; |
||||
|
|
||||
|
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`; |
||||
|
|
||||
|
try { |
||||
|
await document.fonts.load(fontString); |
||||
|
} catch { |
||||
|
await document.fonts.ready; |
||||
|
} |
||||
|
|
||||
|
if (cancelled) return; |
||||
|
|
||||
|
let numericFontSize: number; |
||||
|
if (typeof props.fontSize === 'number') { |
||||
|
numericFontSize = props.fontSize; |
||||
|
} else { |
||||
|
const temp = document.createElement('span'); |
||||
|
temp.style.fontSize = props.fontSize; |
||||
|
document.body.appendChild(temp); |
||||
|
numericFontSize = parseFloat(getComputedStyle(temp).fontSize); |
||||
|
document.body.removeChild(temp); |
||||
|
} |
||||
|
|
||||
|
const offscreen = document.createElement('canvas'); |
||||
|
const offCtx = offscreen.getContext('2d')!; |
||||
|
offCtx.font = fontString; |
||||
|
offCtx.textBaseline = 'alphabetic'; |
||||
|
|
||||
|
let totalWidth = 0; |
||||
|
if (props.letterSpacing !== 0) { |
||||
|
for (const char of text.value) { |
||||
|
totalWidth += offCtx.measureText(char).width + props.letterSpacing; |
||||
|
} |
||||
|
totalWidth -= props.letterSpacing; |
||||
|
} else { |
||||
|
totalWidth = offCtx.measureText(text.value).width; |
||||
|
} |
||||
|
|
||||
|
const metrics = offCtx.measureText(text.value); |
||||
|
const ascent = metrics.actualBoundingBoxAscent ?? numericFontSize; |
||||
|
const descent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2; |
||||
|
const height = Math.ceil(ascent + descent); |
||||
|
|
||||
|
offscreen.width = Math.ceil(totalWidth) + 20; |
||||
|
offscreen.height = height; |
||||
|
|
||||
|
offCtx.font = fontString; |
||||
|
offCtx.textBaseline = 'alphabetic'; |
||||
|
|
||||
|
if (props.gradient && props.gradient.length >= 2) { |
||||
|
const grad = offCtx.createLinearGradient(0, 0, offscreen.width, 0); |
||||
|
props.gradient.forEach((c, i) => grad.addColorStop(i / (props.gradient!.length - 1), c)); |
||||
|
offCtx.fillStyle = grad; |
||||
|
} else { |
||||
|
offCtx.fillStyle = props.color; |
||||
|
} |
||||
|
|
||||
|
let x = 10; |
||||
|
for (const char of text.value) { |
||||
|
offCtx.fillText(char, x, ascent); |
||||
|
x += offCtx.measureText(char).width + props.letterSpacing; |
||||
|
} |
||||
|
|
||||
|
const marginX = props.fuzzRange + 20; |
||||
|
const marginY = props.direction === 'vertical' || props.direction === 'both' ? props.fuzzRange + 10 : 0; |
||||
|
|
||||
|
canvas.width = offscreen.width + marginX * 2; |
||||
|
canvas.height = offscreen.height + marginY * 2; |
||||
|
ctx.translate(marginX, marginY); |
||||
|
|
||||
|
let isHovering = false; |
||||
|
let isClicking = false; |
||||
|
let isGlitching = false; |
||||
|
let currentIntensity = props.baseIntensity; |
||||
|
let targetIntensity = props.baseIntensity; |
||||
|
let lastFrameTime = 0; |
||||
|
const frameDuration = 1000 / props.fps; |
||||
|
|
||||
|
const startGlitch = () => { |
||||
|
if (!props.glitchMode || cancelled) return; |
||||
|
glitchTimeoutId = setTimeout(() => { |
||||
|
isGlitching = true; |
||||
|
glitchEndTimeoutId = setTimeout(() => { |
||||
|
isGlitching = false; |
||||
|
startGlitch(); |
||||
|
}, props.glitchDuration); |
||||
|
}, props.glitchInterval); |
||||
|
}; |
||||
|
|
||||
|
if (props.glitchMode) startGlitch(); |
||||
|
|
||||
|
const run = (ts: number) => { |
||||
|
if (cancelled) return; |
||||
|
|
||||
|
if (ts - lastFrameTime < frameDuration) { |
||||
|
animationFrameId = requestAnimationFrame(run); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
lastFrameTime = ts; |
||||
|
ctx.clearRect(-marginX, -marginY, offscreen.width + marginX * 2, offscreen.height + marginY * 2); |
||||
|
|
||||
|
targetIntensity = isClicking || isGlitching ? 1 : isHovering ? props.hoverIntensity : props.baseIntensity; |
||||
|
|
||||
|
if (props.transitionDuration > 0) { |
||||
|
const step = 1 / (props.transitionDuration / frameDuration); |
||||
|
currentIntensity += Math.sign(targetIntensity - currentIntensity) * step; |
||||
|
currentIntensity = Math.min( |
||||
|
Math.max(currentIntensity, Math.min(targetIntensity, currentIntensity)), |
||||
|
Math.max(targetIntensity, currentIntensity) |
||||
|
); |
||||
|
} else { |
||||
|
currentIntensity = targetIntensity; |
||||
|
} |
||||
|
|
||||
|
for (let y = 0; y < offscreen.height; y++) { |
||||
|
const dx = props.direction !== 'vertical' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange : 0; |
||||
|
const dy = |
||||
|
props.direction !== 'horizontal' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange * 0.5 : 0; |
||||
|
|
||||
|
ctx.drawImage(offscreen, 0, y, offscreen.width, 1, dx, y + dy, offscreen.width, 1); |
||||
|
} |
||||
|
|
||||
|
animationFrameId = requestAnimationFrame(run); |
||||
|
}; |
||||
|
|
||||
|
animationFrameId = requestAnimationFrame(run); |
||||
|
|
||||
|
const rectCheck = (x: number, y: number) => |
||||
|
x >= marginX && x <= marginX + offscreen.width && y >= marginY && y <= marginY + offscreen.height; |
||||
|
|
||||
|
const mouseMove = (e: MouseEvent) => { |
||||
|
if (!props.enableHover) return; |
||||
|
const rect = canvas.getBoundingClientRect(); |
||||
|
isHovering = rectCheck(e.clientX - rect.left, e.clientY - rect.top); |
||||
|
}; |
||||
|
|
||||
|
const mouseLeave = () => (isHovering = false); |
||||
|
|
||||
|
const click = () => { |
||||
|
if (!props.clickEffect) return; |
||||
|
isClicking = true; |
||||
|
clearTimeout(clickTimeoutId); |
||||
|
clickTimeoutId = setTimeout(() => (isClicking = false), 150); |
||||
|
}; |
||||
|
|
||||
|
if (props.enableHover) { |
||||
|
canvas.addEventListener('mousemove', mouseMove); |
||||
|
canvas.addEventListener('mouseleave', mouseLeave); |
||||
|
} |
||||
|
|
||||
|
if (props.clickEffect) { |
||||
|
canvas.addEventListener('click', click); |
||||
|
} |
||||
|
|
||||
|
onBeforeUnmount(() => { |
||||
|
cancelled = true; |
||||
|
cancelAnimationFrame(animationFrameId); |
||||
|
clearTimeout(glitchTimeoutId); |
||||
|
clearTimeout(glitchEndTimeoutId); |
||||
|
clearTimeout(clickTimeoutId); |
||||
|
canvas.removeEventListener('mousemove', mouseMove); |
||||
|
canvas.removeEventListener('mouseleave', mouseLeave); |
||||
|
canvas.removeEventListener('click', click); |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
onMounted(init); |
||||
|
|
||||
|
watch( |
||||
|
() => ({ ...props, text: text.value }), |
||||
|
() => { |
||||
|
cancelled = true; |
||||
|
cancelAnimationFrame(animationFrameId); |
||||
|
cancelled = false; |
||||
|
init(); |
||||
|
} |
||||
|
); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<canvas ref="canvasRef" :class="className" /> |
||||
|
</template> |
||||
@ -0,0 +1,176 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { ref, onMounted, onBeforeUnmount, watch, computed, useTemplateRef } from 'vue'; |
||||
|
import { gsap } from 'gsap'; |
||||
|
|
||||
|
interface TextTypeProps { |
||||
|
className?: string; |
||||
|
showCursor?: boolean; |
||||
|
hideCursorWhileTyping?: boolean; |
||||
|
cursorCharacter?: string; |
||||
|
cursorBlinkDuration?: number; |
||||
|
cursorClassName?: string; |
||||
|
text: string | string[]; |
||||
|
as?: string; |
||||
|
typingSpeed?: number; |
||||
|
initialDelay?: number; |
||||
|
pauseDuration?: number; |
||||
|
deletingSpeed?: number; |
||||
|
loop?: boolean; |
||||
|
textColors?: string[]; |
||||
|
variableSpeed?: { min: number; max: number }; |
||||
|
onSentenceComplete?: (sentence: string, index: number) => void; |
||||
|
startOnVisible?: boolean; |
||||
|
reverseMode?: boolean; |
||||
|
} |
||||
|
|
||||
|
const props = withDefaults(defineProps<TextTypeProps>(), { |
||||
|
as: 'div', |
||||
|
typingSpeed: 50, |
||||
|
initialDelay: 0, |
||||
|
pauseDuration: 2000, |
||||
|
deletingSpeed: 30, |
||||
|
loop: true, |
||||
|
className: '', |
||||
|
showCursor: true, |
||||
|
hideCursorWhileTyping: false, |
||||
|
cursorCharacter: '|', |
||||
|
cursorBlinkDuration: 0.5, |
||||
|
textColors: () => [], |
||||
|
startOnVisible: false, |
||||
|
reverseMode: false |
||||
|
}); |
||||
|
|
||||
|
const displayedText = ref(''); |
||||
|
const currentCharIndex = ref(0); |
||||
|
const isDeleting = ref(false); |
||||
|
const currentTextIndex = ref(0); |
||||
|
const isVisible = ref(!props.startOnVisible); |
||||
|
const cursorRef = useTemplateRef('cursorRef'); |
||||
|
const containerRef = useTemplateRef('containerRef'); |
||||
|
|
||||
|
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text])); |
||||
|
|
||||
|
const getRandomSpeed = () => { |
||||
|
if (!props.variableSpeed) return props.typingSpeed; |
||||
|
const { min, max } = props.variableSpeed; |
||||
|
return Math.random() * (max - min) + min; |
||||
|
}; |
||||
|
|
||||
|
const getCurrentTextColor = () => { |
||||
|
if (!props.textColors.length) return '#ffffff'; |
||||
|
return props.textColors[currentTextIndex.value % props.textColors.length]; |
||||
|
}; |
||||
|
|
||||
|
let timeout: ReturnType<typeof setTimeout> | null = null; |
||||
|
|
||||
|
const clearTimeoutIfNeeded = () => { |
||||
|
if (timeout) clearTimeout(timeout); |
||||
|
}; |
||||
|
|
||||
|
const executeTypingAnimation = () => { |
||||
|
const currentText = textArray.value[currentTextIndex.value]; |
||||
|
const processedText = props.reverseMode ? currentText.split('').reverse().join('') : currentText; |
||||
|
|
||||
|
if (isDeleting.value) { |
||||
|
if (displayedText.value === '') { |
||||
|
isDeleting.value = false; |
||||
|
if (currentTextIndex.value === textArray.value.length - 1 && !props.loop) return; |
||||
|
|
||||
|
props.onSentenceComplete?.(textArray.value[currentTextIndex.value], currentTextIndex.value); |
||||
|
|
||||
|
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length; |
||||
|
currentCharIndex.value = 0; |
||||
|
timeout = setTimeout(() => {}, props.pauseDuration); |
||||
|
} else { |
||||
|
timeout = setTimeout(() => { |
||||
|
displayedText.value = displayedText.value.slice(0, -1); |
||||
|
}, props.deletingSpeed); |
||||
|
} |
||||
|
} else { |
||||
|
if (currentCharIndex.value < processedText.length) { |
||||
|
timeout = setTimeout( |
||||
|
() => { |
||||
|
displayedText.value += processedText[currentCharIndex.value]; |
||||
|
currentCharIndex.value += 1; |
||||
|
}, |
||||
|
props.variableSpeed ? getRandomSpeed() : props.typingSpeed |
||||
|
); |
||||
|
} else if (textArray.value.length > 1) { |
||||
|
timeout = setTimeout(() => { |
||||
|
isDeleting.value = true; |
||||
|
}, props.pauseDuration); |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
watch( |
||||
|
[displayedText, currentCharIndex, isDeleting, isVisible], |
||||
|
() => { |
||||
|
if (!isVisible.value) return; |
||||
|
clearTimeoutIfNeeded(); |
||||
|
|
||||
|
if (currentCharIndex.value === 0 && !isDeleting.value && displayedText.value === '') { |
||||
|
timeout = setTimeout(() => { |
||||
|
executeTypingAnimation(); |
||||
|
}, props.initialDelay); |
||||
|
} else { |
||||
|
executeTypingAnimation(); |
||||
|
} |
||||
|
}, |
||||
|
{ immediate: true } |
||||
|
); |
||||
|
|
||||
|
onMounted(() => { |
||||
|
if (props.showCursor && cursorRef.value) { |
||||
|
gsap.set(cursorRef.value, { opacity: 1 }); |
||||
|
gsap.to(cursorRef.value, { |
||||
|
opacity: 0, |
||||
|
duration: props.cursorBlinkDuration, |
||||
|
repeat: -1, |
||||
|
yoyo: true, |
||||
|
ease: 'power2.inOut' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
if (props.startOnVisible && containerRef.value) { |
||||
|
const observer = new IntersectionObserver( |
||||
|
entries => { |
||||
|
entries.forEach(entry => { |
||||
|
if (entry.isIntersecting) isVisible.value = true; |
||||
|
}); |
||||
|
}, |
||||
|
{ threshold: 0.1 } |
||||
|
); |
||||
|
if (containerRef.value instanceof Element) { |
||||
|
observer.observe(containerRef.value); |
||||
|
} |
||||
|
onBeforeUnmount(() => observer.disconnect()); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
onBeforeUnmount(() => { |
||||
|
clearTimeoutIfNeeded(); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<component |
||||
|
:is="as" |
||||
|
ref="containerRef" |
||||
|
:class="`inline-block whitespace-pre-wrap tracking-tight ${className}`" |
||||
|
v-bind="$attrs" |
||||
|
> |
||||
|
<span class="inline" :style="{ color: getCurrentTextColor() }"> |
||||
|
{{ displayedText }} |
||||
|
</span> |
||||
|
<span |
||||
|
v-if="showCursor" |
||||
|
ref="cursorRef" |
||||
|
:class="`ml-1 inline-block opacity-100 ${ |
||||
|
hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting) ? 'hidden' : '' |
||||
|
} ${cursorClassName}`" |
||||
|
> |
||||
|
{{ cursorCharacter }} |
||||
|
</span> |
||||
|
</component> |
||||
|
</template> |
||||
@ -0,0 +1,42 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { useRouter } from 'vue-router' |
||||
|
import Particles from '@/components/Particles/Particles.vue' |
||||
|
import FuzzyText from '@/components/FuzzyText/FuzzyText.vue'; |
||||
|
|
||||
|
const router = useRouter() |
||||
|
|
||||
|
const handleGoLogin = () => { |
||||
|
router.push('/login') |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="not-found-page"> |
||||
|
<Particles :particle-count="50" :particle-colors="['#ffffff', '#aaaaaa']" class="particles-bg" /> |
||||
|
<div class="fuzzy-container"> |
||||
|
<FuzzyText :font-size="140" :font-weight="900" color="#fff" :enable-hover="true" :base-intensity="0.18" :hover-intensity="0.5"> |
||||
|
404 |
||||
|
</FuzzyText> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.not-found-page { |
||||
|
min-height: 100vh; |
||||
|
background: #000000; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
position: relative; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.particles-bg { |
||||
|
position: fixed !important; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
width: 100% !important; |
||||
|
height: 100% !important; |
||||
|
} |
||||
|
</style> |
||||
Loading…
Reference in new issue