mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
7.9 KiB
JSON
1 line
7.9 KiB
JSON
{"name":"FuzzyText","title":"FuzzyText","description":"Vibrating fuzzy text with controllable hover intensity.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, useSlots, useTemplateRef, watch } from 'vue';\n\ninterface FuzzyTextProps {\n fontSize?: number | string;\n fontWeight?: string | number;\n fontFamily?: string;\n color?: string;\n enableHover?: boolean;\n baseIntensity?: number;\n hoverIntensity?: number;\n fuzzRange?: number;\n fps?: number;\n direction?: 'horizontal' | 'vertical' | 'both';\n transitionDuration?: number;\n clickEffect?: boolean;\n glitchMode?: boolean;\n glitchInterval?: number;\n glitchDuration?: number;\n gradient?: string[] | null;\n letterSpacing?: number;\n className?: string;\n}\n\nconst props = withDefaults(defineProps<FuzzyTextProps>(), {\n fontSize: 'clamp(2rem, 8vw, 8rem)',\n fontWeight: 900,\n fontFamily: 'inherit',\n color: '#fff',\n enableHover: true,\n baseIntensity: 0.18,\n hoverIntensity: 0.5,\n fuzzRange: 30,\n fps: 60,\n direction: 'horizontal',\n transitionDuration: 0,\n clickEffect: false,\n glitchMode: false,\n glitchInterval: 2000,\n glitchDuration: 200,\n gradient: null,\n letterSpacing: 0,\n className: ''\n});\n\nconst canvasRef = useTemplateRef<HTMLCanvasElement & { cleanupFuzzyText?: () => void }>('canvasRef');\nconst slots = useSlots();\n\nlet animationFrameId: number;\nlet glitchTimeoutId: ReturnType<typeof setTimeout>;\nlet glitchEndTimeoutId: ReturnType<typeof setTimeout>;\nlet clickTimeoutId: ReturnType<typeof setTimeout>;\nlet cancelled = false;\n\nconst text = computed(() => (slots.default?.() ?? []).map(v => v.children).join(''));\n\nconst init = async () => {\n const canvas = canvasRef.value;\n if (!canvas) return;\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return;\n\n const computedFontFamily =\n props.fontFamily === 'inherit' ? window.getComputedStyle(canvas).fontFamily || 'sans-serif' : props.fontFamily;\n\n const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize;\n\n const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;\n\n try {\n await document.fonts.load(fontString);\n } catch {\n await document.fonts.ready;\n }\n\n if (cancelled) return;\n\n let numericFontSize: number;\n if (typeof props.fontSize === 'number') {\n numericFontSize = props.fontSize;\n } else {\n const temp = document.createElement('span');\n temp.style.fontSize = props.fontSize;\n document.body.appendChild(temp);\n numericFontSize = parseFloat(getComputedStyle(temp).fontSize);\n document.body.removeChild(temp);\n }\n\n const offscreen = document.createElement('canvas');\n const offCtx = offscreen.getContext('2d')!;\n offCtx.font = fontString;\n offCtx.textBaseline = 'alphabetic';\n\n let totalWidth = 0;\n if (props.letterSpacing !== 0) {\n for (const char of text.value) {\n totalWidth += offCtx.measureText(char).width + props.letterSpacing;\n }\n totalWidth -= props.letterSpacing;\n } else {\n totalWidth = offCtx.measureText(text.value).width;\n }\n\n const metrics = offCtx.measureText(text.value);\n const ascent = metrics.actualBoundingBoxAscent ?? numericFontSize;\n const descent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;\n const height = Math.ceil(ascent + descent);\n\n offscreen.width = Math.ceil(totalWidth) + 20;\n offscreen.height = height;\n\n offCtx.font = fontString;\n offCtx.textBaseline = 'alphabetic';\n\n if (props.gradient && props.gradient.length >= 2) {\n const grad = offCtx.createLinearGradient(0, 0, offscreen.width, 0);\n props.gradient.forEach((c, i) => grad.addColorStop(i / (props.gradient!.length - 1), c));\n offCtx.fillStyle = grad;\n } else {\n offCtx.fillStyle = props.color;\n }\n\n let x = 10;\n for (const char of text.value) {\n offCtx.fillText(char, x, ascent);\n x += offCtx.measureText(char).width + props.letterSpacing;\n }\n\n const marginX = props.fuzzRange + 20;\n const marginY = props.direction === 'vertical' || props.direction === 'both' ? props.fuzzRange + 10 : 0;\n\n canvas.width = offscreen.width + marginX * 2;\n canvas.height = offscreen.height + marginY * 2;\n ctx.translate(marginX, marginY);\n\n let isHovering = false;\n let isClicking = false;\n let isGlitching = false;\n let currentIntensity = props.baseIntensity;\n let targetIntensity = props.baseIntensity;\n let lastFrameTime = 0;\n const frameDuration = 1000 / props.fps;\n\n const startGlitch = () => {\n if (!props.glitchMode || cancelled) return;\n glitchTimeoutId = setTimeout(() => {\n isGlitching = true;\n glitchEndTimeoutId = setTimeout(() => {\n isGlitching = false;\n startGlitch();\n }, props.glitchDuration);\n }, props.glitchInterval);\n };\n\n if (props.glitchMode) startGlitch();\n\n const run = (ts: number) => {\n if (cancelled) return;\n\n if (ts - lastFrameTime < frameDuration) {\n animationFrameId = requestAnimationFrame(run);\n return;\n }\n\n lastFrameTime = ts;\n ctx.clearRect(-marginX, -marginY, offscreen.width + marginX * 2, offscreen.height + marginY * 2);\n\n targetIntensity = isClicking || isGlitching ? 1 : isHovering ? props.hoverIntensity : props.baseIntensity;\n\n if (props.transitionDuration > 0) {\n const step = 1 / (props.transitionDuration / frameDuration);\n currentIntensity += Math.sign(targetIntensity - currentIntensity) * step;\n currentIntensity = Math.min(\n Math.max(currentIntensity, Math.min(targetIntensity, currentIntensity)),\n Math.max(targetIntensity, currentIntensity)\n );\n } else {\n currentIntensity = targetIntensity;\n }\n\n for (let y = 0; y < offscreen.height; y++) {\n const dx = props.direction !== 'vertical' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange : 0;\n const dy =\n props.direction !== 'horizontal' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange * 0.5 : 0;\n\n ctx.drawImage(offscreen, 0, y, offscreen.width, 1, dx, y + dy, offscreen.width, 1);\n }\n\n animationFrameId = requestAnimationFrame(run);\n };\n\n animationFrameId = requestAnimationFrame(run);\n\n const rectCheck = (x: number, y: number) =>\n x >= marginX && x <= marginX + offscreen.width && y >= marginY && y <= marginY + offscreen.height;\n\n const mouseMove = (e: MouseEvent) => {\n if (!props.enableHover) return;\n const rect = canvas.getBoundingClientRect();\n isHovering = rectCheck(e.clientX - rect.left, e.clientY - rect.top);\n };\n\n const mouseLeave = () => (isHovering = false);\n\n const click = () => {\n if (!props.clickEffect) return;\n isClicking = true;\n clearTimeout(clickTimeoutId);\n clickTimeoutId = setTimeout(() => (isClicking = false), 150);\n };\n\n if (props.enableHover) {\n canvas.addEventListener('mousemove', mouseMove);\n canvas.addEventListener('mouseleave', mouseLeave);\n }\n\n if (props.clickEffect) {\n canvas.addEventListener('click', click);\n }\n\n onBeforeUnmount(() => {\n cancelled = true;\n cancelAnimationFrame(animationFrameId);\n clearTimeout(glitchTimeoutId);\n clearTimeout(glitchEndTimeoutId);\n clearTimeout(clickTimeoutId);\n canvas.removeEventListener('mousemove', mouseMove);\n canvas.removeEventListener('mouseleave', mouseLeave);\n canvas.removeEventListener('click', click);\n });\n};\n\nonMounted(init);\n\nwatch(\n () => ({ ...props, text: text.value }),\n () => {\n cancelled = true;\n cancelAnimationFrame(animationFrameId);\n cancelled = false;\n init();\n }\n);\n</script>\n\n<template>\n <canvas ref=\"canvasRef\" :class=\"className\" />\n</template>\n","path":"FuzzyText/FuzzyText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["TextAnimations"]} |