mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
1 line
5.4 KiB
JSON
1 line
5.4 KiB
JSON
{"name":"CurvedLoop","title":"CurvedLoop","description":"Flowing looping text path along a customizable curve with drag interaction.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';\n\ninterface CurvedLoopProps {\n marqueeText?: string;\n speed?: number;\n className?: string;\n curveAmount?: number;\n direction?: 'left' | 'right';\n interactive?: boolean;\n}\n\nconst props = withDefaults(defineProps<CurvedLoopProps>(), {\n marqueeText: '',\n speed: 2,\n className: '',\n curveAmount: 400,\n direction: 'left',\n interactive: true\n});\n\nconst text = computed(() => {\n const hasTrailing = /\\s|\\u00A0$/.test(props.marqueeText);\n return (hasTrailing ? props.marqueeText.replace(/\\s+$/, '') : props.marqueeText) + '\\u00A0';\n});\n\nconst measureRef = ref<SVGTextElement | null>(null);\nconst textPathRef = ref<SVGTextPathElement | null>(null);\nconst pathRef = ref<SVGPathElement | null>(null);\nconst spacing = ref(0);\nconst offset = ref(0);\nconst uid = Math.random().toString(36).substr(2, 9);\nconst pathId = `curve-${uid}`;\n\nconst pathD = computed(() => `M-100,40 Q500,${40 + props.curveAmount} 1540,40`);\n\nconst dragRef = ref(false);\nconst lastXRef = ref(0);\nconst dirRef = ref<'left' | 'right'>(props.direction);\nconst velRef = ref(0);\n\nlet animationFrame: number | null = null;\n\nconst textLength = computed(() => spacing.value);\nconst totalText = computed(() => {\n return textLength.value\n ? Array(Math.ceil(1800 / textLength.value) + 2)\n .fill(text.value)\n .join('')\n : text.value;\n});\nconst ready = computed(() => spacing.value > 0);\n\nconst updateSpacing = () => {\n if (measureRef.value) {\n spacing.value = measureRef.value.getComputedTextLength();\n }\n};\n\nconst animate = () => {\n if (!spacing.value || !ready.value) return;\n\n const step = () => {\n if (!dragRef.value && textPathRef.value) {\n const delta = dirRef.value === 'right' ? props.speed : -props.speed;\n const currentOffset = parseFloat(textPathRef.value.getAttribute('startOffset') || '0');\n let newOffset = currentOffset + delta;\n\n const wrapPoint = spacing.value;\n if (newOffset <= -wrapPoint) newOffset += wrapPoint;\n if (newOffset >= wrapPoint) newOffset -= wrapPoint;\n\n textPathRef.value.setAttribute('startOffset', newOffset + 'px');\n offset.value = newOffset;\n }\n animationFrame = requestAnimationFrame(step);\n };\n step();\n};\n\nconst stopAnimation = () => {\n if (animationFrame) {\n cancelAnimationFrame(animationFrame);\n animationFrame = null;\n }\n};\n\nconst onPointerDown = (e: PointerEvent) => {\n if (!props.interactive) return;\n dragRef.value = true;\n lastXRef.value = e.clientX;\n velRef.value = 0;\n (e.target as HTMLElement).setPointerCapture(e.pointerId);\n};\n\nconst onPointerMove = (e: PointerEvent) => {\n if (!props.interactive || !dragRef.value || !textPathRef.value) return;\n const dx = e.clientX - lastXRef.value;\n lastXRef.value = e.clientX;\n velRef.value = dx;\n\n const currentOffset = parseFloat(textPathRef.value.getAttribute('startOffset') || '0');\n let newOffset = currentOffset + dx;\n\n const wrapPoint = spacing.value;\n if (newOffset <= -wrapPoint) newOffset += wrapPoint;\n if (newOffset >= wrapPoint) newOffset -= wrapPoint;\n\n textPathRef.value.setAttribute('startOffset', newOffset + 'px');\n offset.value = newOffset;\n};\n\nconst endDrag = () => {\n if (!props.interactive) return;\n dragRef.value = false;\n dirRef.value = velRef.value > 0 ? 'right' : 'left';\n};\n\nconst cursorStyle = computed(() => {\n return props.interactive ? (dragRef.value ? 'grabbing' : 'grab') : 'auto';\n});\n\nonMounted(() => {\n nextTick(() => {\n updateSpacing();\n animate();\n });\n});\n\nonUnmounted(() => {\n stopAnimation();\n});\n\nwatch([text, () => props.className], () => {\n nextTick(() => {\n updateSpacing();\n });\n});\n\nwatch([spacing, () => props.speed], () => {\n stopAnimation();\n if (spacing.value) {\n animate();\n }\n});\n</script>\n\n<template>\n <div\n class=\"min-h-screen flex items-center justify-center w-full\"\n :style=\"{\n visibility: ready ? 'visible' : 'hidden',\n cursor: cursorStyle\n }\"\n @pointerdown=\"onPointerDown\"\n @pointermove=\"onPointerMove\"\n @pointerup=\"endDrag\"\n @pointerleave=\"endDrag\"\n >\n <svg\n class=\"select-none w-full overflow-visible block aspect-[100/12] text-[6rem] font-bold uppercase leading-none\"\n viewBox=\"0 0 1440 120\"\n >\n <text ref=\"measureRef\" xml:space=\"preserve\" style=\"visibility: hidden; opacity: 0; pointer-events: none\">\n {{ text }}\n </text>\n\n <defs>\n <path ref=\"pathRef\" :id=\"pathId\" :d=\"pathD\" fill=\"none\" stroke=\"transparent\" />\n </defs>\n\n <text v-if=\"ready\" xml:space=\"preserve\" :class=\"`fill-white ${className}`\">\n <textPath ref=\"textPathRef\" :href=\"`#${pathId}`\" :startOffset=\"offset + 'px'\" xml:space=\"preserve\">\n {{ totalText }}\n </textPath>\n </text>\n </svg>\n </div>\n</template>\n","path":"CurvedLoop/CurvedLoop.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[],"devDependencies":[],"categories":["TextAnimations"]} |