mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
1 line
7.9 KiB
JSON
1 line
7.9 KiB
JSON
{"name":"FallingText","title":"FallingText","description":"Characters fall with gravity + bounce creating a playful entrance.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';\nimport Matter from 'matter-js';\n\ninterface FallingTextProps {\n text?: string;\n highlightWords?: string[];\n trigger?: 'auto' | 'scroll' | 'click' | 'hover';\n backgroundColor?: string;\n wireframes?: boolean;\n gravity?: number;\n mouseConstraintStiffness?: number;\n fontSize?: string;\n}\n\nconst props = withDefaults(defineProps<FallingTextProps>(), {\n text: '',\n highlightWords: () => [],\n trigger: 'auto',\n backgroundColor: 'transparent',\n wireframes: false,\n gravity: 1,\n mouseConstraintStiffness: 0.2,\n fontSize: '1rem'\n});\n\nconst containerRef = useTemplateRef<HTMLDivElement>('containerRef');\nconst textRef = useTemplateRef<HTMLDivElement>('textRef');\nconst canvasContainerRef = useTemplateRef<HTMLDivElement>('canvasContainerRef');\n\nconst effectStarted = ref(false);\n\nlet engine: Matter.Engine | null = null;\nlet render: Matter.Render | null = null;\nlet runner: Matter.Runner | null = null;\nlet mouseConstraint: Matter.MouseConstraint | null = null;\nlet wordBodies: Array<{ elem: HTMLElement; body: Matter.Body }> = [];\nlet intersectionObserver: IntersectionObserver | null = null;\nlet animationFrameId: number | null = null;\n\nconst createTextHTML = () => {\n if (!textRef.value) return;\n\n const words = props.text.split(' ');\n const newHTML = words\n .map(word => {\n const isHighlighted = props.highlightWords.some(hw => word.startsWith(hw));\n return `<span class=\"inline-block mx-[2px] select-none ${isHighlighted ? 'text-green-500 font-bold' : ''}\">${word}</span>`;\n })\n .join(' ');\n\n textRef.value.innerHTML = newHTML;\n};\n\nconst setupTrigger = () => {\n if (props.trigger === 'auto') {\n setTimeout(() => {\n effectStarted.value = true;\n }, 100);\n return;\n }\n\n if (props.trigger === 'scroll' && containerRef.value) {\n intersectionObserver = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n effectStarted.value = true;\n intersectionObserver?.disconnect();\n }\n },\n { threshold: 0.1 }\n );\n intersectionObserver.observe(containerRef.value);\n }\n};\n\nconst handleTrigger = () => {\n if (!effectStarted.value && (props.trigger === 'click' || props.trigger === 'hover')) {\n effectStarted.value = true;\n }\n};\n\nconst startPhysics = async () => {\n if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return;\n\n await nextTick();\n\n const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter;\n\n const containerRect = containerRef.value.getBoundingClientRect();\n const width = containerRect.width;\n const height = containerRect.height;\n\n if (width <= 0 || height <= 0) return;\n\n engine = Engine.create();\n engine.world.gravity.y = props.gravity;\n\n render = Render.create({\n element: canvasContainerRef.value,\n engine,\n options: {\n width,\n height,\n background: props.backgroundColor,\n wireframes: props.wireframes\n }\n });\n\n const boundaryOptions = {\n isStatic: true,\n render: { fillStyle: 'transparent' }\n };\n\n const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions);\n const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions);\n const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions);\n const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions);\n\n const wordSpans = textRef.value.querySelectorAll('span') as NodeListOf<HTMLElement>;\n wordBodies = Array.from(wordSpans).map(elem => {\n const rect = elem.getBoundingClientRect();\n const containerRect = containerRef.value!.getBoundingClientRect();\n\n const x = rect.left - containerRect.left + rect.width / 2;\n const y = rect.top - containerRect.top + rect.height / 2;\n\n const body = Bodies.rectangle(x, y, rect.width, rect.height, {\n render: { fillStyle: 'transparent' },\n restitution: 0.8,\n frictionAir: 0.01,\n friction: 0.2\n });\n\n Matter.Body.setVelocity(body, {\n x: (Math.random() - 0.5) * 5,\n y: 0\n });\n Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05);\n\n return { elem, body };\n });\n\n wordBodies.forEach(({ elem, body }) => {\n elem.style.position = 'absolute';\n elem.style.left = `${body.position.x - (body.bounds.max.x - body.bounds.min.x) / 2}px`;\n elem.style.top = `${body.position.y - (body.bounds.max.y - body.bounds.min.y) / 2}px`;\n elem.style.transform = 'none';\n });\n\n const mouse = Mouse.create(containerRef.value);\n mouseConstraint = MouseConstraint.create(engine, {\n mouse,\n constraint: {\n stiffness: props.mouseConstraintStiffness,\n render: { visible: false }\n }\n });\n render.mouse = mouse;\n\n World.add(engine.world, [floor, leftWall, rightWall, ceiling, mouseConstraint, ...wordBodies.map(wb => wb.body)]);\n\n runner = Runner.create();\n Runner.run(runner, engine);\n Render.run(render);\n\n const updateLoop = () => {\n wordBodies.forEach(({ body, elem }) => {\n const { x, y } = body.position;\n elem.style.left = `${x}px`;\n elem.style.top = `${y}px`;\n elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;\n });\n Matter.Engine.update(engine!);\n animationFrameId = requestAnimationFrame(updateLoop);\n };\n updateLoop();\n};\n\nconst cleanup = () => {\n if (animationFrameId) {\n cancelAnimationFrame(animationFrameId);\n animationFrameId = null;\n }\n\n if (render) {\n Matter.Render.stop(render);\n if (render.canvas && canvasContainerRef.value) {\n canvasContainerRef.value.removeChild(render.canvas);\n }\n render = null;\n }\n\n if (runner && engine) {\n Matter.Runner.stop(runner);\n runner = null;\n }\n\n if (engine) {\n Matter.World.clear(engine.world, false);\n Matter.Engine.clear(engine);\n engine = null;\n }\n\n if (intersectionObserver) {\n intersectionObserver.disconnect();\n intersectionObserver = null;\n }\n\n mouseConstraint = null;\n wordBodies = [];\n};\n\nwatch(\n () => [props.text, props.highlightWords],\n () => {\n createTextHTML();\n },\n { immediate: true, deep: true }\n);\n\nwatch(\n () => props.trigger,\n () => {\n effectStarted.value = false;\n cleanup();\n setupTrigger();\n },\n { immediate: true }\n);\n\nwatch(\n () => effectStarted.value,\n started => {\n if (started) {\n startPhysics();\n }\n }\n);\n\nwatch(\n () => [props.gravity, props.wireframes, props.backgroundColor, props.mouseConstraintStiffness],\n () => {\n if (effectStarted.value) {\n cleanup();\n startPhysics();\n }\n },\n { deep: true }\n);\n\nonMounted(() => {\n createTextHTML();\n setupTrigger();\n});\n\nonUnmounted(() => {\n cleanup();\n});\n</script>\n\n<template>\n <div\n ref=\"containerRef\"\n class=\"relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden\"\n @click=\"props.trigger === 'click' ? handleTrigger() : undefined\"\n @mouseenter=\"props.trigger === 'hover' ? handleTrigger() : undefined\"\n >\n <div\n ref=\"textRef\"\n class=\"inline-block\"\n :style=\"{\n fontSize: props.fontSize,\n lineHeight: 1.4\n }\"\n />\n\n <div class=\"absolute top-0 left-0 z-0\" ref=\"canvasContainerRef\" />\n </div>\n</template>\n","path":"FallingText/FallingText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"matter-js","version":"^0.20.0"}],"devDependencies":[],"categories":["TextAnimations"]} |