mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
7.1 KiB
JSON
1 line
7.1 KiB
JSON
{"name":"VariableProximity","title":"VariableProximity","description":"Letter styling changes continuously with pointer distance mapping.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <span\n ref=\"rootRef\"\n :class=\"[props.className]\"\n :style=\"{\n display: 'inline',\n ...props.style\n }\"\n @click=\"props.onClick\"\n >\n <span\n v-for=\"(word, wordIndex) in words\"\n :key=\"wordIndex\"\n :style=\"{ display: 'inline-block', whiteSpace: 'nowrap' }\"\n >\n <span\n v-for=\"(letter, letterIndex) in word.split('')\"\n :key=\"getLetterKey(wordIndex, letterIndex)\"\n :style=\"{\n display: 'inline-block',\n fontVariationSettings: props.fromFontVariationSettings\n }\"\n class=\"letter\"\n :data-index=\"getGlobalLetterIndex(wordIndex, letterIndex)\"\n aria-hidden=\"true\"\n >\n {{ letter }}\n </span>\n <span v-if=\"wordIndex < words.length - 1\" class=\"inline-block\"> </span>\n </span>\n <span class=\"absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap clip-rect-0 border-0\">\n {{ props.label }}\n </span>\n </span>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, onUnmounted, nextTick, type CSSProperties } from 'vue';\n\nexport type FalloffType = 'linear' | 'exponential' | 'gaussian';\n\ninterface VariableProximityProps {\n label: string;\n fromFontVariationSettings: string;\n toFontVariationSettings: string;\n containerRef?: HTMLElement | null | undefined;\n radius?: number;\n falloff?: FalloffType;\n className?: string;\n style?: CSSProperties;\n onClick?: () => void;\n}\n\nconst props = withDefaults(defineProps<VariableProximityProps>(), {\n radius: 50,\n falloff: 'linear',\n className: '',\n style: () => ({}),\n onClick: undefined\n});\n\nconst rootRef = ref<HTMLElement | null>(null);\nconst letterElements = ref<HTMLElement[]>([]);\nconst mousePosition = ref({ x: 0, y: 0 });\nconst lastPosition = ref<{ x: number | null; y: number | null }>({ x: null, y: null });\nconst interpolatedSettings = ref<string[]>([]);\n\nlet animationFrameId: number | null = null;\n\nconst words = computed(() => props.label.split(' '));\n\nconst parsedSettings = computed(() => {\n const parseSettings = (settingsStr: string) => {\n const result = new Map();\n settingsStr.split(',').forEach(s => {\n const parts = s.trim().split(' ');\n if (parts.length === 2) {\n const name = parts[0].replace(/['\"]/g, '');\n const value = parseFloat(parts[1]);\n result.set(name, value);\n }\n });\n return result;\n };\n\n const fromSettings = parseSettings(props.fromFontVariationSettings);\n const toSettings = parseSettings(props.toFontVariationSettings);\n\n return Array.from(fromSettings.entries()).map(([axis, fromValue]) => ({\n axis,\n fromValue,\n toValue: toSettings.get(axis) ?? fromValue\n }));\n});\n\nconst calculateDistance = (x1: number, y1: number, x2: number, y2: number) =>\n Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);\n\nconst calculateFalloff = (distance: number) => {\n const norm = Math.min(Math.max(1 - distance / props.radius, 0), 1);\n switch (props.falloff) {\n case 'exponential':\n return norm ** 2;\n case 'gaussian':\n return Math.exp(-((distance / (props.radius / 2)) ** 2) / 2);\n case 'linear':\n default:\n return norm;\n }\n};\n\nconst getLetterKey = (wordIndex: number, letterIndex: number) => `${wordIndex}-${letterIndex}`;\n\nconst getGlobalLetterIndex = (wordIndex: number, letterIndex: number) => {\n let globalIndex = 0;\n for (let i = 0; i < wordIndex; i++) {\n globalIndex += words.value[i].length;\n }\n return globalIndex + letterIndex;\n};\n\nconst initializeLetterElements = () => {\n if (!rootRef.value) return;\n\n const elements = rootRef.value.querySelectorAll('.letter');\n letterElements.value = Array.from(elements) as HTMLElement[];\n\n console.log(`Found ${letterElements.value.length} letter elements`);\n};\n\nconst handleMouseMove = (ev: MouseEvent) => {\n const container = props.containerRef || rootRef.value;\n if (!container) return;\n\n const rect = container.getBoundingClientRect();\n mousePosition.value = {\n x: ev.clientX - rect.left,\n y: ev.clientY - rect.top\n };\n};\n\nconst handleTouchMove = (ev: TouchEvent) => {\n const container = props.containerRef || rootRef.value;\n if (!container) return;\n\n const touch = ev.touches[0];\n const rect = container.getBoundingClientRect();\n mousePosition.value = {\n x: touch.clientX - rect.left,\n y: touch.clientY - rect.top\n };\n};\n\nconst animationLoop = () => {\n const container = props.containerRef || rootRef.value;\n if (!container || letterElements.value.length === 0) {\n animationFrameId = requestAnimationFrame(animationLoop);\n return;\n }\n\n const containerRect = container.getBoundingClientRect();\n\n if (lastPosition.value.x === mousePosition.value.x && lastPosition.value.y === mousePosition.value.y) {\n animationFrameId = requestAnimationFrame(animationLoop);\n return;\n }\n\n lastPosition.value = { x: mousePosition.value.x, y: mousePosition.value.y };\n\n const newSettings = Array(letterElements.value.length).fill(props.fromFontVariationSettings);\n\n letterElements.value.forEach((letterEl, index) => {\n if (!letterEl) return;\n\n const rect = letterEl.getBoundingClientRect();\n const letterCenterX = rect.left + rect.width / 2 - containerRect.left;\n const letterCenterY = rect.top + rect.height / 2 - containerRect.top;\n\n const distance = calculateDistance(mousePosition.value.x, mousePosition.value.y, letterCenterX, letterCenterY);\n\n if (distance >= props.radius) {\n return;\n }\n\n const falloffValue = calculateFalloff(distance);\n const setting = parsedSettings.value\n .map(({ axis, fromValue, toValue }) => {\n const interpolatedValue = fromValue + (toValue - fromValue) * falloffValue;\n return `'${axis}' ${interpolatedValue}`;\n })\n .join(', ');\n\n newSettings[index] = setting;\n });\n\n interpolatedSettings.value = newSettings;\n\n letterElements.value.forEach((letterEl, index) => {\n letterEl.style.fontVariationSettings = interpolatedSettings.value[index];\n });\n\n animationFrameId = requestAnimationFrame(animationLoop);\n};\n\nonMounted(() => {\n nextTick(() => {\n initializeLetterElements();\n\n window.addEventListener('mousemove', handleMouseMove);\n window.addEventListener('touchmove', handleTouchMove);\n\n animationFrameId = requestAnimationFrame(animationLoop);\n });\n});\n\nonUnmounted(() => {\n window.removeEventListener('mousemove', handleMouseMove);\n window.removeEventListener('touchmove', handleTouchMove);\n\n if (animationFrameId) {\n cancelAnimationFrame(animationFrameId);\n }\n});\n</script>\n","path":"VariableProximity/VariableProximity.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["TextAnimations"]} |